diff --git a/packages/react-debug-tools/src/__tests__/ReactHooksInspection-test.internal.js b/packages/react-debug-tools/src/__tests__/ReactHooksInspection-test.internal.js index 326e3c39bf..63ec249079 100644 --- a/packages/react-debug-tools/src/__tests__/ReactHooksInspection-test.internal.js +++ b/packages/react-debug-tools/src/__tests__/ReactHooksInspection-test.internal.js @@ -25,11 +25,7 @@ describe('ReactHooksInspection', () => { ReactDebugTools = require('react-debug-tools'); }); - if (!__EXPERIMENTAL__) { - it("empty test so Jest doesn't complain", () => {}); - return; - } - + // @gate experimental it('should inspect a simple useResponder hook', () => { const TestResponder = React.DEPRECATED_createResponder('TestResponder', {}); @@ -51,6 +47,7 @@ describe('ReactHooksInspection', () => { ]); }); + // @gate experimental it('should inspect a simple ReactDOM.useEvent hook', () => { let clickHandle; let ref; diff --git a/packages/react-devtools-shared/src/__tests__/storeStressTestConcurrent-test.js b/packages/react-devtools-shared/src/__tests__/storeStressTestConcurrent-test.js index b972cf3177..360b78a3ad 100644 --- a/packages/react-devtools-shared/src/__tests__/storeStressTestConcurrent-test.js +++ b/packages/react-devtools-shared/src/__tests__/storeStressTestConcurrent-test.js @@ -27,6 +27,7 @@ describe('StoreStressConcurrent', () => { print = require('./storeSerializer').print; }); + // TODO: Remove this in favor of @gate pragma if (!__EXPERIMENTAL__) { it("empty test so Jest doesn't complain", () => {}); return; diff --git a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js index 10cd143c54..773ab3232a 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerPartialHydration-test.internal.js @@ -87,11 +87,7 @@ describe('ReactDOMServerPartialHydration', () => { SuspenseList = React.SuspenseList; }); - if (!__EXPERIMENTAL__) { - it("empty test so Jest doesn't complain", () => {}); - return; - } - + // @gate experimental it('hydrates a parent even if a child Suspense boundary is blocked', async () => { let suspend = false; let resolve; @@ -150,6 +146,7 @@ describe('ReactDOMServerPartialHydration', () => { expect(ref.current).toBe(span); }); + // @gate experimental it('calls the hydration callbacks after hydration or deletion', async () => { let suspend = false; let resolve; @@ -240,6 +237,7 @@ describe('ReactDOMServerPartialHydration', () => { expect(deleted.length).toBe(1); }); + // @gate experimental it('calls the onDeleted hydration callback if the parent gets deleted', async () => { let suspend = false; const promise = new Promise(() => {}); @@ -297,6 +295,7 @@ describe('ReactDOMServerPartialHydration', () => { expect(deleted.length).toBe(1); }); + // @gate experimental || www it('warns and replaces the boundary content in legacy mode', async () => { let suspend = false; let resolve; @@ -368,6 +367,7 @@ describe('ReactDOMServerPartialHydration', () => { expect(container.textContent).toBe('Hello'); }); + // @gate experimental it('can insert siblings before the dehydrated boundary', () => { let suspend = false; const promise = new Promise(() => {}); @@ -426,6 +426,7 @@ describe('ReactDOMServerPartialHydration', () => { expect(container.firstChild.firstChild.textContent).toBe('First'); }); + // @gate experimental it('can delete the dehydrated boundary before it is hydrated', () => { let suspend = false; const promise = new Promise(() => {}); @@ -482,6 +483,7 @@ describe('ReactDOMServerPartialHydration', () => { expect(container.firstChild.children[1].textContent).toBe('After'); }); + // @gate experimental it('blocks updates to hydrate the content first if props have changed', async () => { let suspend = false; let resolve; @@ -553,6 +555,7 @@ describe('ReactDOMServerPartialHydration', () => { expect(span.className).toBe('hi'); }); + // @gate experimental it('shows the fallback if props have changed before hydration completes and is still suspended', async () => { let suspend = false; let resolve; @@ -622,6 +625,7 @@ describe('ReactDOMServerPartialHydration', () => { expect(container.textContent).toBe('Hi'); }); + // @gate experimental it('shows the fallback of the outer if fallback is missing', async () => { // This is the same exact test as above but with a nested Suspense without a fallback. // This should be a noop. @@ -695,6 +699,7 @@ describe('ReactDOMServerPartialHydration', () => { expect(container.textContent).toBe('Hi'); }); + // @gate experimental it('clears nested suspense boundaries if they did not hydrate yet', async () => { let suspend = false; let resolve; @@ -767,6 +772,7 @@ describe('ReactDOMServerPartialHydration', () => { expect(container.textContent).toBe('Hi Hi'); }); + // @gate experimental it('hydrates first if props changed but we are able to resolve within a timeout', async () => { let suspend = false; let resolve; @@ -847,6 +853,7 @@ describe('ReactDOMServerPartialHydration', () => { expect(span.className).toBe('hi'); }); + // @gate experimental it('blocks the update to hydrate first if context has changed', async () => { let suspend = false; let resolve; @@ -931,6 +938,7 @@ describe('ReactDOMServerPartialHydration', () => { expect(span.className).toBe('hi'); }); + // @gate experimental it('shows the fallback if context has changed before hydration completes and is still suspended', async () => { let suspend = false; let resolve; @@ -1014,6 +1022,7 @@ describe('ReactDOMServerPartialHydration', () => { expect(container.textContent).toBe('Hi'); }); + // @gate experimental it('replaces the fallback with client content if it is not rendered by the server', async () => { let suspend = false; const promise = new Promise(resolvePromise => {}); @@ -1062,6 +1071,7 @@ describe('ReactDOMServerPartialHydration', () => { expect(ref.current).toBe(span); }); + // @gate experimental it('replaces the fallback within the suspended time if there is a nested suspense', async () => { let suspend = false; const promise = new Promise(resolvePromise => {}); @@ -1121,6 +1131,7 @@ describe('ReactDOMServerPartialHydration', () => { expect(ref.current).toBe(span); }); + // @gate experimental it('replaces the fallback within the suspended time if there is a nested suspense in a nested suspense', async () => { let suspend = false; const promise = new Promise(resolvePromise => {}); @@ -1182,6 +1193,7 @@ describe('ReactDOMServerPartialHydration', () => { expect(ref.current).toBe(span); }); + // @gate experimental it('waits for pending content to come in from the server and then hydrates it', async () => { let suspend = false; const promise = new Promise(resolvePromise => {}); @@ -1269,6 +1281,7 @@ describe('ReactDOMServerPartialHydration', () => { expect(ref.current).toBe(span); }); + // @gate experimental it('handles an error on the client if the server ends up erroring', async () => { let suspend = false; const promise = new Promise(resolvePromise => {}); @@ -1363,6 +1376,7 @@ describe('ReactDOMServerPartialHydration', () => { expect(ref.current).toBe(div); }); + // @gate experimental it('shows inserted items in a SuspenseList before content is hydrated', async () => { let suspend = false; let resolve; @@ -1448,6 +1462,7 @@ describe('ReactDOMServerPartialHydration', () => { expect(ref.current).toBe(spanB); }); + // @gate experimental it('shows is able to hydrate boundaries even if others in a list are pending', async () => { let suspend = false; let resolve; @@ -1522,6 +1537,7 @@ describe('ReactDOMServerPartialHydration', () => { expect(container.textContent).toBe('ALoading B'); }); + // @gate experimental it('shows inserted items before pending in a SuspenseList as fallbacks', async () => { let suspend = false; let resolve; @@ -1613,6 +1629,7 @@ describe('ReactDOMServerPartialHydration', () => { expect(container.textContent).toBe('ABC'); }); + // @gate experimental it('can client render nested boundaries', async () => { let suspend = false; const promise = new Promise(() => {}); @@ -1667,6 +1684,7 @@ describe('ReactDOMServerPartialHydration', () => { expect(container.lastChild.data).toBe('unrelated comment'); }); + // @gate experimental it('can hydrate TWO suspense boundaries', async () => { const ref1 = React.createRef(); const ref2 = React.createRef(); @@ -1706,6 +1724,7 @@ describe('ReactDOMServerPartialHydration', () => { expect(ref2.current).toBe(span2); }); + // @gate experimental it('regenerates if it cannot hydrate before changes to props/context expire', async () => { let suspend = false; const promise = new Promise(resolvePromise => {}); @@ -1787,6 +1806,7 @@ describe('ReactDOMServerPartialHydration', () => { expect(newSpan.className).toBe('hi'); }); + // @gate experimental it('does not invoke an event on a hydrated node until it commits', async () => { let suspend = false; let resolve; @@ -1868,6 +1888,7 @@ describe('ReactDOMServerPartialHydration', () => { document.body.removeChild(container); }); + // @gate experimental it('does not invoke an event on a hydrated EventResponder until it commits', async () => { let suspend = false; let resolve; @@ -1949,6 +1970,7 @@ describe('ReactDOMServerPartialHydration', () => { document.body.removeChild(container); }); + // @gate experimental it('invokes discrete events on nested suspense boundaries in a root (legacy system)', async () => { let suspend = false; let resolve; @@ -2028,6 +2050,7 @@ describe('ReactDOMServerPartialHydration', () => { document.body.removeChild(container); }); + // @gate experimental it('invokes discrete events on nested suspense boundaries in a root (responder system)', async () => { let suspend = false; let resolve; @@ -2110,6 +2133,7 @@ describe('ReactDOMServerPartialHydration', () => { document.body.removeChild(container); }); + // @gate experimental it('does not invoke the parent of dehydrated boundary event', async () => { let suspend = false; let resolve; @@ -2184,6 +2208,7 @@ describe('ReactDOMServerPartialHydration', () => { document.body.removeChild(container); }); + // @gate experimental it('does not invoke an event on a parent tree when a subtree is dehydrated', async () => { let suspend = false; let resolve; @@ -2261,6 +2286,7 @@ describe('ReactDOMServerPartialHydration', () => { document.body.removeChild(parentContainer); }); + // @gate experimental it('blocks only on the last continuous event (legacy system)', async () => { let suspend1 = false; let resolve1; @@ -2365,129 +2391,125 @@ describe('ReactDOMServerPartialHydration', () => { document.body.removeChild(container); }); - if (__EXPERIMENTAL__) { - it('blocks only on the last continuous event (Responder system)', async () => { - useHover = require('react-interactions/events/hover').useHover; + // @gate experimental + it('blocks only on the last continuous event (Responder system)', async () => { + useHover = require('react-interactions/events/hover').useHover; - let suspend1 = false; - let resolve1; - const promise1 = new Promise( - resolvePromise => (resolve1 = resolvePromise), - ); - let suspend2 = false; - let resolve2; - const promise2 = new Promise( - resolvePromise => (resolve2 = resolvePromise), - ); + let suspend1 = false; + let resolve1; + const promise1 = new Promise(resolvePromise => (resolve1 = resolvePromise)); + let suspend2 = false; + let resolve2; + const promise2 = new Promise(resolvePromise => (resolve2 = resolvePromise)); - function First({text}) { - if (suspend1) { - throw promise1; - } else { - return 'Hello'; - } + function First({text}) { + if (suspend1) { + throw promise1; + } else { + return 'Hello'; } + } - function Second({text}) { - if (suspend2) { - throw promise2; - } else { - return 'World'; - } + function Second({text}) { + if (suspend2) { + throw promise2; + } else { + return 'World'; } + } - const ops = []; + const ops = []; - function App() { - const listener1 = useHover({ - onHoverStart() { - ops.push('Hover Start First'); - }, - onHoverEnd() { - ops.push('Hover End First'); - }, - }); - const listener2 = useHover({ - onHoverStart() { - ops.push('Hover Start Second'); - }, - onHoverEnd() { - ops.push('Hover End Second'); - }, - }); - return ( -
- - - {/* We suspend after to test what happens when we eager + function App() { + const listener1 = useHover({ + onHoverStart() { + ops.push('Hover Start First'); + }, + onHoverEnd() { + ops.push('Hover End First'); + }, + }); + const listener2 = useHover({ + onHoverStart() { + ops.push('Hover Start Second'); + }, + onHoverEnd() { + ops.push('Hover End Second'); + }, + }); + return ( +
+ + + {/* We suspend after to test what happens when we eager attach the listener. */} - - - - - - - -
- ); - } + +
+ + + + + +
+ ); + } - const finalHTML = ReactDOMServer.renderToString(); - const container = document.createElement('div'); - container.innerHTML = finalHTML; + const finalHTML = ReactDOMServer.renderToString(); + const container = document.createElement('div'); + container.innerHTML = finalHTML; - // We need this to be in the document since we'll dispatch events on it. - document.body.appendChild(container); + // We need this to be in the document since we'll dispatch events on it. + document.body.appendChild(container); - const appDiv = container.getElementsByTagName('div')[0]; - const firstSpan = appDiv.getElementsByTagName('span')[0]; - const secondSpan = appDiv.getElementsByTagName('span')[1]; - expect(firstSpan.textContent).toBe(''); - expect(secondSpan.textContent).toBe('World'); + const appDiv = container.getElementsByTagName('div')[0]; + const firstSpan = appDiv.getElementsByTagName('span')[0]; + const secondSpan = appDiv.getElementsByTagName('span')[1]; + expect(firstSpan.textContent).toBe(''); + expect(secondSpan.textContent).toBe('World'); - // On the client we don't have all data yet but we want to start - // hydrating anyway. - suspend1 = true; - suspend2 = true; - const root = ReactDOM.createRoot(container, {hydrate: true}); - root.render(); + // On the client we don't have all data yet but we want to start + // hydrating anyway. + suspend1 = true; + suspend2 = true; + const root = ReactDOM.createRoot(container, {hydrate: true}); + root.render(); - Scheduler.unstable_flushAll(); - jest.runAllTimers(); + Scheduler.unstable_flushAll(); + jest.runAllTimers(); - dispatchMouseEvent(appDiv, null); - dispatchMouseEvent(firstSpan, appDiv); - dispatchMouseEvent(secondSpan, firstSpan); + dispatchMouseEvent(appDiv, null); + dispatchMouseEvent(firstSpan, appDiv); + dispatchMouseEvent(secondSpan, firstSpan); - // Neither target is yet hydrated. - expect(ops).toEqual([]); + // Neither target is yet hydrated. + expect(ops).toEqual([]); - // Resolving the second promise so that rendering can complete. - suspend2 = false; - resolve2(); - await promise2; + // Resolving the second promise so that rendering can complete. + suspend2 = false; + resolve2(); + await promise2; - Scheduler.unstable_flushAll(); - jest.runAllTimers(); + Scheduler.unstable_flushAll(); + jest.runAllTimers(); - // We've unblocked the current hover target so we should be - // able to replay it now. - expect(ops).toEqual(['Hover Start Second']); + // We've unblocked the current hover target so we should be + // able to replay it now. + expect(ops).toEqual(['Hover Start Second']); - // Resolving the first promise has no effect now. - suspend1 = false; - resolve1(); - await promise1; + // Resolving the first promise has no effect now. + suspend1 = false; + resolve1(); + await promise1; - Scheduler.unstable_flushAll(); - jest.runAllTimers(); + Scheduler.unstable_flushAll(); + jest.runAllTimers(); - expect(ops).toEqual(['Hover Start Second']); + expect(ops).toEqual(['Hover Start Second']); - document.body.removeChild(container); - }); - } + document.body.removeChild(container); + }); + // @gate experimental it('finishes normal pri work before continuing to hydrate a retry', async () => { let suspend = false; let resolve; diff --git a/packages/react-dom/src/__tests__/ReactDOMServerSelectiveHydration-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerSelectiveHydration-test.internal.js index 4b02cd9c73..b50023c1d6 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerSelectiveHydration-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerSelectiveHydration-test.internal.js @@ -106,11 +106,7 @@ describe('ReactDOMServerSelectiveHydration', () => { Suspense = React.Suspense; }); - if (!__EXPERIMENTAL__) { - it("empty test so Jest doesn't complain", () => {}); - return; - } - + // @gate experimental it('hydrates the target boundary synchronously during a click', async () => { function Child({text}) { Scheduler.unstable_yieldValue(text); @@ -173,6 +169,7 @@ describe('ReactDOMServerSelectiveHydration', () => { document.body.removeChild(container); }); + // @gate experimental it('hydrates at higher pri if sync did not work first time', async () => { let suspend = false; let resolve; @@ -257,6 +254,7 @@ describe('ReactDOMServerSelectiveHydration', () => { document.body.removeChild(container); }); + // @gate experimental it('hydrates at higher pri for secondary discrete events', async () => { let suspend = false; let resolve; @@ -349,247 +347,249 @@ describe('ReactDOMServerSelectiveHydration', () => { document.body.removeChild(container); }); - if (__EXPERIMENTAL__) { - it('hydrates the target boundary synchronously during a click (flare)', async () => { - const usePress = require('react-interactions/events/press').usePress; + // @gate experimental + it('hydrates the target boundary synchronously during a click (flare)', async () => { + const usePress = require('react-interactions/events/press').usePress; - function Child({text}) { - Scheduler.unstable_yieldValue(text); - const listener = usePress({ - onPress() { - Scheduler.unstable_yieldValue('Clicked ' + text); - }, - }); + function Child({text}) { + Scheduler.unstable_yieldValue(text); + const listener = usePress({ + onPress() { + Scheduler.unstable_yieldValue('Clicked ' + text); + }, + }); - return {text}; + return {text}; + } + + function App() { + Scheduler.unstable_yieldValue('App'); + return ( +
+ + + + + + +
+ ); + } + + const finalHTML = ReactDOMServer.renderToString(); + + expect(Scheduler).toHaveYielded(['App', 'A', 'B']); + + const container = document.createElement('div'); + // We need this to be in the document since we'll dispatch events on it. + document.body.appendChild(container); + + container.innerHTML = finalHTML; + + const root = ReactDOM.createRoot(container, {hydrate: true}); + root.render(); + + // Nothing has been hydrated so far. + expect(Scheduler).toHaveYielded([]); + + const span = container.getElementsByTagName('span')[1]; + + const target = createEventTarget(span); + + // This should synchronously hydrate the root App and the second suspense + // boundary. + const preventDefault = jest.fn(); + target.virtualclick({preventDefault}); + + // The event should have been canceled because we called preventDefault. + expect(preventDefault).toHaveBeenCalled(); + + // We rendered App, B and then invoked the event without rendering A. + expect(Scheduler).toHaveYielded(['App', 'B', 'Clicked B']); + + // After continuing the scheduler, we finally hydrate A. + expect(Scheduler).toFlushAndYield(['A']); + + document.body.removeChild(container); + }); + + // @gate experimental + it('hydrates at higher pri if sync did not work first time (flare)', async () => { + const usePress = require('react-interactions/events/press').usePress; + let suspend = false; + let resolve; + const promise = new Promise(resolvePromise => (resolve = resolvePromise)); + + function Child({text}) { + if ((text === 'A' || text === 'D') && suspend) { + throw promise; } + Scheduler.unstable_yieldValue(text); - function App() { - Scheduler.unstable_yieldValue('App'); - return ( -
- - - - - - -
- ); + const listener = usePress({ + onPress() { + Scheduler.unstable_yieldValue('Clicked ' + text); + }, + }); + return {text}; + } + + function App() { + Scheduler.unstable_yieldValue('App'); + return ( +
+ + + + + + + + + + + + +
+ ); + } + + const finalHTML = ReactDOMServer.renderToString(); + + expect(Scheduler).toHaveYielded(['App', 'A', 'B', 'C', 'D']); + + const container = document.createElement('div'); + // We need this to be in the document since we'll dispatch events on it. + document.body.appendChild(container); + + container.innerHTML = finalHTML; + + const spanD = container.getElementsByTagName('span')[3]; + + suspend = true; + + // A and D will be suspended. We'll click on D which should take + // priority, after we unsuspend. + const root = ReactDOM.createRoot(container, {hydrate: true}); + root.render(); + + // Nothing has been hydrated so far. + expect(Scheduler).toHaveYielded([]); + + // This click target cannot be hydrated yet because it's suspended. + const result = dispatchClickEvent(spanD); + + expect(Scheduler).toHaveYielded(['App']); + + expect(result).toBe(true); + + // Continuing rendering will render B next. + expect(Scheduler).toFlushAndYield(['B', 'C']); + + suspend = false; + resolve(); + await promise; + + // After the click, we should prioritize D and the Click first, + // and only after that render A and C. + expect(Scheduler).toFlushAndYield(['D', 'Clicked D', 'A']); + + document.body.removeChild(container); + }); + + // @gate experimental + it('hydrates at higher pri for secondary discrete events (flare)', async () => { + const usePress = require('react-interactions/events/press').usePress; + let suspend = false; + let resolve; + const promise = new Promise(resolvePromise => (resolve = resolvePromise)); + + function Child({text}) { + if ((text === 'A' || text === 'D') && suspend) { + throw promise; } + Scheduler.unstable_yieldValue(text); - const finalHTML = ReactDOMServer.renderToString(); + const listener = usePress({ + onPress() { + Scheduler.unstable_yieldValue('Clicked ' + text); + }, + }); + return {text}; + } - expect(Scheduler).toHaveYielded(['App', 'A', 'B']); + function App() { + Scheduler.unstable_yieldValue('App'); + return ( +
+ + + + + + + + + + + + +
+ ); + } - const container = document.createElement('div'); - // We need this to be in the document since we'll dispatch events on it. - document.body.appendChild(container); + const finalHTML = ReactDOMServer.renderToString(); - container.innerHTML = finalHTML; + expect(Scheduler).toHaveYielded(['App', 'A', 'B', 'C', 'D']); - const root = ReactDOM.createRoot(container, {hydrate: true}); - root.render(); + const container = document.createElement('div'); + // We need this to be in the document since we'll dispatch events on it. + document.body.appendChild(container); - // Nothing has been hydrated so far. - expect(Scheduler).toHaveYielded([]); + container.innerHTML = finalHTML; - const span = container.getElementsByTagName('span')[1]; + const spanA = container.getElementsByTagName('span')[0]; + const spanC = container.getElementsByTagName('span')[2]; + const spanD = container.getElementsByTagName('span')[3]; - const target = createEventTarget(span); + suspend = true; - // This should synchronously hydrate the root App and the second suspense - // boundary. - const preventDefault = jest.fn(); - target.virtualclick({preventDefault}); + // A and D will be suspended. We'll click on D which should take + // priority, after we unsuspend. + const root = ReactDOM.createRoot(container, {hydrate: true}); + root.render(); - // The event should have been canceled because we called preventDefault. - expect(preventDefault).toHaveBeenCalled(); + // Nothing has been hydrated so far. + expect(Scheduler).toHaveYielded([]); - // We rendered App, B and then invoked the event without rendering A. - expect(Scheduler).toHaveYielded(['App', 'B', 'Clicked B']); + // This click target cannot be hydrated yet because the first is Suspended. + dispatchClickEvent(spanA); + dispatchClickEvent(spanC); + dispatchClickEvent(spanD); - // After continuing the scheduler, we finally hydrate A. - expect(Scheduler).toFlushAndYield(['A']); + expect(Scheduler).toHaveYielded(['App']); - document.body.removeChild(container); - }); + suspend = false; + resolve(); + await promise; - it('hydrates at higher pri if sync did not work first time (flare)', async () => { - const usePress = require('react-interactions/events/press').usePress; - let suspend = false; - let resolve; - const promise = new Promise(resolvePromise => (resolve = resolvePromise)); + // We should prioritize hydrating A, C and D first since we clicked in + // them. Only after they're done will we hydrate B. + expect(Scheduler).toFlushAndYield([ + 'A', + 'Clicked A', + 'C', + 'Clicked C', + 'D', + 'Clicked D', + // B should render last since it wasn't clicked. + 'B', + ]); - function Child({text}) { - if ((text === 'A' || text === 'D') && suspend) { - throw promise; - } - Scheduler.unstable_yieldValue(text); - - const listener = usePress({ - onPress() { - Scheduler.unstable_yieldValue('Clicked ' + text); - }, - }); - return {text}; - } - - function App() { - Scheduler.unstable_yieldValue('App'); - return ( -
- - - - - - - - - - - - -
- ); - } - - const finalHTML = ReactDOMServer.renderToString(); - - expect(Scheduler).toHaveYielded(['App', 'A', 'B', 'C', 'D']); - - const container = document.createElement('div'); - // We need this to be in the document since we'll dispatch events on it. - document.body.appendChild(container); - - container.innerHTML = finalHTML; - - const spanD = container.getElementsByTagName('span')[3]; - - suspend = true; - - // A and D will be suspended. We'll click on D which should take - // priority, after we unsuspend. - const root = ReactDOM.createRoot(container, {hydrate: true}); - root.render(); - - // Nothing has been hydrated so far. - expect(Scheduler).toHaveYielded([]); - - // This click target cannot be hydrated yet because it's suspended. - const result = dispatchClickEvent(spanD); - - expect(Scheduler).toHaveYielded(['App']); - - expect(result).toBe(true); - - // Continuing rendering will render B next. - expect(Scheduler).toFlushAndYield(['B', 'C']); - - suspend = false; - resolve(); - await promise; - - // After the click, we should prioritize D and the Click first, - // and only after that render A and C. - expect(Scheduler).toFlushAndYield(['D', 'Clicked D', 'A']); - - document.body.removeChild(container); - }); - - it('hydrates at higher pri for secondary discrete events (flare)', async () => { - const usePress = require('react-interactions/events/press').usePress; - let suspend = false; - let resolve; - const promise = new Promise(resolvePromise => (resolve = resolvePromise)); - - function Child({text}) { - if ((text === 'A' || text === 'D') && suspend) { - throw promise; - } - Scheduler.unstable_yieldValue(text); - - const listener = usePress({ - onPress() { - Scheduler.unstable_yieldValue('Clicked ' + text); - }, - }); - return {text}; - } - - function App() { - Scheduler.unstable_yieldValue('App'); - return ( -
- - - - - - - - - - - - -
- ); - } - - const finalHTML = ReactDOMServer.renderToString(); - - expect(Scheduler).toHaveYielded(['App', 'A', 'B', 'C', 'D']); - - const container = document.createElement('div'); - // We need this to be in the document since we'll dispatch events on it. - document.body.appendChild(container); - - container.innerHTML = finalHTML; - - const spanA = container.getElementsByTagName('span')[0]; - const spanC = container.getElementsByTagName('span')[2]; - const spanD = container.getElementsByTagName('span')[3]; - - suspend = true; - - // A and D will be suspended. We'll click on D which should take - // priority, after we unsuspend. - const root = ReactDOM.createRoot(container, {hydrate: true}); - root.render(); - - // Nothing has been hydrated so far. - expect(Scheduler).toHaveYielded([]); - - // This click target cannot be hydrated yet because the first is Suspended. - dispatchClickEvent(spanA); - dispatchClickEvent(spanC); - dispatchClickEvent(spanD); - - expect(Scheduler).toHaveYielded(['App']); - - suspend = false; - resolve(); - await promise; - - // We should prioritize hydrating A, C and D first since we clicked in - // them. Only after they're done will we hydrate B. - expect(Scheduler).toFlushAndYield([ - 'A', - 'Clicked A', - 'C', - 'Clicked C', - 'D', - 'Clicked D', - // B should render last since it wasn't clicked. - 'B', - ]); - - document.body.removeChild(container); - }); - } + document.body.removeChild(container); + }); + // @gate experimental it('hydrates the hovered targets as higher priority for continuous events', async () => { let suspend = false; let resolve; @@ -690,6 +690,7 @@ describe('ReactDOMServerSelectiveHydration', () => { document.body.removeChild(container); }); + // @gate experimental it('hydrates the last target path first for continuous events', async () => { let suspend = false; let resolve; @@ -775,6 +776,7 @@ describe('ReactDOMServerSelectiveHydration', () => { document.body.removeChild(container); }); + // @gate experimental it('hydrates the last explicitly hydrated target at higher priority', async () => { function Child({text}) { Scheduler.unstable_yieldValue(text); @@ -823,6 +825,7 @@ describe('ReactDOMServerSelectiveHydration', () => { expect(Scheduler).toFlushAndYield(['App', 'C', 'B', 'A']); }); + // @gate experimental it('hydrates before an update even if hydration moves away from it', async () => { function Child({text}) { Scheduler.unstable_yieldValue(text); diff --git a/packages/react-dom/src/__tests__/ReactDOMServerSuspense-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerSuspense-test.internal.js index 67fdc71bcc..cc3b563d0f 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerSuspense-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerSuspense-test.internal.js @@ -44,11 +44,6 @@ describe('ReactDOMServerSuspense', () => { resetModules(); }); - if (!__EXPERIMENTAL__) { - it("empty test so Jest doesn't complain", () => {}); - return; - } - function Text(props) { return
{props.text}
; } @@ -57,6 +52,7 @@ describe('ReactDOMServerSuspense', () => { throw new Promise(() => {}); } + // @gate experimental || www it('should render the children when no promise is thrown', async () => { const c = await serverRender(
@@ -71,6 +67,7 @@ describe('ReactDOMServerSuspense', () => { expect(e.textContent).toBe('Children'); }); + // @gate experimental || www it('should render the fallback when a promise thrown', async () => { const c = await serverRender(
@@ -85,6 +82,7 @@ describe('ReactDOMServerSuspense', () => { expect(e.textContent).toBe('Fallback'); }); + // @gate experimental || www it('should work with nested suspense components', async () => { const c = await serverRender(
@@ -105,6 +103,7 @@ describe('ReactDOMServerSuspense', () => { ); }); + // @gate experimental it('server renders a SuspenseList component and its children', async () => { const example = ( @@ -137,33 +136,36 @@ describe('ReactDOMServerSuspense', () => { expect(divB).toBe(divB2); }); - itThrowsWhenRendering( - 'a suspending component outside a Suspense node', - async render => { - await render( -
- - - -
, - 1, - ); - }, - 'Add a component higher in the tree', - ); + // TODO: Remove this in favor of @gate pragma + if (__EXPERIMENTAL__) { + itThrowsWhenRendering( + 'a suspending component outside a Suspense node', + async render => { + await render( +
+ + + +
, + 1, + ); + }, + 'Add a component higher in the tree', + ); - itThrowsWhenRendering( - 'a suspending component without a Suspense above', - async render => { - await render( -
- -
, - 1, - ); - }, - 'Add a component higher in the tree', - ); + itThrowsWhenRendering( + 'a suspending component without a Suspense above', + async render => { + await render( +
+ +
, + 1, + ); + }, + 'Add a component higher in the tree', + ); + } it('does not get confused by throwing null', () => { function Bad() { diff --git a/packages/react-dom/src/__tests__/renderSubtreeIntoContainer-test.js b/packages/react-dom/src/__tests__/renderSubtreeIntoContainer-test.js index 678f96de9f..99d1bd92e4 100644 --- a/packages/react-dom/src/__tests__/renderSubtreeIntoContainer-test.js +++ b/packages/react-dom/src/__tests__/renderSubtreeIntoContainer-test.js @@ -18,324 +18,315 @@ const renderSubtreeIntoContainer = require('react-dom') const ReactFeatureFlags = require('shared/ReactFeatureFlags'); -// Once this flag is always true, we should delete this test file -if (__EXPERIMENTAL__) { - describe('renderSubtreeIntoContainer', () => { - it('empty test', () => { - // Empty test to prevent "Your test suite must contain at least one test." error. - }); +describe('renderSubtreeIntoContainer', () => { + it('should pass context when rendering subtree elsewhere', () => { + const portal = document.createElement('div'); + + class Component extends React.Component { + static contextTypes = { + foo: PropTypes.string.isRequired, + }; + + render() { + return
{this.context.foo}
; + } + } + + class Parent extends React.Component { + static childContextTypes = { + foo: PropTypes.string.isRequired, + }; + + getChildContext() { + return { + foo: 'bar', + }; + } + + render() { + return null; + } + + componentDidMount() { + if (ReactFeatureFlags.warnUnstableRenderSubtreeIntoContainer) { + expect( + function() { + renderSubtreeIntoContainer(this, , portal); + }.bind(this), + ).toWarnDev( + 'ReactDOM.unstable_renderSubtreeIntoContainer() is deprecated and ' + + 'will be removed in a future major release. Consider using React Portals instead.', + ); + } else { + renderSubtreeIntoContainer(this, , portal); + } + } + } + + ReactTestUtils.renderIntoDocument(); + expect(portal.firstChild.innerHTML).toBe('bar'); }); -} else { - describe('renderSubtreeIntoContainer', () => { - it('should pass context when rendering subtree elsewhere', () => { - const portal = document.createElement('div'); - class Component extends React.Component { - static contextTypes = { - foo: PropTypes.string.isRequired, - }; + it('should throw if parentComponent is invalid', () => { + const portal = document.createElement('div'); - render() { - return
{this.context.foo}
; - } + class Component extends React.Component { + static contextTypes = { + foo: PropTypes.string.isRequired, + }; + + render() { + return
{this.context.foo}
; } + } - class Parent extends React.Component { - static childContextTypes = { - foo: PropTypes.string.isRequired, - }; + // ESLint is confused here and thinks Parent is unused, presumably because + // it is only used inside of the class body? + // eslint-disable-next-line no-unused-vars + class Parent extends React.Component { + static childContextTypes = { + foo: PropTypes.string.isRequired, + }; - getChildContext() { - return { - foo: 'bar', - }; - } - - render() { - return null; - } - - componentDidMount() { - if (ReactFeatureFlags.warnUnstableRenderSubtreeIntoContainer) { - expect( - function() { - renderSubtreeIntoContainer(this, , portal); - }.bind(this), - ).toWarnDev( - 'ReactDOM.unstable_renderSubtreeIntoContainer() is deprecated and ' + - 'will be removed in a future major release. Consider using React Portals instead.', - ); - } else { - renderSubtreeIntoContainer(this, , portal); - } - } - } - - ReactTestUtils.renderIntoDocument(); - expect(portal.firstChild.innerHTML).toBe('bar'); - }); - - it('should throw if parentComponent is invalid', () => { - const portal = document.createElement('div'); - - class Component extends React.Component { - static contextTypes = { - foo: PropTypes.string.isRequired, - }; - - render() { - return
{this.context.foo}
; - } - } - - // ESLint is confused here and thinks Parent is unused, presumably because - // it is only used inside of the class body? - // eslint-disable-next-line no-unused-vars - class Parent extends React.Component { - static childContextTypes = { - foo: PropTypes.string.isRequired, - }; - - getChildContext() { - return { - foo: 'bar', - }; - } - - render() { - return null; - } - - componentDidMount() { - expect(function() { - renderSubtreeIntoContainer(, , portal); - }).toThrowError('parentComponentmust be a valid React Component'); - } - } - }); - - it('should update context if it changes due to setState', () => { - const container = document.createElement('div'); - document.body.appendChild(container); - const portal = document.createElement('div'); - - class Component extends React.Component { - static contextTypes = { - foo: PropTypes.string.isRequired, - getFoo: PropTypes.func.isRequired, - }; - - render() { - return
{this.context.foo + '-' + this.context.getFoo()}
; - } - } - - class Parent extends React.Component { - static childContextTypes = { - foo: PropTypes.string.isRequired, - getFoo: PropTypes.func.isRequired, - }; - - state = { - bar: 'initial', - }; - - getChildContext() { - return { - foo: this.state.bar, - getFoo: () => this.state.bar, - }; - } - - render() { - return null; - } - - componentDidMount() { - renderSubtreeIntoContainer(this, , portal); - } - - componentDidUpdate() { - renderSubtreeIntoContainer(this, , portal); - } - } - - const instance = ReactDOM.render(, container); - expect(portal.firstChild.innerHTML).toBe('initial-initial'); - instance.setState({bar: 'changed'}); - expect(portal.firstChild.innerHTML).toBe('changed-changed'); - }); - - it('should update context if it changes due to re-render', () => { - const container = document.createElement('div'); - document.body.appendChild(container); - const portal = document.createElement('div'); - - class Component extends React.Component { - static contextTypes = { - foo: PropTypes.string.isRequired, - getFoo: PropTypes.func.isRequired, - }; - - render() { - return
{this.context.foo + '-' + this.context.getFoo()}
; - } - } - - class Parent extends React.Component { - static childContextTypes = { - foo: PropTypes.string.isRequired, - getFoo: PropTypes.func.isRequired, - }; - - getChildContext() { - return { - foo: this.props.bar, - getFoo: () => this.props.bar, - }; - } - - render() { - return null; - } - - componentDidMount() { - renderSubtreeIntoContainer(this, , portal); - } - - componentDidUpdate() { - renderSubtreeIntoContainer(this, , portal); - } - } - - ReactDOM.render(, container); - expect(portal.firstChild.innerHTML).toBe('initial-initial'); - ReactDOM.render(, container); - expect(portal.firstChild.innerHTML).toBe('changed-changed'); - }); - - it('should render portal with non-context-provider parent', () => { - const container = document.createElement('div'); - document.body.appendChild(container); - const portal = document.createElement('div'); - - class Parent extends React.Component { - render() { - return null; - } - - componentDidMount() { - renderSubtreeIntoContainer(this,
hello
, portal); - } - } - - ReactDOM.render(, container); - expect(portal.firstChild.innerHTML).toBe('hello'); - }); - - it('should get context through non-context-provider parent', () => { - const container = document.createElement('div'); - document.body.appendChild(container); - const portal = document.createElement('div'); - - class Parent extends React.Component { - render() { - return ; - } - getChildContext() { - return {value: this.props.value}; - } - static childContextTypes = { - value: PropTypes.string.isRequired, + getChildContext() { + return { + foo: 'bar', }; } - class Middle extends React.Component { - render() { - return null; - } - componentDidMount() { - renderSubtreeIntoContainer(this, , portal); - } + render() { + return null; } - class Child extends React.Component { - static contextTypes = { - value: PropTypes.string.isRequired, - }; - render() { - return
{this.context.value}
; - } + componentDidMount() { + expect(function() { + renderSubtreeIntoContainer(, , portal); + }).toThrowError('parentComponentmust be a valid React Component'); } - - ReactDOM.render(, container); - expect(portal.textContent).toBe('foo'); - }); - - it('should get context through middle non-context-provider layer', () => { - const container = document.createElement('div'); - document.body.appendChild(container); - const portal1 = document.createElement('div'); - const portal2 = document.createElement('div'); - - class Parent extends React.Component { - render() { - return null; - } - getChildContext() { - return {value: this.props.value}; - } - componentDidMount() { - renderSubtreeIntoContainer(this, , portal1); - } - static childContextTypes = { - value: PropTypes.string.isRequired, - }; - } - - class Middle extends React.Component { - render() { - return null; - } - componentDidMount() { - renderSubtreeIntoContainer(this, , portal2); - } - } - - class Child extends React.Component { - static contextTypes = { - value: PropTypes.string.isRequired, - }; - render() { - return
{this.context.value}
; - } - } - - ReactDOM.render(, container); - expect(portal2.textContent).toBe('foo'); - }); - - it('fails gracefully when mixing React 15 and 16', () => { - class C extends React.Component { - render() { - return
; - } - } - const c = ReactDOM.render(, document.createElement('div')); - // React 15 calls this: - // https://github.com/facebook/react/blob/77b71fc3c4/src/renderers/dom/client/ReactMount.js#L478-L479 - expect(() => { - c._reactInternalInstance._processChildContext({}); - }).toThrow( - __DEV__ - ? '_processChildContext is not available in React 16+. This likely ' + - 'means you have multiple copies of React and are attempting to nest ' + - 'a React 15 tree inside a React 16 tree using ' + - "unstable_renderSubtreeIntoContainer, which isn't supported. Try to " + - 'make sure you have only one copy of React (and ideally, switch to ' + - 'ReactDOM.createPortal).' - : "Cannot read property '_processChildContext' of undefined", - ); - }); + } }); -} + + it('should update context if it changes due to setState', () => { + const container = document.createElement('div'); + document.body.appendChild(container); + const portal = document.createElement('div'); + + class Component extends React.Component { + static contextTypes = { + foo: PropTypes.string.isRequired, + getFoo: PropTypes.func.isRequired, + }; + + render() { + return
{this.context.foo + '-' + this.context.getFoo()}
; + } + } + + class Parent extends React.Component { + static childContextTypes = { + foo: PropTypes.string.isRequired, + getFoo: PropTypes.func.isRequired, + }; + + state = { + bar: 'initial', + }; + + getChildContext() { + return { + foo: this.state.bar, + getFoo: () => this.state.bar, + }; + } + + render() { + return null; + } + + componentDidMount() { + renderSubtreeIntoContainer(this, , portal); + } + + componentDidUpdate() { + renderSubtreeIntoContainer(this, , portal); + } + } + + const instance = ReactDOM.render(, container); + expect(portal.firstChild.innerHTML).toBe('initial-initial'); + instance.setState({bar: 'changed'}); + expect(portal.firstChild.innerHTML).toBe('changed-changed'); + }); + + it('should update context if it changes due to re-render', () => { + const container = document.createElement('div'); + document.body.appendChild(container); + const portal = document.createElement('div'); + + class Component extends React.Component { + static contextTypes = { + foo: PropTypes.string.isRequired, + getFoo: PropTypes.func.isRequired, + }; + + render() { + return
{this.context.foo + '-' + this.context.getFoo()}
; + } + } + + class Parent extends React.Component { + static childContextTypes = { + foo: PropTypes.string.isRequired, + getFoo: PropTypes.func.isRequired, + }; + + getChildContext() { + return { + foo: this.props.bar, + getFoo: () => this.props.bar, + }; + } + + render() { + return null; + } + + componentDidMount() { + renderSubtreeIntoContainer(this, , portal); + } + + componentDidUpdate() { + renderSubtreeIntoContainer(this, , portal); + } + } + + ReactDOM.render(, container); + expect(portal.firstChild.innerHTML).toBe('initial-initial'); + ReactDOM.render(, container); + expect(portal.firstChild.innerHTML).toBe('changed-changed'); + }); + + it('should render portal with non-context-provider parent', () => { + const container = document.createElement('div'); + document.body.appendChild(container); + const portal = document.createElement('div'); + + class Parent extends React.Component { + render() { + return null; + } + + componentDidMount() { + renderSubtreeIntoContainer(this,
hello
, portal); + } + } + + ReactDOM.render(, container); + expect(portal.firstChild.innerHTML).toBe('hello'); + }); + + it('should get context through non-context-provider parent', () => { + const container = document.createElement('div'); + document.body.appendChild(container); + const portal = document.createElement('div'); + + class Parent extends React.Component { + render() { + return ; + } + getChildContext() { + return {value: this.props.value}; + } + static childContextTypes = { + value: PropTypes.string.isRequired, + }; + } + + class Middle extends React.Component { + render() { + return null; + } + componentDidMount() { + renderSubtreeIntoContainer(this, , portal); + } + } + + class Child extends React.Component { + static contextTypes = { + value: PropTypes.string.isRequired, + }; + render() { + return
{this.context.value}
; + } + } + + ReactDOM.render(, container); + expect(portal.textContent).toBe('foo'); + }); + + it('should get context through middle non-context-provider layer', () => { + const container = document.createElement('div'); + document.body.appendChild(container); + const portal1 = document.createElement('div'); + const portal2 = document.createElement('div'); + + class Parent extends React.Component { + render() { + return null; + } + getChildContext() { + return {value: this.props.value}; + } + componentDidMount() { + renderSubtreeIntoContainer(this, , portal1); + } + static childContextTypes = { + value: PropTypes.string.isRequired, + }; + } + + class Middle extends React.Component { + render() { + return null; + } + componentDidMount() { + renderSubtreeIntoContainer(this, , portal2); + } + } + + class Child extends React.Component { + static contextTypes = { + value: PropTypes.string.isRequired, + }; + render() { + return
{this.context.value}
; + } + } + + ReactDOM.render(, container); + expect(portal2.textContent).toBe('foo'); + }); + + it('fails gracefully when mixing React 15 and 16', () => { + class C extends React.Component { + render() { + return
; + } + } + const c = ReactDOM.render(, document.createElement('div')); + // React 15 calls this: + // https://github.com/facebook/react/blob/77b71fc3c4/src/renderers/dom/client/ReactMount.js#L478-L479 + expect(() => { + c._reactInternalInstance._processChildContext({}); + }).toThrow( + __DEV__ + ? '_processChildContext is not available in React 16+. This likely ' + + 'means you have multiple copies of React and are attempting to nest ' + + 'a React 15 tree inside a React 16 tree using ' + + "unstable_renderSubtreeIntoContainer, which isn't supported. Try to " + + 'make sure you have only one copy of React (and ideally, switch to ' + + 'ReactDOM.createPortal).' + : "Cannot read property '_processChildContext' of undefined", + ); + }); +}); diff --git a/packages/react-dom/src/events/__tests__/DOMModernPluginEventSystem-test.internal.js b/packages/react-dom/src/events/__tests__/DOMModernPluginEventSystem-test.internal.js index 44c4ab26fe..f73c32580d 100644 --- a/packages/react-dom/src/events/__tests__/DOMModernPluginEventSystem-test.internal.js +++ b/packages/react-dom/src/events/__tests__/DOMModernPluginEventSystem-test.internal.js @@ -1097,11 +1097,7 @@ describe('DOMModernPluginEventSystem', () => { ReactTestUtils = require('react-dom/test-utils'); }); - if (!__EXPERIMENTAL__) { - it("empty test so Jest doesn't complain", () => {}); - return; - } - + // @gate experimental it('should create the same event listener map', () => { const listenerMaps = []; @@ -1119,6 +1115,7 @@ describe('DOMModernPluginEventSystem', () => { expect(listenerMaps[0]).toEqual(listenerMaps[1]); }); + // @gate experimental it('can render correctly with the ReactDOMServer', () => { const clickEvent = jest.fn(); @@ -1136,6 +1133,7 @@ describe('DOMModernPluginEventSystem', () => { expect(output).toBe(`
Hello world
`); }); + // @gate experimental it('can render correctly with the ReactDOMServer hydration', () => { const clickEvent = jest.fn(); const spanRef = React.createRef(); @@ -1164,6 +1162,7 @@ describe('DOMModernPluginEventSystem', () => { expect(clickEvent).toHaveBeenCalledTimes(1); }); + // @gate experimental it('should correctly work for a basic "click" listener', () => { let log = []; const clickEvent = jest.fn(event => { @@ -1272,6 +1271,7 @@ describe('DOMModernPluginEventSystem', () => { expect(clickEvent2).toBeCalledTimes(1); }); + // @gate experimental it('should correctly work for setting and clearing a basic "click" listener', () => { const clickEvent = jest.fn(); const divRef = React.createRef(); @@ -1315,6 +1315,7 @@ describe('DOMModernPluginEventSystem', () => { expect(clickEvent).toBeCalledTimes(0); }); + // @gate experimental it('should handle the target being a text node', () => { const clickEvent = jest.fn(); const buttonRef = React.createRef(); @@ -1337,6 +1338,7 @@ describe('DOMModernPluginEventSystem', () => { expect(clickEvent).toBeCalledTimes(1); }); + // @gate experimental it('handle propagation of click events', () => { const buttonRef = React.createRef(); const divRef = React.createRef(); @@ -1390,6 +1392,7 @@ describe('DOMModernPluginEventSystem', () => { expect(log[3]).toEqual(['bubble', buttonElement]); }); + // @gate experimental it('handle propagation of click events mixed with onClick events', () => { const buttonRef = React.createRef(); const divRef = React.createRef(); @@ -1442,6 +1445,7 @@ describe('DOMModernPluginEventSystem', () => { expect(log[5]).toEqual(['bubble', buttonElement]); }); + // @gate experimental it('should correctly work for a basic "click" listener on the outer target', () => { const log = []; const clickEvent = jest.fn(event => { @@ -1507,6 +1511,7 @@ describe('DOMModernPluginEventSystem', () => { expect(clickEvent).toBeCalledTimes(2); }); + // @gate experimental it('should correctly handle many nested target listeners', () => { const buttonRef = React.createRef(); const targetListener1 = jest.fn(); @@ -1572,6 +1577,7 @@ describe('DOMModernPluginEventSystem', () => { expect(targetListener4).toHaveBeenCalledTimes(2); }); + // @gate experimental it('should correctly handle stopPropagation corrrectly for target events', () => { const buttonRef = React.createRef(); const divRef = React.createRef(); @@ -1605,6 +1611,7 @@ describe('DOMModernPluginEventSystem', () => { expect(clickEvent).toHaveBeenCalledTimes(0); }); + // @gate experimental it('should correctly handle stopPropagation corrrectly for many target events', () => { const buttonRef = React.createRef(); const targetListerner1 = jest.fn(e => e.stopPropagation()); @@ -1639,6 +1646,7 @@ describe('DOMModernPluginEventSystem', () => { expect(targetListerner4).toHaveBeenCalledTimes(1); }); + // @gate experimental it('should correctly handle stopPropagation for mixed capture/bubbling target listeners', () => { const buttonRef = React.createRef(); const targetListerner1 = jest.fn(e => e.stopPropagation()); @@ -1725,6 +1733,7 @@ describe('DOMModernPluginEventSystem', () => { expect(log).toEqual([{counter: 1}]); }); + // @gate experimental it('should correctly work for a basic "click" listener that upgrades', () => { const clickEvent = jest.fn(); const buttonRef = React.createRef(); @@ -1776,6 +1785,7 @@ describe('DOMModernPluginEventSystem', () => { expect(clickEvent).toHaveBeenCalledTimes(1); }); + // @gate experimental it('should correctly work for a basic "click" listener that upgrades #2', () => { const clickEvent = jest.fn(); const buttonRef = React.createRef(); @@ -1827,6 +1837,7 @@ describe('DOMModernPluginEventSystem', () => { expect(clickEvent).toHaveBeenCalledTimes(1); }); + // @gate experimental it('should correctly work for a basic "click" window listener', () => { const log = []; const clickEvent = jest.fn(event => { @@ -1878,6 +1889,7 @@ describe('DOMModernPluginEventSystem', () => { expect(clickEvent).toBeCalledTimes(2); }); + // @gate experimental it('handle propagation of click events on the window', () => { const buttonRef = React.createRef(); const divRef = React.createRef(); @@ -1937,6 +1949,7 @@ describe('DOMModernPluginEventSystem', () => { expect(log[5]).toEqual(['bubble', window]); }); + // @gate experimental it('should correctly handle stopPropagation for mixed listeners', () => { const buttonRef = React.createRef(); const rootListerner1 = jest.fn(e => e.stopPropagation()); @@ -1975,6 +1988,7 @@ describe('DOMModernPluginEventSystem', () => { expect(rootListerner2).toHaveBeenCalledTimes(0); }); + // @gate experimental it('should correctly handle stopPropagation for delegated listeners', () => { const buttonRef = React.createRef(); const rootListerner1 = jest.fn(e => e.stopPropagation()); @@ -2014,6 +2028,7 @@ describe('DOMModernPluginEventSystem', () => { expect(rootListerner4).toHaveBeenCalledTimes(0); }); + // @gate experimental it('handle propagation of click events on the window and document', () => { const buttonRef = React.createRef(); const divRef = React.createRef(); @@ -2079,6 +2094,7 @@ describe('DOMModernPluginEventSystem', () => { expect(log[7]).toEqual(['bubble', window]); }); + // @gate experimental it('handles propagation of custom user events', () => { const buttonRef = React.createRef(); const divRef = React.createRef(); @@ -2153,6 +2169,7 @@ describe('DOMModernPluginEventSystem', () => { expect(log[5]).toEqual(['bubble', buttonElement]); }); + // @gate experimental it('beforeblur and afterblur are called after a focused element is unmounted', () => { const log = []; // We have to persist here because we want to read relatedTarget later. @@ -2202,6 +2219,7 @@ describe('DOMModernPluginEventSystem', () => { expect(log).toEqual(['beforeblur', 'afterblur']); }); + // @gate experimental it('beforeblur and afterblur are called after a nested focused element is unmounted', () => { const log = []; // We have to persist here because we want to read relatedTarget later. @@ -2346,6 +2364,7 @@ describe('DOMModernPluginEventSystem', () => { ReactDOMServer = require('react-dom/server'); }); + // @gate experimental it('handle propagation of click events on a scope', () => { const buttonRef = React.createRef(); const log = []; @@ -2390,6 +2409,7 @@ describe('DOMModernPluginEventSystem', () => { ]); }); + // @gate experimental it('handle mixed propagation of click events on a scope', () => { const buttonRef = React.createRef(); const divRef = React.createRef(); @@ -2464,6 +2484,7 @@ describe('DOMModernPluginEventSystem', () => { ]); }); + // @gate experimental it('should not handle the target being a dangling text node within a scope', () => { const clickEvent = jest.fn(); const buttonRef = React.createRef(); @@ -2495,6 +2516,7 @@ describe('DOMModernPluginEventSystem', () => { expect(clickEvent).toBeCalledTimes(0); }); + // @gate experimental it('handle stopPropagation (inner) correctly between scopes', () => { const buttonRef = React.createRef(); const outerOnClick = jest.fn(); @@ -2531,6 +2553,7 @@ describe('DOMModernPluginEventSystem', () => { expect(outerOnClick).toHaveBeenCalledTimes(0); }); + // @gate experimental it('handle stopPropagation (outer) correctly between scopes', () => { const buttonRef = React.createRef(); const outerOnClick = jest.fn(e => e.stopPropagation()); @@ -2567,6 +2590,8 @@ describe('DOMModernPluginEventSystem', () => { expect(outerOnClick).toHaveBeenCalledTimes(1); }); + // @gate experimental + it('handle stopPropagation (inner and outer) correctly between scopes', () => { const buttonRef = React.createRef(); const onClick = jest.fn(e => e.stopPropagation()); diff --git a/packages/react-dom/src/events/__tests__/DeprecatedDOMEventResponderSystem-test.internal.js b/packages/react-dom/src/events/__tests__/DeprecatedDOMEventResponderSystem-test.internal.js index 932bc4e990..9c57ac6531 100644 --- a/packages/react-dom/src/events/__tests__/DeprecatedDOMEventResponderSystem-test.internal.js +++ b/packages/react-dom/src/events/__tests__/DeprecatedDOMEventResponderSystem-test.internal.js @@ -66,11 +66,6 @@ function dispatchClickEvent(element) { describe('DOMEventResponderSystem', () => { let container; - if (!__EXPERIMENTAL__) { - it("empty test so Jest doesn't complain", () => {}); - return; - } - beforeEach(() => { jest.resetModules(); ReactFeatureFlags = require('shared/ReactFeatureFlags'); @@ -89,6 +84,7 @@ describe('DOMEventResponderSystem', () => { container = null; }); + // @gate experimental it('can mount and render correctly with the ReactTestRenderer', () => { jest.resetModules(); ReactFeatureFlags = require('shared/ReactFeatureFlags'); @@ -106,6 +102,7 @@ describe('DOMEventResponderSystem', () => { expect(renderer).toMatchRenderedOutput(
Hello world
); }); + // @gate experimental it('can render correctly with the ReactDOMServer', () => { const TestResponder = createEventResponder({}); @@ -118,6 +115,7 @@ describe('DOMEventResponderSystem', () => { expect(output).toBe(`
Hello world
`); }); + // @gate experimental it('can render correctly with the ReactDOMServer hydration', () => { const onEvent = jest.fn(); const TestResponder = createEventResponder({ @@ -147,6 +145,7 @@ describe('DOMEventResponderSystem', () => { expect(onEvent).toHaveBeenCalledTimes(1); }); + // @gate experimental it('the event responders should fire on click event', () => { let eventResponderFiredCount = 0; const eventLog = []; @@ -202,6 +201,7 @@ describe('DOMEventResponderSystem', () => { expect(eventResponderFiredCount).toBe(2); }); + // @gate experimental it('the event responders should fire on click event (passive events forced)', () => { // JSDOM does not support passive events, so this manually overrides the value to be true const checkPassiveEvents = require('react-dom/src/events/checkPassiveEvents'); @@ -246,6 +246,7 @@ describe('DOMEventResponderSystem', () => { ]); }); + // @gate experimental it('nested event responders should not fire multiple times', () => { let eventResponderFiredCount = 0; let eventLog = []; @@ -327,6 +328,7 @@ describe('DOMEventResponderSystem', () => { ]); }); + // @gate experimental it('nested event responders should fire in the correct order', () => { let eventLog = []; const buttonRef = React.createRef(); @@ -390,6 +392,7 @@ describe('DOMEventResponderSystem', () => { expect(eventLog).toEqual(['B [bubble]', 'A [bubble]']); }); + // @gate experimental it('nested event responders should fire in the correct order #2', () => { const eventLog = []; const buttonRef = React.createRef(); @@ -426,6 +429,7 @@ describe('DOMEventResponderSystem', () => { expect(eventLog).toEqual(['B [bubble]']); }); + // @gate experimental it('custom event dispatching for click -> magicClick works', () => { const eventLog = []; const buttonRef = React.createRef(); @@ -472,6 +476,7 @@ describe('DOMEventResponderSystem', () => { expect(eventLog).toEqual(['magic event fired', 'magicclick', 'bubble']); }); + // @gate experimental it('the event responder onMount() function should fire', () => { let onMountFired = 0; @@ -505,6 +510,7 @@ describe('DOMEventResponderSystem', () => { expect(onMountFired).toEqual(2); }); + // @gate experimental it('the event responder onUnmount() function should fire', () => { let onUnmountFired = 0; @@ -551,6 +557,7 @@ describe('DOMEventResponderSystem', () => { expect(onUnmountFired).toEqual(4); }); + // @gate experimental it('the event responder onUnmount() function should fire using scopes', () => { let onUnmountFired = 0; @@ -598,6 +605,7 @@ describe('DOMEventResponderSystem', () => { expect(onUnmountFired).toEqual(4); }); + // @gate experimental it('the event responder onUnmount() function should fire with state', () => { let counter = 0; @@ -621,6 +629,7 @@ describe('DOMEventResponderSystem', () => { expect(counter).toEqual(5); }); + // @gate experimental it('the event responder target listeners should correctly fire for only their events', () => { let clickEventComponent1Fired = 0; let clickEventComponent2Fired = 0; @@ -682,6 +691,7 @@ describe('DOMEventResponderSystem', () => { ]); }); + // @gate experimental it('the event responder system should warn on accessing invalid properties', () => { const TestResponder = createEventResponder({ targetEventTypes: ['click'], @@ -747,6 +757,7 @@ describe('DOMEventResponderSystem', () => { expect(container.innerHTML).toBe(''); }); + // @gate experimental it('should work with event responder hooks', () => { const buttonRef = React.createRef(); const eventLogs = []; @@ -839,6 +850,7 @@ describe('DOMEventResponderSystem', () => { expect(log).toEqual([{counter: 1}]); }); + // @gate experimental it('should correctly pass through event properties', () => { const timeStamps = []; const ref = React.createRef(); @@ -903,6 +915,7 @@ describe('DOMEventResponderSystem', () => { ]); }); + // @gate experimental it('should not propagate target events through portals by default', () => { const buttonRef = React.createRef(); const onEvent = jest.fn(); @@ -926,6 +939,7 @@ describe('DOMEventResponderSystem', () => { expect(onEvent).not.toBeCalled(); }); + // @gate experimental it('should propagate target events through portals when enabled', () => { const buttonRef = React.createRef(); const onEvent = jest.fn(); @@ -950,6 +964,7 @@ describe('DOMEventResponderSystem', () => { expect(onEvent).toBeCalled(); }); + // @gate experimental it('event upgrading should work correctly', () => { let eventResponderFiredCount = 0; const buttonRef = React.createRef(); diff --git a/packages/react-interactions/events/src/dom/__tests__/ContextMenu-test.internal.js b/packages/react-interactions/events/src/dom/__tests__/ContextMenu-test.internal.js index affcf8e6fb..379203bf33 100644 --- a/packages/react-interactions/events/src/dom/__tests__/ContextMenu-test.internal.js +++ b/packages/react-interactions/events/src/dom/__tests__/ContextMenu-test.internal.js @@ -28,8 +28,13 @@ function initializeModules(hasPointerEvents) { ReactFeatureFlags.enableDeprecatedFlareAPI = true; React = require('react'); ReactDOM = require('react-dom'); - useContextMenu = require('react-interactions/events/context-menu') - .useContextMenu; + + // TODO: This import throws outside of experimental mode. Figure out better + // strategy for gated imports. + if (__EXPERIMENTAL__) { + useContextMenu = require('react-interactions/events/context-menu') + .useContextMenu; + } } const forcePointerEvents = true; @@ -38,11 +43,6 @@ const table = [[forcePointerEvents], [!forcePointerEvents]]; describe.each(table)('ContextMenu responder', hasPointerEvents => { let container; - if (!__EXPERIMENTAL__) { - it("empty test so Jest doesn't complain", () => {}); - return; - } - beforeEach(() => { initializeModules(hasPointerEvents); container = document.createElement('div'); @@ -56,6 +56,7 @@ describe.each(table)('ContextMenu responder', hasPointerEvents => { }); describe('all platforms', () => { + // @gate experimental it('mouse right-click', () => { const onContextMenu = jest.fn(); const preventDefault = jest.fn(); @@ -79,6 +80,7 @@ describe.each(table)('ContextMenu responder', hasPointerEvents => { ); }); + // @gate experimental it('touch long-press', () => { const onContextMenu = jest.fn(); const preventDefault = jest.fn(); @@ -102,6 +104,7 @@ describe.each(table)('ContextMenu responder', hasPointerEvents => { ); }); + // @gate experimental it('"disabled" is true', () => { const onContextMenu = jest.fn(); const ref = React.createRef(); @@ -119,6 +122,7 @@ describe.each(table)('ContextMenu responder', hasPointerEvents => { expect(onContextMenu).toHaveBeenCalledTimes(0); }); + // @gate experimental it('"preventDefault" is false', () => { const preventDefault = jest.fn(); const onContextMenu = jest.fn(); @@ -149,6 +153,7 @@ describe.each(table)('ContextMenu responder', hasPointerEvents => { platform.clear(); }); + // @gate experimental it('mouse modified left-click', () => { const onContextMenu = jest.fn(); const ref = React.createRef(); @@ -181,6 +186,7 @@ describe.each(table)('ContextMenu responder', hasPointerEvents => { platform.clear(); }); + // @gate experimental it('mouse modified left-click', () => { const onContextMenu = jest.fn(); const ref = React.createRef(); diff --git a/packages/react-interactions/events/src/dom/__tests__/Focus-test.internal.js b/packages/react-interactions/events/src/dom/__tests__/Focus-test.internal.js index 0b751d5c06..162e752a85 100644 --- a/packages/react-interactions/events/src/dom/__tests__/Focus-test.internal.js +++ b/packages/react-interactions/events/src/dom/__tests__/Focus-test.internal.js @@ -28,8 +28,13 @@ function initializeModules(hasPointerEvents) { ReactFeatureFlags.enableDeprecatedFlareAPI = true; React = require('react'); ReactDOM = require('react-dom'); - FocusResponder = require('react-interactions/events/focus').FocusResponder; - useFocus = require('react-interactions/events/focus').useFocus; + + // TODO: This import throws outside of experimental mode. Figure out better + // strategy for gated imports. + if (__EXPERIMENTAL__) { + FocusResponder = require('react-interactions/events/focus').FocusResponder; + useFocus = require('react-interactions/events/focus').useFocus; + } } const forcePointerEvents = true; @@ -38,11 +43,6 @@ const table = [[forcePointerEvents], [!forcePointerEvents]]; describe.each(table)('Focus responder', hasPointerEvents => { let container; - if (!__EXPERIMENTAL__) { - it("empty test so Jest doesn't complain", () => {}); - return; - } - beforeEach(() => { initializeModules(hasPointerEvents); container = document.createElement('div'); @@ -58,7 +58,7 @@ describe.each(table)('Focus responder', hasPointerEvents => { describe('disabled', () => { let onBlur, onFocus, ref; - beforeEach(() => { + const componentInit = () => { onBlur = jest.fn(); onFocus = jest.fn(); ref = React.createRef(); @@ -71,9 +71,11 @@ describe.each(table)('Focus responder', hasPointerEvents => { return
; }; ReactDOM.render(, container); - }); + }; + // @gate experimental it('does not call callbacks', () => { + componentInit(); const target = createEventTarget(ref.current); target.focus(); target.blur(); @@ -85,7 +87,7 @@ describe.each(table)('Focus responder', hasPointerEvents => { describe('onBlur', () => { let onBlur, ref; - beforeEach(() => { + const componentInit = () => { onBlur = jest.fn(); ref = React.createRef(); const Component = () => { @@ -95,9 +97,11 @@ describe.each(table)('Focus responder', hasPointerEvents => { return
; }; ReactDOM.render(, container); - }); + }; + // @gate experimental it('is called after "blur" event', () => { + componentInit(); const target = createEventTarget(ref.current); target.focus(); target.blur(); @@ -125,21 +129,25 @@ describe.each(table)('Focus responder', hasPointerEvents => { ReactDOM.render(, container); }; - beforeEach(componentInit); - + // @gate experimental it('is called after "focus" event', () => { + componentInit(); const target = createEventTarget(ref.current); target.focus(); expect(onFocus).toHaveBeenCalledTimes(1); }); + // @gate experimental it('is not called if descendants of target receive focus', () => { + componentInit(); const target = createEventTarget(innerRef.current); target.focus(); expect(onFocus).not.toBeCalled(); }); + // @gate experimental it('is called with the correct pointerType: mouse', () => { + componentInit(); const target = createEventTarget(ref.current); target.pointerdown(); target.pointerup(); @@ -149,7 +157,9 @@ describe.each(table)('Focus responder', hasPointerEvents => { ); }); + // @gate experimental it('is called with the correct pointerType: touch', () => { + componentInit(); const target = createEventTarget(ref.current); const pointerType = 'touch'; target.pointerdown({pointerType}); @@ -161,7 +171,9 @@ describe.each(table)('Focus responder', hasPointerEvents => { }); if (hasPointerEvents) { + // @gate experimental it('is called with the correct pointerType: pen', () => { + componentInit(); const target = createEventTarget(ref.current); const pointerType = 'pen'; target.pointerdown({pointerType}); @@ -173,7 +185,9 @@ describe.each(table)('Focus responder', hasPointerEvents => { }); } + // @gate experimental it('is called with the correct pointerType using a keyboard', () => { + componentInit(); const target = createEventTarget(ref.current); target.keydown({key: 'LeftArrow'}); target.focus(); @@ -183,6 +197,7 @@ describe.each(table)('Focus responder', hasPointerEvents => { ); }); + // @gate experimental it('is called with the correct pointerType using Tab+altKey on Mac', () => { platform.set('mac'); jest.resetModules(); @@ -207,7 +222,7 @@ describe.each(table)('Focus responder', hasPointerEvents => { describe('onFocusChange', () => { let onFocusChange, ref, innerRef; - beforeEach(() => { + const componentInit = () => { onFocusChange = jest.fn(); ref = React.createRef(); innerRef = React.createRef(); @@ -222,9 +237,11 @@ describe.each(table)('Focus responder', hasPointerEvents => { ); }; ReactDOM.render(, container); - }); + }; + // @gate experimental it('is called after "blur" and "focus" events', () => { + componentInit(); const target = createEventTarget(ref.current); target.focus(); expect(onFocusChange).toHaveBeenCalledTimes(1); @@ -234,7 +251,9 @@ describe.each(table)('Focus responder', hasPointerEvents => { expect(onFocusChange).toHaveBeenCalledWith(false); }); + // @gate experimental it('is not called after "blur" and "focus" events on descendants', () => { + componentInit(); const target = createEventTarget(innerRef.current); target.focus(); expect(onFocusChange).toHaveBeenCalledTimes(0); @@ -246,7 +265,7 @@ describe.each(table)('Focus responder', hasPointerEvents => { describe('onFocusVisibleChange', () => { let onFocusVisibleChange, ref, innerRef; - beforeEach(() => { + const componentInit = () => { onFocusVisibleChange = jest.fn(); ref = React.createRef(); innerRef = React.createRef(); @@ -261,9 +280,11 @@ describe.each(table)('Focus responder', hasPointerEvents => { ); }; ReactDOM.render(, container); - }); + }; + // @gate experimental it('is called after "focus" and "blur" if keyboard navigation is active', () => { + componentInit(); const target = createEventTarget(ref.current); const containerTarget = createEventTarget(container); // use keyboard first @@ -276,7 +297,9 @@ describe.each(table)('Focus responder', hasPointerEvents => { expect(onFocusVisibleChange).toHaveBeenCalledWith(false); }); + // @gate experimental it('is called if non-keyboard event is dispatched on target previously focused with keyboard', () => { + componentInit(); const target = createEventTarget(ref.current); const containerTarget = createEventTarget(container); // use keyboard first @@ -293,7 +316,9 @@ describe.each(table)('Focus responder', hasPointerEvents => { expect(onFocusVisibleChange).toHaveBeenCalledTimes(2); }); + // @gate experimental it('is not called after "focus" and "blur" events without keyboard', () => { + componentInit(); const target = createEventTarget(ref.current); const containerTarget = createEventTarget(container); target.pointerdown(); @@ -303,7 +328,9 @@ describe.each(table)('Focus responder', hasPointerEvents => { expect(onFocusVisibleChange).toHaveBeenCalledTimes(0); }); + // @gate experimental it('is not called after "blur" and "focus" events on descendants', () => { + componentInit(); const innerTarget = createEventTarget(innerRef.current); const containerTarget = createEventTarget(container); containerTarget.keydown({key: 'Tab'}); @@ -315,6 +342,7 @@ describe.each(table)('Focus responder', hasPointerEvents => { }); describe('nested Focus components', () => { + // @gate experimental it('propagates events in the correct order', () => { const events = []; const innerRef = React.createRef(); @@ -367,6 +395,7 @@ describe.each(table)('Focus responder', hasPointerEvents => { }); }); + // @gate experimental it('expect displayName to show up for event component', () => { expect(FocusResponder.displayName).toBe('Focus'); }); diff --git a/packages/react-interactions/events/src/dom/__tests__/FocusWithin-test.internal.js b/packages/react-interactions/events/src/dom/__tests__/FocusWithin-test.internal.js index 8efb1a37ce..76b065a1ee 100644 --- a/packages/react-interactions/events/src/dom/__tests__/FocusWithin-test.internal.js +++ b/packages/react-interactions/events/src/dom/__tests__/FocusWithin-test.internal.js @@ -25,10 +25,15 @@ const initializeModules = hasPointerEvents => { ReactFeatureFlags.enableDeprecatedFlareAPI = true; React = require('react'); ReactDOM = require('react-dom'); - FocusWithinResponder = require('react-interactions/events/focus') - .FocusWithinResponder; - useFocusWithin = require('react-interactions/events/focus').useFocusWithin; Scheduler = require('scheduler'); + + // TODO: This import throws outside of experimental mode. Figure out better + // strategy for gated imports. + if (__EXPERIMENTAL__) { + FocusWithinResponder = require('react-interactions/events/focus') + .FocusWithinResponder; + useFocusWithin = require('react-interactions/events/focus').useFocusWithin; + } }; const forcePointerEvents = true; @@ -37,11 +42,6 @@ const table = [[forcePointerEvents], [!forcePointerEvents]]; describe.each(table)('FocusWithin responder', hasPointerEvents => { let container; - if (!__EXPERIMENTAL__) { - it("empty test so Jest doesn't complain", () => {}); - return; - } - beforeEach(() => { initializeModules(); container = document.createElement('div'); @@ -57,7 +57,7 @@ describe.each(table)('FocusWithin responder', hasPointerEvents => { describe('disabled', () => { let onFocusWithinChange, onFocusWithinVisibleChange, ref; - beforeEach(() => { + const componentInit = () => { onFocusWithinChange = jest.fn(); onFocusWithinVisibleChange = jest.fn(); ref = React.createRef(); @@ -70,9 +70,11 @@ describe.each(table)('FocusWithin responder', hasPointerEvents => { return
; }; ReactDOM.render(, container); - }); + }; + // @gate experimental it('prevents custom events being dispatched', () => { + componentInit(); const target = createEventTarget(ref.current); target.focus(); target.blur(); @@ -96,15 +98,17 @@ describe.each(table)('FocusWithin responder', hasPointerEvents => { ); }; - beforeEach(() => { + const componentInit = () => { onFocusWithinChange = jest.fn(); ref = React.createRef(); innerRef = React.createRef(); innerRef2 = React.createRef(); ReactDOM.render(, container); - }); + }; + // @gate experimental it('is called after "blur" and "focus" events on focus target', () => { + componentInit(); const target = createEventTarget(ref.current); target.focus(); expect(onFocusWithinChange).toHaveBeenCalledTimes(1); @@ -114,7 +118,9 @@ describe.each(table)('FocusWithin responder', hasPointerEvents => { expect(onFocusWithinChange).toHaveBeenCalledWith(false); }); + // @gate experimental it('is called after "blur" and "focus" events on descendants', () => { + componentInit(); const target = createEventTarget(innerRef.current); target.focus(); expect(onFocusWithinChange).toHaveBeenCalledTimes(1); @@ -124,7 +130,9 @@ describe.each(table)('FocusWithin responder', hasPointerEvents => { expect(onFocusWithinChange).toHaveBeenCalledWith(false); }); + // @gate experimental it('is only called once when focus moves within and outside the subtree', () => { + componentInit(); const node = ref.current; const innerNode1 = innerRef.current; const innerNode2 = innerRef.current; @@ -165,15 +173,17 @@ describe.each(table)('FocusWithin responder', hasPointerEvents => { ); }; - beforeEach(() => { + const componentInit = () => { onFocusWithinVisibleChange = jest.fn(); ref = React.createRef(); innerRef = React.createRef(); innerRef2 = React.createRef(); ReactDOM.render(, container); - }); + }; + // @gate experimental it('is called after "focus" and "blur" on focus target if keyboard was used', () => { + componentInit(); const target = createEventTarget(ref.current); const containerTarget = createEventTarget(container); // use keyboard first @@ -186,7 +196,9 @@ describe.each(table)('FocusWithin responder', hasPointerEvents => { expect(onFocusWithinVisibleChange).toHaveBeenCalledWith(false); }); + // @gate experimental it('is called after "focus" and "blur" on descendants if keyboard was used', () => { + componentInit(); const innerTarget = createEventTarget(innerRef.current); const containerTarget = createEventTarget(container); // use keyboard first @@ -199,7 +211,9 @@ describe.each(table)('FocusWithin responder', hasPointerEvents => { expect(onFocusWithinVisibleChange).toHaveBeenCalledWith(false); }); + // @gate experimental it('is called if non-keyboard event is dispatched on target previously focused with keyboard', () => { + componentInit(); const node = ref.current; const innerNode1 = innerRef.current; const innerNode2 = innerRef2.current; @@ -235,7 +249,9 @@ describe.each(table)('FocusWithin responder', hasPointerEvents => { expect(onFocusWithinVisibleChange).toHaveBeenCalledTimes(4); }); + // @gate experimental it('is not called after "focus" and "blur" events without keyboard', () => { + componentInit(); const innerTarget = createEventTarget(innerRef.current); innerTarget.pointerdown(); innerTarget.pointerup(); @@ -243,7 +259,9 @@ describe.each(table)('FocusWithin responder', hasPointerEvents => { expect(onFocusWithinVisibleChange).toHaveBeenCalledTimes(0); }); + // @gate experimental it('is only called once when focus moves within and outside the subtree', () => { + componentInit(); const node = ref.current; const innerNode1 = innerRef.current; const innerNode2 = innerRef2.current; @@ -280,6 +298,7 @@ describe.each(table)('FocusWithin responder', hasPointerEvents => { innerRef2 = React.createRef(); }); + // @gate experimental it('is called after a focused element is unmounted', () => { const Component = ({show}) => { const listener = useFocusWithin({ @@ -310,6 +329,7 @@ describe.each(table)('FocusWithin responder', hasPointerEvents => { ); }); + // @gate experimental it('is called after a nested focused element is unmounted', () => { const Component = ({show}) => { const listener = useFocusWithin({ @@ -405,6 +425,7 @@ describe.each(table)('FocusWithin responder', hasPointerEvents => { }); }); + // @gate experimental it('expect displayName to show up for event component', () => { expect(FocusWithinResponder.displayName).toBe('FocusWithin'); }); diff --git a/packages/react-interactions/events/src/dom/__tests__/Hover-test.internal.js b/packages/react-interactions/events/src/dom/__tests__/Hover-test.internal.js index 35f71ebaba..19f6269f2d 100644 --- a/packages/react-interactions/events/src/dom/__tests__/Hover-test.internal.js +++ b/packages/react-interactions/events/src/dom/__tests__/Hover-test.internal.js @@ -24,8 +24,13 @@ function initializeModules(hasPointerEvents) { ReactFeatureFlags.enableDeprecatedFlareAPI = true; React = require('react'); ReactDOM = require('react-dom'); - HoverResponder = require('react-interactions/events/hover').HoverResponder; - useHover = require('react-interactions/events/hover').useHover; + + // TODO: This import throws outside of experimental mode. Figure out better + // strategy for gated imports. + if (__EXPERIMENTAL__) { + HoverResponder = require('react-interactions/events/hover').HoverResponder; + useHover = require('react-interactions/events/hover').useHover; + } } const forcePointerEvents = true; @@ -34,11 +39,6 @@ const table = [[forcePointerEvents], [!forcePointerEvents]]; describe.each(table)('Hover responder', hasPointerEvents => { let container; - if (!__EXPERIMENTAL__) { - it("empty test so Jest doesn't complain", () => {}); - return; - } - beforeEach(() => { initializeModules(hasPointerEvents); container = document.createElement('div'); @@ -54,7 +54,7 @@ describe.each(table)('Hover responder', hasPointerEvents => { describe('disabled', () => { let onHoverChange, onHoverStart, onHoverMove, onHoverEnd, ref; - beforeEach(() => { + const componentInit = () => { onHoverChange = jest.fn(); onHoverStart = jest.fn(); onHoverMove = jest.fn(); @@ -71,9 +71,11 @@ describe.each(table)('Hover responder', hasPointerEvents => { return
; }; ReactDOM.render(, container); - }); + }; + // @gate experimental it('does not call callbacks', () => { + componentInit(); const target = createEventTarget(ref.current); target.pointerenter(); target.pointerexit(); @@ -87,7 +89,7 @@ describe.each(table)('Hover responder', hasPointerEvents => { describe('onHoverStart', () => { let onHoverStart, ref; - beforeEach(() => { + const componentInit = () => { onHoverStart = jest.fn(); ref = React.createRef(); const Component = () => { @@ -97,22 +99,28 @@ describe.each(table)('Hover responder', hasPointerEvents => { return
; }; ReactDOM.render(, container); - }); + }; + // @gate experimental it('is called for mouse pointers', () => { + componentInit(); const target = createEventTarget(ref.current); target.pointerenter(); expect(onHoverStart).toHaveBeenCalledTimes(1); }); + // @gate experimental it('is not called for touch pointers', () => { + componentInit(); const target = createEventTarget(ref.current); target.pointerdown({pointerType: 'touch'}); target.pointerup({pointerType: 'touch'}); expect(onHoverStart).not.toBeCalled(); }); + // @gate experimental it('is called if a mouse pointer is used after a touch pointer', () => { + componentInit(); const target = createEventTarget(ref.current); target.pointerdown({pointerType: 'touch'}); target.pointerup({pointerType: 'touch'}); @@ -124,7 +132,7 @@ describe.each(table)('Hover responder', hasPointerEvents => { describe('onHoverChange', () => { let onHoverChange, ref; - beforeEach(() => { + const componentInit = () => { onHoverChange = jest.fn(); ref = React.createRef(); const Component = () => { @@ -134,9 +142,11 @@ describe.each(table)('Hover responder', hasPointerEvents => { return
; }; ReactDOM.render(, container); - }); + }; + // @gate experimental it('is called for mouse pointers', () => { + componentInit(); const target = createEventTarget(ref.current); target.pointerenter(); expect(onHoverChange).toHaveBeenCalledTimes(1); @@ -146,7 +156,9 @@ describe.each(table)('Hover responder', hasPointerEvents => { expect(onHoverChange).toHaveBeenCalledWith(false); }); + // @gate experimental it('is not called for touch pointers', () => { + componentInit(); const target = createEventTarget(ref.current); target.pointerdown({pointerType: 'touch'}); target.pointerup({pointerType: 'touch'}); @@ -157,7 +169,7 @@ describe.each(table)('Hover responder', hasPointerEvents => { describe('onHoverEnd', () => { let onHoverEnd, ref; - beforeEach(() => { + const componentInit = () => { onHoverEnd = jest.fn(); ref = React.createRef(); const Component = () => { @@ -167,9 +179,11 @@ describe.each(table)('Hover responder', hasPointerEvents => { return
; }; ReactDOM.render(, container); - }); + }; + // @gate experimental it('is called for mouse pointers', () => { + componentInit(); const target = createEventTarget(ref.current); target.pointerenter(); target.pointerexit(); @@ -177,7 +191,9 @@ describe.each(table)('Hover responder', hasPointerEvents => { }); if (hasPointerEvents) { + // @gate experimental it('is called once for cancelled mouse pointers', () => { + componentInit(); const target = createEventTarget(ref.current); target.pointerenter(); target.pointercancel(); @@ -192,14 +208,18 @@ describe.each(table)('Hover responder', hasPointerEvents => { }); } + // @gate experimental it('is not called for touch pointers', () => { + componentInit(); const target = createEventTarget(ref.current); target.pointerdown({pointerType: 'touch'}); target.pointerup({pointerType: 'touch'}); expect(onHoverEnd).not.toBeCalled(); }); + // @gate experimental it('should correctly work with React Portals', () => { + componentInit(); const portalNode = document.createElement('div'); const divRef = React.createRef(); const spanRef = React.createRef(); @@ -227,6 +247,7 @@ describe.each(table)('Hover responder', hasPointerEvents => { }); describe('onHoverMove', () => { + // @gate experimental it('is called after the active pointer moves"', () => { const onHoverMove = jest.fn(); const ref = React.createRef(); @@ -250,6 +271,7 @@ describe.each(table)('Hover responder', hasPointerEvents => { }); describe('nested Hover components', () => { + // @gate experimental it('not propagate by default', () => { const events = []; const innerRef = React.createRef(); @@ -310,10 +332,12 @@ describe.each(table)('Hover responder', hasPointerEvents => { }); }); + // @gate experimental it('expect displayName to show up for event component', () => { expect(HoverResponder.displayName).toBe('Hover'); }); + // @gate experimental it('should correctly pass through event properties', () => { const timeStamps = []; const ref = React.createRef(); diff --git a/packages/react-interactions/events/src/dom/__tests__/Input-test.internal.js b/packages/react-interactions/events/src/dom/__tests__/Input-test.internal.js index 77084ac4d9..bcbe4d5224 100644 --- a/packages/react-interactions/events/src/dom/__tests__/Input-test.internal.js +++ b/packages/react-interactions/events/src/dom/__tests__/Input-test.internal.js @@ -38,18 +38,18 @@ const modulesInit = () => { React = require('react'); ReactDOM = require('react-dom'); Scheduler = require('scheduler'); - InputResponder = require('react-interactions/events/input').InputResponder; - useInput = require('react-interactions/events/input').useInput; + + // TODO: This import throws outside of experimental mode. Figure out better + // strategy for gated imports. + if (__EXPERIMENTAL__) { + InputResponder = require('react-interactions/events/input').InputResponder; + useInput = require('react-interactions/events/input').useInput; + } }; describe('Input event responder', () => { let container; - if (!__EXPERIMENTAL__) { - it("empty test so Jest doesn't complain", () => {}); - return; - } - beforeEach(() => { jest.resetModules(); modulesInit(); @@ -66,7 +66,7 @@ describe('Input event responder', () => { describe('disabled', () => { let onChange, onValueChange, ref; - beforeEach(() => { + const componentInit = () => { onChange = jest.fn(); onValueChange = jest.fn(); ref = React.createRef(); @@ -80,9 +80,11 @@ describe('Input event responder', () => { return ; } ReactDOM.render(, container); - }); + }; + // @gate experimental it('prevents custom events being dispatched', () => { + componentInit(); ref.current.dispatchEvent( new Event('change', {bubbles: true, cancelable: true}), ); @@ -104,6 +106,7 @@ describe('Input event responder', () => { // keep track of the "current" value and only fire events when it changes. // See https://github.com/facebook/react/pull/5746. + // @gate experimental it('should consider initial text value to be current', () => { let onChangeCalled = 0; let onValueChangeCalled = 0; @@ -151,6 +154,7 @@ describe('Input event responder', () => { } }); + // @gate experimental it('should consider initial checkbox checked=true to be current', () => { let onChangeCalled = 0; let onValueChangeCalled = 0; @@ -194,6 +198,7 @@ describe('Input event responder', () => { expect(onValueChangeCalled).toBe(0); }); + // @gate experimental it('should consider initial checkbox checked=false to be current', () => { let onChangeCalled = 0; let onValueChangeCalled = 0; @@ -236,6 +241,7 @@ describe('Input event responder', () => { expect(onValueChangeCalled).toBe(0); }); + // @gate experimental it('should fire change for checkbox input', () => { let onChangeCalled = 0; let onValueChangeCalled = 0; @@ -283,6 +289,7 @@ describe('Input event responder', () => { expect(onValueChangeCalled).toBe(2); }); + // @gate experimental it('should not fire change setting the value programmatically', () => { let onChangeCalled = 0; let onValueChangeCalled = 0; @@ -345,6 +352,7 @@ describe('Input event responder', () => { expect(onValueChangeCalled).toBe(1); }); + // @gate experimental it('should not distinguish equal string and number values', () => { let onChangeCalled = 0; let onValueChangeCalled = 0; @@ -390,6 +398,7 @@ describe('Input event responder', () => { }); // See a similar input test above for a detailed description of why. + // @gate experimental it('should not fire change when setting checked programmatically', () => { let onChangeCalled = 0; let onValueChangeCalled = 0; @@ -441,6 +450,7 @@ describe('Input event responder', () => { expect(onValueChangeCalled).toBe(1); }); + // @gate experimental it('should only fire change for checked radio button once', () => { let onChangeCalled = 0; let onValueChangeCalled = 0; @@ -477,6 +487,7 @@ describe('Input event responder', () => { expect(onValueChangeCalled).toBe(1); }); + // @gate experimental it('should track radio button cousins in a group', () => { let onChangeCalled1 = 0; let onValueChangeCalled1 = 0; @@ -566,6 +577,7 @@ describe('Input event responder', () => { expect(onValueChangeCalled2).toBe(1); }); + // @gate experimental it('should deduplicate input value change events', () => { let onChangeCalled = 0; let onValueChangeCalled = 0; @@ -672,6 +684,7 @@ describe('Input event responder', () => { }); }); + // @gate experimental it('should listen for both change and input events when supported', () => { let onChangeCalled = 0; let onValueChangeCalled = 0; @@ -711,6 +724,7 @@ describe('Input event responder', () => { expect(onValueChangeCalled).toBe(2); }); + // @gate experimental it('should only fire events when the value changes for range inputs', () => { let onChangeCalled = 0; let onValueChangeCalled = 0; @@ -756,6 +770,7 @@ describe('Input event responder', () => { expect(onValueChangeCalled).toBe(2); }); + // @gate experimental || build === "production" it('does not crash for nodes with custom value property', () => { let originalCreateElement; // https://github.com/facebook/react/issues/10196 @@ -793,6 +808,7 @@ describe('Input event responder', () => { }); describe('concurrent mode', () => { + // @gate experimental // @gate experimental it('text input', () => { const root = ReactDOM.createRoot(container); @@ -849,6 +865,7 @@ describe('Input event responder', () => { expect(input.value).toBe('changed [!]'); }); + // @gate experimental // @gate experimental it('checkbox input', () => { const root = ReactDOM.createRoot(container); @@ -918,6 +935,7 @@ describe('Input event responder', () => { expect(input.checked).toBe(false); }); + // @gate experimental // @gate experimental it('textarea', () => { const root = ReactDOM.createRoot(container); @@ -976,6 +994,7 @@ describe('Input event responder', () => { }); }); + // @gate experimental it('expect displayName to show up for event component', () => { expect(InputResponder.displayName).toBe('Input'); }); diff --git a/packages/react-interactions/events/src/dom/__tests__/Keyboard-test.internal.js b/packages/react-interactions/events/src/dom/__tests__/Keyboard-test.internal.js index bd649765d5..7cfe8db7f7 100644 --- a/packages/react-interactions/events/src/dom/__tests__/Keyboard-test.internal.js +++ b/packages/react-interactions/events/src/dom/__tests__/Keyboard-test.internal.js @@ -22,17 +22,17 @@ function initializeModules(hasPointerEvents) { ReactFeatureFlags.enableDeprecatedFlareAPI = true; React = require('react'); ReactDOM = require('react-dom'); - useKeyboard = require('react-interactions/events/keyboard').useKeyboard; + + // TODO: This import throws outside of experimental mode. Figure out better + // strategy for gated imports. + if (__EXPERIMENTAL__) { + useKeyboard = require('react-interactions/events/keyboard').useKeyboard; + } } describe('Keyboard responder', () => { let container; - if (!__EXPERIMENTAL__) { - it("empty test so Jest doesn't complain", () => {}); - return; - } - beforeEach(() => { initializeModules(); container = document.createElement('div'); @@ -82,6 +82,7 @@ describe('Keyboard responder', () => { }; } + // @gate experimental test('propagates key event when a continuePropagation() is used', () => { const { onClickInner, @@ -104,6 +105,7 @@ describe('Keyboard responder', () => { expect(onClickOuter).toBeCalled(); }); + // @gate experimental test('does not propagate key event by default', () => { const { onClickInner, @@ -129,7 +131,7 @@ describe('Keyboard responder', () => { describe('disabled', () => { let onKeyDown, onKeyUp, ref; - beforeEach(() => { + const componentInit = () => { onKeyDown = jest.fn(); onKeyUp = jest.fn(); ref = React.createRef(); @@ -138,9 +140,11 @@ describe('Keyboard responder', () => { return
; }; ReactDOM.render(, container); - }); + }; + // @gate experimental test('does not call callbacks', () => { + componentInit(); const target = createEventTarget(ref.current); target.keydown(); target.keyup(); @@ -152,7 +156,7 @@ describe('Keyboard responder', () => { describe('onClick', () => { let onClick, ref; - beforeEach(() => { + const componentInit = () => { onClick = jest.fn(e => { e.preventDefault(); }); @@ -162,10 +166,12 @@ describe('Keyboard responder', () => { return
; }; ReactDOM.render(, container); - }); + }; // e.g, "Enter" on link + // @gate experimental test('click is between key events', () => { + componentInit(); const target = createEventTarget(ref.current); target.keydown({key: 'Enter'}); target.keyup({key: 'Enter'}); @@ -187,7 +193,9 @@ describe('Keyboard responder', () => { }); // e.g., "Spacebar" on button + // @gate experimental test('click is after key events', () => { + componentInit(); const target = createEventTarget(ref.current); target.keydown({key: 'Enter'}); target.keyup({key: 'Enter'}); @@ -209,7 +217,9 @@ describe('Keyboard responder', () => { }); // e.g, generated by a screen-reader + // @gate experimental test('click is orphan', () => { + componentInit(); const target = createEventTarget(ref.current); target.virtualclick(); expect(onClick).toHaveBeenCalledTimes(1); @@ -232,7 +242,7 @@ describe('Keyboard responder', () => { describe('onKeyDown', () => { let onKeyDown, ref; - beforeEach(() => { + const componentInit = () => { onKeyDown = jest.fn(); ref = React.createRef(); const Component = () => { @@ -240,9 +250,11 @@ describe('Keyboard responder', () => { return
; }; ReactDOM.render(, container); - }); + }; + // @gate experimental test('key down', () => { + componentInit(); const target = createEventTarget(ref.current); target.keydown({key: 'Q'}); expect(onKeyDown).toHaveBeenCalledTimes(1); @@ -263,7 +275,9 @@ describe('Keyboard responder', () => { ); }); + // @gate experimental test('modified key down', () => { + componentInit(); const target = createEventTarget(ref.current); target.keydown({ key: 'Q', @@ -293,7 +307,7 @@ describe('Keyboard responder', () => { describe('onKeyUp', () => { let onKeyUp, ref; - beforeEach(() => { + const componentInit = () => { onKeyUp = jest.fn(); ref = React.createRef(); const Component = () => { @@ -301,9 +315,11 @@ describe('Keyboard responder', () => { return
; }; ReactDOM.render(, container); - }); + }; + // @gate experimental test('key up', () => { + componentInit(); const target = createEventTarget(ref.current); target.keydown({key: 'Q'}); target.keyup({key: 'Q'}); @@ -325,7 +341,9 @@ describe('Keyboard responder', () => { ); }); + // @gate experimental test('modified key up', () => { + componentInit(); const target = createEventTarget(ref.current); target.keydown({key: 'Q'}); target.keyup({ @@ -364,6 +382,7 @@ describe('Keyboard responder', () => { return ref; } + // @gate experimental test('does not prevent native click by default', () => { const onClick = jest.fn(); const preventDefault = jest.fn(); @@ -381,6 +400,7 @@ describe('Keyboard responder', () => { ); }); + // @gate experimental test('prevents native behaviour with preventDefault', () => { const onClick = jest.fn(e => e.preventDefault()); const preventDefault = jest.fn(); @@ -409,6 +429,7 @@ describe('Keyboard responder', () => { return ref; } + // @gate experimental test('key config matches', () => { const onKeyDown = jest.fn(e => { if (e.key === 'Tab') { @@ -434,6 +455,7 @@ describe('Keyboard responder', () => { ); }); + // @gate experimental test('key config matches (modifier keys)', () => { const onKeyDown = jest.fn(e => { if (e.key === 'Tab' && e.shiftKey) { @@ -457,6 +479,7 @@ describe('Keyboard responder', () => { ); }); + // @gate experimental test('key config does not match (modifier keys)', () => { const onKeyDown = jest.fn(e => { if (e.key === 'Tab' && e.shiftKey) { diff --git a/packages/react-interactions/events/src/dom/__tests__/MixedResponders-test-internal.js b/packages/react-interactions/events/src/dom/__tests__/MixedResponders-test-internal.js index 2a062a40fb..2a31c5f37f 100644 --- a/packages/react-interactions/events/src/dom/__tests__/MixedResponders-test-internal.js +++ b/packages/react-interactions/events/src/dom/__tests__/MixedResponders-test-internal.js @@ -19,11 +19,6 @@ let Scheduler; describe('mixing responders with the heritage event system', () => { let container; - if (!__EXPERIMENTAL__) { - it("empty test so Jest doesn't complain", () => {}); - return; - } - beforeEach(() => { ReactFeatureFlags = require('shared/ReactFeatureFlags'); ReactFeatureFlags.enableDeprecatedFlareAPI = true; @@ -39,11 +34,7 @@ describe('mixing responders with the heritage event system', () => { container = null; }); - if (!__EXPERIMENTAL__) { - it("empty test so Jest doesn't complain", () => {}); - return; - } - + // @gate experimental it('should properly only flush sync once when the event systems are mixed', () => { const useTap = require('react-interactions/events/tap').useTap; const ref = React.createRef(); @@ -113,6 +104,7 @@ describe('mixing responders with the heritage event system', () => { document.body.removeChild(newContainer); }); + // @gate experimental it('should properly flush sync when the event systems are mixed with unstable_flushDiscreteUpdates', () => { const useTap = require('react-interactions/events/tap').useTap; const ref = React.createRef(); @@ -182,6 +174,7 @@ describe('mixing responders with the heritage event system', () => { document.body.removeChild(newContainer); }); + // @gate experimental it( 'should only flush before outermost discrete event handler when mixing ' + 'event systems', @@ -241,6 +234,7 @@ describe('mixing responders with the heritage event system', () => { ); describe('mixing the Input and Press repsonders', () => { + // @gate experimental it('is async for non-input events', () => { const useTap = require('react-interactions/events/tap').useTap; const useInput = require('react-interactions/events/input').useInput; diff --git a/packages/react-interactions/events/src/dom/__tests__/Press-test.internal.js b/packages/react-interactions/events/src/dom/__tests__/Press-test.internal.js index 07aa6c5e3b..59f3488ba7 100644 --- a/packages/react-interactions/events/src/dom/__tests__/Press-test.internal.js +++ b/packages/react-interactions/events/src/dom/__tests__/Press-test.internal.js @@ -30,19 +30,17 @@ function initializeModules(hasPointerEvents) { ReactFeatureFlags.enableDeprecatedFlareAPI = true; React = require('react'); ReactDOM = require('react-dom'); - usePress = require('react-interactions/events/press').usePress; -} -const pointerTypesTable = [['mouse'], ['touch']]; + // TODO: This import throws outside of experimental mode. Figure out better + // strategy for gated imports. + if (__EXPERIMENTAL__) { + usePress = require('react-interactions/events/press').usePress; + } +} describeWithPointerEvent('Press responder', hasPointerEvents => { let container; - if (!__EXPERIMENTAL__) { - it("empty test so Jest doesn't complain", () => {}); - return; - } - beforeEach(() => { initializeModules(hasPointerEvents); container = document.createElement('div'); @@ -59,7 +57,7 @@ describeWithPointerEvent('Press responder', hasPointerEvents => { describe('disabled', () => { let onPressStart, onPressChange, onPressMove, onPressEnd, onPress, ref; - beforeEach(() => { + const componentInit = () => { onPressStart = jest.fn(); onPressChange = jest.fn(); onPressMove = jest.fn(); @@ -78,9 +76,11 @@ describeWithPointerEvent('Press responder', hasPointerEvents => { return
; }; ReactDOM.render(, container); - }); + }; + // @gate experimental test('does not call callbacks for pointers', () => { + componentInit(); const target = createEventTarget(ref.current); target.pointerdown(); target.pointerup(); @@ -91,7 +91,9 @@ describeWithPointerEvent('Press responder', hasPointerEvents => { expect(onPress).not.toBeCalled(); }); + // @gate experimental test('does not call callbacks for keyboard', () => { + componentInit(); const target = createEventTarget(ref.current); target.keydown({key: 'Enter'}); target.keyup({key: 'Enter'}); @@ -106,7 +108,7 @@ describeWithPointerEvent('Press responder', hasPointerEvents => { describe('onPressStart', () => { let onPressStart, ref; - beforeEach(() => { + const componentInit = () => { onPressStart = jest.fn(); ref = React.createRef(); const Component = () => { @@ -117,21 +119,33 @@ describeWithPointerEvent('Press responder', hasPointerEvents => { }; ReactDOM.render(, container); document.elementFromPoint = () => ref.current; + }; + + // @gate experimental + it('is called after pointer down: mouse', () => { + componentInit(); + const target = createEventTarget(ref.current); + target.pointerdown({pointerType: 'mouse'}); + expect(onPressStart).toHaveBeenCalledTimes(1); + expect(onPressStart).toHaveBeenCalledWith( + expect.objectContaining({pointerType: 'mouse', type: 'pressstart'}), + ); }); - it.each(pointerTypesTable)( - 'is called after pointer down: %s', - pointerType => { - const target = createEventTarget(ref.current); - target.pointerdown({pointerType}); - expect(onPressStart).toHaveBeenCalledTimes(1); - expect(onPressStart).toHaveBeenCalledWith( - expect.objectContaining({pointerType, type: 'pressstart'}), - ); - }, - ); + // @gate experimental + it('is called after pointer down: mouse', () => { + componentInit(); + const target = createEventTarget(ref.current); + target.pointerdown({pointerType: 'touch'}); + expect(onPressStart).toHaveBeenCalledTimes(1); + expect(onPressStart).toHaveBeenCalledWith( + expect.objectContaining({pointerType: 'touch', type: 'pressstart'}), + ); + }); + // @gate experimental it('is called after middle-button pointer down', () => { + componentInit(); const target = createEventTarget(ref.current); const pointerType = 'mouse'; target.pointerdown({ @@ -150,7 +164,9 @@ describeWithPointerEvent('Press responder', hasPointerEvents => { ); }); + // @gate experimental it('is called after virtual middle-button pointer down', () => { + componentInit(); const target = createEventTarget(ref.current); const pointerType = 'mouse'; target.pointerdown({ @@ -169,7 +185,9 @@ describeWithPointerEvent('Press responder', hasPointerEvents => { ); }); + // @gate experimental it('ignores any events not caused by primary/middle-click or touch/pen contact', () => { + componentInit(); const target = createEventTarget(ref.current); target.pointerdown({buttons: buttonsType.secondary}); target.pointerup({buttons: buttonsType.secondary}); @@ -178,7 +196,9 @@ describeWithPointerEvent('Press responder', hasPointerEvents => { expect(onPressStart).toHaveBeenCalledTimes(0); }); + // @gate experimental it('is called once after "keydown" events for Enter', () => { + componentInit(); const target = createEventTarget(ref.current); target.keydown({key: 'Enter'}); target.keydown({key: 'Enter'}); @@ -189,7 +209,9 @@ describeWithPointerEvent('Press responder', hasPointerEvents => { ); }); + // @gate experimental it('is called once after "keydown" events for Spacebar', () => { + componentInit(); const target = createEventTarget(ref.current); const preventDefault = jest.fn(); target.keydown({key: ' ', preventDefault}); @@ -204,7 +226,9 @@ describeWithPointerEvent('Press responder', hasPointerEvents => { ); }); + // @gate experimental it('is not called after "keydown" for other keys', () => { + componentInit(); const target = createEventTarget(ref.current); target.keydown({key: 'a'}); expect(onPressStart).not.toBeCalled(); @@ -214,7 +238,7 @@ describeWithPointerEvent('Press responder', hasPointerEvents => { describe('onPressEnd', () => { let onPressEnd, ref; - beforeEach(() => { + const componentInit = () => { onPressEnd = jest.fn(); ref = React.createRef(); const Component = () => { @@ -225,22 +249,35 @@ describeWithPointerEvent('Press responder', hasPointerEvents => { }; ReactDOM.render(, container); document.elementFromPoint = () => ref.current; + }; + + // @gate experimental + it('is called after pointer up: mouse', () => { + componentInit(); + const target = createEventTarget(ref.current); + target.pointerdown({pointerType: 'mouse'}); + target.pointerup({pointerType: 'mouse'}); + expect(onPressEnd).toHaveBeenCalledTimes(1); + expect(onPressEnd).toHaveBeenCalledWith( + expect.objectContaining({pointerType: 'mouse', type: 'pressend'}), + ); }); - it.each(pointerTypesTable)( - 'is called after pointer up: %s', - pointerType => { - const target = createEventTarget(ref.current); - target.pointerdown({pointerType}); - target.pointerup({pointerType}); - expect(onPressEnd).toHaveBeenCalledTimes(1); - expect(onPressEnd).toHaveBeenCalledWith( - expect.objectContaining({pointerType, type: 'pressend'}), - ); - }, - ); + // @gate experimental + it('is called after pointer up: touch', () => { + componentInit(); + const target = createEventTarget(ref.current); + target.pointerdown({pointerType: 'touch'}); + target.pointerup({pointerType: 'touch'}); + expect(onPressEnd).toHaveBeenCalledTimes(1); + expect(onPressEnd).toHaveBeenCalledWith( + expect.objectContaining({pointerType: 'touch', type: 'pressend'}), + ); + }); + // @gate experimental it('is called after middle-button pointer up', () => { + componentInit(); const target = createEventTarget(ref.current); target.pointerdown({ button: buttonType.auxiliary, @@ -258,7 +295,9 @@ describeWithPointerEvent('Press responder', hasPointerEvents => { ); }); + // @gate experimental it('is called after virtual middle-button pointer up', () => { + componentInit(); const target = createEventTarget(ref.current); target.pointerdown({ button: buttonType.auxiliary, @@ -276,7 +315,9 @@ describeWithPointerEvent('Press responder', hasPointerEvents => { ); }); + // @gate experimental it('is called after "keyup" event for Enter', () => { + componentInit(); const target = createEventTarget(ref.current); target.keydown({key: 'Enter'}); // click occurs before keyup @@ -288,7 +329,9 @@ describeWithPointerEvent('Press responder', hasPointerEvents => { ); }); + // @gate experimental it('is called after "keyup" event for Spacebar', () => { + componentInit(); const target = createEventTarget(ref.current); target.keydown({key: ' '}); target.keyup({key: ' '}); @@ -298,14 +341,18 @@ describeWithPointerEvent('Press responder', hasPointerEvents => { ); }); + // @gate experimental it('is not called after "keyup" event for other keys', () => { + componentInit(); const target = createEventTarget(ref.current); target.keydown({key: 'Enter'}); target.keyup({key: 'a'}); expect(onPressEnd).not.toBeCalled(); }); + // @gate experimental it('is called with keyboard modifiers', () => { + componentInit(); const target = createEventTarget(ref.current); target.keydown({key: 'Enter'}); target.keyup({ @@ -331,7 +378,7 @@ describeWithPointerEvent('Press responder', hasPointerEvents => { describe('onPressChange', () => { let onPressChange, ref; - beforeEach(() => { + const componentInit = () => { onPressChange = jest.fn(); ref = React.createRef(); const Component = () => { @@ -342,22 +389,35 @@ describeWithPointerEvent('Press responder', hasPointerEvents => { }; ReactDOM.render(, container); document.elementFromPoint = () => ref.current; + }; + + // @gate experimental + it('is called after pointer down and up: %s', () => { + componentInit(); + const target = createEventTarget(ref.current); + target.pointerdown({pointerType: 'mouse'}); + expect(onPressChange).toHaveBeenCalledTimes(1); + expect(onPressChange).toHaveBeenCalledWith(true); + target.pointerup({pointerType: 'mouse'}); + expect(onPressChange).toHaveBeenCalledTimes(2); + expect(onPressChange).toHaveBeenCalledWith(false); }); - it.each(pointerTypesTable)( - 'is called after pointer down and up: %s', - pointerType => { - const target = createEventTarget(ref.current); - target.pointerdown({pointerType}); - expect(onPressChange).toHaveBeenCalledTimes(1); - expect(onPressChange).toHaveBeenCalledWith(true); - target.pointerup({pointerType}); - expect(onPressChange).toHaveBeenCalledTimes(2); - expect(onPressChange).toHaveBeenCalledWith(false); - }, - ); + // @gate experimental + it('is called after pointer down and up: touch', () => { + componentInit(); + const target = createEventTarget(ref.current); + target.pointerdown({pointerType: 'touch'}); + expect(onPressChange).toHaveBeenCalledTimes(1); + expect(onPressChange).toHaveBeenCalledWith(true); + target.pointerup({pointerType: 'touch'}); + expect(onPressChange).toHaveBeenCalledTimes(2); + expect(onPressChange).toHaveBeenCalledWith(false); + }); + // @gate experimental it('is called after valid "keydown" and "keyup" events', () => { + componentInit(); const target = createEventTarget(ref.current); target.keydown({key: 'Enter'}); expect(onPressChange).toHaveBeenCalledTimes(1); @@ -371,7 +431,7 @@ describeWithPointerEvent('Press responder', hasPointerEvents => { describe('onPress', () => { let onPress, ref; - beforeEach(() => { + const componentInit = () => { onPress = jest.fn(); ref = React.createRef(); const Component = () => { @@ -388,22 +448,35 @@ describeWithPointerEvent('Press responder', hasPointerEvents => { right: 100, }); document.elementFromPoint = () => ref.current; + }; + + // @gate experimental + it('is called after pointer up: %s', () => { + componentInit(); + const target = createEventTarget(ref.current); + target.pointerdown({pointerType: 'mouse'}); + target.pointerup({pointerType: 'mouse', x: 10, y: 10}); + expect(onPress).toHaveBeenCalledTimes(1); + expect(onPress).toHaveBeenCalledWith( + expect.objectContaining({pointerType: 'mouse', type: 'press'}), + ); }); - it.each(pointerTypesTable)( - 'is called after pointer up: %s', - pointerType => { - const target = createEventTarget(ref.current); - target.pointerdown({pointerType}); - target.pointerup({pointerType, x: 10, y: 10}); - expect(onPress).toHaveBeenCalledTimes(1); - expect(onPress).toHaveBeenCalledWith( - expect.objectContaining({pointerType, type: 'press'}), - ); - }, - ); + // @gate experimental + it('is called after pointer up: %s', () => { + componentInit(); + const target = createEventTarget(ref.current); + target.pointerdown({pointerType: 'touch'}); + target.pointerup({pointerType: 'touch', x: 10, y: 10}); + expect(onPress).toHaveBeenCalledTimes(1); + expect(onPress).toHaveBeenCalledWith( + expect.objectContaining({pointerType: 'touch', type: 'press'}), + ); + }); + // @gate experimental it('is not called after middle-button press', () => { + componentInit(); const target = createEventTarget(ref.current); target.pointerdown({ button: buttonType.auxiliary, @@ -414,7 +487,9 @@ describeWithPointerEvent('Press responder', hasPointerEvents => { expect(onPress).not.toHaveBeenCalled(); }); + // @gate experimental it('is not called after virtual middle-button press', () => { + componentInit(); const target = createEventTarget(ref.current); target.pointerdown({ button: buttonType.auxiliary, @@ -425,7 +500,9 @@ describeWithPointerEvent('Press responder', hasPointerEvents => { expect(onPress).not.toHaveBeenCalled(); }); + // @gate experimental it('is called after valid "keyup" event', () => { + componentInit(); const target = createEventTarget(ref.current); target.keydown({key: 'Enter'}); target.keyup({key: 'Enter'}); @@ -435,7 +512,9 @@ describeWithPointerEvent('Press responder', hasPointerEvents => { ); }); + // @gate experimental it('is not called after invalid "keyup" event', () => { + componentInit(); const inputRef = React.createRef(); const Component = () => { const listener = usePress({onPress}); @@ -450,7 +529,9 @@ describeWithPointerEvent('Press responder', hasPointerEvents => { expect(onPress).not.toBeCalled(); }); + // @gate experimental it('is called with modifier keys', () => { + componentInit(); const target = createEventTarget(ref.current); target.pointerdown({metaKey: true, pointerType: 'mouse'}); target.pointerup({metaKey: true, pointerType: 'mouse'}); @@ -463,7 +544,9 @@ describeWithPointerEvent('Press responder', hasPointerEvents => { ); }); + // @gate experimental it('is called once after virtual screen reader "click" event', () => { + componentInit(); const target = createEventTarget(ref.current); const preventDefault = jest.fn(); target.virtualclick({preventDefault}); @@ -481,7 +564,7 @@ describeWithPointerEvent('Press responder', hasPointerEvents => { describe('onPressMove', () => { let onPressMove, ref; - beforeEach(() => { + const componentInit = () => { onPressMove = jest.fn(); ref = React.createRef(); const Component = () => { @@ -498,25 +581,41 @@ describeWithPointerEvent('Press responder', hasPointerEvents => { right: 100, }); document.elementFromPoint = () => ref.current; + }; + + // @gate experimental + it('is called after pointer move: mouse', () => { + componentInit(); + const node = ref.current; + const target = createEventTarget(node); + target.setBoundingClientRect({x: 0, y: 0, width: 100, height: 100}); + target.pointerdown({pointerType: 'mouse'}); + target.pointermove({pointerType: 'mouse', x: 10, y: 10}); + target.pointermove({pointerType: 'mouse', x: 20, y: 20}); + expect(onPressMove).toHaveBeenCalledTimes(2); + expect(onPressMove).toHaveBeenCalledWith( + expect.objectContaining({pointerType: 'mouse', type: 'pressmove'}), + ); }); - it.each(pointerTypesTable)( - 'is called after pointer move: %s', - pointerType => { - const node = ref.current; - const target = createEventTarget(node); - target.setBoundingClientRect({x: 0, y: 0, width: 100, height: 100}); - target.pointerdown({pointerType}); - target.pointermove({pointerType, x: 10, y: 10}); - target.pointermove({pointerType, x: 20, y: 20}); - expect(onPressMove).toHaveBeenCalledTimes(2); - expect(onPressMove).toHaveBeenCalledWith( - expect.objectContaining({pointerType, type: 'pressmove'}), - ); - }, - ); + // @gate experimental + it('is called after pointer move: touch', () => { + componentInit(); + const node = ref.current; + const target = createEventTarget(node); + target.setBoundingClientRect({x: 0, y: 0, width: 100, height: 100}); + target.pointerdown({pointerType: 'touch'}); + target.pointermove({pointerType: 'touch', x: 10, y: 10}); + target.pointermove({pointerType: 'touch', x: 20, y: 20}); + expect(onPressMove).toHaveBeenCalledTimes(2); + expect(onPressMove).toHaveBeenCalledWith( + expect.objectContaining({pointerType: 'touch', type: 'pressmove'}), + ); + }); + // @gate experimental it('is not called if pointer move occurs during keyboard press', () => { + componentInit(); const target = createEventTarget(ref.current); target.setBoundingClientRect({x: 0, y: 0, width: 100, height: 100}); target.keydown({key: 'Enter'}); @@ -531,6 +630,7 @@ describeWithPointerEvent('Press responder', hasPointerEvents => { }); describe('link components', () => { + // @gate experimental it('prevents native behavior by default', () => { const onPress = jest.fn(); const preventDefault = jest.fn(); @@ -551,6 +651,7 @@ describeWithPointerEvent('Press responder', hasPointerEvents => { ); }); + // @gate experimental it('prevents native behaviour for keyboard events by default', () => { const onPress = jest.fn(); const preventDefaultClick = jest.fn(); @@ -575,6 +676,7 @@ describeWithPointerEvent('Press responder', hasPointerEvents => { ); }); + // @gate experimental it('deeply prevents native behaviour by default', () => { const onPress = jest.fn(); const preventDefault = jest.fn(); @@ -596,6 +698,7 @@ describeWithPointerEvent('Press responder', hasPointerEvents => { expect(preventDefault).toBeCalled(); }); + // @gate experimental it('prevents native behaviour by default with nested elements', () => { const onPress = jest.fn(); const preventDefault = jest.fn(); @@ -620,6 +723,7 @@ describeWithPointerEvent('Press responder', hasPointerEvents => { ); }); + // @gate experimental it('uses native behaviour for interactions with modifier keys', () => { const onPress = jest.fn(); const preventDefault = jest.fn(); @@ -642,6 +746,7 @@ describeWithPointerEvent('Press responder', hasPointerEvents => { }); }); + // @gate experimental it('uses native behaviour for pointer events if preventDefault is false', () => { const onPress = jest.fn(); const preventDefault = jest.fn(); @@ -662,6 +767,7 @@ describeWithPointerEvent('Press responder', hasPointerEvents => { ); }); + // @gate experimental it('uses native behaviour for keyboard events if preventDefault is false', () => { const onPress = jest.fn(); const preventDefault = jest.fn(); @@ -685,6 +791,7 @@ describeWithPointerEvent('Press responder', hasPointerEvents => { }); }); + // @gate experimental it('should not trigger an invariant in addRootEventTypes()', () => { const ref = React.createRef(); @@ -701,6 +808,7 @@ describeWithPointerEvent('Press responder', hasPointerEvents => { target.pointerdown(); }); + // @gate experimental it('when blur occurs on a pressed target, we should disengage press', () => { const onPress = jest.fn(); const onPressStart = jest.fn(); diff --git a/packages/react-interactions/events/src/dom/__tests__/PressLegacy-test.internal.js b/packages/react-interactions/events/src/dom/__tests__/PressLegacy-test.internal.js index 53f3a9cbb8..abb9518890 100644 --- a/packages/react-interactions/events/src/dom/__tests__/PressLegacy-test.internal.js +++ b/packages/react-interactions/events/src/dom/__tests__/PressLegacy-test.internal.js @@ -30,9 +30,14 @@ function initializeModules(hasPointerEvents) { ReactFeatureFlags.enableDeprecatedFlareAPI = true; React = require('react'); ReactDOM = require('react-dom'); - PressResponder = require('react-interactions/events/press-legacy') - .PressResponder; - usePress = require('react-interactions/events/press-legacy').usePress; + + // TODO: This import throws outside of experimental mode. Figure out better + // strategy for gated imports. + if (__EXPERIMENTAL__) { + PressResponder = require('react-interactions/events/press-legacy') + .PressResponder; + usePress = require('react-interactions/events/press-legacy').usePress; + } } function removePressMoveStrings(eventString) { @@ -50,11 +55,6 @@ const pointerTypesTable = [['mouse'], ['touch']]; describe.each(environmentTable)('Press responder', hasPointerEvents => { let container; - if (!__EXPERIMENTAL__) { - it("empty test so Jest doesn't complain", () => {}); - return; - } - beforeEach(() => { initializeModules(hasPointerEvents); container = document.createElement('div'); @@ -71,7 +71,7 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { describe('disabled', () => { let onPressStart, onPress, onPressEnd, ref; - beforeEach(() => { + const componentInit = () => { onPressStart = jest.fn(); onPress = jest.fn(); onPressEnd = jest.fn(); @@ -87,9 +87,11 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { }; ReactDOM.render(, container); document.elementFromPoint = () => ref.current; - }); + }; + // @gate experimental it('does not call callbacks', () => { + componentInit(); const target = createEventTarget(ref.current); target.pointerdown(); target.pointerup(); @@ -102,7 +104,7 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { describe('onPressStart', () => { let onPressStart, ref; - beforeEach(() => { + const componentInit = () => { onPressStart = jest.fn(); ref = React.createRef(); const Component = () => { @@ -113,21 +115,33 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { }; ReactDOM.render(, container); document.elementFromPoint = () => ref.current; + }; + + // @gate experimental + it('is called after pointer down: mouse', () => { + componentInit(); + const target = createEventTarget(ref.current); + target.pointerdown({pointerType: 'mouse'}); + expect(onPressStart).toHaveBeenCalledTimes(1); + expect(onPressStart).toHaveBeenCalledWith( + expect.objectContaining({pointerType: 'mouse', type: 'pressstart'}), + ); }); - it.each(pointerTypesTable)( - 'is called after pointer down: %s', - pointerType => { - const target = createEventTarget(ref.current); - target.pointerdown({pointerType}); - expect(onPressStart).toHaveBeenCalledTimes(1); - expect(onPressStart).toHaveBeenCalledWith( - expect.objectContaining({pointerType, type: 'pressstart'}), - ); - }, - ); + // @gate experimental + it('is called after pointer down: touch', () => { + componentInit(); + const target = createEventTarget(ref.current); + target.pointerdown({pointerType: 'touch'}); + expect(onPressStart).toHaveBeenCalledTimes(1); + expect(onPressStart).toHaveBeenCalledWith( + expect.objectContaining({pointerType: 'touch', type: 'pressstart'}), + ); + }); + // @gate experimental it('is called after middle-button pointer down', () => { + componentInit(); const target = createEventTarget(ref.current); target.pointerdown({ button: buttonType.auxiliary, @@ -144,7 +158,9 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { ); }); + // @gate experimental it('is not called after pointer move following middle-button press', () => { + componentInit(); const node = ref.current; const target = createEventTarget(node); target.setBoundingClientRect({x: 0, y: 0, width: 100, height: 100}); @@ -159,7 +175,9 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { expect(onPressStart).toHaveBeenCalledTimes(1); }); + // @gate experimental it('ignores any events not caused by primary/middle-click or touch/pen contact', () => { + componentInit(); const target = createEventTarget(ref.current); target.pointerdown({buttons: buttonsType.secondary}); target.pointerup({buttons: buttonsType.secondary}); @@ -168,7 +186,9 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { expect(onPressStart).toHaveBeenCalledTimes(0); }); + // @gate experimental it('is called once after "keydown" events for Enter', () => { + componentInit(); const target = createEventTarget(ref.current); target.keydown({key: 'Enter'}); target.keydown({key: 'Enter'}); @@ -179,7 +199,9 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { ); }); + // @gate experimental it('is called once after "keydown" events for Spacebar', () => { + componentInit(); const target = createEventTarget(ref.current); const preventDefault = jest.fn(); target.keydown({key: ' ', preventDefault}); @@ -194,7 +216,9 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { ); }); + // @gate experimental it('is not called after "keydown" for other keys', () => { + componentInit(); const target = createEventTarget(ref.current); target.keydown({key: 'a'}); expect(onPressStart).not.toBeCalled(); @@ -204,7 +228,7 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { describe('onPressEnd', () => { let onPressEnd, ref; - beforeEach(() => { + const componentInit = () => { onPressEnd = jest.fn(); ref = React.createRef(); const Component = () => { @@ -215,22 +239,35 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { }; ReactDOM.render(, container); document.elementFromPoint = () => ref.current; + }; + + // @gate experimental + it('is called after pointer up: mouse', () => { + componentInit(); + const target = createEventTarget(ref.current); + target.pointerdown({pointerType: 'mouse'}); + target.pointerup({pointerType: 'mouse'}); + expect(onPressEnd).toHaveBeenCalledTimes(1); + expect(onPressEnd).toHaveBeenCalledWith( + expect.objectContaining({pointerType: 'mouse', type: 'pressend'}), + ); }); - it.each(pointerTypesTable)( - 'is called after pointer up: %s', - pointerType => { - const target = createEventTarget(ref.current); - target.pointerdown({pointerType}); - target.pointerup({pointerType}); - expect(onPressEnd).toHaveBeenCalledTimes(1); - expect(onPressEnd).toHaveBeenCalledWith( - expect.objectContaining({pointerType, type: 'pressend'}), - ); - }, - ); + // @gate experimental + it('is called after pointer up: touch', () => { + componentInit(); + const target = createEventTarget(ref.current); + target.pointerdown({pointerType: 'touch'}); + target.pointerup({pointerType: 'touch'}); + expect(onPressEnd).toHaveBeenCalledTimes(1); + expect(onPressEnd).toHaveBeenCalledWith( + expect.objectContaining({pointerType: 'touch', type: 'pressend'}), + ); + }); + // @gate experimental it('is called after middle-button pointer up', () => { + componentInit(); const target = createEventTarget(ref.current); target.pointerdown({ buttons: buttonsType.auxiliary, @@ -247,7 +284,9 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { ); }); + // @gate experimental it('is called after "keyup" event for Enter', () => { + componentInit(); const target = createEventTarget(ref.current); target.keydown({key: 'Enter'}); // click occurs before keyup @@ -259,7 +298,9 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { ); }); + // @gate experimental it('is called after "keyup" event for Spacebar', () => { + componentInit(); const target = createEventTarget(ref.current); target.keydown({key: ' '}); target.keyup({key: ' '}); @@ -269,14 +310,18 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { ); }); + // @gate experimental it('is not called after "keyup" event for other keys', () => { + componentInit(); const target = createEventTarget(ref.current); target.keydown({key: 'Enter'}); target.keyup({key: 'a'}); expect(onPressEnd).not.toBeCalled(); }); + // @gate experimental it('is called with keyboard modifiers', () => { + componentInit(); const target = createEventTarget(ref.current); target.keydown({key: 'Enter'}); target.keyup({ @@ -302,7 +347,7 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { describe('onPressChange', () => { let onPressChange, ref; - beforeEach(() => { + const componentInit = () => { onPressChange = jest.fn(); ref = React.createRef(); const Component = () => { @@ -313,22 +358,35 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { }; ReactDOM.render(, container); document.elementFromPoint = () => ref.current; + }; + + // @gate experimental + it('is called after pointer down and up: mouse', () => { + componentInit(); + const target = createEventTarget(ref.current); + target.pointerdown({pointerType: 'mouse'}); + expect(onPressChange).toHaveBeenCalledTimes(1); + expect(onPressChange).toHaveBeenCalledWith(true); + target.pointerup({pointerType: 'mouse'}); + expect(onPressChange).toHaveBeenCalledTimes(2); + expect(onPressChange).toHaveBeenCalledWith(false); }); - it.each(pointerTypesTable)( - 'is called after pointer down and up: %s', - pointerType => { - const target = createEventTarget(ref.current); - target.pointerdown({pointerType}); - expect(onPressChange).toHaveBeenCalledTimes(1); - expect(onPressChange).toHaveBeenCalledWith(true); - target.pointerup({pointerType}); - expect(onPressChange).toHaveBeenCalledTimes(2); - expect(onPressChange).toHaveBeenCalledWith(false); - }, - ); + // @gate experimental + it('is called after pointer down and up: touch', () => { + componentInit(); + const target = createEventTarget(ref.current); + target.pointerdown({pointerType: 'touch'}); + expect(onPressChange).toHaveBeenCalledTimes(1); + expect(onPressChange).toHaveBeenCalledWith(true); + target.pointerup({pointerType: 'touch'}); + expect(onPressChange).toHaveBeenCalledTimes(2); + expect(onPressChange).toHaveBeenCalledWith(false); + }); + // @gate experimental it('is called after valid "keydown" and "keyup" events', () => { + componentInit(); const target = createEventTarget(ref.current); target.keydown({key: 'Enter'}); expect(onPressChange).toHaveBeenCalledTimes(1); @@ -342,7 +400,7 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { describe('onPress', () => { let onPress, ref; - beforeEach(() => { + const componentInit = () => { onPress = jest.fn(); ref = React.createRef(); const Component = () => { @@ -359,22 +417,35 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { right: 100, }); document.elementFromPoint = () => ref.current; + }; + + // @gate experimental + it('is called after pointer up: mouse', () => { + componentInit(); + const target = createEventTarget(ref.current); + target.pointerdown({pointerType: 'mouse'}); + target.pointerup({pointerType: 'mouse', x: 10, y: 10}); + expect(onPress).toHaveBeenCalledTimes(1); + expect(onPress).toHaveBeenCalledWith( + expect.objectContaining({pointerType: 'mouse', type: 'press'}), + ); }); - it.each(pointerTypesTable)( - 'is called after pointer up: %s', - pointerType => { - const target = createEventTarget(ref.current); - target.pointerdown({pointerType}); - target.pointerup({pointerType, x: 10, y: 10}); - expect(onPress).toHaveBeenCalledTimes(1); - expect(onPress).toHaveBeenCalledWith( - expect.objectContaining({pointerType, type: 'press'}), - ); - }, - ); + // @gate experimental + it('is called after pointer up: touch', () => { + componentInit(); + const target = createEventTarget(ref.current); + target.pointerdown({pointerType: 'touch'}); + target.pointerup({pointerType: 'touch', x: 10, y: 10}); + expect(onPress).toHaveBeenCalledTimes(1); + expect(onPress).toHaveBeenCalledWith( + expect.objectContaining({pointerType: 'touch', type: 'press'}), + ); + }); + // @gate experimental it('is not called after middle-button press', () => { + componentInit(); const target = createEventTarget(ref.current); target.pointerdown({ buttons: buttonsType.auxiliary, @@ -384,7 +455,9 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { expect(onPress).not.toHaveBeenCalled(); }); + // @gate experimental it('is not called after virtual middle-button press', () => { + componentInit(); const target = createEventTarget(ref.current); target.pointerdown({ button: buttonType.auxiliary, @@ -395,7 +468,9 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { expect(onPress).not.toHaveBeenCalled(); }); + // @gate experimental it('is called after valid "keyup" event', () => { + componentInit(); const target = createEventTarget(ref.current); target.keydown({key: 'Enter'}); target.keyup({key: 'Enter'}); @@ -405,7 +480,9 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { ); }); + // @gate experimental it('is not called after invalid "keyup" event', () => { + componentInit(); const inputRef = React.createRef(); const Component = () => { const listener = usePress({onPress}); @@ -420,7 +497,9 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { expect(onPress).not.toBeCalled(); }); + // @gate experimental it('is called with modifier keys', () => { + componentInit(); const target = createEventTarget(ref.current); target.pointerdown({metaKey: true, pointerType: 'mouse'}); target.pointerup({metaKey: true, pointerType: 'mouse'}); @@ -433,7 +512,9 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { ); }); + // @gate experimental it('is called if target rect is not right but the target is (for mouse events)', () => { + componentInit(); const buttonRef = React.createRef(); const divRef = React.createRef(); @@ -455,7 +536,9 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { expect(onPress).toBeCalled(); }); + // @gate experimental it('is called once after virtual screen reader "click" event', () => { + componentInit(); const target = createEventTarget(ref.current); const preventDefault = jest.fn(); target.virtualclick({preventDefault}); @@ -473,7 +556,7 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { describe('onPressMove', () => { let onPressMove, ref; - beforeEach(() => { + const componentInit = () => { onPressMove = jest.fn(); ref = React.createRef(); const Component = () => { @@ -490,25 +573,41 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { right: 100, }); document.elementFromPoint = () => ref.current; + }; + + // @gate experimental + it('is called after pointer move: mouse', () => { + componentInit(); + const node = ref.current; + const target = createEventTarget(node); + target.setBoundingClientRect({x: 0, y: 0, width: 100, height: 100}); + target.pointerdown({pointerType: 'mouse'}); + target.pointermove({pointerType: 'mouse', x: 10, y: 10}); + target.pointermove({pointerType: 'mouse', x: 20, y: 20}); + expect(onPressMove).toHaveBeenCalledTimes(2); + expect(onPressMove).toHaveBeenCalledWith( + expect.objectContaining({pointerType: 'mouse', type: 'pressmove'}), + ); }); - it.each(pointerTypesTable)( - 'is called after pointer move: %s', - pointerType => { - const node = ref.current; - const target = createEventTarget(node); - target.setBoundingClientRect({x: 0, y: 0, width: 100, height: 100}); - target.pointerdown({pointerType}); - target.pointermove({pointerType, x: 10, y: 10}); - target.pointermove({pointerType, x: 20, y: 20}); - expect(onPressMove).toHaveBeenCalledTimes(2); - expect(onPressMove).toHaveBeenCalledWith( - expect.objectContaining({pointerType, type: 'pressmove'}), - ); - }, - ); + // @gate experimental + it('is called after pointer move: touch', () => { + componentInit(); + const node = ref.current; + const target = createEventTarget(node); + target.setBoundingClientRect({x: 0, y: 0, width: 100, height: 100}); + target.pointerdown({pointerType: 'touch'}); + target.pointermove({pointerType: 'touch', x: 10, y: 10}); + target.pointermove({pointerType: 'touch', x: 20, y: 20}); + expect(onPressMove).toHaveBeenCalledTimes(2); + expect(onPressMove).toHaveBeenCalledWith( + expect.objectContaining({pointerType: 'touch', type: 'pressmove'}), + ); + }); + // @gate experimental it('is not called if pointer move occurs during keyboard press', () => { + componentInit(); const target = createEventTarget(ref.current); target.setBoundingClientRect({x: 0, y: 0, width: 100, height: 100}); target.keydown({key: 'Enter'}); @@ -525,7 +624,7 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { describe.each(pointerTypesTable)('press with movement: %s', pointerType => { let events, ref, outerRef; - beforeEach(() => { + const componentInit = () => { events = []; ref = React.createRef(); outerRef = React.createRef(); @@ -548,7 +647,7 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { }; ReactDOM.render(, container); document.elementFromPoint = () => ref.current; - }); + }; const rectMock = {width: 100, height: 100, x: 50, y: 50}; const pressRectOffset = 20; @@ -569,7 +668,9 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { * │ HitRect X │ <= Move to X and release * └──────────────────┘ */ + // @gate experimental it('"onPress*" events are called immediately', () => { + componentInit(); const target = createEventTarget(ref.current); target.setBoundingClientRect(rectMock); target.pointerdown({pointerType}); @@ -585,7 +686,9 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { ]); }); + // @gate experimental it('"onPress*" events are correctly called with target change', () => { + componentInit(); const target = createEventTarget(ref.current); const outerTarget = createEventTarget(outerRef.current); target.setBoundingClientRect(rectMock); @@ -614,7 +717,9 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { ]); }); + // @gate experimental it('press retention offset can be configured', () => { + componentInit(); const localEvents = []; const localRef = React.createRef(); const createEventHandler = msg => () => { @@ -654,7 +759,9 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { ]); }); + // @gate experimental it('responder region accounts for decrease in element dimensions', () => { + componentInit(); const target = createEventTarget(ref.current); target.setBoundingClientRect(rectMock); target.pointerdown({pointerType}); @@ -674,7 +781,9 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { ]); }); + // @gate experimental it('responder region accounts for increase in element dimensions', () => { + componentInit(); const target = createEventTarget(ref.current); target.setBoundingClientRect(rectMock); target.pointerdown({pointerType}); @@ -704,7 +813,9 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { * └──────────────────┘ * X <= Move to X and release */ + // @gate experimental it('"onPress" is not called on release', () => { + componentInit(); const target = createEventTarget(ref.current); const targetContainer = createEventTarget(container); target.setBoundingClientRect(rectMock); @@ -727,7 +838,9 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { }); }); + // @gate experimental it('"onPress" is called on re-entry to hit rect', () => { + componentInit(); const target = createEventTarget(ref.current); const targetContainer = createEventTarget(container); target.setBoundingClientRect(rectMock); @@ -759,6 +872,7 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { describe('nested responders', () => { if (hasPointerEvents) { + // @gate experimental it('dispatch events in the correct order', () => { const events = []; const ref = React.createRef(); @@ -819,6 +933,7 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { } describe('correctly not propagate', () => { + // @gate experimental it('for onPress', () => { const ref = React.createRef(); const onPress = jest.fn(); @@ -845,6 +960,7 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { expect(onPress).toHaveBeenCalledTimes(1); }); + // @gate experimental it('for onPressStart/onPressEnd', () => { const ref = React.createRef(); const onPressStart = jest.fn(); @@ -874,6 +990,7 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { expect(onPressEnd).toHaveBeenCalledTimes(1); }); + // @gate experimental it('for onPressChange', () => { const ref = React.createRef(); const onPressChange = jest.fn(); @@ -903,6 +1020,7 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { }); describe('link components', () => { + // @gate experimental it('prevents native behavior by default', () => { const onPress = jest.fn(); const preventDefault = jest.fn(); @@ -923,6 +1041,7 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { ); }); + // @gate experimental it('prevents native behaviour for keyboard events by default', () => { const onPress = jest.fn(); const preventDefault = jest.fn(); @@ -943,6 +1062,7 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { ); }); + // @gate experimental it('deeply prevents native behaviour by default', () => { const onPress = jest.fn(); const preventDefault = jest.fn(); @@ -964,6 +1084,7 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { expect(preventDefault).toBeCalled(); }); + // @gate experimental it('prevents native behaviour by default with nested elements', () => { const onPress = jest.fn(); const preventDefault = jest.fn(); @@ -988,6 +1109,7 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { ); }); + // @gate experimental it('uses native behaviour for interactions with modifier keys', () => { const onPress = jest.fn(); const preventDefault = jest.fn(); @@ -1010,6 +1132,7 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { }); }); + // @gate experimental it('uses native behaviour for pointer events if preventDefault is false', () => { const onPress = jest.fn(); const preventDefault = jest.fn(); @@ -1030,6 +1153,7 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { ); }); + // @gate experimental it('uses native behaviour for keyboard events if preventDefault is false', () => { const onPress = jest.fn(); const preventDefault = jest.fn(); @@ -1053,7 +1177,8 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { }); describe('responder cancellation', () => { - it.each(pointerTypesTable)('ends on pointer cancel', pointerType => { + // @gate experimental + it('ends on pointer cancel: mouse', () => { const onPressEnd = jest.fn(); const ref = React.createRef(); @@ -1064,12 +1189,30 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { ReactDOM.render(, container); const target = createEventTarget(ref.current); - target.pointerdown({pointerType}); - target.pointercancel({pointerType}); + target.pointerdown({pointerType: 'mouse'}); + target.pointercancel({pointerType: 'mouse'}); + expect(onPressEnd).toHaveBeenCalledTimes(1); + }); + + // @gate experimental + it('ends on pointer cancel: touch', () => { + const onPressEnd = jest.fn(); + const ref = React.createRef(); + + const Component = () => { + const listener = usePress({onPressEnd}); + return ; + }; + ReactDOM.render(, container); + + const target = createEventTarget(ref.current); + target.pointerdown({pointerType: 'touch'}); + target.pointercancel({pointerType: 'touch'}); expect(onPressEnd).toHaveBeenCalledTimes(1); }); }); + // @gate experimental it('does end on "scroll" to document (not mouse)', () => { const onPressEnd = jest.fn(); const ref = React.createRef(); @@ -1087,6 +1230,7 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { expect(onPressEnd).toHaveBeenCalledTimes(1); }); + // @gate experimental it('does end on "scroll" to a parent container (not mouse)', () => { const onPressEnd = jest.fn(); const ref = React.createRef(); @@ -1109,6 +1253,7 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { expect(onPressEnd).toHaveBeenCalledTimes(1); }); + // @gate experimental it('does not end on "scroll" to an element outside', () => { const onPressEnd = jest.fn(); const ref = React.createRef(); @@ -1132,10 +1277,12 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { expect(onPressEnd).not.toBeCalled(); }); + // @gate experimental it('expect displayName to show up for event component', () => { expect(PressResponder.displayName).toBe('Press'); }); + // @gate experimental it('should not trigger an invariant in addRootEventTypes()', () => { const ref = React.createRef(); @@ -1152,6 +1299,7 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { target.pointerdown(); }); + // @gate experimental it('event.preventDefault works as expected', () => { const onPress = jest.fn(e => e.preventDefault()); const onPressStart = jest.fn(e => e.preventDefault()); @@ -1173,6 +1321,7 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { expect(onPressEnd).toBeCalled(); }); + // @gate experimental it('when blur occurs on a pressed target, we should disengage press', () => { const onPress = jest.fn(); const onPressStart = jest.fn(); diff --git a/packages/react-interactions/events/src/dom/__tests__/Tap-test.internal.js b/packages/react-interactions/events/src/dom/__tests__/Tap-test.internal.js index 17c550a248..f986874450 100644 --- a/packages/react-interactions/events/src/dom/__tests__/Tap-test.internal.js +++ b/packages/react-interactions/events/src/dom/__tests__/Tap-test.internal.js @@ -31,7 +31,12 @@ function initializeModules(hasPointerEvents) { ReactFeatureFlags.enableDeprecatedFlareAPI = true; React = require('react'); ReactDOM = require('react-dom'); - useTap = require('react-interactions/events/tap').useTap; + + // TODO: This import throws outside of experimental mode. Figure out better + // strategy for gated imports. + if (__EXPERIMENTAL__) { + useTap = require('react-interactions/events/tap').useTap; + } } const coordinatesInside = {x: 51, y: 51}; @@ -73,11 +78,6 @@ function tapAndReleaseOutside({ describeWithPointerEvent('Tap responder', hasPointerEvents => { let container; - if (!__EXPERIMENTAL__) { - it("empty test so Jest doesn't complain", () => {}); - return; - } - beforeEach(() => { initializeModules(hasPointerEvents); container = document.createElement('div'); @@ -91,6 +91,7 @@ describeWithPointerEvent('Tap responder', hasPointerEvents => { resetActivePointers(); }); + // @gate experimental test('supports repeated use', () => { const ref = React.createRef(); const Component = () => { @@ -114,7 +115,7 @@ describeWithPointerEvent('Tap responder', hasPointerEvents => { describe('disabled', () => { let onTapStart, onTapChange, onTapUpdate, onTapCancel, onTapEnd, ref; - beforeEach(() => { + const componentInit = () => { onTapStart = jest.fn(); onTapChange = jest.fn(); onTapUpdate = jest.fn(); @@ -133,9 +134,11 @@ describeWithPointerEvent('Tap responder', hasPointerEvents => { return
; }; ReactDOM.render(, container); - }); + }; + // @gate experimental test('does not call callbacks', () => { + componentInit(); const target = createEventTarget(ref.current); target.pointerdown(); target.pointerup(); @@ -159,7 +162,7 @@ describeWithPointerEvent('Tap responder', hasPointerEvents => { document.elementFromPoint = () => ref.current; } - beforeEach(() => { + const componentInit = () => { onTapCancel = jest.fn(); onTapUpdate = jest.fn(); ref = React.createRef(); @@ -168,9 +171,11 @@ describeWithPointerEvent('Tap responder', hasPointerEvents => { onTapCancel, onTapUpdate, }); - }); + }; + // @gate experimental test('ignores values less than 10', () => { + componentInit(); render({ maximumDistance: 5, onTapCancel, @@ -184,27 +189,33 @@ describeWithPointerEvent('Tap responder', hasPointerEvents => { expect(onTapCancel).toHaveBeenCalledTimes(0); }); - testWithPointerType('below threshold', pointerType => { - const target = createEventTarget(ref.current); - target.pointerdown({pointerType, x: 0, y: 0}); - target.pointermove({pointerType, x: 10, y: 10}); - expect(onTapUpdate).toHaveBeenCalledTimes(1); - expect(onTapCancel).toHaveBeenCalledTimes(0); - }); + // TODO: Get rid of this condition somehow. Perhaps with a dynamic verion of + // the @gate pragma. + if (__EXPERIMENTAL__) { + testWithPointerType('below threshold', pointerType => { + componentInit(); + const target = createEventTarget(ref.current); + target.pointerdown({pointerType, x: 0, y: 0}); + target.pointermove({pointerType, x: 10, y: 10}); + expect(onTapUpdate).toHaveBeenCalledTimes(1); + expect(onTapCancel).toHaveBeenCalledTimes(0); + }); - testWithPointerType('above threshold', pointerType => { - const target = createEventTarget(ref.current); - target.pointerdown({pointerType, x: 0, y: 0}); - target.pointermove({pointerType, x: 15, y: 14}); - expect(onTapUpdate).toHaveBeenCalledTimes(0); - expect(onTapCancel).toHaveBeenCalledTimes(1); - }); + testWithPointerType('above threshold', pointerType => { + componentInit(); + const target = createEventTarget(ref.current); + target.pointerdown({pointerType, x: 0, y: 0}); + target.pointermove({pointerType, x: 15, y: 14}); + expect(onTapUpdate).toHaveBeenCalledTimes(0); + expect(onTapCancel).toHaveBeenCalledTimes(1); + }); + } }); describe('onAuxiliaryTap', () => { let onAuxiliaryTap, ref; - beforeEach(() => { + const componentInit = () => { onAuxiliaryTap = jest.fn(); ref = React.createRef(); const Component = () => { @@ -213,9 +224,11 @@ describeWithPointerEvent('Tap responder', hasPointerEvents => { }; ReactDOM.render(, container); document.elementFromPoint = () => ref.current; - }); + }; + // @gate experimental test('auxiliary-button pointer up', () => { + componentInit(); const pointerType = 'mouse'; const button = buttonType.auxiliary; const buttons = buttonsType.auxiliary; @@ -225,7 +238,9 @@ describeWithPointerEvent('Tap responder', hasPointerEvents => { expect(onAuxiliaryTap).toHaveBeenCalledTimes(1); }); + // @gate experimental test('modifier-button pointer up', () => { + componentInit(); const pointerType = 'mouse'; const button = buttonType.primary; const buttons = buttonsType.primary; @@ -239,7 +254,7 @@ describeWithPointerEvent('Tap responder', hasPointerEvents => { describe('onTapStart', () => { let onTapStart, ref; - beforeEach(() => { + const componentInit = () => { onTapStart = jest.fn(); ref = React.createRef(); const Component = () => { @@ -248,54 +263,61 @@ describeWithPointerEvent('Tap responder', hasPointerEvents => { }; ReactDOM.render(, container); document.elementFromPoint = () => ref.current; - }); + }; - testWithPointerType('pointer down', pointerType => { - const target = createEventTarget(ref.current); - const nativeEvent = { - button: buttonType.primary, - buttons: buttonsType.primary, - pageX: 10, - pageY: 10, - pointerType, - x: 10, - y: 10, - }; - target.pointerdown(nativeEvent); - // 'pointerup' is only for the MouseEvent/TouchEvent fallback - // implementation. We also dispatch 'pointerup' so that this test covers - // the case where browsers dispatch an emulated mousedown (and mouseup) - // event *after* a touch ends. - target.pointerup(nativeEvent); - expect(onTapStart).toHaveBeenCalledTimes(1); - expect(onTapStart).toHaveBeenCalledWith( - expect.objectContaining({ - altKey: false, - ctrlKey: false, - height: pointerType === 'mouse' ? 1 : 23, - metaKey: false, + // TODO: Get rid of this condition somehow. Perhaps with a dynamic verion of + // the @gate pragma. + if (__EXPERIMENTAL__) { + testWithPointerType('pointer down', pointerType => { + componentInit(); + const target = createEventTarget(ref.current); + const nativeEvent = { + button: buttonType.primary, + buttons: buttonsType.primary, pageX: 10, pageY: 10, pointerType, - pressure: pointerType === 'touch' ? 1 : 0.5, - screenX: 10, - screenY: 60, - shiftKey: false, - tangentialPressure: 0, - target: target.node, - tiltX: 0, - tiltY: 0, - timeStamp: expect.any(Number), - twist: 0, - type: 'tap:start', - width: pointerType === 'mouse' ? 1 : 23, x: 10, y: 10, - }), - ); - }); + }; + target.pointerdown(nativeEvent); + // 'pointerup' is only for the MouseEvent/TouchEvent fallback + // implementation. We also dispatch 'pointerup' so that this test covers + // the case where browsers dispatch an emulated mousedown (and mouseup) + // event *after* a touch ends. + target.pointerup(nativeEvent); + expect(onTapStart).toHaveBeenCalledTimes(1); + expect(onTapStart).toHaveBeenCalledWith( + expect.objectContaining({ + altKey: false, + ctrlKey: false, + height: pointerType === 'mouse' ? 1 : 23, + metaKey: false, + pageX: 10, + pageY: 10, + pointerType, + pressure: pointerType === 'touch' ? 1 : 0.5, + screenX: 10, + screenY: 60, + shiftKey: false, + tangentialPressure: 0, + target: target.node, + tiltX: 0, + tiltY: 0, + timeStamp: expect.any(Number), + twist: 0, + type: 'tap:start', + width: pointerType === 'mouse' ? 1 : 23, + x: 10, + y: 10, + }), + ); + }); + } + // @gate experimental test('second pointer on target', () => { + componentInit(); const pointerType = 'touch'; const target = createEventTarget(ref.current); const button = buttonType.primary; @@ -306,79 +328,84 @@ describeWithPointerEvent('Tap responder', hasPointerEvents => { expect(onTapStart).toHaveBeenCalledTimes(1); }); - testWithPointerType('ignored buttons and modifiers', pointerType => { - const target = createEventTarget(ref.current); - if (pointerType !== 'touch') { - // right-click + // TODO: Get rid of this condition somehow. Perhaps with a dynamic verion of + // the @gate pragma. + if (__EXPERIMENTAL__) { + testWithPointerType('ignored buttons and modifiers', pointerType => { + componentInit(); + const target = createEventTarget(ref.current); + if (pointerType !== 'touch') { + // right-click + target.pointerdown({ + button: buttonType.secondary, + buttons: buttonsType.secondary, + pointerType, + }); + target.pointerup({pointerType}); + // middle-click + target.pointerdown({ + button: buttonType.auxiliary, + buttons: buttonsType.auxiliary, + pointerType, + }); + target.pointerup({pointerType}); + // virtual middle-click with misleading 'buttons' value + target.pointerdown({ + button: buttonType.auxiliary, + buttons: 0, + pointerType, + }); + target.pointerup({pointerType}); + // pen eraser + target.pointerdown({ + button: buttonType.eraser, + buttons: buttonsType.eraser, + pointerType, + }); + target.pointerup({pointerType}); + } + // alt-click target.pointerdown({ - button: buttonType.secondary, - buttons: buttonsType.secondary, + button: buttonType.primary, + buttons: buttonsType.primary, + altKey: true, pointerType, }); target.pointerup({pointerType}); - // middle-click + // ctrl-click target.pointerdown({ - button: buttonType.auxiliary, - buttons: buttonsType.auxiliary, + button: buttonType.primary, + buttons: buttonsType.primary, + ctrlKey: true, pointerType, }); target.pointerup({pointerType}); - // virtual middle-click with misleading 'buttons' value + // meta-click target.pointerdown({ - button: buttonType.auxiliary, - buttons: 0, + button: buttonType.primary, + buttons: buttonsType.primary, + metaKey: true, pointerType, }); target.pointerup({pointerType}); - // pen eraser + // shift-click target.pointerdown({ - button: buttonType.eraser, - buttons: buttonsType.eraser, + button: buttonType.primary, + buttons: buttonsType.primary, + shiftKey: true, pointerType, }); target.pointerup({pointerType}); - } - // alt-click - target.pointerdown({ - button: buttonType.primary, - buttons: buttonsType.primary, - altKey: true, - pointerType, - }); - target.pointerup({pointerType}); - // ctrl-click - target.pointerdown({ - button: buttonType.primary, - buttons: buttonsType.primary, - ctrlKey: true, - pointerType, - }); - target.pointerup({pointerType}); - // meta-click - target.pointerdown({ - button: buttonType.primary, - buttons: buttonsType.primary, - metaKey: true, - pointerType, - }); - target.pointerup({pointerType}); - // shift-click - target.pointerdown({ - button: buttonType.primary, - buttons: buttonsType.primary, - shiftKey: true, - pointerType, - }); - target.pointerup({pointerType}); - expect(onTapStart).toHaveBeenCalledTimes(0); - }); + expect(onTapStart).toHaveBeenCalledTimes(0); + }); + } }); describe('onTapEnd', () => { let onTapEnd, ref; - beforeEach(() => { + const componentInit = () => { onTapEnd = jest.fn(); ref = React.createRef(); const Component = () => { @@ -387,87 +414,96 @@ describeWithPointerEvent('Tap responder', hasPointerEvents => { }; ReactDOM.render(, container); document.elementFromPoint = () => ref.current; - }); + }; - testWithPointerType('pointer up', pointerType => { - const target = createEventTarget(ref.current); - const button = buttonType.primary; - const buttons = buttonsType.primary; - target.pointerdown({button, buttons, pointerType}); - target.pointerup({ - button, - buttons, - pageX: 10, - pageY: 10, - pointerType, - x: 10, - y: 10, - }); - expect(onTapEnd).toHaveBeenCalledTimes(1); - expect(onTapEnd).toHaveBeenCalledWith( - expect.objectContaining({ - altKey: false, - ctrlKey: false, - height: pointerType === 'mouse' ? 1 : 23, - metaKey: false, + // TODO: Get rid of this condition somehow. Perhaps with a dynamic verion of + // the @gate pragma. + if (__EXPERIMENTAL__) { + testWithPointerType('pointer up', pointerType => { + componentInit(); + const target = createEventTarget(ref.current); + const button = buttonType.primary; + const buttons = buttonsType.primary; + target.pointerdown({button, buttons, pointerType}); + target.pointerup({ + button, + buttons, pageX: 10, pageY: 10, pointerType, - pressure: 0, - screenX: 10, - screenY: 60, - shiftKey: false, - tangentialPressure: 0, - target: target.node, - tiltX: 0, - tiltY: 0, - timeStamp: expect.any(Number), - twist: 0, - type: 'tap:end', - width: pointerType === 'mouse' ? 1 : 23, x: 10, y: 10, - }), - ); - }); - - testWithPointerType('zero-dimension hit rect', pointerType => { - const targetRef = React.createRef(); - const innerRef = React.createRef(); - - const Component = () => { - const listener = useTap({onTapEnd}); - return ( -
-
+ }); + expect(onTapEnd).toHaveBeenCalledTimes(1); + expect(onTapEnd).toHaveBeenCalledWith( + expect.objectContaining({ + altKey: false, + ctrlKey: false, + height: pointerType === 'mouse' ? 1 : 23, + metaKey: false, + pageX: 10, + pageY: 10, + pointerType, + pressure: 0, + screenX: 10, + screenY: 60, + shiftKey: false, + tangentialPressure: 0, + target: target.node, + tiltX: 0, + tiltY: 0, + timeStamp: expect.any(Number), + twist: 0, + type: 'tap:end', + width: pointerType === 'mouse' ? 1 : 23, + x: 10, + y: 10, + }), ); - }; - ReactDOM.render(, container); - document.elementFromPoint = () => innerRef.current; - - const target = createEventTarget(targetRef.current); - target.setBoundingClientRect({x: 0, y: 0, width: 0, height: 0}); - const innerTarget = createEventTarget(innerRef.current); - innerTarget.pointerdown({pointerType}); - innerTarget.pointerup({pointerType}); - expect(onTapEnd).toBeCalled(); - }); - - testWithPointerType('pointer up outside target', pointerType => { - const downTarget = createEventTarget(ref.current); - const upTarget = createEventTarget(container); - tapAndReleaseOutside({ - hasPointerEvents, - downTarget, - upTarget, - pointerType, }); - expect(onTapEnd).not.toBeCalled(); - }); + + testWithPointerType('zero-dimension hit rect', pointerType => { + componentInit(); + const targetRef = React.createRef(); + const innerRef = React.createRef(); + + const Component = () => { + const listener = useTap({onTapEnd}); + return ( +
+
+ ); + }; + ReactDOM.render(, container); + document.elementFromPoint = () => innerRef.current; + + const target = createEventTarget(targetRef.current); + target.setBoundingClientRect({x: 0, y: 0, width: 0, height: 0}); + const innerTarget = createEventTarget(innerRef.current); + innerTarget.pointerdown({pointerType}); + innerTarget.pointerup({pointerType}); + expect(onTapEnd).toBeCalled(); + }); + + testWithPointerType('pointer up outside target', pointerType => { + componentInit(); + const downTarget = createEventTarget(ref.current); + const upTarget = createEventTarget(container); + tapAndReleaseOutside({ + hasPointerEvents, + downTarget, + upTarget, + pointerType, + }); + expect(onTapEnd).not.toBeCalled(); + }); + } if (hasPointerEvents) { + // @gate experimental test('second pointer up off target', () => { + componentInit(); const pointerType = 'touch'; const target = createEventTarget(ref.current); const offTarget = createEventTarget(container); @@ -490,7 +526,9 @@ describeWithPointerEvent('Tap responder', hasPointerEvents => { }); } + // @gate experimental test('ignored buttons and modifiers', () => { + componentInit(); const target = createEventTarget(ref.current); // right-click target.pointerdown({ @@ -553,7 +591,7 @@ describeWithPointerEvent('Tap responder', hasPointerEvents => { const rect = {x: 0, y: 0, width: 100, height: 100}; const coordinates = {x: 10, y: 10}; - beforeEach(() => { + const componentInit = () => { onTapUpdate = jest.fn(); ref = React.createRef(); const Component = () => { @@ -562,81 +600,90 @@ describeWithPointerEvent('Tap responder', hasPointerEvents => { }; ReactDOM.render(, container); document.elementFromPoint = () => ref.current; - }); + }; - testWithPointerType('requires activation', pointerType => { - const target = createEventTarget(ref.current); - target.setBoundingClientRect(rect); - if (pointerType !== 'touch') { - target.pointerhover({pointerType, ...coordinates}); - target.pointermove({pointerType, ...coordinates}); - } - expect(onTapUpdate).not.toBeCalled(); - }); + // TODO: Get rid of this condition somehow. Perhaps with a dynamic verion of + // the @gate pragma. + if (__EXPERIMENTAL__) { + testWithPointerType('requires activation', pointerType => { + componentInit(); + const target = createEventTarget(ref.current); + target.setBoundingClientRect(rect); + if (pointerType !== 'touch') { + target.pointerhover({pointerType, ...coordinates}); + target.pointermove({pointerType, ...coordinates}); + } + expect(onTapUpdate).not.toBeCalled(); + }); - testWithPointerType('pointer move', pointerType => { - const target = createEventTarget(ref.current); - target.setBoundingClientRect(rect); - target.pointerdown({pointerType}); + testWithPointerType('pointer move', pointerType => { + componentInit(); + const target = createEventTarget(ref.current); + target.setBoundingClientRect(rect); + target.pointerdown({pointerType}); - target.pointermove({pointerType, x: 10, y: 10}); - expect(onTapUpdate).toHaveBeenCalledWith( - expect.objectContaining({ - altKey: false, - ctrlKey: false, - height: pointerType === 'mouse' ? 1 : 23, - metaKey: false, - pageX: 10, - pageY: 10, - pointerType, - pressure: pointerType === 'touch' ? 1 : 0.5, - screenX: 10, - screenY: 60, - shiftKey: false, - tangentialPressure: 0, - target: target.node, - tiltX: 0, - tiltY: 0, - timeStamp: expect.any(Number), - twist: 0, - type: 'tap:update', - width: pointerType === 'mouse' ? 1 : 23, - x: 10, - y: 10, - }), - ); + target.pointermove({pointerType, x: 10, y: 10}); + expect(onTapUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + altKey: false, + ctrlKey: false, + height: pointerType === 'mouse' ? 1 : 23, + metaKey: false, + pageX: 10, + pageY: 10, + pointerType, + pressure: pointerType === 'touch' ? 1 : 0.5, + screenX: 10, + screenY: 60, + shiftKey: false, + tangentialPressure: 0, + target: target.node, + tiltX: 0, + tiltY: 0, + timeStamp: expect.any(Number), + twist: 0, + type: 'tap:update', + width: pointerType === 'mouse' ? 1 : 23, + x: 10, + y: 10, + }), + ); - target.pointermove({pointerType, x: 20, y: 20}); - expect(onTapUpdate).toHaveBeenCalledWith( - expect.objectContaining({pointerType, x: 20, y: 20}), - ); + target.pointermove({pointerType, x: 20, y: 20}); + expect(onTapUpdate).toHaveBeenCalledWith( + expect.objectContaining({pointerType, x: 20, y: 20}), + ); - expect(onTapUpdate).toHaveBeenCalledTimes(2); - }); + expect(onTapUpdate).toHaveBeenCalledTimes(2); + }); - testWithPointerType('pointer moves outside target', pointerType => { - const target = createEventTarget(ref.current); - const containerTarget = createEventTarget(container); - target.setBoundingClientRect(rect); - target.pointerdown({pointerType}); - target.pointermove({pointerType, x: 10, y: 10}); - expect(onTapUpdate).toHaveBeenCalledTimes(1); + testWithPointerType('pointer moves outside target', pointerType => { + componentInit(); + const target = createEventTarget(ref.current); + const containerTarget = createEventTarget(container); + target.setBoundingClientRect(rect); + target.pointerdown({pointerType}); + target.pointermove({pointerType, x: 10, y: 10}); + expect(onTapUpdate).toHaveBeenCalledTimes(1); - // NOTE: this assumes the PointerEvent implementation calls - // 'releasePointerCapture' for touch pointers - if (!hasPointerEvents && pointerType === 'touch') { - document.elementFromPoint = () => containerTarget.node; - target.pointermove({pointerType, x: 101, y: 101}); - } else { - containerTarget.pointermove({pointerType, x: 101, y: 101}); - } + // NOTE: this assumes the PointerEvent implementation calls + // 'releasePointerCapture' for touch pointers + if (!hasPointerEvents && pointerType === 'touch') { + document.elementFromPoint = () => containerTarget.node; + target.pointermove({pointerType, x: 101, y: 101}); + } else { + containerTarget.pointermove({pointerType, x: 101, y: 101}); + } - // No extra 'onTapUpdate' calls when the pointer is outside the target - expect(onTapUpdate).toHaveBeenCalledTimes(1); - }); + // No extra 'onTapUpdate' calls when the pointer is outside the target + expect(onTapUpdate).toHaveBeenCalledTimes(1); + }); + } if (hasPointerEvents) { + // @gate experimental test('second pointer off target', () => { + componentInit(); const pointerType = 'touch'; const target = createEventTarget(ref.current); const offTarget = createEventTarget(container); @@ -659,7 +706,7 @@ describeWithPointerEvent('Tap responder', hasPointerEvents => { eventsLog.push(msg); }; - beforeEach(() => { + const componentInit = () => { eventsLog = []; onTapChange = jest.fn(); ref = React.createRef(); @@ -677,47 +724,54 @@ describeWithPointerEvent('Tap responder', hasPointerEvents => { }; ReactDOM.render(, container); document.elementFromPoint = () => ref.current; - }); + }; - testWithPointerType('pointer down/up', pointerType => { - const target = createEventTarget(ref.current); - target.pointerdown({pointerType}); - expect(onTapChange).toHaveBeenCalledTimes(1); - expect(onTapChange).toHaveBeenCalledWith(true); - target.pointerup({pointerType, x: 0, y: 0}); - expect(onTapChange).toHaveBeenCalledTimes(2); - expect(onTapChange).toHaveBeenCalledWith(false); - expect(eventsLog).toEqual(['start', 'change', 'change', 'end']); - }); - - testWithPointerType('pointer cancel', pointerType => { - const target = createEventTarget(ref.current); - target.pointerdown({pointerType}); - expect(onTapChange).toHaveBeenCalledTimes(1); - expect(onTapChange).toHaveBeenCalledWith(true); - target.pointercancel({pointerType}); - expect(onTapChange).toHaveBeenCalledTimes(2); - expect(onTapChange).toHaveBeenCalledWith(false); - expect(eventsLog).toEqual(['start', 'change', 'change', 'cancel']); - }); - - testWithPointerType('pointer move outside target', pointerType => { - const downTarget = createEventTarget(ref.current); - const upTarget = createEventTarget(container); - tapAndMoveOutside({ - hasPointerEvents, - downTarget, - upTarget, - pointerType, + // TODO: Get rid of this condition somehow. Perhaps with a dynamic verion of + // the @gate pragma. + if (__EXPERIMENTAL__) { + testWithPointerType('pointer down/up', pointerType => { + componentInit(); + const target = createEventTarget(ref.current); + target.pointerdown({pointerType}); + expect(onTapChange).toHaveBeenCalledTimes(1); + expect(onTapChange).toHaveBeenCalledWith(true); + target.pointerup({pointerType, x: 0, y: 0}); + expect(onTapChange).toHaveBeenCalledTimes(2); + expect(onTapChange).toHaveBeenCalledWith(false); + expect(eventsLog).toEqual(['start', 'change', 'change', 'end']); }); - expect(onTapChange).toHaveBeenCalledTimes(2); - }); + + testWithPointerType('pointer cancel', pointerType => { + componentInit(); + const target = createEventTarget(ref.current); + target.pointerdown({pointerType}); + expect(onTapChange).toHaveBeenCalledTimes(1); + expect(onTapChange).toHaveBeenCalledWith(true); + target.pointercancel({pointerType}); + expect(onTapChange).toHaveBeenCalledTimes(2); + expect(onTapChange).toHaveBeenCalledWith(false); + expect(eventsLog).toEqual(['start', 'change', 'change', 'cancel']); + }); + + testWithPointerType('pointer move outside target', pointerType => { + componentInit(); + const downTarget = createEventTarget(ref.current); + const upTarget = createEventTarget(container); + tapAndMoveOutside({ + hasPointerEvents, + downTarget, + upTarget, + pointerType, + }); + expect(onTapChange).toHaveBeenCalledTimes(2); + }); + } }); describe('onTapCancel', () => { let onTapCancel, onTapUpdate, parentRef, ref, siblingRef; - beforeEach(() => { + const componentInit = () => { onTapCancel = jest.fn(); onTapUpdate = jest.fn(); parentRef = React.createRef(); @@ -733,43 +787,50 @@ describeWithPointerEvent('Tap responder', hasPointerEvents => { ); }; ReactDOM.render(, container); - }); + }; - testWithPointerType('pointer cancel', pointerType => { - const target = createEventTarget(ref.current); - target.pointerdown({pointerType}); - target.pointercancel({pointerType}); - expect(onTapCancel).toHaveBeenCalledTimes(1); - expect(onTapCancel).toHaveBeenCalledWith( - expect.objectContaining({ - altKey: false, - ctrlKey: false, - height: 1, - metaKey: false, - pageX: 0, - pageY: 0, - pointerType, - pressure: 0, - screenX: 0, - screenY: 0, - shiftKey: false, - tangentialPressure: 0, - target: target.node, - tiltX: 0, - tiltY: 0, - timeStamp: expect.any(Number), - twist: 0, - type: 'tap:cancel', - width: 1, - x: 0, - y: 0, - }), - ); - target.pointermove({pointerType, x: 5, y: 5}); - expect(onTapUpdate).not.toBeCalled(); - }); + // TODO: Get rid of this condition somehow. Perhaps with a dynamic verion of + // the @gate pragma. + if (__EXPERIMENTAL__) { + testWithPointerType('pointer cancel', pointerType => { + componentInit(); + const target = createEventTarget(ref.current); + target.pointerdown({pointerType}); + target.pointercancel({pointerType}); + expect(onTapCancel).toHaveBeenCalledTimes(1); + expect(onTapCancel).toHaveBeenCalledWith( + expect.objectContaining({ + altKey: false, + ctrlKey: false, + height: 1, + metaKey: false, + pageX: 0, + pageY: 0, + pointerType, + pressure: 0, + screenX: 0, + screenY: 0, + shiftKey: false, + tangentialPressure: 0, + target: target.node, + tiltX: 0, + tiltY: 0, + timeStamp: expect.any(Number), + twist: 0, + type: 'tap:cancel', + width: 1, + x: 0, + y: 0, + }), + ); + target.pointermove({pointerType, x: 5, y: 5}); + expect(onTapUpdate).not.toBeCalled(); + }); + } + // @gate experimental test('second pointer on target', () => { + componentInit(); const pointerType = 'touch'; const target = createEventTarget(ref.current); const button = buttonType.primary; @@ -780,7 +841,9 @@ describeWithPointerEvent('Tap responder', hasPointerEvents => { }); if (hasPointerEvents) { + // @gate experimental test('second pointer off target', () => { + componentInit(); const pointerType = 'touch'; const target = createEventTarget(ref.current); const offTarget = createEventTarget(container); @@ -792,19 +855,26 @@ describeWithPointerEvent('Tap responder', hasPointerEvents => { }); } - testWithPointerType('pointer move outside target', pointerType => { - const downTarget = createEventTarget(ref.current); - const upTarget = createEventTarget(container); - tapAndMoveOutside({ - hasPointerEvents, - downTarget, - upTarget, - pointerType, + // TODO: Get rid of this condition somehow. Perhaps with a dynamic verion of + // the @gate pragma. + if (__EXPERIMENTAL__) { + testWithPointerType('pointer move outside target', pointerType => { + componentInit(); + const downTarget = createEventTarget(ref.current); + const upTarget = createEventTarget(container); + tapAndMoveOutside({ + hasPointerEvents, + downTarget, + upTarget, + pointerType, + }); + expect(onTapCancel).toBeCalled(); }); - expect(onTapCancel).toBeCalled(); - }); + } + // @gate experimental test('ignored modifiers', () => { + componentInit(); const target = createEventTarget(ref.current); const button = buttonType.primary; const buttons = buttonsType.primary; @@ -824,13 +894,17 @@ describeWithPointerEvent('Tap responder', hasPointerEvents => { expect(onTapCancel).toHaveBeenCalledTimes(4); }); + // @gate experimental test('long press context menu', () => { + componentInit(); const target = createEventTarget(ref.current); target.contextmenu({}, {pointerType: 'touch'}); expect(onTapCancel).toHaveBeenCalledTimes(1); }); + // @gate experimental test('parent scroll (non-mouse)', () => { + componentInit(); const target = createEventTarget(ref.current); const parentTarget = createEventTarget(parentRef.current); target.pointerdown({pointerType: 'touch'}); @@ -838,7 +912,9 @@ describeWithPointerEvent('Tap responder', hasPointerEvents => { expect(onTapCancel).toHaveBeenCalledTimes(1); }); + // @gate experimental test('sibling scroll', () => { + componentInit(); const target = createEventTarget(ref.current); const siblingTarget = createEventTarget(siblingRef.current); target.pointerdown(); @@ -846,7 +922,9 @@ describeWithPointerEvent('Tap responder', hasPointerEvents => { expect(onTapCancel).not.toBeCalled(); }); + // @gate experimental test('document scroll (non-mouse)', () => { + componentInit(); const target = createEventTarget(ref.current); const documentTarget = createEventTarget(document); target.pointerdown({pointerType: 'touch'}); @@ -855,7 +933,9 @@ describeWithPointerEvent('Tap responder', hasPointerEvents => { }); // Scroll on an element not managed by React + // @gate experimental test('root container scroll (non-mouse)', () => { + componentInit(); const target = createEventTarget(ref.current); const containerTarget = createEventTarget(container); target.pointerdown({pointerType: 'touch'}); @@ -867,7 +947,7 @@ describeWithPointerEvent('Tap responder', hasPointerEvents => { describe('preventDefault', () => { let onTapEnd, ref, innerRef, preventDefault, remount; - beforeEach(() => { + const componentInit = () => { remount = function(shouldPreventDefault) { onTapEnd = jest.fn(); preventDefault = jest.fn(); @@ -887,9 +967,11 @@ describeWithPointerEvent('Tap responder', hasPointerEvents => { ReactDOM.render(, container); }; remount(); - }); + }; + // @gate experimental test('prevents native behavior by default', () => { + componentInit(); const target = createEventTarget(ref.current); target.pointerdown(); target.pointerup({preventDefault}); @@ -899,7 +981,9 @@ describeWithPointerEvent('Tap responder', hasPointerEvents => { ); }); + // @gate experimental test('prevents native behaviour by default (inner target)', () => { + componentInit(); const innerTarget = createEventTarget(innerRef.current); innerTarget.pointerdown(); innerTarget.pointerup({preventDefault}); @@ -909,7 +993,9 @@ describeWithPointerEvent('Tap responder', hasPointerEvents => { ); }); + // @gate experimental test('allows native behaviour if false', () => { + componentInit(); remount(false); const target = createEventTarget(ref.current); diff --git a/packages/react-noop-renderer/src/ReactNoopPersistent.js b/packages/react-noop-renderer/src/ReactNoopPersistent.js index 3fc7f64445..dd9aa34d7d 100644 --- a/packages/react-noop-renderer/src/ReactNoopPersistent.js +++ b/packages/react-noop-renderer/src/ReactNoopPersistent.js @@ -24,6 +24,7 @@ export const { getOrCreateRootContainer, createRoot, createBlockingRoot, + createLegacyRoot, getChildrenAsJSX, getPendingChildrenAsJSX, createPortal, diff --git a/packages/react-reconciler/src/__tests__/ReactFiberFundamental-test.internal.js b/packages/react-reconciler/src/__tests__/ReactFiberFundamental-test.internal.js index 081b0be2d3..f465226d20 100644 --- a/packages/react-reconciler/src/__tests__/ReactFiberFundamental-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactFiberFundamental-test.internal.js @@ -50,16 +50,12 @@ function initReactDOMServer() { } describe('ReactFiberFundamental', () => { - if (!__EXPERIMENTAL__) { - it("empty test so Jest doesn't complain", () => {}); - return; - } - describe('NoopRenderer', () => { beforeEach(() => { initNoopRenderer(); }); + // @gate experimental it('should render a simple fundamental component with a single child', () => { const FundamentalComponent = createReactFundamentalComponent({ reconcileChildren: true, @@ -94,6 +90,7 @@ describe('ReactFiberFundamental', () => { initTestRenderer(); }); + // @gate experimental it('should render a simple fundamental component with a single child', () => { const FundamentalComponent = createReactFundamentalComponent({ reconcileChildren: true, @@ -130,6 +127,7 @@ describe('ReactFiberFundamental', () => { initReactDOM(); }); + // @gate experimental it('should render a simple fundamental component with a single child', () => { const FundamentalComponent = createReactFundamentalComponent({ reconcileChildren: true, @@ -155,6 +153,7 @@ describe('ReactFiberFundamental', () => { expect(container.innerHTML).toBe(''); }); + // @gate experimental it('should render a simple fundamental component without reconcileChildren', () => { const FundamentalComponent = createReactFundamentalComponent({ reconcileChildren: false, @@ -186,6 +185,7 @@ describe('ReactFiberFundamental', () => { initReactDOMServer(); }); + // @gate experimental it('should render a simple fundamental component with a single child', () => { const getInstance = jest.fn(); const FundamentalComponent = createReactFundamentalComponent({ @@ -210,6 +210,7 @@ describe('ReactFiberFundamental', () => { expect(output).toBe('
Hello world again
'); }); + // @gate experimental it('should render a simple fundamental component without reconcileChildren', () => { const FundamentalComponent = createReactFundamentalComponent({ reconcileChildren: false, diff --git a/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.internal.js b/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.internal.js index dfe89a510b..84ddb0c0dc 100644 --- a/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.internal.js @@ -1121,384 +1121,380 @@ describe('ReactHooksWithNoopRenderer', () => { }, ); - if ( - require('shared/ReactFeatureFlags') - .deferPassiveEffectCleanupDuringUnmount && - require('shared/ReactFeatureFlags') - .runAllPassiveEffectDestroysBeforeCreates - ) { - it('defers passive effect destroy functions during unmount', () => { - function Child({bar, foo}) { - React.useEffect(() => { - Scheduler.unstable_yieldValue('passive bar create'); - return () => { - Scheduler.unstable_yieldValue('passive bar destroy'); - }; - }, [bar]); - React.useLayoutEffect(() => { - Scheduler.unstable_yieldValue('layout bar create'); - return () => { - Scheduler.unstable_yieldValue('layout bar destroy'); - }; - }, [bar]); - React.useEffect(() => { - Scheduler.unstable_yieldValue('passive foo create'); - return () => { - Scheduler.unstable_yieldValue('passive foo destroy'); - }; - }, [foo]); - React.useLayoutEffect(() => { - Scheduler.unstable_yieldValue('layout foo create'); - return () => { - Scheduler.unstable_yieldValue('layout foo destroy'); - }; - }, [foo]); - Scheduler.unstable_yieldValue('render'); - return null; - } + // @gate deferPassiveEffectCleanupDuringUnmount && runAllPassiveEffectDestroysBeforeCreates + it('defers passive effect destroy functions during unmount', () => { + function Child({bar, foo}) { + React.useEffect(() => { + Scheduler.unstable_yieldValue('passive bar create'); + return () => { + Scheduler.unstable_yieldValue('passive bar destroy'); + }; + }, [bar]); + React.useLayoutEffect(() => { + Scheduler.unstable_yieldValue('layout bar create'); + return () => { + Scheduler.unstable_yieldValue('layout bar destroy'); + }; + }, [bar]); + React.useEffect(() => { + Scheduler.unstable_yieldValue('passive foo create'); + return () => { + Scheduler.unstable_yieldValue('passive foo destroy'); + }; + }, [foo]); + React.useLayoutEffect(() => { + Scheduler.unstable_yieldValue('layout foo create'); + return () => { + Scheduler.unstable_yieldValue('layout foo destroy'); + }; + }, [foo]); + Scheduler.unstable_yieldValue('render'); + return null; + } - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - 'render', - 'layout bar create', - 'layout foo create', - 'Sync effect', - ]); - // Effects are deferred until after the commit - expect(Scheduler).toFlushAndYield([ - 'passive bar create', - 'passive foo create', - ]); - }); - - // This update is exists to test an internal implementation detail: - // Effects without updating dependencies lose their layout/passive tag during an update. - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - 'render', - 'layout foo destroy', - 'layout foo create', - 'Sync effect', - ]); - // Effects are deferred until after the commit - expect(Scheduler).toFlushAndYield([ - 'passive foo destroy', - 'passive foo create', - ]); - }); - - // Unmount the component and verify that passive destroy functions are deferred until post-commit. - act(() => { - ReactNoop.render(null, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - 'layout bar destroy', - 'layout foo destroy', - 'Sync effect', - ]); - // Effects are deferred until after the commit - expect(Scheduler).toFlushAndYield([ - 'passive bar destroy', - 'passive foo destroy', - ]); - }); + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'render', + 'layout bar create', + 'layout foo create', + 'Sync effect', + ]); + // Effects are deferred until after the commit + expect(Scheduler).toFlushAndYield([ + 'passive bar create', + 'passive foo create', + ]); }); - it('does not warn about state updates for unmounted components with pending passive unmounts', () => { - let completePendingRequest = null; - function Component() { - Scheduler.unstable_yieldValue('Component'); - const [didLoad, setDidLoad] = React.useState(false); - React.useLayoutEffect(() => { - Scheduler.unstable_yieldValue('layout create'); - return () => { - Scheduler.unstable_yieldValue('layout destroy'); - }; - }, []); - React.useEffect(() => { - Scheduler.unstable_yieldValue('passive create'); - // Mimic an XHR request with a complete handler that updates state. - completePendingRequest = () => setDidLoad(true); - return () => { - Scheduler.unstable_yieldValue('passive destroy'); - }; - }, []); - return didLoad; - } - - act(() => { - ReactNoop.renderToRootWithID(, 'root', () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - 'Component', - 'layout create', - 'Sync effect', - ]); - ReactNoop.flushPassiveEffects(); - expect(Scheduler).toHaveYielded(['passive create']); - - // Unmount but don't process pending passive destroy function - ReactNoop.unmountRootWithID('root'); - expect(Scheduler).toFlushAndYieldThrough(['layout destroy']); - - // Simulate an XHR completing, which will cause a state update- - // but should not log a warning. - completePendingRequest(); - - ReactNoop.flushPassiveEffects(); - expect(Scheduler).toHaveYielded(['passive destroy']); - }); + // This update is exists to test an internal implementation detail: + // Effects without updating dependencies lose their layout/passive tag during an update. + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'render', + 'layout foo destroy', + 'layout foo create', + 'Sync effect', + ]); + // Effects are deferred until after the commit + expect(Scheduler).toFlushAndYield([ + 'passive foo destroy', + 'passive foo create', + ]); }); - it('still warns about state updates for unmounted components with no pending passive unmounts', () => { - let completePendingRequest = null; - function Component() { - Scheduler.unstable_yieldValue('Component'); - const [didLoad, setDidLoad] = React.useState(false); - React.useLayoutEffect(() => { - Scheduler.unstable_yieldValue('layout create'); - // Mimic an XHR request with a complete handler that updates state. - completePendingRequest = () => setDidLoad(true); - return () => { - Scheduler.unstable_yieldValue('layout destroy'); - }; - }, []); - return didLoad; - } - - act(() => { - ReactNoop.renderToRootWithID(, 'root', () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - 'Component', - 'layout create', - 'Sync effect', - ]); - - // Unmount but don't process pending passive destroy function - ReactNoop.unmountRootWithID('root'); - expect(Scheduler).toFlushAndYieldThrough(['layout destroy']); - - // Simulate an XHR completing. - expect(completePendingRequest).toErrorDev( - "Warning: Can't perform a React state update on an unmounted component.", - ); - }); + // Unmount the component and verify that passive destroy functions are deferred until post-commit. + act(() => { + ReactNoop.render(null, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'layout bar destroy', + 'layout foo destroy', + 'Sync effect', + ]); + // Effects are deferred until after the commit + expect(Scheduler).toFlushAndYield([ + 'passive bar destroy', + 'passive foo destroy', + ]); }); + }); - it('still warns if there are pending passive unmount effects but not for the current fiber', () => { - let completePendingRequest = null; - function ComponentWithXHR() { - Scheduler.unstable_yieldValue('Component'); - const [didLoad, setDidLoad] = React.useState(false); - React.useLayoutEffect(() => { - Scheduler.unstable_yieldValue('a:layout create'); - return () => { - Scheduler.unstable_yieldValue('a:layout destroy'); - }; - }, []); - React.useEffect(() => { - Scheduler.unstable_yieldValue('a:passive create'); - // Mimic an XHR request with a complete handler that updates state. - completePendingRequest = () => setDidLoad(true); - }, []); - return didLoad; - } + // @gate deferPassiveEffectCleanupDuringUnmount && runAllPassiveEffectDestroysBeforeCreates + it('does not warn about state updates for unmounted components with pending passive unmounts', () => { + let completePendingRequest = null; + function Component() { + Scheduler.unstable_yieldValue('Component'); + const [didLoad, setDidLoad] = React.useState(false); + React.useLayoutEffect(() => { + Scheduler.unstable_yieldValue('layout create'); + return () => { + Scheduler.unstable_yieldValue('layout destroy'); + }; + }, []); + React.useEffect(() => { + Scheduler.unstable_yieldValue('passive create'); + // Mimic an XHR request with a complete handler that updates state. + completePendingRequest = () => setDidLoad(true); + return () => { + Scheduler.unstable_yieldValue('passive destroy'); + }; + }, []); + return didLoad; + } - function ComponentWithPendingPassiveUnmount() { - React.useEffect(() => { - Scheduler.unstable_yieldValue('b:passive create'); - return () => { - Scheduler.unstable_yieldValue('b:passive destroy'); - }; - }, []); - return null; - } + act(() => { + ReactNoop.renderToRootWithID(, 'root', () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'Component', + 'layout create', + 'Sync effect', + ]); + ReactNoop.flushPassiveEffects(); + expect(Scheduler).toHaveYielded(['passive create']); - act(() => { - ReactNoop.renderToRootWithID( - <> - - - , - 'root', - () => Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - 'Component', - 'a:layout create', - 'Sync effect', - ]); - ReactNoop.flushPassiveEffects(); - expect(Scheduler).toHaveYielded([ - 'a:passive create', - 'b:passive create', - ]); + // Unmount but don't process pending passive destroy function + ReactNoop.unmountRootWithID('root'); + expect(Scheduler).toFlushAndYieldThrough(['layout destroy']); - // Unmount but don't process pending passive destroy function - ReactNoop.unmountRootWithID('root'); - expect(Scheduler).toFlushAndYieldThrough(['a:layout destroy']); + // Simulate an XHR completing, which will cause a state update- + // but should not log a warning. + completePendingRequest(); - // Simulate an XHR completing in the component without a pending passive effect.. - expect(completePendingRequest).toErrorDev( - "Warning: Can't perform a React state update on an unmounted component.", - ); - }); + ReactNoop.flushPassiveEffects(); + expect(Scheduler).toHaveYielded(['passive destroy']); }); + }); - it('still warns if there are updates after pending passive unmount effects have been flushed', () => { - let updaterFunction; + it('warns about state updates for unmounted components with no pending passive unmounts', () => { + let completePendingRequest = null; + function Component() { + Scheduler.unstable_yieldValue('Component'); + const [didLoad, setDidLoad] = React.useState(false); + React.useLayoutEffect(() => { + Scheduler.unstable_yieldValue('layout create'); + // Mimic an XHR request with a complete handler that updates state. + completePendingRequest = () => setDidLoad(true); + return () => { + Scheduler.unstable_yieldValue('layout destroy'); + }; + }, []); + return didLoad; + } - function Component() { - Scheduler.unstable_yieldValue('Component'); - const [state, setState] = React.useState(false); - updaterFunction = setState; - React.useEffect(() => { - Scheduler.unstable_yieldValue('passive create'); - return () => { - Scheduler.unstable_yieldValue('passive destroy'); - }; - }, []); - return state; - } + act(() => { + ReactNoop.renderToRootWithID(, 'root', () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'Component', + 'layout create', + 'Sync effect', + ]); - act(() => { - ReactNoop.renderToRootWithID(, 'root', () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - }); + // Unmount but don't process pending passive destroy function + ReactNoop.unmountRootWithID('root'); + expect(Scheduler).toFlushAndYieldThrough(['layout destroy']); + + // Simulate an XHR completing. + expect(completePendingRequest).toErrorDev( + "Warning: Can't perform a React state update on an unmounted component.", + ); + }); + }); + + // @gate deferPassiveEffectCleanupDuringUnmount && runAllPassiveEffectDestroysBeforeCreates + it('still warns if there are pending passive unmount effects but not for the current fiber', () => { + let completePendingRequest = null; + function ComponentWithXHR() { + Scheduler.unstable_yieldValue('Component'); + const [didLoad, setDidLoad] = React.useState(false); + React.useLayoutEffect(() => { + Scheduler.unstable_yieldValue('a:layout create'); + return () => { + Scheduler.unstable_yieldValue('a:layout destroy'); + }; + }, []); + React.useEffect(() => { + Scheduler.unstable_yieldValue('a:passive create'); + // Mimic an XHR request with a complete handler that updates state. + completePendingRequest = () => setDidLoad(true); + }, []); + return didLoad; + } + + function ComponentWithPendingPassiveUnmount() { + React.useEffect(() => { + Scheduler.unstable_yieldValue('b:passive create'); + return () => { + Scheduler.unstable_yieldValue('b:passive destroy'); + }; + }, []); + return null; + } + + act(() => { + ReactNoop.renderToRootWithID( + <> + + + , + 'root', + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'Component', + 'a:layout create', + 'Sync effect', + ]); + ReactNoop.flushPassiveEffects(); expect(Scheduler).toHaveYielded([ + 'a:passive create', + 'b:passive create', + ]); + + // Unmount but don't process pending passive destroy function + ReactNoop.unmountRootWithID('root'); + expect(Scheduler).toFlushAndYieldThrough(['a:layout destroy']); + + // Simulate an XHR completing in the component without a pending passive effect.. + expect(completePendingRequest).toErrorDev( + "Warning: Can't perform a React state update on an unmounted component.", + ); + }); + }); + + it('warns if there are updates after pending passive unmount effects have been flushed', () => { + let updaterFunction; + + function Component() { + Scheduler.unstable_yieldValue('Component'); + const [state, setState] = React.useState(false); + updaterFunction = setState; + React.useEffect(() => { + Scheduler.unstable_yieldValue('passive create'); + return () => { + Scheduler.unstable_yieldValue('passive destroy'); + }; + }, []); + return state; + } + + act(() => { + ReactNoop.renderToRootWithID(, 'root', () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + }); + expect(Scheduler).toHaveYielded([ + 'Component', + 'Sync effect', + 'passive create', + ]); + + ReactNoop.unmountRootWithID('root'); + expect(Scheduler).toFlushAndYield(['passive destroy']); + + act(() => { + expect(() => { + updaterFunction(true); + }).toErrorDev( + "Warning: Can't perform a React state update on an unmounted component. " + + 'This is a no-op, but it indicates a memory leak in your application. ' + + 'To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function.\n' + + ' in Component (at **)', + ); + }); + }); + + it('does not show a warning when a component updates its own state from within passive unmount function', () => { + function Component() { + Scheduler.unstable_yieldValue('Component'); + const [didLoad, setDidLoad] = React.useState(false); + React.useEffect(() => { + Scheduler.unstable_yieldValue('passive create'); + return () => { + setDidLoad(true); + Scheduler.unstable_yieldValue('passive destroy'); + }; + }, []); + return didLoad; + } + + act(() => { + ReactNoop.renderToRootWithID(, 'root', () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ 'Component', 'Sync effect', 'passive create', ]); + // Unmount but don't process pending passive destroy function ReactNoop.unmountRootWithID('root'); expect(Scheduler).toFlushAndYield(['passive destroy']); - - act(() => { - expect(() => { - updaterFunction(true); - }).toErrorDev( - "Warning: Can't perform a React state update on an unmounted component. " + - 'This is a no-op, but it indicates a memory leak in your application. ' + - 'To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function.\n' + - ' in Component (at **)', - ); - }); }); + }); - it('does not show a warning when a component updates its own state from within passive unmount function', () => { - function Component() { - Scheduler.unstable_yieldValue('Component'); - const [didLoad, setDidLoad] = React.useState(false); - React.useEffect(() => { - Scheduler.unstable_yieldValue('passive create'); - return () => { - setDidLoad(true); - Scheduler.unstable_yieldValue('passive destroy'); - }; - }, []); - return didLoad; - } + it('does not show a warning when a component updates a childs state from within passive unmount function', () => { + function Parent() { + Scheduler.unstable_yieldValue('Parent'); + const updaterRef = React.useRef(null); + React.useEffect(() => { + Scheduler.unstable_yieldValue('Parent passive create'); + return () => { + updaterRef.current(true); + Scheduler.unstable_yieldValue('Parent passive destroy'); + }; + }, []); + return ; + } - act(() => { - ReactNoop.renderToRootWithID(, 'root', () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - 'Component', - 'Sync effect', - 'passive create', - ]); + function Child({updaterRef}) { + Scheduler.unstable_yieldValue('Child'); + const [state, setState] = React.useState(false); + React.useEffect(() => { + Scheduler.unstable_yieldValue('Child passive create'); + updaterRef.current = setState; + }, []); + return state; + } - // Unmount but don't process pending passive destroy function - ReactNoop.unmountRootWithID('root'); - expect(Scheduler).toFlushAndYield(['passive destroy']); - }); + act(() => { + ReactNoop.renderToRootWithID(, 'root'); + expect(Scheduler).toFlushAndYieldThrough([ + 'Parent', + 'Child', + 'Child passive create', + 'Parent passive create', + ]); + + // Unmount but don't process pending passive destroy function + ReactNoop.unmountRootWithID('root'); + expect(Scheduler).toFlushAndYield(['Parent passive destroy']); }); + }); - it('does not show a warning when a component updates a childs state from within passive unmount function', () => { - function Parent() { - Scheduler.unstable_yieldValue('Parent'); - const updaterRef = React.useRef(null); - React.useEffect(() => { - Scheduler.unstable_yieldValue('Parent passive create'); - return () => { - updaterRef.current(true); - Scheduler.unstable_yieldValue('Parent passive destroy'); - }; - }, []); - return ; - } + it('does not show a warning when a component updates a parents state from within passive unmount function', () => { + function Parent() { + const [state, setState] = React.useState(false); + Scheduler.unstable_yieldValue('Parent'); + return ; + } - function Child({updaterRef}) { - Scheduler.unstable_yieldValue('Child'); - const [state, setState] = React.useState(false); - React.useEffect(() => { - Scheduler.unstable_yieldValue('Child passive create'); - updaterRef.current = setState; - }, []); - return state; - } + function Child({setState, state}) { + Scheduler.unstable_yieldValue('Child'); + React.useEffect(() => { + Scheduler.unstable_yieldValue('Child passive create'); + return () => { + Scheduler.unstable_yieldValue('Child passive destroy'); + setState(true); + }; + }, []); + return state; + } - act(() => { - ReactNoop.renderToRootWithID(, 'root'); - expect(Scheduler).toFlushAndYieldThrough([ - 'Parent', - 'Child', - 'Child passive create', - 'Parent passive create', - ]); + act(() => { + ReactNoop.renderToRootWithID(, 'root'); + expect(Scheduler).toFlushAndYieldThrough([ + 'Parent', + 'Child', + 'Child passive create', + ]); - // Unmount but don't process pending passive destroy function - ReactNoop.unmountRootWithID('root'); - expect(Scheduler).toFlushAndYield(['Parent passive destroy']); - }); + // Unmount but don't process pending passive destroy function + ReactNoop.unmountRootWithID('root'); + expect(Scheduler).toFlushAndYield(['Child passive destroy']); }); - - it('does not show a warning when a component updates a parents state from within passive unmount function', () => { - function Parent() { - const [state, setState] = React.useState(false); - Scheduler.unstable_yieldValue('Parent'); - return ; - } - - function Child({setState, state}) { - Scheduler.unstable_yieldValue('Child'); - React.useEffect(() => { - Scheduler.unstable_yieldValue('Child passive create'); - return () => { - Scheduler.unstable_yieldValue('Child passive destroy'); - setState(true); - }; - }, []); - return state; - } - - act(() => { - ReactNoop.renderToRootWithID(, 'root'); - expect(Scheduler).toFlushAndYieldThrough([ - 'Parent', - 'Child', - 'Child passive create', - ]); - - // Unmount but don't process pending passive destroy function - ReactNoop.unmountRootWithID('root'); - expect(Scheduler).toFlushAndYield(['Child passive destroy']); - }); - }); - } + }); it('updates have async priority', () => { function Counter(props) { diff --git a/packages/react-reconciler/src/__tests__/ReactScope-test.internal.js b/packages/react-reconciler/src/__tests__/ReactScope-test.internal.js index 15edff4082..feed4a16f6 100644 --- a/packages/react-reconciler/src/__tests__/ReactScope-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactScope-test.internal.js @@ -26,11 +26,6 @@ describe('ReactScope', () => { Scheduler = require('scheduler'); }); - if (!__EXPERIMENTAL__) { - it("empty test so Jest doesn't complain", () => {}); - return; - } - describe('ReactDOM', () => { let ReactDOM; let container; @@ -47,6 +42,7 @@ describe('ReactScope', () => { container = null; }); + // @gate experimental it('DO_NOT_USE_queryAllNodes() works as intended', () => { const testScopeQuery = (type, props) => true; const TestScope = React.unstable_createScope(); @@ -81,6 +77,7 @@ describe('ReactScope', () => { expect(scopeRef.current).toBe(null); }); + // @gate experimental it('DO_NOT_USE_queryAllNodes() provides the correct host instance', () => { const testScopeQuery = (type, props) => type === 'div'; const TestScope = React.unstable_createScope(); @@ -127,6 +124,7 @@ describe('ReactScope', () => { expect(scopeRef.current).toBe(null); }); + // @gate experimental it('DO_NOT_USE_queryFirstNode() works as intended', () => { const testScopeQuery = (type, props) => true; const TestScope = React.unstable_createScope(); @@ -161,6 +159,7 @@ describe('ReactScope', () => { expect(scopeRef.current).toBe(null); }); + // @gate experimental it('containsNode() works as intended', () => { const TestScope = React.unstable_createScope(); const scopeRef = React.createRef(); @@ -210,6 +209,7 @@ describe('ReactScope', () => { expect(scopeRef.current.containsNode(emRef.current)).toBe(false); }); + // @gate experimental it('scopes support server-side rendering and hydration', () => { const TestScope = React.unstable_createScope(); const scopeRef = React.createRef(); @@ -240,6 +240,7 @@ describe('ReactScope', () => { expect(nodes).toEqual([divRef.current, spanRef.current, aRef.current]); }); + // @gate experimental it('event responders can be attached to scopes', () => { let onKeyDown = jest.fn(); const TestScope = React.unstable_createScope(); @@ -282,6 +283,7 @@ describe('ReactScope', () => { expect(onKeyDown).toHaveBeenCalledTimes(1); }); + // @gate experimental it('getChildContextValues() works as intended', () => { const TestContext = React.createContext(); const TestScope = React.unstable_createScope(); @@ -310,6 +312,7 @@ describe('ReactScope', () => { expect(scopeRef.current).toBe(null); }); + // @gate experimental it('correctly works with suspended boundaries that are hydrated', async () => { let suspend = false; let resolve; @@ -384,6 +387,7 @@ describe('ReactScope', () => { ReactTestRenderer = require('react-test-renderer'); }); + // @gate experimental it('DO_NOT_USE_queryAllNodes() works as intended', () => { const testScopeQuery = (type, props) => true; const TestScope = React.unstable_createScope(); @@ -420,6 +424,7 @@ describe('ReactScope', () => { expect(nodes).toEqual([aRef.current, divRef.current, spanRef.current]); }); + // @gate experimental it('DO_NOT_USE_queryFirstNode() works as intended', () => { const testScopeQuery = (type, props) => true; const TestScope = React.unstable_createScope(); @@ -456,6 +461,7 @@ describe('ReactScope', () => { expect(node).toEqual(aRef.current); }); + // @gate experimental it('containsNode() works as intended', () => { const TestScope = React.unstable_createScope(); const scopeRef = React.createRef(); diff --git a/packages/react-reconciler/src/__tests__/ReactSuspenseList-test.internal.js b/packages/react-reconciler/src/__tests__/ReactSuspenseList-test.internal.js index 3ed8f664bd..413f9a72e6 100644 --- a/packages/react-reconciler/src/__tests__/ReactSuspenseList-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactSuspenseList-test.internal.js @@ -6,11 +6,6 @@ let Suspense; let SuspenseList; describe('ReactSuspenseList', () => { - if (!__EXPERIMENTAL__) { - it("empty test so Jest doesn't complain", () => {}); - return; - } - beforeEach(() => { jest.resetModules(); ReactFeatureFlags = require('shared/ReactFeatureFlags'); @@ -47,6 +42,7 @@ describe('ReactSuspenseList', () => { return Component; } + // @gate experimental it('warns if an unsupported revealOrder option is used', () => { function Foo() { return ( @@ -66,6 +62,7 @@ describe('ReactSuspenseList', () => { ]); }); + // @gate experimental it('warns if a upper case revealOrder option is used', () => { function Foo() { return ( @@ -85,6 +82,7 @@ describe('ReactSuspenseList', () => { ]); }); + // @gate experimental it('warns if a misspelled revealOrder option is used', () => { function Foo() { return ( @@ -105,6 +103,7 @@ describe('ReactSuspenseList', () => { ]); }); + // @gate experimental it('warns if a single element is passed to a "forwards" list', () => { function Foo({children}) { return {children}; @@ -137,6 +136,7 @@ describe('ReactSuspenseList', () => { ]); }); + // @gate experimental it('warns if a single fragment is passed to a "backwards" list', () => { function Foo() { return ( @@ -157,6 +157,7 @@ describe('ReactSuspenseList', () => { ]); }); + // @gate experimental it('warns if a nested array is passed to a "forwards" list', () => { function Foo({items}) { return ( @@ -184,6 +185,7 @@ describe('ReactSuspenseList', () => { ]); }); + // @gate experimental it('shows content independently by default', async () => { const A = createAsyncText('A'); const B = createAsyncText('B'); @@ -250,6 +252,7 @@ describe('ReactSuspenseList', () => { ); }); + // @gate experimental it('shows content independently in legacy mode regardless of option', async () => { const A = createAsyncText('A'); const B = createAsyncText('B'); @@ -322,6 +325,7 @@ describe('ReactSuspenseList', () => { ); }); + // @gate experimental it('displays all "together"', async () => { const A = createAsyncText('A'); const B = createAsyncText('B'); @@ -391,6 +395,7 @@ describe('ReactSuspenseList', () => { ); }); + // @gate experimental it('displays all "together" even when nested as siblings', async () => { const A = createAsyncText('A'); const B = createAsyncText('B'); @@ -476,6 +481,7 @@ describe('ReactSuspenseList', () => { ); }); + // @gate experimental it('displays all "together" in nested SuspenseLists', async () => { const A = createAsyncText('A'); const B = createAsyncText('B'); @@ -537,6 +543,7 @@ describe('ReactSuspenseList', () => { ); }); + // @gate experimental it('displays all "together" in nested SuspenseLists where the inner is default', async () => { const A = createAsyncText('A'); const B = createAsyncText('B'); @@ -596,6 +603,7 @@ describe('ReactSuspenseList', () => { ); }); + // @gate experimental it('displays all "together" during an update', async () => { const A = createAsyncText('A'); const B = createAsyncText('B'); @@ -680,6 +688,7 @@ describe('ReactSuspenseList', () => { ); }); + // @gate experimental it('avoided boundaries can be coordinate with SuspenseList', async () => { const A = createAsyncText('A'); const B = createAsyncText('B'); @@ -778,6 +787,7 @@ describe('ReactSuspenseList', () => { ); }); + // @gate experimental it('displays each items in "forwards" order', async () => { const A = createAsyncText('A'); const B = createAsyncText('B'); @@ -843,6 +853,7 @@ describe('ReactSuspenseList', () => { ); }); + // @gate experimental it('displays each items in "backwards" order', async () => { const A = createAsyncText('A'); const B = createAsyncText('B'); @@ -908,6 +919,7 @@ describe('ReactSuspenseList', () => { ); }); + // @gate experimental it('displays added row at the top "together" and the bottom in "forwards" order', async () => { const A = createAsyncText('A'); const B = createAsyncText('B'); @@ -1062,6 +1074,7 @@ describe('ReactSuspenseList', () => { ); }); + // @gate experimental it('displays added row at the top "together" and the bottom in "backwards" order', async () => { const A = createAsyncText('A'); const B = createAsyncText('B'); @@ -1246,6 +1259,7 @@ describe('ReactSuspenseList', () => { ); }); + // @gate experimental it('switches to rendering fallbacks if the tail takes long CPU time', async () => { function Foo() { return ( @@ -1308,6 +1322,7 @@ describe('ReactSuspenseList', () => { ); }); + // @gate experimental it('only shows one loading state at a time for "collapsed" tail insertions', async () => { const A = createAsyncText('A'); const B = createAsyncText('B'); @@ -1377,6 +1392,7 @@ describe('ReactSuspenseList', () => { ); }); + // @gate experimental it('warns if an unsupported tail option is used', () => { function Foo() { return ( @@ -1397,6 +1413,7 @@ describe('ReactSuspenseList', () => { ]); }); + // @gate experimental it('warns if a tail option is used with "together"', () => { function Foo() { return ( @@ -1417,6 +1434,7 @@ describe('ReactSuspenseList', () => { ]); }); + // @gate experimental it('renders one "collapsed" fallback even if CPU time elapsed', async () => { function Foo() { return ( @@ -1483,6 +1501,7 @@ describe('ReactSuspenseList', () => { ); }); + // @gate experimental it('adding to the middle does not collapse insertions (forwards)', async () => { const A = createAsyncText('A'); const B = createAsyncText('B'); @@ -1625,6 +1644,7 @@ describe('ReactSuspenseList', () => { ); }); + // @gate experimental it('adding to the middle does not collapse insertions (backwards)', async () => { const A = createAsyncText('A'); const B = createAsyncText('B'); @@ -1772,6 +1792,7 @@ describe('ReactSuspenseList', () => { ); }); + // @gate experimental it('adding to the middle of committed tail does not collapse insertions', async () => { const A = createAsyncText('A'); const B = createAsyncText('B'); @@ -1929,6 +1950,7 @@ describe('ReactSuspenseList', () => { ); }); + // @gate experimental it('only shows no initial loading state "hidden" tail insertions', async () => { const A = createAsyncText('A'); const B = createAsyncText('B'); @@ -1992,6 +2014,7 @@ describe('ReactSuspenseList', () => { ); }); + // @gate experimental it('eventually resolves a nested forwards suspense list', async () => { const B = createAsyncText('B'); @@ -2054,6 +2077,7 @@ describe('ReactSuspenseList', () => { ); }); + // @gate experimental it('eventually resolves a nested forwards suspense list with a hidden tail', async () => { const B = createAsyncText('B'); @@ -2100,6 +2124,7 @@ describe('ReactSuspenseList', () => { ); }); + // @gate experimental it('eventually resolves two nested forwards suspense lists with a hidden tail', async () => { const B = createAsyncText('B'); @@ -2167,6 +2192,7 @@ describe('ReactSuspenseList', () => { ); }); + // @gate experimental it('can do unrelated adjacent updates', async () => { let updateAdjacent; function Adjacent() { @@ -2213,6 +2239,7 @@ describe('ReactSuspenseList', () => { ); }); + // @gate experimental it('is able to re-suspend the last rows during an update with hidden', async () => { const AsyncB = createAsyncText('B'); @@ -2301,6 +2328,7 @@ describe('ReactSuspenseList', () => { expect(previousInst).toBe(setAsyncB); }); + // @gate experimental it('is able to re-suspend the last rows during an update with hidden', async () => { const AsyncB = createAsyncText('B'); @@ -2389,6 +2417,7 @@ describe('ReactSuspenseList', () => { expect(previousInst).toBe(setAsyncB); }); + // @gate experimental it('is able to interrupt a partially rendered tree and continue later', async () => { const AsyncA = createAsyncText('A'); @@ -2486,6 +2515,7 @@ describe('ReactSuspenseList', () => { ); }); + // @gate experimental it('can resume class components when revealed together', async () => { const A = createAsyncText('A'); const B = createAsyncText('B'); diff --git a/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js b/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js index 511777a9ea..b9520351c4 100644 --- a/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js @@ -11,11 +11,6 @@ let resolveText; let rejectText; describe('ReactSuspenseWithNoopRenderer', () => { - if (!__EXPERIMENTAL__) { - it("empty test so Jest doesn't complain", () => {}); - return; - } - beforeEach(() => { jest.resetModules(); @@ -922,6 +917,7 @@ describe('ReactSuspenseWithNoopRenderer', () => { expect(ReactNoop.getChildren()).toEqual([span('Async')]); }); + // @gate experimental it('starts working on an update even if its priority falls between two suspended levels', async () => { function App(props) { return ( @@ -2331,6 +2327,7 @@ describe('ReactSuspenseWithNoopRenderer', () => { timeoutMs: 2000, }; + // @gate experimental it('top level render', async () => { function App({page}) { return ( @@ -2385,6 +2382,7 @@ describe('ReactSuspenseWithNoopRenderer', () => { expect(ReactNoop.getChildren()).toEqual([span('B')]); }); + // @gate experimental it('hooks', async () => { let transitionToPage; function App() { @@ -2452,6 +2450,7 @@ describe('ReactSuspenseWithNoopRenderer', () => { expect(ReactNoop.getChildren()).toEqual([span('B')]); }); + // @gate experimental it('classes', async () => { let transitionToPage; class App extends React.Component { @@ -2523,6 +2522,7 @@ describe('ReactSuspenseWithNoopRenderer', () => { }); }); + // @gate experimental it('disables suspense config when nothing is passed to withSuspenseConfig', async () => { function App({page}) { return ( @@ -2597,6 +2597,7 @@ describe('ReactSuspenseWithNoopRenderer', () => { ]); }); + // @gate experimental it('withSuspenseConfig timeout applies when we use an updated avoided boundary', async () => { function App({page}) { return ( @@ -2645,6 +2646,7 @@ describe('ReactSuspenseWithNoopRenderer', () => { ]); }); + // @gate experimental it('withSuspenseConfig timeout applies when we use a newly created avoided boundary', async () => { function App({page}) { return ( @@ -2692,6 +2694,7 @@ describe('ReactSuspenseWithNoopRenderer', () => { ]); }); + // @gate experimental it('supports delaying a busy spinner from disappearing', async () => { const SUSPENSE_CONFIG = { timeoutMs: 10000, @@ -2854,6 +2857,7 @@ describe('ReactSuspenseWithNoopRenderer', () => { expect(root).toMatchRenderedOutput(); }); + // @gate experimental it('should not render hidden content while suspended on higher pri', async () => { function Offscreen() { Scheduler.unstable_yieldValue('Offscreen'); @@ -2908,6 +2912,7 @@ describe('ReactSuspenseWithNoopRenderer', () => { ); }); + // @gate experimental it('should be able to unblock higher pri content before suspended hidden', async () => { function Offscreen() { Scheduler.unstable_yieldValue('Offscreen'); @@ -3624,6 +3629,7 @@ describe('ReactSuspenseWithNoopRenderer', () => { ); }); + // @gate experimental it('regression: ping at high priority causes update to be dropped', async () => { const {useState, useTransition} = React; diff --git a/packages/react-reconciler/src/__tests__/useMutableSource-test.internal.js b/packages/react-reconciler/src/__tests__/useMutableSource-test.internal.js index fce1cd634e..698102b6b8 100644 --- a/packages/react-reconciler/src/__tests__/useMutableSource-test.internal.js +++ b/packages/react-reconciler/src/__tests__/useMutableSource-test.internal.js @@ -140,1579 +140,1587 @@ describe('useMutableSource', () => { return
{`${label}:${snapshot}`}
; } - if (!__EXPERIMENTAL__) { - it("empty test so Jest doesn't complain", () => {}); - } else { - beforeEach(loadModules); + beforeEach(loadModules); - it('should subscribe to a source and schedule updates when it changes', () => { - const source = createSource('one'); - const mutableSource = createMutableSource(source); + // @gate experimental + it('should subscribe to a source and schedule updates when it changes', () => { + const source = createSource('one'); + const mutableSource = createMutableSource(source); - act(() => { - ReactNoop.renderToRootWithID( - <> - - - , - 'root', - () => Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - 'a:one', - 'b:one', - 'Sync effect', - ]); - - // Subscriptions should be passive - expect(source.listenerCount).toBe(0); - ReactNoop.flushPassiveEffects(); - expect(source.listenerCount).toBe(2); - - // Changing values should schedule an update with React - source.value = 'two'; - expect(Scheduler).toFlushAndYieldThrough(['a:two', 'b:two']); - - // Umounting a component should remove its subscriptino. - ReactNoop.renderToRootWithID( - <> - - , - 'root', - () => Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYield(['a:two', 'Sync effect']); - ReactNoop.flushPassiveEffects(); - expect(source.listenerCount).toBe(1); - - // Umounting a root should remove the remaining event listeners - ReactNoop.unmountRootWithID('root'); - expect(Scheduler).toFlushAndYield([]); - ReactNoop.flushPassiveEffects(); - expect(source.listenerCount).toBe(0); - - // Changes to source should not trigger an updates or warnings. - source.value = 'three'; - expect(Scheduler).toFlushAndYield([]); - }); - }); - - it('should restart work if a new source is mutated during render', () => { - const source = createSource('one'); - const mutableSource = createMutableSource(source); - - act(() => { - ReactNoop.render( - <> - - - , - () => Scheduler.unstable_yieldValue('Sync effect'), - ); - - // Do enough work to read from one component - expect(Scheduler).toFlushAndYieldThrough(['a:one']); - - // Mutate source before continuing work - source.value = 'two'; - - // Render work should restart and the updated value should be used - expect(Scheduler).toFlushAndYield(['a:two', 'b:two', 'Sync effect']); - }); - }); - - it('should schedule an update if a new source is mutated between render and commit (subscription)', () => { - const source = createSource('one'); - const mutableSource = createMutableSource(source); - - act(() => { - ReactNoop.render( - <> - - - , - () => Scheduler.unstable_yieldValue('Sync effect'), - ); - - // Finish rendering - expect(Scheduler).toFlushAndYieldThrough([ - 'a:one', - 'b:one', - 'Sync effect', - ]); - - // Mutate source before subscriptions are attached - expect(source.listenerCount).toBe(0); - source.value = 'two'; - - // Mutation should be detected, and a new render should be scheduled - expect(Scheduler).toFlushAndYield(['a:two', 'b:two']); - }); - }); - - it('should unsubscribe and resubscribe if a new source is used', () => { - const sourceA = createSource('a-one'); - const mutableSourceA = createMutableSource(sourceA); - - const sourceB = createSource('b-one'); - const mutableSourceB = createMutableSource(sourceB); - - act(() => { - ReactNoop.render( + act(() => { + ReactNoop.renderToRootWithID( + <> , - () => Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYield(['only:a-one', 'Sync effect']); - ReactNoop.flushPassiveEffects(); - expect(sourceA.listenerCount).toBe(1); - - // Changing values should schedule an update with React - sourceA.value = 'a-two'; - expect(Scheduler).toFlushAndYield(['only:a-two']); - - // If we re-render with a new source, the old one should be unsubscribed. - ReactNoop.render( - , - () => Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYield(['only:b-one', 'Sync effect']); - ReactNoop.flushPassiveEffects(); - expect(sourceA.listenerCount).toBe(0); - expect(sourceB.listenerCount).toBe(1); - - // Changing to original source should not schedule updates with React - sourceA.value = 'a-three'; - expect(Scheduler).toFlushAndYield([]); - - // Changing new source value should schedule an update with React - sourceB.value = 'b-two'; - expect(Scheduler).toFlushAndYield(['only:b-two']); - }); - }); - - it('should unsubscribe and resubscribe if a new subscribe function is provided', () => { - const source = createSource('a-one'); - const mutableSource = createMutableSource(source); - - const unsubscribeA = jest.fn(); - const subscribeA = jest.fn(s => { - const unsubscribe = defaultSubscribe(s); - return () => { - unsubscribe(); - unsubscribeA(); - }; - }); - const unsubscribeB = jest.fn(); - const subscribeB = jest.fn(s => { - const unsubscribe = defaultSubscribe(s); - return () => { - unsubscribe(); - unsubscribeB(); - }; - }); - - act(() => { - ReactNoop.renderToRootWithID( - , - 'root', - () => Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYield(['only:a-one', 'Sync effect']); - ReactNoop.flushPassiveEffects(); - expect(source.listenerCount).toBe(1); - expect(subscribeA).toHaveBeenCalledTimes(1); - - // If we re-render with a new subscription function, - // the old unsubscribe function should be called. - ReactNoop.renderToRootWithID( - , - 'root', - () => Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYield(['only:a-one', 'Sync effect']); - ReactNoop.flushPassiveEffects(); - expect(source.listenerCount).toBe(1); - expect(unsubscribeA).toHaveBeenCalledTimes(1); - expect(subscribeB).toHaveBeenCalledTimes(1); - - // Unmounting should call the newer unsunscribe. - ReactNoop.unmountRootWithID('root'); - expect(Scheduler).toFlushAndYield([]); - ReactNoop.flushPassiveEffects(); - expect(source.listenerCount).toBe(0); - expect(unsubscribeB).toHaveBeenCalledTimes(1); - }); - }); - - it('should re-use previously read snapshot value when reading is unsafe', () => { - const source = createSource('one'); - const mutableSource = createMutableSource(source); - - act(() => { - ReactNoop.render( - <> - - - , - () => Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYield(['a:one', 'b:one', 'Sync effect']); - - // Changing values should schedule an update with React. - // Start working on this update but don't finish it. - source.value = 'two'; - expect(Scheduler).toFlushAndYieldThrough(['a:two']); - - // Re-renders that occur before the udpate is processed - // should reuse snapshot so long as the config has not changed - ReactNoop.flushSync(() => { - ReactNoop.render( - <> - - - , - () => Scheduler.unstable_yieldValue('Sync effect'), - ); - }); - expect(Scheduler).toHaveYielded(['a:one', 'b:one', 'Sync effect']); - - expect(Scheduler).toFlushAndYield(['a:two', 'b:two']); - }); - }); - - it('should read from source on newly mounted subtree if no pending updates are scheduled for source', () => { - const source = createSource('one'); - const mutableSource = createMutableSource(source); - - act(() => { - ReactNoop.render( - <> - - , - () => Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYield(['a:one', 'Sync effect']); - - ReactNoop.render( - <> - - - , - () => Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYield(['a:one', 'b:one', 'Sync effect']); - }); - }); - - it('should throw and restart render if source and snapshot are unavailable during an update', () => { - const source = createSource('one'); - const mutableSource = createMutableSource(source); - - act(() => { - ReactNoop.render( - <> - - - , - () => Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYield(['a:one', 'b:one', 'Sync effect']); - ReactNoop.flushPassiveEffects(); - - // Changing values should schedule an update with React. - // Start working on this update but don't finish it. - Scheduler.unstable_runWithPriority( - Scheduler.unstable_LowPriority, - () => { - source.value = 'two'; - expect(Scheduler).toFlushAndYieldThrough(['a:two']); - }, - ); - - const newGetSnapshot = s => 'new:' + defaultGetSnapshot(s); - - // Force a higher priority render with a new config. - // This should signal that the snapshot is not safe and trigger a full re-render. - Scheduler.unstable_runWithPriority( - Scheduler.unstable_UserBlockingPriority, - () => { - ReactNoop.render( - <> - - - , - () => Scheduler.unstable_yieldValue('Sync effect'), - ); - }, - ); - expect(Scheduler).toFlushAndYieldThrough([ - 'a:new:two', - 'b:new:two', - 'Sync effect', - ]); - }); - }); - - it('should throw and restart render if source and snapshot are unavailable during a sync update', () => { - const source = createSource('one'); - const mutableSource = createMutableSource(source); - - act(() => { - ReactNoop.render( - <> - - - , - () => Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYield(['a:one', 'b:one', 'Sync effect']); - ReactNoop.flushPassiveEffects(); - - // Changing values should schedule an update with React. - // Start working on this update but don't finish it. - Scheduler.unstable_runWithPriority( - Scheduler.unstable_LowPriority, - () => { - source.value = 'two'; - expect(Scheduler).toFlushAndYieldThrough(['a:two']); - }, - ); - - const newGetSnapshot = s => 'new:' + defaultGetSnapshot(s); - - // Force a higher priority render with a new config. - // This should signal that the snapshot is not safe and trigger a full re-render. - ReactNoop.flushSync(() => { - ReactNoop.render( - <> - - - , - () => Scheduler.unstable_yieldValue('Sync effect'), - ); - }); - expect(Scheduler).toHaveYielded([ - 'a:new:two', - 'b:new:two', - 'Sync effect', - ]); - }); - }); - - it('should only update components whose subscriptions fire', () => { - const source = createComplexSource('a:one', 'b:one'); - const mutableSource = createMutableSource(source); - - // Subscribe to part of the store. - const getSnapshotA = s => s.valueA; - const subscribeA = (s, callback) => s.subscribeA(callback); - const getSnapshotB = s => s.valueB; - const subscribeB = (s, callback) => s.subscribeB(callback); - - act(() => { - ReactNoop.render( - <> - - - , - () => Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYield([ - 'a:a:one', - 'b:b:one', - 'Sync effect', - ]); - - // Changes to part of the store (e.g. A) should not render other parts. - source.valueA = 'a:two'; - expect(Scheduler).toFlushAndYield(['a:a:two']); - source.valueB = 'b:two'; - expect(Scheduler).toFlushAndYield(['b:b:two']); - }); - }); - - it('should detect tearing in part of the store not yet subscribed to', () => { - const source = createComplexSource('a:one', 'b:one'); - const mutableSource = createMutableSource(source); - - // Subscribe to part of the store. - const getSnapshotA = s => s.valueA; - const subscribeA = (s, callback) => s.subscribeA(callback); - const getSnapshotB = s => s.valueB; - const subscribeB = (s, callback) => s.subscribeB(callback); - - act(() => { - ReactNoop.render( - <> - - , - () => Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYield(['a:a:one', 'Sync effect']); - - // Because the store has not chagned yet, there are no pending updates, - // so it is considered safe to read from when we start this render. - ReactNoop.render( - <> - - - - , - () => Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough(['a:a:one', 'b:b:one']); - - // Mutating the source should trigger a tear detection on the next read, - // which should throw and re-render the entire tree. - source.valueB = 'b:two'; - - expect(Scheduler).toFlushAndYield([ - 'a:a:one', - 'b:b:two', - 'c:b:two', - 'Sync effect', - ]); - }); - }); - - it('does not schedule an update for subscriptions that fire with an unchanged snapshot', () => { - const MockComponent = jest.fn(Component); - - const source = createSource('one'); - const mutableSource = createMutableSource(source); - - act(() => { - ReactNoop.render( - , - () => Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough(['only:one', 'Sync effect']); - ReactNoop.flushPassiveEffects(); - expect(source.listenerCount).toBe(1); - - // Notify subscribe function but don't change the value - source.value = 'one'; - expect(Scheduler).toFlushWithoutYielding(); - }); - }); - - it('should throw and restart if getSnapshot changes between scheduled update and re-render', () => { - const source = createSource('one'); - const mutableSource = createMutableSource(source); - - const newGetSnapshot = s => 'new:' + defaultGetSnapshot(s); - - let updateGetSnapshot; - - function WrapperWithState() { - const tuple = React.useState(() => defaultGetSnapshot); - updateGetSnapshot = tuple[1]; - return ( - - ); - } - - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYield(['only:one', 'Sync effect']); - ReactNoop.flushPassiveEffects(); - - // Change the source (and schedule an update). - Scheduler.unstable_runWithPriority( - Scheduler.unstable_LowPriority, - () => { - source.value = 'two'; - }, - ); - - // Schedule a higher priority update that changes getSnapshot. - Scheduler.unstable_runWithPriority( - Scheduler.unstable_UserBlockingPriority, - () => { - updateGetSnapshot(() => newGetSnapshot); - }, - ); - - expect(Scheduler).toFlushAndYield(['only:new:two']); - }); - }); - - it('should recover from a mutation during yield when other work is scheduled', () => { - const source = createSource('one'); - const mutableSource = createMutableSource(source); - - act(() => { - // Start a render that uses the mutable source. - ReactNoop.render( - <> - - - , - ); - expect(Scheduler).toFlushAndYieldThrough(['a:one']); - - // Mutate source - source.value = 'two'; - - // Now render something different. - ReactNoop.render(
); - expect(Scheduler).toFlushAndYield([]); - }); - }); - - it('should not throw if the new getSnapshot returns the same snapshot value', () => { - const source = createSource('one'); - const mutableSource = createMutableSource(source); - - const onRenderA = jest.fn(); - const onRenderB = jest.fn(); - - let updateGetSnapshot; - - function WrapperWithState() { - const tuple = React.useState(() => defaultGetSnapshot); - updateGetSnapshot = tuple[1]; - return ( - ); - } - - act(() => { - ReactNoop.render( - <> - - - - - - - , - () => Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYield(['a:one', 'b:one', 'Sync effect']); - ReactNoop.flushPassiveEffects(); - expect(onRenderA).toHaveBeenCalledTimes(1); - expect(onRenderB).toHaveBeenCalledTimes(1); - - // If B's getSnapshot function updates, but the snapshot it returns is the same, - // only B should re-render (to update its state). - updateGetSnapshot(() => s => defaultGetSnapshot(s)); - expect(Scheduler).toFlushAndYield(['b:one']); - ReactNoop.flushPassiveEffects(); - expect(onRenderA).toHaveBeenCalledTimes(1); - expect(onRenderB).toHaveBeenCalledTimes(2); - }); - }); - - it('should not throw if getSnapshot changes but the source can be safely read from anyway', () => { - const source = createSource('one'); - const mutableSource = createMutableSource(source); - - const newGetSnapshot = s => 'new:' + defaultGetSnapshot(s); - - let updateGetSnapshot; - - function WrapperWithState() { - const tuple = React.useState(() => defaultGetSnapshot); - updateGetSnapshot = tuple[1]; - return ( - - ); - } - - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYield(['only:one', 'Sync effect']); - ReactNoop.flushPassiveEffects(); - - // Change the source (and schedule an update) - // but also change the snapshot function too. - ReactNoop.batchedUpdates(() => { - source.value = 'two'; - updateGetSnapshot(() => newGetSnapshot); - }); - - expect(Scheduler).toFlushAndYield(['only:new:two']); - }); - }); - - it('should still schedule an update if an eager selector throws after a mutation', () => { - const source = createSource({ - friends: [ - {id: 1, name: 'Foo'}, - {id: 2, name: 'Bar'}, - ], - }); - const mutableSource = createMutableSource(source); - - function FriendsList() { - const getSnapshot = React.useCallback( - ({value}) => Array.from(value.friends), - [], - ); - const friends = useMutableSource( - mutableSource, - getSnapshot, - defaultSubscribe, - ); - return ( -
    - {friends.map(friend => ( - - ))} -
- ); - } - - function Friend({id}) { - const getSnapshot = React.useCallback( - ({value}) => { - // This selector is intentionally written in a way that will throw - // if no matching friend exists in the store. - return value.friends.find(friend => friend.id === id).name; - }, - [id], - ); - const name = useMutableSource( - mutableSource, - getSnapshot, - defaultSubscribe, - ); - Scheduler.unstable_yieldValue(`${id}:${name}`); - return
  • {name}
  • ; - } - - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYield(['1:Foo', '2:Bar', 'Sync effect']); - - // This mutation will cause the "Bar" component to throw, - // since its value will no longer be a part of the store. - // Mutable source should still schedule an update though, - // which should unmount "Bar" and mount "Baz". - source.value = { - friends: [ - {id: 1, name: 'Foo'}, - {id: 3, name: 'Baz'}, - ], - }; - expect(Scheduler).toFlushAndYield(['1:Foo', '3:Baz']); - }); - }); - - it('should not warn about updates that fire between unmount and passive unsubcribe', () => { - const source = createSource('one'); - const mutableSource = createMutableSource(source); - - function Wrapper() { - React.useLayoutEffect(() => () => { - Scheduler.unstable_yieldValue('layout unmount'); - }); - return ( - + , + 'root', + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'a:one', + 'b:one', + 'Sync effect', + ]); + + // Subscriptions should be passive + expect(source.listenerCount).toBe(0); + ReactNoop.flushPassiveEffects(); + expect(source.listenerCount).toBe(2); + + // Changing values should schedule an update with React + source.value = 'two'; + expect(Scheduler).toFlushAndYieldThrough(['a:two', 'b:two']); + + // Umounting a component should remove its subscriptino. + ReactNoop.renderToRootWithID( + <> + + , + 'root', + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYield(['a:two', 'Sync effect']); + ReactNoop.flushPassiveEffects(); + expect(source.listenerCount).toBe(1); + + // Umounting a root should remove the remaining event listeners + ReactNoop.unmountRootWithID('root'); + expect(Scheduler).toFlushAndYield([]); + ReactNoop.flushPassiveEffects(); + expect(source.listenerCount).toBe(0); + + // Changes to source should not trigger an updates or warnings. + source.value = 'three'; + expect(Scheduler).toFlushAndYield([]); + }); + }); + + // @gate experimental + it('should restart work if a new source is mutated during render', () => { + const source = createSource('one'); + const mutableSource = createMutableSource(source); + + act(() => { + ReactNoop.render( + <> + + + , + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + + // Do enough work to read from one component + expect(Scheduler).toFlushAndYieldThrough(['a:one']); + + // Mutate source before continuing work + source.value = 'two'; + + // Render work should restart and the updated value should be used + expect(Scheduler).toFlushAndYield(['a:two', 'b:two', 'Sync effect']); + }); + }); + + // @gate experimental + it('should schedule an update if a new source is mutated between render and commit (subscription)', () => { + const source = createSource('one'); + const mutableSource = createMutableSource(source); + + act(() => { + ReactNoop.render( + <> + + + , + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + + // Finish rendering + expect(Scheduler).toFlushAndYieldThrough([ + 'a:one', + 'b:one', + 'Sync effect', + ]); + + // Mutate source before subscriptions are attached + expect(source.listenerCount).toBe(0); + source.value = 'two'; + + // Mutation should be detected, and a new render should be scheduled + expect(Scheduler).toFlushAndYield(['a:two', 'b:two']); + }); + }); + + // @gate experimental + it('should unsubscribe and resubscribe if a new source is used', () => { + const sourceA = createSource('a-one'); + const mutableSourceA = createMutableSource(sourceA); + + const sourceB = createSource('b-one'); + const mutableSourceB = createMutableSource(sourceB); + + act(() => { + ReactNoop.render( + , + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYield(['only:a-one', 'Sync effect']); + ReactNoop.flushPassiveEffects(); + expect(sourceA.listenerCount).toBe(1); + + // Changing values should schedule an update with React + sourceA.value = 'a-two'; + expect(Scheduler).toFlushAndYield(['only:a-two']); + + // If we re-render with a new source, the old one should be unsubscribed. + ReactNoop.render( + , + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYield(['only:b-one', 'Sync effect']); + ReactNoop.flushPassiveEffects(); + expect(sourceA.listenerCount).toBe(0); + expect(sourceB.listenerCount).toBe(1); + + // Changing to original source should not schedule updates with React + sourceA.value = 'a-three'; + expect(Scheduler).toFlushAndYield([]); + + // Changing new source value should schedule an update with React + sourceB.value = 'b-two'; + expect(Scheduler).toFlushAndYield(['only:b-two']); + }); + }); + + // @gate experimental + it('should unsubscribe and resubscribe if a new subscribe function is provided', () => { + const source = createSource('a-one'); + const mutableSource = createMutableSource(source); + + const unsubscribeA = jest.fn(); + const subscribeA = jest.fn(s => { + const unsubscribe = defaultSubscribe(s); + return () => { + unsubscribe(); + unsubscribeA(); + }; + }); + const unsubscribeB = jest.fn(); + const subscribeB = jest.fn(s => { + const unsubscribe = defaultSubscribe(s); + return () => { + unsubscribe(); + unsubscribeB(); + }; + }); + + act(() => { + ReactNoop.renderToRootWithID( + , + 'root', + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYield(['only:a-one', 'Sync effect']); + ReactNoop.flushPassiveEffects(); + expect(source.listenerCount).toBe(1); + expect(subscribeA).toHaveBeenCalledTimes(1); + + // If we re-render with a new subscription function, + // the old unsubscribe function should be called. + ReactNoop.renderToRootWithID( + , + 'root', + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYield(['only:a-one', 'Sync effect']); + ReactNoop.flushPassiveEffects(); + expect(source.listenerCount).toBe(1); + expect(unsubscribeA).toHaveBeenCalledTimes(1); + expect(subscribeB).toHaveBeenCalledTimes(1); + + // Unmounting should call the newer unsunscribe. + ReactNoop.unmountRootWithID('root'); + expect(Scheduler).toFlushAndYield([]); + ReactNoop.flushPassiveEffects(); + expect(source.listenerCount).toBe(0); + expect(unsubscribeB).toHaveBeenCalledTimes(1); + }); + }); + + // @gate experimental + it('should re-use previously read snapshot value when reading is unsafe', () => { + const source = createSource('one'); + const mutableSource = createMutableSource(source); + + act(() => { + ReactNoop.render( + <> + + + , + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYield(['a:one', 'b:one', 'Sync effect']); + + // Changing values should schedule an update with React. + // Start working on this update but don't finish it. + source.value = 'two'; + expect(Scheduler).toFlushAndYieldThrough(['a:two']); + + // Re-renders that occur before the udpate is processed + // should reuse snapshot so long as the config has not changed + ReactNoop.flushSync(() => { + ReactNoop.render( + <> + + + , + () => Scheduler.unstable_yieldValue('Sync effect'), ); - } + }); + expect(Scheduler).toHaveYielded(['a:one', 'b:one', 'Sync effect']); - act(() => { - ReactNoop.renderToRootWithID(, 'root', () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYield(['only:one', 'Sync effect']); - ReactNoop.flushPassiveEffects(); + expect(Scheduler).toFlushAndYield(['a:two', 'b:two']); + }); + }); - // Umounting a root should remove the remaining event listeners in a passive effect - ReactNoop.unmountRootWithID('root'); - expect(Scheduler).toFlushAndYieldThrough(['layout unmount']); + // @gate experimental + it('should read from source on newly mounted subtree if no pending updates are scheduled for source', () => { + const source = createSource('one'); + const mutableSource = createMutableSource(source); - // Changes to source should not cause a warning, - // even though the unsubscribe hasn't run yet (since it's a pending passive effect). + act(() => { + ReactNoop.render( + <> + + , + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYield(['a:one', 'Sync effect']); + + ReactNoop.render( + <> + + + , + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYield(['a:one', 'b:one', 'Sync effect']); + }); + }); + + // @gate experimental + it('should throw and restart render if source and snapshot are unavailable during an update', () => { + const source = createSource('one'); + const mutableSource = createMutableSource(source); + + act(() => { + ReactNoop.render( + <> + + + , + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYield(['a:one', 'b:one', 'Sync effect']); + ReactNoop.flushPassiveEffects(); + + // Changing values should schedule an update with React. + // Start working on this update but don't finish it. + Scheduler.unstable_runWithPriority(Scheduler.unstable_LowPriority, () => { source.value = 'two'; - expect(Scheduler).toFlushAndYield([]); + expect(Scheduler).toFlushAndYieldThrough(['a:two']); }); - }); - it('should support inline selectors and updates that are processed after selector change', async () => { - const source = createSource({ - a: 'initial', - b: 'initial', - }); - const mutableSource = createMutableSource(source); + const newGetSnapshot = s => 'new:' + defaultGetSnapshot(s); - const getSnapshotA = () => source.value.a; - const getSnapshotB = () => source.value.b; - - function mutateB(newB) { - source.value = { - ...source.value, - b: newB, - }; - } - - function App({getSnapshot}) { - const state = useMutableSource( - mutableSource, - getSnapshot, - defaultSubscribe, - ); - return state; - } - - const root = ReactNoop.createRoot(); - await act(async () => { - root.render(); - }); - expect(root).toMatchRenderedOutput('initial'); - - await act(async () => { - mutateB('Updated B'); - root.render(); - }); - expect(root).toMatchRenderedOutput('Updated B'); - - await act(async () => { - mutateB('Another update'); - }); - expect(root).toMatchRenderedOutput('Another update'); - }); - - it('should clear the update queue when getSnapshot changes with pending lower priority updates', async () => { - const source = createSource({ - a: 'initial', - b: 'initial', - }); - const mutableSource = createMutableSource(source); - - const getSnapshotA = () => source.value.a; - const getSnapshotB = () => source.value.b; - - function mutateA(newA) { - source.value = { - ...source.value, - a: newA, - }; - } - - function mutateB(newB) { - source.value = { - ...source.value, - b: newB, - }; - } - - function App({toggle}) { - const state = useMutableSource( - mutableSource, - toggle ? getSnapshotB : getSnapshotA, - defaultSubscribe, - ); - const result = (toggle ? 'B: ' : 'A: ') + state; - return result; - } - - const root = ReactNoop.createRoot(); - await act(async () => { - root.render(); - }); - expect(root).toMatchRenderedOutput('A: initial'); - - await act(async () => { - ReactNoop.discreteUpdates(() => { - // Update both A and B to the same value - mutateA('Update'); - mutateB('Update'); - // Toggle to B in the same batch - root.render(); - }); - // Mutate A at lower priority. This should never be rendered, because - // by the time we get to the lower priority, we've already switched - // to B. - mutateA('OOPS! This mutation should be ignored'); - }); - expect(root).toMatchRenderedOutput('B: Update'); - }); - - it('should clear the update queue when source changes with pending lower priority updates', async () => { - const sourceA = createSource('initial'); - const sourceB = createSource('initial'); - const mutableSourceA = createMutableSource(sourceA); - const mutableSourceB = createMutableSource(sourceB); - - function App({toggle}) { - const state = useMutableSource( - toggle ? mutableSourceB : mutableSourceA, - defaultGetSnapshot, - defaultSubscribe, - ); - const result = (toggle ? 'B: ' : 'A: ') + state; - return result; - } - - const root = ReactNoop.createRoot(); - await act(async () => { - root.render(); - }); - expect(root).toMatchRenderedOutput('A: initial'); - - await act(async () => { - ReactNoop.discreteUpdates(() => { - // Update both A and B to the same value - sourceA.value = 'Update'; - sourceB.value = 'Update'; - // Toggle to B in the same batch - root.render(); - }); - // Mutate A at lower priority. This should never be rendered, because - // by the time we get to the lower priority, we've already switched - // to B. - sourceA.value = 'OOPS! This mutation should be ignored'; - }); - expect(root).toMatchRenderedOutput('B: Update'); - }); - - it('should always treat reading as potentially unsafe when getSnapshot changes between renders', async () => { - const source = createSource({ - a: 'foo', - b: 'bar', - }); - const mutableSource = createMutableSource(source); - - const getSnapshotA = () => source.value.a; - const getSnapshotB = () => source.value.b; - - function mutateA(newA) { - source.value = { - ...source.value, - a: newA, - }; - } - - function App({getSnapshotFirst, getSnapshotSecond}) { - const first = useMutableSource( - mutableSource, - getSnapshotFirst, - defaultSubscribe, - ); - const second = useMutableSource( - mutableSource, - getSnapshotSecond, - defaultSubscribe, - ); - - let result = `x: ${first}, y: ${second}`; - - if (getSnapshotFirst === getSnapshotSecond) { - // When both getSnapshot functions are equal, - // the two values must be consistent. - if (first !== second) { - result = 'Oops, tearing!'; - } - } - - React.useEffect(() => { - Scheduler.unstable_yieldValue(result); - }, [result]); - - return result; - } - - const root = ReactNoop.createRoot(); - await act(async () => { - root.render( - , - ); - }); - // x and y start out reading from different parts of the store. - expect(Scheduler).toHaveYielded(['x: foo, y: bar']); - - await act(async () => { - ReactNoop.discreteUpdates(() => { - // At high priority, toggle y so that it reads from A instead of B. - // Simultaneously, mutate A. - mutateA('baz'); - root.render( - , + // Force a higher priority render with a new config. + // This should signal that the snapshot is not safe and trigger a full re-render. + Scheduler.unstable_runWithPriority( + Scheduler.unstable_UserBlockingPriority, + () => { + ReactNoop.render( + <> + + + , + () => Scheduler.unstable_yieldValue('Sync effect'), ); - - // If this update were processed before the next mutation, - // it would be expected to yield "baz" and "baz". - }); - - // At lower priority, mutate A again. - // This happens to match the initial value of B. - mutateA('bar'); - - // When this update is processed, - // it is expected to yield "bar" and "bar". - }); - - // Check that we didn't commit any inconsistent states. - // The actual sequence of work will be: - // 1. React renders the high-pri update, sees a new getSnapshot, detects the source has been further mutated, and throws - // 2. React re-renders with all pending updates, including the second mutation, and renders "bar" and "bar". - expect(Scheduler).toHaveYielded(['x: bar, y: bar']); + }, + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'a:new:two', + 'b:new:two', + 'Sync effect', + ]); }); + }); - it('getSnapshot changes and then source is mutated in between paint and passive effect phase', async () => { - const source = createSource({ - a: 'foo', - b: 'bar', + // @gate experimental + it('should throw and restart render if source and snapshot are unavailable during a sync update', () => { + const source = createSource('one'); + const mutableSource = createMutableSource(source); + + act(() => { + ReactNoop.render( + <> + + + , + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYield(['a:one', 'b:one', 'Sync effect']); + ReactNoop.flushPassiveEffects(); + + // Changing values should schedule an update with React. + // Start working on this update but don't finish it. + Scheduler.unstable_runWithPriority(Scheduler.unstable_LowPriority, () => { + source.value = 'two'; + expect(Scheduler).toFlushAndYieldThrough(['a:two']); }); - const mutableSource = createMutableSource(source); - function mutateB(newB) { - source.value = { - ...source.value, - b: newB, - }; - } + const newGetSnapshot = s => 'new:' + defaultGetSnapshot(s); - const getSnapshotA = () => source.value.a; - const getSnapshotB = () => source.value.b; - - function App({getSnapshot}) { - const value = useMutableSource( - mutableSource, - getSnapshot, - defaultSubscribe, + // Force a higher priority render with a new config. + // This should signal that the snapshot is not safe and trigger a full re-render. + ReactNoop.flushSync(() => { + ReactNoop.render( + <> + + + , + () => Scheduler.unstable_yieldValue('Sync effect'), ); - - Scheduler.unstable_yieldValue('Render: ' + value); - React.useEffect(() => { - Scheduler.unstable_yieldValue('Commit: ' + value); - }, [value]); - - return value; - } - - const root = ReactNoop.createRoot(); - await act(async () => { - root.render(); - }); - expect(Scheduler).toHaveYielded(['Render: foo', 'Commit: foo']); - - await act(async () => { - // Switch getSnapshot to read from B instead - root.render(); - // Render and finish the tree, but yield right after paint, before - // the passive effects have fired. - expect(Scheduler).toFlushUntilNextPaint(['Render: bar']); - // Then mutate B. - mutateB('baz'); }); expect(Scheduler).toHaveYielded([ - // Fires the effect from the previous render - 'Commit: bar', - // During that effect, it should detect that the snapshot has changed - // and re-render. - 'Render: baz', - 'Commit: baz', + 'a:new:two', + 'b:new:two', + 'Sync effect', ]); - expect(root).toMatchRenderedOutput('baz'); }); + }); - it('getSnapshot changes and then source is mutated in between paint and passive effect phase, case 2', async () => { - const source = createSource({ - a: 'a0', - b: 'b0', + // @gate experimental + it('should only update components whose subscriptions fire', () => { + const source = createComplexSource('a:one', 'b:one'); + const mutableSource = createMutableSource(source); + + // Subscribe to part of the store. + const getSnapshotA = s => s.valueA; + const subscribeA = (s, callback) => s.subscribeA(callback); + const getSnapshotB = s => s.valueB; + const subscribeB = (s, callback) => s.subscribeB(callback); + + act(() => { + ReactNoop.render( + <> + + + , + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYield(['a:a:one', 'b:b:one', 'Sync effect']); + + // Changes to part of the store (e.g. A) should not render other parts. + source.valueA = 'a:two'; + expect(Scheduler).toFlushAndYield(['a:a:two']); + source.valueB = 'b:two'; + expect(Scheduler).toFlushAndYield(['b:b:two']); + }); + }); + + // @gate experimental + it('should detect tearing in part of the store not yet subscribed to', () => { + const source = createComplexSource('a:one', 'b:one'); + const mutableSource = createMutableSource(source); + + // Subscribe to part of the store. + const getSnapshotA = s => s.valueA; + const subscribeA = (s, callback) => s.subscribeA(callback); + const getSnapshotB = s => s.valueB; + const subscribeB = (s, callback) => s.subscribeB(callback); + + act(() => { + ReactNoop.render( + <> + + , + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYield(['a:a:one', 'Sync effect']); + + // Because the store has not chagned yet, there are no pending updates, + // so it is considered safe to read from when we start this render. + ReactNoop.render( + <> + + + + , + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough(['a:a:one', 'b:b:one']); + + // Mutating the source should trigger a tear detection on the next read, + // which should throw and re-render the entire tree. + source.valueB = 'b:two'; + + expect(Scheduler).toFlushAndYield([ + 'a:a:one', + 'b:b:two', + 'c:b:two', + 'Sync effect', + ]); + }); + }); + + // @gate experimental + it('does not schedule an update for subscriptions that fire with an unchanged snapshot', () => { + const MockComponent = jest.fn(Component); + + const source = createSource('one'); + const mutableSource = createMutableSource(source); + + act(() => { + ReactNoop.render( + , + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough(['only:one', 'Sync effect']); + ReactNoop.flushPassiveEffects(); + expect(source.listenerCount).toBe(1); + + // Notify subscribe function but don't change the value + source.value = 'one'; + expect(Scheduler).toFlushWithoutYielding(); + }); + }); + + // @gate experimental + it('should throw and restart if getSnapshot changes between scheduled update and re-render', () => { + const source = createSource('one'); + const mutableSource = createMutableSource(source); + + const newGetSnapshot = s => 'new:' + defaultGetSnapshot(s); + + let updateGetSnapshot; + + function WrapperWithState() { + const tuple = React.useState(() => defaultGetSnapshot); + updateGetSnapshot = tuple[1]; + return ( + + ); + } + + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYield(['only:one', 'Sync effect']); + ReactNoop.flushPassiveEffects(); + + // Change the source (and schedule an update). + Scheduler.unstable_runWithPriority(Scheduler.unstable_LowPriority, () => { + source.value = 'two'; }); - const mutableSource = createMutableSource(source); - const getSnapshotA = () => source.value.a; - const getSnapshotB = () => source.value.b; + // Schedule a higher priority update that changes getSnapshot. + Scheduler.unstable_runWithPriority( + Scheduler.unstable_UserBlockingPriority, + () => { + updateGetSnapshot(() => newGetSnapshot); + }, + ); - function mutateA(newA) { - source.value = { - ...source.value, - a: newA, - }; + expect(Scheduler).toFlushAndYield(['only:new:two']); + }); + }); + + // @gate experimental + it('should recover from a mutation during yield when other work is scheduled', () => { + const source = createSource('one'); + const mutableSource = createMutableSource(source); + + act(() => { + // Start a render that uses the mutable source. + ReactNoop.render( + <> + + + , + ); + expect(Scheduler).toFlushAndYieldThrough(['a:one']); + + // Mutate source + source.value = 'two'; + + // Now render something different. + ReactNoop.render(
    ); + expect(Scheduler).toFlushAndYield([]); + }); + }); + + // @gate experimental + it('should not throw if the new getSnapshot returns the same snapshot value', () => { + const source = createSource('one'); + const mutableSource = createMutableSource(source); + + const onRenderA = jest.fn(); + const onRenderB = jest.fn(); + + let updateGetSnapshot; + + function WrapperWithState() { + const tuple = React.useState(() => defaultGetSnapshot); + updateGetSnapshot = tuple[1]; + return ( + + ); + } + + act(() => { + ReactNoop.render( + <> + + + + + + + , + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYield(['a:one', 'b:one', 'Sync effect']); + ReactNoop.flushPassiveEffects(); + expect(onRenderA).toHaveBeenCalledTimes(1); + expect(onRenderB).toHaveBeenCalledTimes(1); + + // If B's getSnapshot function updates, but the snapshot it returns is the same, + // only B should re-render (to update its state). + updateGetSnapshot(() => s => defaultGetSnapshot(s)); + expect(Scheduler).toFlushAndYield(['b:one']); + ReactNoop.flushPassiveEffects(); + expect(onRenderA).toHaveBeenCalledTimes(1); + expect(onRenderB).toHaveBeenCalledTimes(2); + }); + }); + + // @gate experimental + it('should not throw if getSnapshot changes but the source can be safely read from anyway', () => { + const source = createSource('one'); + const mutableSource = createMutableSource(source); + + const newGetSnapshot = s => 'new:' + defaultGetSnapshot(s); + + let updateGetSnapshot; + + function WrapperWithState() { + const tuple = React.useState(() => defaultGetSnapshot); + updateGetSnapshot = tuple[1]; + return ( + + ); + } + + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYield(['only:one', 'Sync effect']); + ReactNoop.flushPassiveEffects(); + + // Change the source (and schedule an update) + // but also change the snapshot function too. + ReactNoop.batchedUpdates(() => { + source.value = 'two'; + updateGetSnapshot(() => newGetSnapshot); + }); + + expect(Scheduler).toFlushAndYield(['only:new:two']); + }); + }); + + // @gate experimental + it('should still schedule an update if an eager selector throws after a mutation', () => { + const source = createSource({ + friends: [ + {id: 1, name: 'Foo'}, + {id: 2, name: 'Bar'}, + ], + }); + const mutableSource = createMutableSource(source); + + function FriendsList() { + const getSnapshot = React.useCallback( + ({value}) => Array.from(value.friends), + [], + ); + const friends = useMutableSource( + mutableSource, + getSnapshot, + defaultSubscribe, + ); + return ( +
      + {friends.map(friend => ( + + ))} +
    + ); + } + + function Friend({id}) { + const getSnapshot = React.useCallback( + ({value}) => { + // This selector is intentionally written in a way that will throw + // if no matching friend exists in the store. + return value.friends.find(friend => friend.id === id).name; + }, + [id], + ); + const name = useMutableSource( + mutableSource, + getSnapshot, + defaultSubscribe, + ); + Scheduler.unstable_yieldValue(`${id}:${name}`); + return
  • {name}
  • ; + } + + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYield(['1:Foo', '2:Bar', 'Sync effect']); + + // This mutation will cause the "Bar" component to throw, + // since its value will no longer be a part of the store. + // Mutable source should still schedule an update though, + // which should unmount "Bar" and mount "Baz". + source.value = { + friends: [ + {id: 1, name: 'Foo'}, + {id: 3, name: 'Baz'}, + ], + }; + expect(Scheduler).toFlushAndYield(['1:Foo', '3:Baz']); + }); + }); + + // @gate experimental + it('should not warn about updates that fire between unmount and passive unsubcribe', () => { + const source = createSource('one'); + const mutableSource = createMutableSource(source); + + function Wrapper() { + React.useLayoutEffect(() => () => { + Scheduler.unstable_yieldValue('layout unmount'); + }); + return ( + + ); + } + + act(() => { + ReactNoop.renderToRootWithID(, 'root', () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYield(['only:one', 'Sync effect']); + ReactNoop.flushPassiveEffects(); + + // Umounting a root should remove the remaining event listeners in a passive effect + ReactNoop.unmountRootWithID('root'); + expect(Scheduler).toFlushAndYieldThrough(['layout unmount']); + + // Changes to source should not cause a warning, + // even though the unsubscribe hasn't run yet (since it's a pending passive effect). + source.value = 'two'; + expect(Scheduler).toFlushAndYield([]); + }); + }); + + // @gate experimental + it('should support inline selectors and updates that are processed after selector change', async () => { + const source = createSource({ + a: 'initial', + b: 'initial', + }); + const mutableSource = createMutableSource(source); + + const getSnapshotA = () => source.value.a; + const getSnapshotB = () => source.value.b; + + function mutateB(newB) { + source.value = { + ...source.value, + b: newB, + }; + } + + function App({getSnapshot}) { + const state = useMutableSource( + mutableSource, + getSnapshot, + defaultSubscribe, + ); + return state; + } + + const root = ReactNoop.createRoot(); + await act(async () => { + root.render(); + }); + expect(root).toMatchRenderedOutput('initial'); + + await act(async () => { + mutateB('Updated B'); + root.render(); + }); + expect(root).toMatchRenderedOutput('Updated B'); + + await act(async () => { + mutateB('Another update'); + }); + expect(root).toMatchRenderedOutput('Another update'); + }); + + // @gate experimental + it('should clear the update queue when getSnapshot changes with pending lower priority updates', async () => { + const source = createSource({ + a: 'initial', + b: 'initial', + }); + const mutableSource = createMutableSource(source); + + const getSnapshotA = () => source.value.a; + const getSnapshotB = () => source.value.b; + + function mutateA(newA) { + source.value = { + ...source.value, + a: newA, + }; + } + + function mutateB(newB) { + source.value = { + ...source.value, + b: newB, + }; + } + + function App({toggle}) { + const state = useMutableSource( + mutableSource, + toggle ? getSnapshotB : getSnapshotA, + defaultSubscribe, + ); + const result = (toggle ? 'B: ' : 'A: ') + state; + return result; + } + + const root = ReactNoop.createRoot(); + await act(async () => { + root.render(); + }); + expect(root).toMatchRenderedOutput('A: initial'); + + await act(async () => { + ReactNoop.discreteUpdates(() => { + // Update both A and B to the same value + mutateA('Update'); + mutateB('Update'); + // Toggle to B in the same batch + root.render(); + }); + // Mutate A at lower priority. This should never be rendered, because + // by the time we get to the lower priority, we've already switched + // to B. + mutateA('OOPS! This mutation should be ignored'); + }); + expect(root).toMatchRenderedOutput('B: Update'); + }); + + // @gate experimental + it('should clear the update queue when source changes with pending lower priority updates', async () => { + const sourceA = createSource('initial'); + const sourceB = createSource('initial'); + const mutableSourceA = createMutableSource(sourceA); + const mutableSourceB = createMutableSource(sourceB); + + function App({toggle}) { + const state = useMutableSource( + toggle ? mutableSourceB : mutableSourceA, + defaultGetSnapshot, + defaultSubscribe, + ); + const result = (toggle ? 'B: ' : 'A: ') + state; + return result; + } + + const root = ReactNoop.createRoot(); + await act(async () => { + root.render(); + }); + expect(root).toMatchRenderedOutput('A: initial'); + + await act(async () => { + ReactNoop.discreteUpdates(() => { + // Update both A and B to the same value + sourceA.value = 'Update'; + sourceB.value = 'Update'; + // Toggle to B in the same batch + root.render(); + }); + // Mutate A at lower priority. This should never be rendered, because + // by the time we get to the lower priority, we've already switched + // to B. + sourceA.value = 'OOPS! This mutation should be ignored'; + }); + expect(root).toMatchRenderedOutput('B: Update'); + }); + + // @gate experimental + it('should always treat reading as potentially unsafe when getSnapshot changes between renders', async () => { + const source = createSource({ + a: 'foo', + b: 'bar', + }); + const mutableSource = createMutableSource(source); + + const getSnapshotA = () => source.value.a; + const getSnapshotB = () => source.value.b; + + function mutateA(newA) { + source.value = { + ...source.value, + a: newA, + }; + } + + function App({getSnapshotFirst, getSnapshotSecond}) { + const first = useMutableSource( + mutableSource, + getSnapshotFirst, + defaultSubscribe, + ); + const second = useMutableSource( + mutableSource, + getSnapshotSecond, + defaultSubscribe, + ); + + let result = `x: ${first}, y: ${second}`; + + if (getSnapshotFirst === getSnapshotSecond) { + // When both getSnapshot functions are equal, + // the two values must be consistent. + if (first !== second) { + result = 'Oops, tearing!'; + } } - function App({getSnapshotFirst, getSnapshotSecond}) { - const first = useMutableSource( - mutableSource, - getSnapshotFirst, - defaultSubscribe, - ); - const second = useMutableSource( - mutableSource, - getSnapshotSecond, - defaultSubscribe, - ); + React.useEffect(() => { + Scheduler.unstable_yieldValue(result); + }, [result]); - return `first: ${first}, second: ${second}`; - } + return result; + } - const root = ReactNoop.createRoot(); - await act(async () => { - root.render( - , - ); - }); - expect(root.getChildrenAsJSX()).toEqual('first: a0, second: b0'); + const root = ReactNoop.createRoot(); + await act(async () => { + root.render( + , + ); + }); + // x and y start out reading from different parts of the store. + expect(Scheduler).toHaveYielded(['x: foo, y: bar']); - await act(async () => { - // Switch the second getSnapshot to also read from A + await act(async () => { + ReactNoop.discreteUpdates(() => { + // At high priority, toggle y so that it reads from A instead of B. + // Simultaneously, mutate A. + mutateA('baz'); root.render( , ); - // Render and finish the tree, but yield right after paint, before - // the passive effects have fired. - expect(Scheduler).toFlushUntilNextPaint([]); - // Now mutate A. Both hooks should update. - // This is at high priority so that it doesn't get batched with default - // priority updates that might fire during the passive effect - ReactNoop.discreteUpdates(() => { - mutateA('a1'); - }); - expect(Scheduler).toFlushUntilNextPaint([]); - - expect(root.getChildrenAsJSX()).toEqual('first: a1, second: a1'); + // If this update were processed before the next mutation, + // it would be expected to yield "baz" and "baz". }); + // At lower priority, mutate A again. + // This happens to match the initial value of B. + mutateA('bar'); + + // When this update is processed, + // it is expected to yield "bar" and "bar". + }); + + // Check that we didn't commit any inconsistent states. + // The actual sequence of work will be: + // 1. React renders the high-pri update, sees a new getSnapshot, detects the source has been further mutated, and throws + // 2. React re-renders with all pending updates, including the second mutation, and renders "bar" and "bar". + expect(Scheduler).toHaveYielded(['x: bar, y: bar']); + }); + + // @gate experimental + it('getSnapshot changes and then source is mutated in between paint and passive effect phase', async () => { + const source = createSource({ + a: 'foo', + b: 'bar', + }); + const mutableSource = createMutableSource(source); + + function mutateB(newB) { + source.value = { + ...source.value, + b: newB, + }; + } + + const getSnapshotA = () => source.value.a; + const getSnapshotB = () => source.value.b; + + function App({getSnapshot}) { + const value = useMutableSource( + mutableSource, + getSnapshot, + defaultSubscribe, + ); + + Scheduler.unstable_yieldValue('Render: ' + value); + React.useEffect(() => { + Scheduler.unstable_yieldValue('Commit: ' + value); + }, [value]); + + return value; + } + + const root = ReactNoop.createRoot(); + await act(async () => { + root.render(); + }); + expect(Scheduler).toHaveYielded(['Render: foo', 'Commit: foo']); + + await act(async () => { + // Switch getSnapshot to read from B instead + root.render(); + // Render and finish the tree, but yield right after paint, before + // the passive effects have fired. + expect(Scheduler).toFlushUntilNextPaint(['Render: bar']); + // Then mutate B. + mutateB('baz'); + }); + expect(Scheduler).toHaveYielded([ + // Fires the effect from the previous render + 'Commit: bar', + // During that effect, it should detect that the snapshot has changed + // and re-render. + 'Render: baz', + 'Commit: baz', + ]); + expect(root).toMatchRenderedOutput('baz'); + }); + + // @gate experimental + it('getSnapshot changes and then source is mutated in between paint and passive effect phase, case 2', async () => { + const source = createSource({ + a: 'a0', + b: 'b0', + }); + const mutableSource = createMutableSource(source); + + const getSnapshotA = () => source.value.a; + const getSnapshotB = () => source.value.b; + + function mutateA(newA) { + source.value = { + ...source.value, + a: newA, + }; + } + + function App({getSnapshotFirst, getSnapshotSecond}) { + const first = useMutableSource( + mutableSource, + getSnapshotFirst, + defaultSubscribe, + ); + const second = useMutableSource( + mutableSource, + getSnapshotSecond, + defaultSubscribe, + ); + + return `first: ${first}, second: ${second}`; + } + + const root = ReactNoop.createRoot(); + await act(async () => { + root.render( + , + ); + }); + expect(root.getChildrenAsJSX()).toEqual('first: a0, second: b0'); + + await act(async () => { + // Switch the second getSnapshot to also read from A + root.render( + , + ); + // Render and finish the tree, but yield right after paint, before + // the passive effects have fired. + expect(Scheduler).toFlushUntilNextPaint([]); + + // Now mutate A. Both hooks should update. + // This is at high priority so that it doesn't get batched with default + // priority updates that might fire during the passive effect + ReactNoop.discreteUpdates(() => { + mutateA('a1'); + }); + expect(Scheduler).toFlushUntilNextPaint([]); + expect(root.getChildrenAsJSX()).toEqual('first: a1, second: a1'); }); - it('getSnapshot changes and then source is mutated during interleaved event', async () => { - const {useEffect} = React; + expect(root.getChildrenAsJSX()).toEqual('first: a1, second: a1'); + }); - const source = createComplexSource('1', '2'); - const mutableSource = createMutableSource(source); + // @gate experimental + it('getSnapshot changes and then source is mutated during interleaved event', async () => { + const {useEffect} = React; - // Subscribe to part of the store. - const getSnapshotA = s => s.valueA; - const subscribeA = (s, callback) => s.subscribeA(callback); - const configA = [getSnapshotA, subscribeA]; + const source = createComplexSource('1', '2'); + const mutableSource = createMutableSource(source); - const getSnapshotB = s => s.valueB; - const subscribeB = (s, callback) => s.subscribeB(callback); - const configB = [getSnapshotB, subscribeB]; + // Subscribe to part of the store. + const getSnapshotA = s => s.valueA; + const subscribeA = (s, callback) => s.subscribeA(callback); + const configA = [getSnapshotA, subscribeA]; - function App({parentConfig, childConfig}) { - const [getSnapshot, subscribe] = parentConfig; - const parentValue = useMutableSource( - mutableSource, - getSnapshot, - subscribe, - ); + const getSnapshotB = s => s.valueB; + const subscribeB = (s, callback) => s.subscribeB(callback); + const configB = [getSnapshotB, subscribeB]; - Scheduler.unstable_yieldValue('Parent: ' + parentValue); + function App({parentConfig, childConfig}) { + const [getSnapshot, subscribe] = parentConfig; + const parentValue = useMutableSource( + mutableSource, + getSnapshot, + subscribe, + ); - return ( - - ); - } + Scheduler.unstable_yieldValue('Parent: ' + parentValue); - function Child({parentConfig, childConfig, parentValue}) { - const [getSnapshot, subscribe] = childConfig; - const childValue = useMutableSource( - mutableSource, - getSnapshot, - subscribe, - ); + return ( + + ); + } - Scheduler.unstable_yieldValue('Child: ' + childValue); + function Child({parentConfig, childConfig, parentValue}) { + const [getSnapshot, subscribe] = childConfig; + const childValue = useMutableSource( + mutableSource, + getSnapshot, + subscribe, + ); - let result = `${parentValue}, ${childValue}`; + Scheduler.unstable_yieldValue('Child: ' + childValue); - if (parentConfig === childConfig) { - // When both components read using the same config, the two values - // must be consistent. - if (parentValue !== childValue) { - result = 'Oops, tearing!'; - } + let result = `${parentValue}, ${childValue}`; + + if (parentConfig === childConfig) { + // When both components read using the same config, the two values + // must be consistent. + if (parentValue !== childValue) { + result = 'Oops, tearing!'; } - - useEffect(() => { - Scheduler.unstable_yieldValue('Commit: ' + result); - }, [result]); - - return result; } - const root = ReactNoop.createRoot(); - await act(async () => { - root.render(); - }); - expect(Scheduler).toHaveYielded([ - 'Parent: 1', - 'Child: 2', - 'Commit: 1, 2', - ]); + useEffect(() => { + Scheduler.unstable_yieldValue('Commit: ' + result); + }, [result]); - await act(async () => { - // Switch the parent and the child to read using the same config - root.render(); - // Start rendering the parent, but yield before rendering the child - expect(Scheduler).toFlushAndYieldThrough(['Parent: 2']); + return result; + } - // Mutate the config. This is at lower priority so that 1) to make sure - // it doesn't happen to get batched with the in-progress render, and 2) - // so it doesn't interrupt the in-progress render. - Scheduler.unstable_runWithPriority( - Scheduler.unstable_IdlePriority, - () => { - source.valueB = '3'; - }, - ); - }); + const root = ReactNoop.createRoot(); + await act(async () => { + root.render(); + }); + expect(Scheduler).toHaveYielded(['Parent: 1', 'Child: 2', 'Commit: 1, 2']); - expect(Scheduler).toHaveYielded([ - // The partial render completes - 'Child: 2', - 'Commit: 2, 2', + await act(async () => { + // Switch the parent and the child to read using the same config + root.render(); + // Start rendering the parent, but yield before rendering the child + expect(Scheduler).toFlushAndYieldThrough(['Parent: 2']); - // Then we start rendering the low priority mutation - 'Parent: 3', - - // Eventually the child corrects itself, because of the check that - // occurs when re-subscribing. - 'Child: 3', - 'Commit: 3, 3', - ]); + // Mutate the config. This is at lower priority so that 1) to make sure + // it doesn't happen to get batched with the in-progress render, and 2) + // so it doesn't interrupt the in-progress render. + Scheduler.unstable_runWithPriority( + Scheduler.unstable_IdlePriority, + () => { + source.valueB = '3'; + }, + ); }); - it('should not tear with newly mounted component when updates were scheduled at a lower priority', async () => { - const source = createSource('one'); - const mutableSource = createMutableSource(source); + expect(Scheduler).toHaveYielded([ + // The partial render completes + 'Child: 2', + 'Commit: 2, 2', - let committedA = null; - let committedB = null; + // Then we start rendering the low priority mutation + 'Parent: 3', - const onRender = () => { - if (committedB !== null) { - expect(committedA).toBe(committedB); - } - }; + // Eventually the child corrects itself, because of the check that + // occurs when re-subscribing. + 'Child: 3', + 'Commit: 3, 3', + ]); + }); - function ComponentA() { - const snapshot = useMutableSource( - mutableSource, - defaultGetSnapshot, - defaultSubscribe, - ); - Scheduler.unstable_yieldValue(`a:${snapshot}`); - React.useEffect(() => { - committedA = snapshot; - }, [snapshot]); - return
    {`a:${snapshot}`}
    ; - } - function ComponentB() { - const snapshot = useMutableSource( - mutableSource, - defaultGetSnapshot, - defaultSubscribe, - ); - Scheduler.unstable_yieldValue(`b:${snapshot}`); - React.useEffect(() => { - committedB = snapshot; - }, [snapshot]); - return
    {`b:${snapshot}`}
    ; + // @gate experimental + it('should not tear with newly mounted component when updates were scheduled at a lower priority', async () => { + const source = createSource('one'); + const mutableSource = createMutableSource(source); + + let committedA = null; + let committedB = null; + + const onRender = () => { + if (committedB !== null) { + expect(committedA).toBe(committedB); } + }; - // Mount ComponentA with data version 1 - act(() => { - ReactNoop.render( - - - , - () => Scheduler.unstable_yieldValue('Sync effect'), - ); - }); - expect(Scheduler).toHaveYielded(['a:one', 'Sync effect']); + function ComponentA() { + const snapshot = useMutableSource( + mutableSource, + defaultGetSnapshot, + defaultSubscribe, + ); + Scheduler.unstable_yieldValue(`a:${snapshot}`); + React.useEffect(() => { + committedA = snapshot; + }, [snapshot]); + return
    {`a:${snapshot}`}
    ; + } + function ComponentB() { + const snapshot = useMutableSource( + mutableSource, + defaultGetSnapshot, + defaultSubscribe, + ); + Scheduler.unstable_yieldValue(`b:${snapshot}`); + React.useEffect(() => { + committedB = snapshot; + }, [snapshot]); + return
    {`b:${snapshot}`}
    ; + } + + // Mount ComponentA with data version 1 + act(() => { + ReactNoop.render( + + + , + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + }); + expect(Scheduler).toHaveYielded(['a:one', 'Sync effect']); + expect(source.listenerCount).toBe(1); + + // Mount ComponentB with version 1 (but don't commit it) + act(() => { + ReactNoop.render( + + + + , + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'a:one', + 'b:one', + 'Sync effect', + ]); expect(source.listenerCount).toBe(1); - // Mount ComponentB with version 1 (but don't commit it) - act(() => { - ReactNoop.render( - - - - , - () => Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - 'a:one', - 'b:one', - 'Sync effect', - ]); - expect(source.listenerCount).toBe(1); + // Mutate -> schedule update for ComponentA + Scheduler.unstable_runWithPriority( + Scheduler.unstable_IdlePriority, + () => { + source.value = 'two'; + }, + ); - // Mutate -> schedule update for ComponentA - Scheduler.unstable_runWithPriority( - Scheduler.unstable_IdlePriority, - () => { - source.value = 'two'; - }, - ); + // Commit ComponentB -> notice the change and schedule an update for ComponentB + expect(Scheduler).toFlushAndYield(['a:two', 'b:two']); + expect(source.listenerCount).toBe(2); + }); + }); - // Commit ComponentB -> notice the change and schedule an update for ComponentB - expect(Scheduler).toFlushAndYield(['a:two', 'b:two']); - expect(source.listenerCount).toBe(2); + if (__DEV__) { + describe('dev warnings', () => { + // @gate experimental + it('should warn if the subscribe function does not return an unsubscribe function', () => { + const source = createSource('one'); + const mutableSource = createMutableSource(source); + + const brokenSubscribe = () => {}; + + expect(() => { + act(() => { + ReactNoop.render( + , + ); + }); + }).toErrorDev( + 'Mutable source subscribe function must return an unsubscribe function.', + ); + }); + + // @gate experimental + it('should error if multiple renderers of the same type use a mutable source at the same time', () => { + const source = createSource('one'); + const mutableSource = createMutableSource(source); + + act(() => { + // Start a render that uses the mutable source. + ReactNoop.render( + <> + + + , + ); + expect(Scheduler).toFlushAndYieldThrough(['a:one']); + + const PrevScheduler = Scheduler; + + // Get a new copy of ReactNoop. + loadModules(); + + spyOnDev(console, 'error'); + + // Use the mutablesource again but with a different renderer. + ReactNoop.render( + , + ); + expect(Scheduler).toFlushAndYieldThrough(['c:one']); + + expect(console.error.calls.argsFor(0)[0]).toContain( + 'Detected multiple renderers concurrently rendering the ' + + 'same mutable source. This is currently unsupported.', + ); + + // TODO (useMutableSource) Act will automatically flush remaining work from render 1, + // but at this point something in the hooks dispatcher has been broken by jest.resetModules() + // Figure out what this is and remove this catch. + expect(() => + PrevScheduler.unstable_flushAllWithoutAsserting(), + ).toThrow('Invalid hook call'); + }); + }); + + // @gate experimental + it('should error if multiple renderers of the same type use a mutable source at the same time with mutation between', () => { + const source = createSource('one'); + const mutableSource = createMutableSource(source); + + act(() => { + // Start a render that uses the mutable source. + ReactNoop.render( + <> + + + , + ); + expect(Scheduler).toFlushAndYieldThrough(['a:one']); + + const PrevScheduler = Scheduler; + + // Get a new copy of ReactNoop. + loadModules(); + + spyOnDev(console, 'error'); + + // Mutate before the new render reads from the source. + source.value = 'two'; + + // Use the mutablesource again but with a different renderer. + ReactNoop.render( + , + ); + expect(Scheduler).toFlushAndYieldThrough(['c:two']); + + expect(console.error.calls.argsFor(0)[0]).toContain( + 'Detected multiple renderers concurrently rendering the ' + + 'same mutable source. This is currently unsupported.', + ); + + // TODO (useMutableSource) Act will automatically flush remaining work from render 1, + // but at this point something in the hooks dispatcher has been broken by jest.resetModules() + // Figure out what this is and remove this catch. + expect(() => + PrevScheduler.unstable_flushAllWithoutAsserting(), + ).toThrow('Invalid hook call'); + }); }); }); - - if (__DEV__) { - describe('dev warnings', () => { - it('should warn if the subscribe function does not return an unsubscribe function', () => { - const source = createSource('one'); - const mutableSource = createMutableSource(source); - - const brokenSubscribe = () => {}; - - expect(() => { - act(() => { - ReactNoop.render( - , - ); - }); - }).toErrorDev( - 'Mutable source subscribe function must return an unsubscribe function.', - ); - }); - - it('should error if multiple renderers of the same type use a mutable source at the same time', () => { - const source = createSource('one'); - const mutableSource = createMutableSource(source); - - act(() => { - // Start a render that uses the mutable source. - ReactNoop.render( - <> - - - , - ); - expect(Scheduler).toFlushAndYieldThrough(['a:one']); - - const PrevScheduler = Scheduler; - - // Get a new copy of ReactNoop. - loadModules(); - - spyOnDev(console, 'error'); - - // Use the mutablesource again but with a different renderer. - ReactNoop.render( - , - ); - expect(Scheduler).toFlushAndYieldThrough(['c:one']); - - expect(console.error.calls.argsFor(0)[0]).toContain( - 'Detected multiple renderers concurrently rendering the ' + - 'same mutable source. This is currently unsupported.', - ); - - // TODO (useMutableSource) Act will automatically flush remaining work from render 1, - // but at this point something in the hooks dispatcher has been broken by jest.resetModules() - // Figure out what this is and remove this catch. - expect(() => - PrevScheduler.unstable_flushAllWithoutAsserting(), - ).toThrow('Invalid hook call'); - }); - }); - - it('should error if multiple renderers of the same type use a mutable source at the same time with mutation between', () => { - const source = createSource('one'); - const mutableSource = createMutableSource(source); - - act(() => { - // Start a render that uses the mutable source. - ReactNoop.render( - <> - - - , - ); - expect(Scheduler).toFlushAndYieldThrough(['a:one']); - - const PrevScheduler = Scheduler; - - // Get a new copy of ReactNoop. - loadModules(); - - spyOnDev(console, 'error'); - - // Mutate before the new render reads from the source. - source.value = 'two'; - - // Use the mutablesource again but with a different renderer. - ReactNoop.render( - , - ); - expect(Scheduler).toFlushAndYieldThrough(['c:two']); - - expect(console.error.calls.argsFor(0)[0]).toContain( - 'Detected multiple renderers concurrently rendering the ' + - 'same mutable source. This is currently unsupported.', - ); - - // TODO (useMutableSource) Act will automatically flush remaining work from render 1, - // but at this point something in the hooks dispatcher has been broken by jest.resetModules() - // Figure out what this is and remove this catch. - expect(() => - PrevScheduler.unstable_flushAllWithoutAsserting(), - ).toThrow('Invalid hook call'); - }); - }); - }); - } } }); diff --git a/packages/react/src/__tests__/ReactDOMTracing-test.internal.js b/packages/react/src/__tests__/ReactDOMTracing-test.internal.js index fa6be59c7d..6a79960e38 100644 --- a/packages/react/src/__tests__/ReactDOMTracing-test.internal.js +++ b/packages/react/src/__tests__/ReactDOMTracing-test.internal.js @@ -62,13 +62,9 @@ describe('ReactDOMTracing', () => { loadModules(); }); - if (!__EXPERIMENTAL__) { - it("empty test so Jest doesn't complain", () => {}); - return; - } - describe('interaction tracing', () => { describe('hidden', () => { + // @gate experimental it('traces interaction through hidden subtree', () => { const Child = () => { const [didMount, setDidMount] = React.useState(false); @@ -145,6 +141,7 @@ describe('ReactDOMTracing', () => { ); }); + // @gate experimental it('traces interaction through hidden subtree when there is other pending traced work', () => { const Child = () => { Scheduler.unstable_yieldValue('Child'); @@ -212,6 +209,7 @@ describe('ReactDOMTracing', () => { ).toHaveBeenLastNotifiedOfInteraction(interaction); }); + // @gate experimental it('traces interaction through hidden subtree that schedules more idle/never work', () => { const Child = () => { const [didMount, setDidMount] = React.useState(false); @@ -294,6 +292,7 @@ describe('ReactDOMTracing', () => { ); }); + // @gate experimental it('does not continue interactions across pre-existing idle work', () => { const Child = () => { Scheduler.unstable_yieldValue('Child'); @@ -394,6 +393,7 @@ describe('ReactDOMTracing', () => { }); }); + // @gate experimental it('should properly trace interactions when there is work of interleaved priorities', () => { const Child = () => { Scheduler.unstable_yieldValue('Child'); @@ -515,6 +515,7 @@ describe('ReactDOMTracing', () => { }); }); + // @gate experimental it('should properly trace interactions through a multi-pass SuspenseList render', () => { const SuspenseList = React.SuspenseList; const Suspense = React.Suspense; @@ -599,7 +600,8 @@ describe('ReactDOMTracing', () => { }); describe('hydration', () => { - it('traces interaction across hydration', async done => { + // @gate experimental + it('traces interaction across hydration', () => { const ref = React.createRef(); function Child() { @@ -644,11 +646,10 @@ describe('ReactDOMTracing', () => { expect( onInteractionScheduledWorkCompleted, ).toHaveBeenLastNotifiedOfInteraction(interaction); - - done(); }); - it('traces interaction across suspended hydration', async done => { + // @gate experimental + it('traces interaction across suspended hydration', async () => { let suspend = false; let resolve; const promise = new Promise( @@ -716,11 +717,10 @@ describe('ReactDOMTracing', () => { expect( onInteractionScheduledWorkCompleted, ).toHaveBeenLastNotifiedOfInteraction(interaction); - - done(); }); - it('traces interaction across client-rendered hydration', async done => { + // @gate experimental + it('traces interaction across client-rendered hydration', () => { let suspend = false; const promise = new Promise(() => {}); const ref = React.createRef(); @@ -788,8 +788,6 @@ describe('ReactDOMTracing', () => { expect( onInteractionScheduledWorkCompleted, ).toHaveBeenLastNotifiedOfInteraction(interaction); - - done(); }); }); }); diff --git a/packages/react/src/__tests__/ReactProfilerDOM-test.internal.js b/packages/react/src/__tests__/ReactProfilerDOM-test.internal.js index b5509ad7de..6c1df098d6 100644 --- a/packages/react/src/__tests__/ReactProfilerDOM-test.internal.js +++ b/packages/react/src/__tests__/ReactProfilerDOM-test.internal.js @@ -53,11 +53,7 @@ describe('ProfilerDOM', () => { return props.text; } - if (!__EXPERIMENTAL__) { - it("empty test so Jest doesn't complain", () => {}); - return; - } - + // @gate experimental it('should correctly trace interactions for async roots', async () => { let resolve; let thenable = { diff --git a/packages/shared/__tests__/ReactError-test.internal.js b/packages/shared/__tests__/ReactError-test.internal.js index cd9fc5697f..0672471ea7 100644 --- a/packages/shared/__tests__/ReactError-test.internal.js +++ b/packages/shared/__tests__/ReactError-test.internal.js @@ -36,29 +36,28 @@ describe('ReactError', () => { } }); - if (__DEV__) { - it("empty test so Jest doesn't complain", () => {}); - } else { - it('should error with minified error code', () => { - expect(() => ReactDOM.render('Hi', null)).toThrowError( - 'Minified React error #200; visit ' + - 'https://reactjs.org/docs/error-decoder.html?invariant=200' + - ' for the full message or use the non-minified dev environment' + - ' for full errors and additional helpful warnings.', - ); - }); - it('should serialize arguments', () => { - function Oops() { - return; - } - Oops.displayName = '#wtf'; - const container = document.createElement('div'); - expect(() => ReactDOM.render(, container)).toThrowError( - 'Minified React error #152; visit ' + - 'https://reactjs.org/docs/error-decoder.html?invariant=152&args[]=%23wtf' + - ' for the full message or use the non-minified dev environment' + - ' for full errors and additional helpful warnings.', - ); - }); - } + // @gate build === "production" + it('should error with minified error code', () => { + expect(() => ReactDOM.render('Hi', null)).toThrowError( + 'Minified React error #200; visit ' + + 'https://reactjs.org/docs/error-decoder.html?invariant=200' + + ' for the full message or use the non-minified dev environment' + + ' for full errors and additional helpful warnings.', + ); + }); + + // @gate build === "production" + it('should serialize arguments', () => { + function Oops() { + return; + } + Oops.displayName = '#wtf'; + const container = document.createElement('div'); + expect(() => ReactDOM.render(, container)).toThrowError( + 'Minified React error #152; visit ' + + 'https://reactjs.org/docs/error-decoder.html?invariant=152&args[]=%23wtf' + + ' for the full message or use the non-minified dev environment' + + ' for full errors and additional helpful warnings.', + ); + }); }); diff --git a/packages/shared/__tests__/describeComponentFrame-test.js b/packages/shared/__tests__/describeComponentFrame-test.js index 3b7b843cae..62fe858059 100644 --- a/packages/shared/__tests__/describeComponentFrame-test.js +++ b/packages/shared/__tests__/describeComponentFrame-test.js @@ -11,7 +11,6 @@ let React; let ReactDOM; -const ReactFeatureFlags = require('shared/ReactFeatureFlags'); describe('Component stack trace displaying', () => { beforeEach(() => { @@ -19,11 +18,7 @@ describe('Component stack trace displaying', () => { ReactDOM = require('react-dom'); }); - if (ReactFeatureFlags.enableComponentStackLocations) { - it("empty test so Jest doesn't complain", () => {}); - return; - } - + // @gate !enableComponentStackLocations || !__DEV__ it('should provide filenames in stack traces', () => { class Component extends React.Component { render() { diff --git a/scripts/jest/TestFlags.js b/scripts/jest/TestFlags.js index 903f406f4f..79d4f17896 100644 --- a/scripts/jest/TestFlags.js +++ b/scripts/jest/TestFlags.js @@ -35,7 +35,11 @@ const environmentFlags = { __DEV__, build: __DEV__ ? 'development' : 'production', + + // TODO: Should "experimental" also imply "modern"? Maybe we should + // always compare to the channel? experimental: __EXPERIMENTAL__, + // Similarly, should stable imply "classic"? stable: !__EXPERIMENTAL__, }; @@ -44,6 +48,18 @@ function getTestFlags() { // not to but there are exceptions. const featureFlags = require('shared/ReactFeatureFlags'); + // TODO: This is a heuristic to detect the release channel by checking a flag + // that is known to only be enabled in www. What we should do instead is set + // the release channel explicitly in the each test config file. + const www = featureFlags.enableSuspenseCallback === true; + const releaseChannel = www + ? __EXPERIMENTAL__ + ? 'modern' + : 'classic' + : __EXPERIMENTAL__ + ? 'experimental' + : 'stable'; + // Return a proxy so we can throw if you attempt to access a flag that // doesn't exist. return new Proxy( @@ -52,6 +68,11 @@ function getTestFlags() { old: featureFlags.enableNewReconciler === true, new: featureFlags.enableNewReconciler === true, + channel: releaseChannel, + modern: releaseChannel === 'modern', + classic: releaseChannel === 'classic', + www, + ...featureFlags, ...environmentFlags, },