diff --git a/packages/react-reconciler/src/__tests__/useMutableSource-test.internal.js b/packages/react-reconciler/src/__tests__/useMutableSource-test.internal.js index 3d875ffc89dc..8bfa1ebc5ca4 100644 --- a/packages/react-reconciler/src/__tests__/useMutableSource-test.internal.js +++ b/packages/react-reconciler/src/__tests__/useMutableSource-test.internal.js @@ -43,8 +43,8 @@ describe('useMutableSource', () => { const callbacksA = []; const callbacksB = []; let revision = 0; - let valueA = 'a:one'; - let valueB = 'b:one'; + let valueA = initialValueA; + let valueB = initialValueB; const subscribeHelper = (callbacks, callback) => { if (callbacks.indexOf(callback) < 0) { @@ -630,7 +630,7 @@ describe('useMutableSource', () => { }); it('should only update components whose subscriptions fire', () => { - const source = createComplexSource('one', 'one'); + const source = createComplexSource('a:one', 'b:one'); const mutableSource = createMutableSource(source); // Subscribe to part of the store. @@ -672,7 +672,7 @@ describe('useMutableSource', () => { }); it('should detect tearing in part of the store not yet subscribed to', () => { - const source = createComplexSource('one', 'one'); + const source = createComplexSource('a:one', 'b:one'); const mutableSource = createMutableSource(source); // Subscribe to part of the store. @@ -1265,6 +1265,248 @@ describe('useMutableSource', () => { expect(Scheduler).toHaveYielded(['x: bar, y: bar']); }); + 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'); + }); + + it('getSnapshot changes and then source is mutated in between paint and passive effect phase, case 2', 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!'; + } + } + + return result; + } + + const root = ReactNoop.createRoot(); + await act(async () => { + root.render( + , + ); + }); + + 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('baz'); + }); + expect(Scheduler).toFlushUntilNextPaint([]); + expect(root.getChildrenAsJSX()).not.toEqual('Oops, tearing!'); + }); + }); + + it('getSnapshot changes and then source is mutated during interleaved event', async () => { + const {useEffect} = React; + + const source = createComplexSource('1', '2'); + const mutableSource = createMutableSource(source); + + // Subscribe to part of the store. + const getSnapshotA = s => s.valueA; + const subscribeA = (s, callback) => s.subscribeA(callback); + const configA = [getSnapshotA, subscribeA]; + + const getSnapshotB = s => s.valueB; + const subscribeB = (s, callback) => s.subscribeB(callback); + const configB = [getSnapshotB, subscribeB]; + + function App({parentConfig, childConfig}) { + const [getSnapshot, subscribe] = parentConfig; + const parentValue = useMutableSource( + mutableSource, + getSnapshot, + subscribe, + ); + + Scheduler.unstable_yieldValue('Parent: ' + parentValue); + + return ( + + ); + } + + function Child({parentConfig, childConfig, parentValue}) { + const [getSnapshot, subscribe] = childConfig; + const childValue = useMutableSource( + mutableSource, + getSnapshot, + subscribe, + ); + + Scheduler.unstable_yieldValue('Child: ' + childValue); + + 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', + ]); + + 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']); + + // 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'; + }, + ); + }); + expect(Scheduler).toHaveYielded([ + 'TODO: This currently tears. Fill in with correct values once bug', + + // Here's the current behavior: + + // The partial render completes + 'Child: 2', + 'Commit: 2, 2', + + // Then we start rendering the low priority mutation + 'Parent: 3', + // But the child never received a mutation event, because it hadn't + // mounted yet. So the render tears. + 'Child: 2', + 'Commit: Oops, tearing!', + + // Eventually the child corrects itself, because of the check that + // occurs when re-subscribing. + 'Child: 3', + 'Commit: 3, 3', + ]); + }); + if (__DEV__) { describe('dev warnings', () => { it('should warn if the subscribe function does not return an unsubscribe function', () => {