diff --git a/OVERVIEW.md b/OVERVIEW.md index f91b420332..09d871462e 100644 --- a/OVERVIEW.md +++ b/OVERVIEW.md @@ -69,16 +69,20 @@ For example, adding a function component `` with an id 2: #### Removing a node -Removing a fiber from the tree (a root or a leaf) only requires sending 2 numbers: +Removing a fiber from the tree (a root or a leaf) requires sending: 1. remove operation constant (`2`) -1. fiber id +1. how many items were removed +1. number of children + * (followed by a children-first list of removed fiber ids) -For example, removing a root fiber with an id of 1: +For example, removing fibers with ids of 35 and 21: ```js [ 2, // remove operation - 1, // fiber id + 1, // number of removed fibers + 35, // first removed id + 21, // second removed id ] ``` diff --git a/src/__tests__/__snapshots__/storeStressSync-test.js.snap b/src/__tests__/__snapshots__/storeStressSync-test.js.snap index d4a4f7e466..801ad148a0 100644 --- a/src/__tests__/__snapshots__/storeStressSync-test.js.snap +++ b/src/__tests__/__snapshots__/storeStressSync-test.js.snap @@ -115,6 +115,260 @@ exports[`StoreStress (Sync Mode) should handle a stress test for Suspense (Sync `; +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense without type change (Sync Mode) 1`] = ` +[root] + ▾ + + ▾ + ▾ + + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense without type change (Sync Mode) 2`] = ` +[root] + ▾ + + ▾ + ▾ + + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense without type change (Sync Mode) 3`] = ` +[root] + ▾ + + ▾ + ▾ + + + + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense without type change (Sync Mode) 4`] = ` +[root] + ▾ + + ▾ + ▾ + + + + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense without type change (Sync Mode) 5`] = ` +[root] + ▾ + + ▾ + ▾ + + + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense without type change (Sync Mode) 6`] = ` +[root] + ▾ + + ▾ + ▾ + + + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense without type change (Sync Mode) 7`] = ` +[root] + ▾ + + ▾ + ▾ + + + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense without type change (Sync Mode) 8`] = ` +[root] + ▾ + + ▾ + ▾ + + + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense without type change (Sync Mode) 9`] = ` +[root] + ▾ + + ▾ + ▾ + + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense without type change (Sync Mode) 10`] = ` +[root] + ▾ + + ▾ + ▾ + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense without type change (Sync Mode) 11`] = ` +[root] + ▾ + + ▾ + ▾ + + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense without type change (Sync Mode) 12`] = ` +[root] + ▾ + + ▾ + ▾ + + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense without type change (Sync Mode) 13`] = ` +[root] + ▾ + + ▾ + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense without type change (Sync Mode) 14`] = ` +[root] + ▾ + + ▾ + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense without type change (Sync Mode) 15`] = ` +[root] + ▾ + + ▾ + + + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense without type change (Sync Mode) 16`] = ` +[root] + ▾ + + ▾ + + + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense without type change (Sync Mode) 17`] = ` +[root] + ▾ + + ▾ + + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense without type change (Sync Mode) 18`] = ` +[root] + ▾ + + ▾ + + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense without type change (Sync Mode) 19`] = ` +[root] + ▾ + + ▾ + + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense without type change (Sync Mode) 20`] = ` +[root] + ▾ + + ▾ + + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense without type change (Sync Mode) 21`] = ` +[root] + ▾ + + ▾ + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense without type change (Sync Mode) 22`] = ` +[root] + ▾ + + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense without type change (Sync Mode) 23`] = ` +[root] + ▾ + + ▾ + + +`; + +exports[`StoreStress (Sync Mode) should handle a stress test for Suspense without type change (Sync Mode) 24`] = ` +[root] + ▾ + + ▾ + + +`; + exports[`StoreStress (Sync Mode) should handle a stress test with different tree operations (Sync Mode): 1: abcde 1`] = ` [root] ▾ diff --git a/src/__tests__/__snapshots__/storeStressTestConcurrent-test.js.snap b/src/__tests__/__snapshots__/storeStressTestConcurrent-test.js.snap index 22f2fdf406..9527d9f602 100644 --- a/src/__tests__/__snapshots__/storeStressTestConcurrent-test.js.snap +++ b/src/__tests__/__snapshots__/storeStressTestConcurrent-test.js.snap @@ -115,6 +115,260 @@ exports[`StoreStressConcurrent should handle a stress test for Suspense (Concurr `; +exports[`StoreStressConcurrent should handle a stress test for Suspense without type change (Concurrent Mode) 1`] = ` +[root] + ▾ + + ▾ + ▾ + + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense without type change (Concurrent Mode) 2`] = ` +[root] + ▾ + + ▾ + ▾ + + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense without type change (Concurrent Mode) 3`] = ` +[root] + ▾ + + ▾ + ▾ + + + + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense without type change (Concurrent Mode) 4`] = ` +[root] + ▾ + + ▾ + ▾ + + + + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense without type change (Concurrent Mode) 5`] = ` +[root] + ▾ + + ▾ + ▾ + + + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense without type change (Concurrent Mode) 6`] = ` +[root] + ▾ + + ▾ + ▾ + + + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense without type change (Concurrent Mode) 7`] = ` +[root] + ▾ + + ▾ + ▾ + + + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense without type change (Concurrent Mode) 8`] = ` +[root] + ▾ + + ▾ + ▾ + + + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense without type change (Concurrent Mode) 9`] = ` +[root] + ▾ + + ▾ + ▾ + + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense without type change (Concurrent Mode) 10`] = ` +[root] + ▾ + + ▾ + ▾ + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense without type change (Concurrent Mode) 11`] = ` +[root] + ▾ + + ▾ + ▾ + + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense without type change (Concurrent Mode) 12`] = ` +[root] + ▾ + + ▾ + ▾ + + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense without type change (Concurrent Mode) 13`] = ` +[root] + ▾ + + ▾ + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense without type change (Concurrent Mode) 14`] = ` +[root] + ▾ + + ▾ + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense without type change (Concurrent Mode) 15`] = ` +[root] + ▾ + + ▾ + + + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense without type change (Concurrent Mode) 16`] = ` +[root] + ▾ + + ▾ + + + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense without type change (Concurrent Mode) 17`] = ` +[root] + ▾ + + ▾ + + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense without type change (Concurrent Mode) 18`] = ` +[root] + ▾ + + ▾ + + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense without type change (Concurrent Mode) 19`] = ` +[root] + ▾ + + ▾ + + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense without type change (Concurrent Mode) 20`] = ` +[root] + ▾ + + ▾ + + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense without type change (Concurrent Mode) 21`] = ` +[root] + ▾ + + ▾ + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense without type change (Concurrent Mode) 22`] = ` +[root] + ▾ + + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense without type change (Concurrent Mode) 23`] = ` +[root] + ▾ + + ▾ + + +`; + +exports[`StoreStressConcurrent should handle a stress test for Suspense without type change (Concurrent Mode) 24`] = ` +[root] + ▾ + + ▾ + + +`; + exports[`StoreStressConcurrent should handle a stress test with different tree operations (Concurrent Mode): 1: abcde 1`] = ` [root] ▾ diff --git a/src/__tests__/storeStressSync-test.js b/src/__tests__/storeStressSync-test.js index 30f48d9a26..54d60ef13e 100644 --- a/src/__tests__/storeStressSync-test.js +++ b/src/__tests__/storeStressSync-test.js @@ -663,4 +663,440 @@ describe('StoreStress (Sync Mode)', () => { } } }); + + it('should handle a stress test for Suspense without type change (Sync Mode)', () => { + const A = () => 'a'; + const B = () => 'b'; + const C = () => 'c'; + const X = () => 'x'; + const Y = () => 'y'; + const Z = () => 'z'; + const a = ; + const b = ; + const c = ; + const z = ; + + // prettier-ignore + const steps = [ + a, + [a], + [a, b, c], + [c, b, a], + [c, null, a], + {c}{a}, +
{c}{a}
, +
{a}{b}
, + [[a]], + null, + b, + a + ]; + + const Never = () => { + throw new Promise(() => {}); + }; + + const MaybeSuspend = ({ children, suspend }) => { + if (suspend) { + return ( +
+ {children} + + +
+ ); + } + return ( +
+ {children} + +
+ ); + }; + + const Root = ({ children }) => { + return children; + }; + + // 1. For each step, check Suspense can render them as initial primary content. + // This is the only step where we use Jest snapshots. + let snapshots = []; + let container = document.createElement('div'); + for (let i = 0; i < steps.length; i++) { + act(() => + ReactDOM.render( + + + + {steps[i]} + + + , + container + ) + ); + // We snapshot each step once so it doesn't regress. + expect(store).toMatchSnapshot(); + snapshots.push(print(store)); + act(() => ReactDOM.unmountComponentAtNode(container)); + expect(print(store)).toBe(''); + } + + // 2. Verify check Suspense can render same steps as initial fallback content. + // We don't actually assert here because the tree includes + // which is different from the snapshots above. So we take more snapshots. + let fallbackSnapshots = []; + for (let i = 0; i < steps.length; i++) { + act(() => + ReactDOM.render( + + + + + {steps[i]} + + + + , + container + ) + ); + // We snapshot each step once so it doesn't regress. + expect(store).toMatchSnapshot(); + fallbackSnapshots.push(print(store)); + act(() => ReactDOM.unmountComponentAtNode(container)); + expect(print(store)).toBe(''); + } + + // 3. Verify we can update from each step to each step in primary mode. + for (let i = 0; i < steps.length; i++) { + for (let j = 0; j < steps.length; j++) { + // Always start with a fresh container and steps[i]. + container = document.createElement('div'); + act(() => + ReactDOM.render( + + + + {steps[i]} + + + , + container + ) + ); + expect(print(store)).toEqual(snapshots[i]); + // Re-render with steps[j]. + act(() => + ReactDOM.render( + + + + {steps[j]} + + + , + container + ) + ); + // Verify the successful transition to steps[j]. + expect(print(store)).toEqual(snapshots[j]); + // Check that we can transition back again. + act(() => + ReactDOM.render( + + + + {steps[i]} + + + , + container + ) + ); + expect(print(store)).toEqual(snapshots[i]); + // Clean up after every iteration. + act(() => ReactDOM.unmountComponentAtNode(container)); + expect(print(store)).toBe(''); + } + } + + // 4. Verify we can update from each step to each step in fallback mode. + for (let i = 0; i < steps.length; i++) { + for (let j = 0; j < steps.length; j++) { + // Always start with a fresh container and steps[i]. + container = document.createElement('div'); + act(() => + ReactDOM.render( + + + + + + + + + + + + , + container + ) + ); + expect(print(store)).toEqual(fallbackSnapshots[i]); + // Re-render with steps[j]. + act(() => + ReactDOM.render( + + + + + + + + + + + + , + container + ) + ); + // Verify the successful transition to steps[j]. + expect(print(store)).toEqual(fallbackSnapshots[j]); + // Check that we can transition back again. + act(() => + ReactDOM.render( + + + + + + + + + + + + , + container + ) + ); + expect(print(store)).toEqual(fallbackSnapshots[i]); + // Clean up after every iteration. + act(() => ReactDOM.unmountComponentAtNode(container)); + expect(print(store)).toBe(''); + } + } + + // 5. Verify we can update from each step to each step when moving primary -> fallback. + for (let i = 0; i < steps.length; i++) { + for (let j = 0; j < steps.length; j++) { + // Always start with a fresh container and steps[i]. + container = document.createElement('div'); + act(() => + ReactDOM.render( + + + + {steps[i]} + + + , + container + ) + ); + expect(print(store)).toEqual(snapshots[i]); + // Re-render with steps[j]. + act(() => + ReactDOM.render( + + + + {steps[i]} + + + , + container + ) + ); + // Verify the successful transition to steps[j]. + expect(print(store)).toEqual(fallbackSnapshots[j]); + // Check that we can transition back again. + act(() => + ReactDOM.render( + + + + {steps[i]} + + + , + container + ) + ); + expect(print(store)).toEqual(snapshots[i]); + // Clean up after every iteration. + act(() => ReactDOM.unmountComponentAtNode(container)); + expect(print(store)).toBe(''); + } + } + + // 6. Verify we can update from each step to each step when moving fallback -> primary. + for (let i = 0; i < steps.length; i++) { + for (let j = 0; j < steps.length; j++) { + // Always start with a fresh container and steps[i]. + container = document.createElement('div'); + act(() => + ReactDOM.render( + + + + {steps[j]} + + + , + container + ) + ); + expect(print(store)).toEqual(fallbackSnapshots[i]); + // Re-render with steps[j]. + act(() => + ReactDOM.render( + + + + {steps[j]} + + + , + container + ) + ); + // Verify the successful transition to steps[j]. + expect(print(store)).toEqual(snapshots[j]); + // Check that we can transition back again. + act(() => + ReactDOM.render( + + + + {steps[j]} + + + , + container + ) + ); + expect(print(store)).toEqual(fallbackSnapshots[i]); + // Clean up after every iteration. + act(() => ReactDOM.unmountComponentAtNode(container)); + expect(print(store)).toBe(''); + } + } + + // 7. Verify we can update from each step to each step when toggling Suspense. + for (let i = 0; i < steps.length; i++) { + for (let j = 0; j < steps.length; j++) { + // Always start with a fresh container and steps[i]. + container = document.createElement('div'); + act(() => + ReactDOM.render( + + + + {steps[i]} + + + , + container + ) + ); + + // We get ID from the index in the tree above: + // Root, X, Suspense, ... + // ^ (index is 2) + const suspenseID = store.getElementIDAtIndex(2); + + // Force fallback. + expect(print(store)).toEqual(snapshots[i]); + act(() => { + const suspenseID = store.getElementIDAtIndex(2); + bridge.send('overrideSuspense', { + id: suspenseID, + rendererID: store.getRendererIDForElement(suspenseID), + forceFallback: true, + }); + }); + expect(print(store)).toEqual(fallbackSnapshots[j]); + + // Stop forcing fallback. + act(() => { + bridge.send('overrideSuspense', { + id: suspenseID, + rendererID: store.getRendererIDForElement(suspenseID), + forceFallback: false, + }); + }); + expect(print(store)).toEqual(snapshots[i]); + + // Trigger actual fallback. + act(() => + ReactDOM.render( + + + + {steps[i]} + + + , + container + ) + ); + expect(print(store)).toEqual(fallbackSnapshots[j]); + + // Force fallback while we're in fallback mode. + act(() => { + bridge.send('overrideSuspense', { + id: suspenseID, + rendererID: store.getRendererIDForElement(suspenseID), + forceFallback: true, + }); + }); + // Keep seeing fallback content. + expect(print(store)).toEqual(fallbackSnapshots[j]); + + // Switch to primary mode. + act(() => + ReactDOM.render( + + + + {steps[i]} + + + , + container + ) + ); + // Fallback is still forced though. + expect(print(store)).toEqual(fallbackSnapshots[j]); + + // Stop forcing fallback. This reverts to primary content. + act(() => { + bridge.send('overrideSuspense', { + id: suspenseID, + rendererID: store.getRendererIDForElement(suspenseID), + forceFallback: false, + }); + }); + // Now we see primary content. + expect(print(store)).toEqual(snapshots[i]); + + // Clean up after every iteration. + act(() => ReactDOM.unmountComponentAtNode(container)); + expect(print(store)).toBe(''); + } + } + }); }); diff --git a/src/__tests__/storeStressTestConcurrent-test.js b/src/__tests__/storeStressTestConcurrent-test.js index 6bfd867337..546db55066 100644 --- a/src/__tests__/storeStressTestConcurrent-test.js +++ b/src/__tests__/storeStressTestConcurrent-test.js @@ -668,4 +668,435 @@ describe('StoreStressConcurrent', () => { } } }); + + it('should handle a stress test for Suspense without type change (Concurrent Mode)', () => { + const A = () => 'a'; + const B = () => 'b'; + const C = () => 'c'; + const X = () => 'x'; + const Y = () => 'y'; + const Z = () => 'z'; + const a =
; + const b = ; + const c = ; + const z = ; + + // prettier-ignore + const steps = [ + a, + [a], + [a, b, c], + [c, b, a], + [c, null, a], + {c}{a}, +
{c}{a}
, +
{a}{b}
, + [[a]], + null, + b, + a + ]; + + const Never = () => { + throw new Promise(() => {}); + }; + + const MaybeSuspend = ({ children, suspend }) => { + if (suspend) { + return ( +
+ {children} + + +
+ ); + } + return ( +
+ {children} + +
+ ); + }; + + const Root = ({ children }) => { + return children; + }; + + // 1. For each step, check Suspense can render them as initial primary content. + // This is the only step where we use Jest snapshots. + let snapshots = []; + let container = document.createElement('div'); + // $FlowFixMe + let root = ReactDOM.unstable_createRoot(container); + for (let i = 0; i < steps.length; i++) { + act(() => + root.render( + + + + {steps[i]} + + + + ) + ); + // We snapshot each step once so it doesn't regress. + expect(store).toMatchSnapshot(); + snapshots.push(print(store)); + act(() => root.unmount()); + expect(print(store)).toBe(''); + } + + // 2. Verify check Suspense can render same steps as initial fallback content. + // We don't actually assert here because the tree includes + // which is different from the snapshots above. So we take more snapshots. + let fallbackSnapshots = []; + for (let i = 0; i < steps.length; i++) { + act(() => + root.render( + + + + + {steps[i]} + + + + + ) + ); + // We snapshot each step once so it doesn't regress. + expect(store).toMatchSnapshot(); + fallbackSnapshots.push(print(store)); + act(() => root.unmount()); + expect(print(store)).toBe(''); + } + + // 3. Verify we can update from each step to each step in primary mode. + for (let i = 0; i < steps.length; i++) { + for (let j = 0; j < steps.length; j++) { + // Always start with a fresh container and steps[i]. + container = document.createElement('div'); + // $FlowFixMe + root = ReactDOM.unstable_createRoot(container); + act(() => + root.render( + + + + {steps[i]} + + + + ) + ); + expect(print(store)).toEqual(snapshots[i]); + // Re-render with steps[j]. + act(() => + root.render( + + + + {steps[j]} + + + + ) + ); + // Verify the successful transition to steps[j]. + expect(print(store)).toEqual(snapshots[j]); + // Check that we can transition back again. + act(() => + root.render( + + + + {steps[i]} + + + + ) + ); + expect(print(store)).toEqual(snapshots[i]); + // Clean up after every iteration. + act(() => root.unmount()); + expect(print(store)).toBe(''); + } + } + + // 4. Verify we can update from each step to each step in fallback mode. + for (let i = 0; i < steps.length; i++) { + for (let j = 0; j < steps.length; j++) { + // Always start with a fresh container and steps[i]. + container = document.createElement('div'); + // $FlowFixMe + root = ReactDOM.unstable_createRoot(container); + act(() => + root.render( + + + + + + + + + + + + + ) + ); + expect(print(store)).toEqual(fallbackSnapshots[i]); + // Re-render with steps[j]. + act(() => + root.render( + + + + + + + + + + + + + ) + ); + // Verify the successful transition to steps[j]. + expect(print(store)).toEqual(fallbackSnapshots[j]); + // Check that we can transition back again. + act(() => + root.render( + + + + + + + + + + + + + ) + ); + expect(print(store)).toEqual(fallbackSnapshots[i]); + // Clean up after every iteration. + act(() => root.unmount()); + expect(print(store)).toBe(''); + } + } + + // 5. Verify we can update from each step to each step when moving primary -> fallback. + for (let i = 0; i < steps.length; i++) { + for (let j = 0; j < steps.length; j++) { + // Always start with a fresh container and steps[i]. + container = document.createElement('div'); + // $FlowFixMe + root = ReactDOM.unstable_createRoot(container); + act(() => + root.render( + + + + {steps[i]} + + + + ) + ); + expect(print(store)).toEqual(snapshots[i]); + // Re-render with steps[j]. + act(() => + root.render( + + + + {steps[i]} + + + + ) + ); + // Verify the successful transition to steps[j]. + expect(print(store)).toEqual(fallbackSnapshots[j]); + // Check that we can transition back again. + act(() => + root.render( + + + + {steps[i]} + + + + ) + ); + expect(print(store)).toEqual(snapshots[i]); + // Clean up after every iteration. + act(() => root.unmount()); + expect(print(store)).toBe(''); + } + } + + // 6. Verify we can update from each step to each step when moving fallback -> primary. + for (let i = 0; i < steps.length; i++) { + for (let j = 0; j < steps.length; j++) { + // Always start with a fresh container and steps[i]. + container = document.createElement('div'); + // $FlowFixMe + root = ReactDOM.unstable_createRoot(container); + act(() => + root.render( + + + + {steps[j]} + + + + ) + ); + expect(print(store)).toEqual(fallbackSnapshots[i]); + // Re-render with steps[j]. + act(() => + root.render( + + + + {steps[j]} + + + + ) + ); + // Verify the successful transition to steps[j]. + expect(print(store)).toEqual(snapshots[j]); + // Check that we can transition back again. + act(() => + root.render( + + + + {steps[j]} + + + + ) + ); + expect(print(store)).toEqual(fallbackSnapshots[i]); + // Clean up after every iteration. + act(() => root.unmount()); + expect(print(store)).toBe(''); + } + } + + // 7. Verify we can update from each step to each step when toggling Suspense. + for (let i = 0; i < steps.length; i++) { + for (let j = 0; j < steps.length; j++) { + // Always start with a fresh container and steps[i]. + container = document.createElement('div'); + // $FlowFixMe + root = ReactDOM.unstable_createRoot(container); + act(() => + root.render( + + + + {steps[i]} + + + + ) + ); + + // We get ID from the index in the tree above: + // Root, X, Suspense, ... + // ^ (index is 2) + const suspenseID = store.getElementIDAtIndex(2); + + // Force fallback. + expect(print(store)).toEqual(snapshots[i]); + act(() => { + const suspenseID = store.getElementIDAtIndex(2); + bridge.send('overrideSuspense', { + id: suspenseID, + rendererID: store.getRendererIDForElement(suspenseID), + forceFallback: true, + }); + }); + expect(print(store)).toEqual(fallbackSnapshots[j]); + + // Stop forcing fallback. + act(() => { + bridge.send('overrideSuspense', { + id: suspenseID, + rendererID: store.getRendererIDForElement(suspenseID), + forceFallback: false, + }); + }); + expect(print(store)).toEqual(snapshots[i]); + + // Trigger actual fallback. + act(() => + root.render( + + + + {steps[i]} + + + + ) + ); + expect(print(store)).toEqual(fallbackSnapshots[j]); + + // Force fallback while we're in fallback mode. + act(() => { + bridge.send('overrideSuspense', { + id: suspenseID, + rendererID: store.getRendererIDForElement(suspenseID), + forceFallback: true, + }); + }); + // Keep seeing fallback content. + expect(print(store)).toEqual(fallbackSnapshots[j]); + + // Switch to primary mode. + act(() => + root.render( + + + + {steps[i]} + + + + ) + ); + // Fallback is still forced though. + expect(print(store)).toEqual(fallbackSnapshots[j]); + + // Stop forcing fallback. This reverts to primary content. + act(() => { + bridge.send('overrideSuspense', { + id: suspenseID, + rendererID: store.getRendererIDForElement(suspenseID), + forceFallback: false, + }); + }); + // Now we see primary content. + expect(print(store)).toEqual(snapshots[i]); + + // Clean up after every iteration. + act(() => root.unmount()); + expect(print(store)).toBe(''); + } + } + }); }); diff --git a/src/backend/renderer.js b/src/backend/renderer.js index af10a64a72..09fc0881da 100644 --- a/src/backend/renderer.js +++ b/src/backend/renderer.js @@ -574,13 +574,21 @@ export function attach( } let pendingOperations: Array = []; - let pendingOperationsQueue: Array> | null = []; + let pendingRealUnmountedIDs: Array = []; + let pendingSimulatedUnmountedIDs: Array = []; + let pendingOperationsQueue: Array | null = []; + // We keep track of which Fibers have been reported as unmounted by React + // during this commit phase so that we don't try to "hide" them or their + // children when Suspense flips to fallback. These Fibers won't have IDs. + let fibersUnmountedInThisCommitPhase: WeakSet = new WeakSet(); + + // TODO: we could make this layer DEV-only and write directly to pendingOperations. let nextOperation: Array = []; function beginNextOperation(size: number): void { nextOperation.length = size; } - function endNextOperation(addToStartOfQueue: boolean): void { + function endNextOperation(): void { if (__DEV__) { for (let i = 0; i < nextOperation.length; i++) { if (!Number.isInteger(nextOperation[i])) { @@ -591,20 +599,16 @@ export function attach( } } } - - if (addToStartOfQueue) { - pendingOperations.splice.apply( - pendingOperations, - [0, 0].concat(nextOperation) - ); - } else { - pendingOperations.push.apply(pendingOperations, nextOperation); - } + pendingOperations.push.apply(pendingOperations, nextOperation); nextOperation.length = 0; } function flushPendingEvents(root: Object): void { - if (pendingOperations.length === 0) { + if ( + pendingOperations.length === 0 && + pendingRealUnmountedIDs.length === 0 && + pendingSimulatedUnmountedIDs.length === 0 + ) { // If we're currently profiling, send an "operations" method even if there are no mutations to the tree. // The frontend needs this no-op info to know how to reconstruct the tree for each commit, // even if a particular commit didn't change the shape of the tree. @@ -613,13 +617,44 @@ export function attach( } } + const ops = new Uint32Array( + // Identify which renderer this update is coming from. + 2 + // [rendererID, rootFiberID] + // All unmounts are batched in a single message. + 2 + // [TREE_OPERATION_REMOVE, removedIDLength] + pendingRealUnmountedIDs.length + + pendingSimulatedUnmountedIDs.length + + // Regular operations + pendingOperations.length + ); + // Identify which renderer this update is coming from. // This enables roots to be mapped to renderers, // Which in turn enables fiber props, states, and hooks to be inspected. - beginNextOperation(2); - nextOperation[0] = rendererID; - nextOperation[1] = getFiberID(getPrimaryFiber(root.current)); - endNextOperation(true); + let i = 0; + ops[i++] = rendererID; + ops[i++] = getFiberID(getPrimaryFiber(root.current)); + + // All unmounts except roots are batched in a single message. + ops[i++] = TREE_OPERATION_REMOVE; + // The first number is how many unmounted IDs we're gonna send. + ops[i++] = + pendingRealUnmountedIDs.length + pendingSimulatedUnmountedIDs.length; + // Fill in the real unmounts in the reverse order. + // They were inserted parents-first by React, but we want children-first. + // So we traverse our array backwards. + for (let j = pendingRealUnmountedIDs.length - 1; j >= 0; j--) { + ops[i++] = pendingRealUnmountedIDs[j]; + } + // Fill in the simulated unmounts (hidden Suspense subtrees) in their order. + // (We want children to go before parents.) + // They go *after* the real unmounts because we know for sure they won't be + // children of already pushed "real" IDs. If they were, we wouldn't be able + // to discover them during the traversal, as they would have been deleted. + ops.set(pendingSimulatedUnmountedIDs, i); + i += pendingSimulatedUnmountedIDs.length; + // Fill in the rest of the operations. + ops.set(pendingOperations, i); // Let the frontend know about tree operations. // The first value in this array will identify which root it corresponds to, @@ -628,13 +663,16 @@ export function attach( // Until the frontend has been connected, store the tree operations. // This will let us avoid walking the tree later when the frontend connects, // and it enables the Profiler's reload-and-profile functionality to work as well. - pendingOperationsQueue.push(pendingOperations); + pendingOperationsQueue.push(ops); } else { // If we've already connected to the frontend, just pass the operations through. - hook.emit('operations', Uint32Array.from(pendingOperations)); + hook.emit('operations', ops); } - pendingOperations = []; + pendingOperations.length = 0; + pendingRealUnmountedIDs.length = 0; + pendingSimulatedUnmountedIDs.length = 0; + fibersUnmountedInThisCommitPhase = new WeakSet(); } function recordMount(fiber: Fiber, parentFiber: Fiber | null) { @@ -656,7 +694,7 @@ export function attach( nextOperation[2] = ElementTypeRoot; nextOperation[3] = isProfilingSupported ? 1 : 0; nextOperation[4] = hasOwnerMetadata ? 1 : 0; - endNextOperation(false); + endNextOperation(); } else { const { displayName, key, type } = getDataForFiber(fiber); const { _debugOwner } = fiber; @@ -701,7 +739,7 @@ export function attach( nextOperation[6 + encodedDisplayNameSize + 1 + i] = encodedKey[i]; } } - endNextOperation(false); + endNextOperation(); } if (isProfiling) { @@ -713,7 +751,7 @@ export function attach( nextOperation[0] = TREE_OPERATION_UPDATE_TREE_BASE_DURATION; nextOperation[1] = id; nextOperation[2] = treeBaseDuration; - endNextOperation(false); + endNextOperation(); const { actualDuration } = fiber; if (actualDuration > 0) { @@ -728,7 +766,7 @@ export function attach( } } - function recordUnmount(fiber: Fiber) { + function recordUnmount(fiber: Fiber, isSimulated: boolean) { const isRoot = fiber.tag === HostRoot; const primaryFiber = getPrimaryFiber(fiber); if (!fiberToIDMap.has(primaryFiber)) { @@ -743,19 +781,22 @@ export function attach( } const id = getFiberID(primaryFiber); if (isRoot) { - beginNextOperation(2); + // Removing a root needs to happen at the end + // so we don't batch it with other unmounts. + beginNextOperation(3); nextOperation[0] = TREE_OPERATION_REMOVE; - nextOperation[1] = id; - endNextOperation(false); + nextOperation[1] = 1; // Remove one item + nextOperation[2] = id; + endNextOperation(); } else if (!shouldFilterFiber(fiber)) { - beginNextOperation(2); - nextOperation[0] = TREE_OPERATION_REMOVE; - nextOperation[1] = id; - // Non-root fibers are deleted during the commit phase. - // They are deleted in the parent-first order. However - // DevTools currently expects deletions to be child-first. - // This is why we prepend the delete operation to the queue. - endNextOperation(true); + // To maintain child-first ordering, + // we'll push it into one of these queues, + // and later arrange them in the correct order. + if (isSimulated) { + pendingSimulatedUnmountedIDs.push(id); + } else { + pendingRealUnmountedIDs.push(id); + } } fiberToIDMap.delete(primaryFiber); idToFiberMap.delete(id); @@ -815,14 +856,35 @@ export function attach( } } + // We use this to simulate unmounting for Suspense trees + // when we switch from primary to fallback. function unmountFiberChildrenRecursively(fiber: Fiber) { if (__DEBUG__) { debug('unmountFiberChildrenRecursively()', fiber); } + + // We might meet a nested Suspense on our way. + const isTimedOutSuspense = + fiber.tag === ReactTypeOfWork.SuspenseComponent && + fiber.memoizedState !== null; + let child = fiber.child; + if (isTimedOutSuspense) { + // If it's showing fallback tree, let's traverse it instead. + const primaryChildFragment = fiber.child; + const fallbackChildFragment = primaryChildFragment.sibling; + // Skip over to the real Fiber child. + child = fallbackChildFragment.child; + } + while (child !== null) { - recordUnmount(child); - unmountFiberChildrenRecursively(child); + // Record simulated unmounts children-first. + // We might find real committed unmounts along the way--skip them. + // Otherwise we would send duplicated messages for the same IDs. + if (!fibersUnmountedInThisCommitPhase.has(child)) { + unmountFiberChildrenRecursively(child); + recordUnmount(child, true); + } child = child.sibling; } } @@ -843,7 +905,7 @@ export function attach( nextOperation[0] = TREE_OPERATION_UPDATE_TREE_BASE_DURATION; nextOperation[1] = getFiberID(getPrimaryFiber(fiber)); nextOperation[2] = treeBaseDuration; - endNextOperation(false); + endNextOperation(); } if (haveProfilerTimesChanged(fiber.alternate, fiber)) { @@ -882,7 +944,7 @@ export function attach( for (let i = 0; i < nextChildren.length; i++) { nextOperation[3 + i] = nextChildren[i]; } - endNextOperation(false); + endNextOperation(); } function findReorderedChildrenRecursively( @@ -1058,8 +1120,8 @@ export function attach( ) { // We may have already queued up some operations before the frontend connected // If so, let the frontend know about them. - localPendingOperationsQueue.forEach(pendingOperations => { - hook.emit('operations', Uint32Array.from(pendingOperations)); + localPendingOperationsQueue.forEach(ops => { + hook.emit('operations', ops); }); } else { // If we have not been profiling, then we can just walk the tree and build up its current state as-is. @@ -1090,10 +1152,13 @@ export function attach( } function handleCommitFiberUnmount(fiber) { + // Remeber this is a real deletion so we don't + // go down this tree when hiding Suspense nodes. + fibersUnmountedInThisCommitPhase.add(fiber); // This is not recursive. // We can't traverse fibers after unmounting so instead // we rely on React telling us about each unmount. - recordUnmount(fiber); + recordUnmount(fiber, false); } function handleCommitFiberRoot(root) { @@ -1133,7 +1198,7 @@ export function attach( updateFiberRecursively(current, alternate, null); } else if (wasMounted && !isMounted) { // Unmount an existing root. - recordUnmount(current); + recordUnmount(current, false); } } else { // Mount a new root. diff --git a/src/devtools/store.js b/src/devtools/store.js index b2b18e118c..048568d5bb 100644 --- a/src/devtools/store.js +++ b/src/devtools/store.js @@ -543,6 +543,33 @@ export default class Store extends EventEmitter { } }; + _adjustParentTreeWeight = ( + parentElement: Element | null, + weightDelta: number + ) => { + let isInsideCollapsedSubTree = false; + + while (parentElement != null) { + parentElement.weight += weightDelta; + + // Additions and deletions within a collapsed subtree should not bubble beyond the collapsed parent. + // Their weight will bubble up when the parent is expanded. + if (parentElement.isCollapsed) { + isInsideCollapsedSubTree = true; + break; + } + + parentElement = ((this._idToElement.get( + parentElement.parentID + ): any): Element); + } + + // Additions and deletions within a collapsed subtree should not affect the overall number of elements. + if (!isInsideCollapsedSubTree) { + this._weightAcrossRoots += weightDelta; + } + }; + onBridgeOperations = (operations: Uint32Array) => { if (!(operations instanceof Uint32Array)) { // $FlowFixMe TODO HACK Temporary workaround for the fact that Chrome is not transferring the typed array. @@ -574,25 +601,16 @@ export default class Store extends EventEmitter { } } - let addedElementIDs: Uint32Array = new Uint32Array(0); - let removedElementIDs: Uint32Array = new Uint32Array(0); + const addedElementIDs: Array = []; + const removedElementIDs: Array = []; let i = 2; while (i < operations.length) { - let id: number = ((null: any): number); - let element: Element = ((null: any): Element); - let ownerID: number = 0; - let parentID: number = ((null: any): number); - let parentElement: Element = ((null: any): Element); - let type: ElementType = ((null: any): ElementType); - let weightDelta: number = 0; - const operation = operations[i]; - switch (operation) { - case TREE_OPERATION_ADD: - id = ((operations[i + 1]: any): number); - type = ((operations[i + 2]: any): ElementType); + case TREE_OPERATION_ADD: { + const id = ((operations[i + 1]: any): number); + const type = ((operations[i + 2]: any): ElementType); i = i + 3; @@ -602,6 +620,8 @@ export default class Store extends EventEmitter { ); } + let ownerID: number = 0; + let parentID: number = ((null: any): number); if (type === ElementTypeRoot) { if (__DEBUG__) { debug('Add', `new root node ${id}`); @@ -672,7 +692,9 @@ export default class Store extends EventEmitter { ); } - parentElement = ((this._idToElement.get(parentID): any): Element); + const parentElement = ((this._idToElement.get( + parentID + ): any): Element); parentElement.children = parentElement.children.concat(id); const element: Element = { @@ -689,70 +711,67 @@ export default class Store extends EventEmitter { }; this._idToElement.set(id, element); - - const oldAddedElementIDs = addedElementIDs; - addedElementIDs = new Uint32Array(addedElementIDs.length + 1); - addedElementIDs.set(oldAddedElementIDs); - addedElementIDs[oldAddedElementIDs.length] = id; - - weightDelta = 1; + addedElementIDs.push(id); + this._adjustParentTreeWeight(parentElement, 1); } break; - case TREE_OPERATION_REMOVE: { - id = ((operations[i + 1]: any): number); - - if (!this._idToElement.has(id)) { - throw Error( - `Cannot remove node ${id} because no matching node was found in the Store.` - ); - } - - i = i + 2; - - element = ((this._idToElement.get(id): any): Element); - parentID = element.parentID; - weightDelta = -element.weight; - - if (element.children.length > 0) { - throw new Error(`Node ${id} was removed before its children.`); - } - - this._idToElement.delete(id); - - if (parentID === 0) { - if (__DEBUG__) { - debug('Remove', `node ${id} root`); - } - - this._roots = this._roots.filter(rootID => rootID !== id); - this._rootIDToRendererID.delete(id); - this._rootIDToCapabilities.delete(id); - - haveRootsChanged = true; - } else { - if (__DEBUG__) { - debug('Remove', `node ${id} from parent ${parentID}`); - } - parentElement = ((this._idToElement.get(parentID): any): Element); - if (parentElement === undefined) { - throw Error( - `Cannot remove node ${id} from parent ${parentID} because no matching node was found in the Store.` - ); - } - parentElement.children = parentElement.children.filter( - childID => childID !== id - ); - } - - // Track removed items so search results can be updated - const oldRemovedElementIDs = removedElementIDs; - removedElementIDs = new Uint32Array(removedElementIDs.length + 1); - removedElementIDs.set(oldRemovedElementIDs); - removedElementIDs[oldRemovedElementIDs.length] = id; - break; } - case TREE_OPERATION_REORDER_CHILDREN: - id = ((operations[i + 1]: any): number); + case TREE_OPERATION_REMOVE: { + const removeLength = ((operations[i + 1]: any): number); + i = i + 2; + + for (let removeIndex = 0; removeIndex < removeLength; removeIndex++) { + const id = ((operations[i]: any): number); + + if (!this._idToElement.has(id)) { + throw Error( + `Cannot remove node ${id} because no matching node was found in the Store.` + ); + } + + i = i + 1; + + const element = ((this._idToElement.get(id): any): Element); + if (element.children.length > 0) { + throw new Error(`Node ${id} was removed before its children.`); + } + + this._idToElement.delete(id); + + const parentID = element.parentID; + let parentElement = null; + if (parentID === 0) { + if (__DEBUG__) { + debug('Remove', `node ${id} root`); + } + + this._roots = this._roots.filter(rootID => rootID !== id); + this._rootIDToRendererID.delete(id); + this._rootIDToCapabilities.delete(id); + + haveRootsChanged = true; + } else { + if (__DEBUG__) { + debug('Remove', `node ${id} from parent ${parentID}`); + } + parentElement = ((this._idToElement.get(parentID): any): Element); + if (parentElement === undefined) { + throw Error( + `Cannot remove node ${id} from parent ${parentID} because no matching node was found in the Store.` + ); + } + parentElement.children = parentElement.children.filter( + childID => childID !== id + ); + } + + this._adjustParentTreeWeight(parentElement, -element.weight); + removedElementIDs.push(id); + } + break; + } + case TREE_OPERATION_REORDER_CHILDREN: { + const id = ((operations[i + 1]: any): number); const numChildren = ((operations[i + 2]: any): number); const nextChildren = ((operations.slice( i + 3, @@ -771,38 +790,29 @@ export default class Store extends EventEmitter { ); } - element = ((this._idToElement.get(id): any): Element); - + const element = ((this._idToElement.get(id): any): Element); const prevChildren = element.children; - if ( - nextChildren.length !== prevChildren.length || - nextChildren.find(childID => { - const childElement = this._idToElement.get(childID); - return childElement == null || childElement.parentID !== id; - }) != null - ) { + if (nextChildren.length !== prevChildren.length) { throw Error( `Children cannot be added or removed during a reorder operation.` ); } - - element.children = Array.from(nextChildren); - - if (!element.isCollapsed) { - const prevWeight = element.weight; - - let nextWeight = element.type === ElementTypeRoot ? 0 : 1; - - nextChildren.forEach(childID => { - const child = ((this._idToElement.get(childID): any): Element); - nextWeight += child.isCollapsed ? 1 : child.weight; - }); - - element.weight = nextWeight; - - weightDelta = nextWeight - prevWeight; + // This check is more expensive so it's gated + if (__DEV__) { + if ( + nextChildren.find(childID => { + const childElement = this._idToElement.get(childID); + return childElement == null || childElement.parentID !== id; + }) != null + ) { + console.error( + `Children cannot be added or removed during a reorder operation.` + ); + } } + element.children = Array.from(nextChildren); break; + } case TREE_OPERATION_UPDATE_TREE_BASE_DURATION: // Base duration updates are only sent while profiling is in progress. // We can ignore them at this point. @@ -812,28 +822,6 @@ export default class Store extends EventEmitter { default: throw Error(`Unsupported Bridge operation ${operation}`); } - - let isInsideCollapsedSubTree = false; - - while (parentElement != null) { - parentElement.weight += weightDelta; - - // Additions and deletions within a collapsed subtree should not bubble beyond the collapsed parent. - // Their weight will bubble up when the parent is expanded. - if (parentElement.isCollapsed) { - isInsideCollapsedSubTree = true; - break; - } - - parentElement = ((this._idToElement.get( - parentElement.parentID - ): any): Element); - } - - // Additions and deletions within a collapsed subtree should not affect the overall number of elements. - if (!isInsideCollapsedSubTree) { - this._weightAcrossRoots += weightDelta; - } } this._revision++; @@ -860,7 +848,10 @@ export default class Store extends EventEmitter { console.groupEnd(); } - this.emit('mutated', [addedElementIDs, removedElementIDs]); + this.emit('mutated', [ + new Uint32Array(addedElementIDs), + new Uint32Array(removedElementIDs), + ]); }; onProfilingStatus = (isProfiling: boolean) => { diff --git a/src/devtools/views/Profiler/CommitTreeBuilder.js b/src/devtools/views/Profiler/CommitTreeBuilder.js index 5f905b1391..a91c7f0ab3 100644 --- a/src/devtools/views/Profiler/CommitTreeBuilder.js +++ b/src/devtools/views/Profiler/CommitTreeBuilder.js @@ -168,18 +168,12 @@ function updateTree( let i = 2; while (i < operations.length) { - let id: number = ((null: any): number); - let node: Node = ((null: any): Node); - let parentID: number = ((null: any): number); - let parentNode: Node = ((null: any): Node); - let type: ElementType = ((null: any): ElementType); - const operation = operations[i]; switch (operation) { case TREE_OPERATION_ADD: - id = ((operations[i + 1]: any): number); - type = ((operations[i + 2]: any): ElementType); + const id = ((operations[i + 1]: any): number); + const type = ((operations[i + 2]: any): ElementType); i = i + 3; @@ -210,7 +204,7 @@ function updateTree( nodes.set(id, node); } else { - parentID = ((operations[i]: any): number); + const parentID = ((operations[i]: any): number); i++; i++; // ownerID @@ -240,7 +234,7 @@ function updateTree( ); } - parentNode = getClonedNode(parentID); + const parentNode = getClonedNode(parentID); parentNode.children = parentNode.children.concat(id); const node: Node = { @@ -254,40 +248,46 @@ function updateTree( nodes.set(id, node); } - break; - case TREE_OPERATION_REMOVE: - id = ((operations[i + 1]: any): number); + break; + case TREE_OPERATION_REMOVE: { + const removeLength = ((operations[i + 1]: any): number); i = i + 2; - if (!nodes.has(id)) { - throw new Error( - 'Commit tree does not contain fiber ' + - id + - '. This is a bug in React DevTools.' - ); - } + for (let removeIndex = 0; removeIndex < removeLength; removeIndex++) { + const id = ((operations[i]: any): number); + i = i + 1; - node = getClonedNode(id); - parentID = node.parentID; - - nodes.delete(id); - - parentNode = getClonedNode(parentID); - if (parentNode == null) { - // No-op - } else { - if (__DEBUG__) { - debug('Remove', `fiber ${id} from parent ${parentID}`); + if (!nodes.has(id)) { + throw new Error( + 'Commit tree does not contain fiber ' + + id + + '. This is a bug in React DevTools.' + ); } - parentNode.children = parentNode.children.filter( - childID => childID !== id - ); + const node = getClonedNode(id); + const parentID = node.parentID; + + nodes.delete(id); + + const parentNode = getClonedNode(parentID); + if (parentNode == null) { + // No-op + } else { + if (__DEBUG__) { + debug('Remove', `fiber ${id} from parent ${parentID}`); + } + + parentNode.children = parentNode.children.filter( + childID => childID !== id + ); + } } break; - case TREE_OPERATION_REORDER_CHILDREN: - id = ((operations[i + 1]: any): number); + } + case TREE_OPERATION_REORDER_CHILDREN: { + const id = ((operations[i + 1]: any): number); const numChildren = ((operations[i + 2]: any): number); const children = ((operations.slice( i + 3, @@ -300,14 +300,15 @@ function updateTree( debug('Re-order', `fiber ${id} children ${children.join(',')}`); } - node = getClonedNode(id); + const node = getClonedNode(id); node.children = Array.from(children); break; - case TREE_OPERATION_UPDATE_TREE_BASE_DURATION: - id = operations[i + 1]; + } + case TREE_OPERATION_UPDATE_TREE_BASE_DURATION: { + const id = operations[i + 1]; - node = getClonedNode(id); + const node = getClonedNode(id); node.treeBaseDuration = operations[i + 2] / 1000; // Convert microseconds back to milliseconds; if (__DEBUG__) { @@ -319,6 +320,7 @@ function updateTree( i = i + 3; break; + } default: throw Error(`Unsupported Bridge operation ${operation}`); }