'use strict';
let React;
let ReactFeatureFlags;
let ReactNoop;
let Scheduler;
let act;
let createMutableSource;
let useMutableSource;
let waitFor;
let waitForAll;
let assertLog;
let waitForPaint;
function loadModules() {
jest.resetModules();
jest.useFakeTimers();
ReactFeatureFlags = require('shared/ReactFeatureFlags');
ReactFeatureFlags.enableProfilerTimer = true;
React = require('react');
ReactNoop = require('react-noop-renderer');
Scheduler = require('scheduler');
act = require('internal-test-utils').act;
const InternalTestUtils = require('internal-test-utils');
waitFor = InternalTestUtils.waitFor;
waitForAll = InternalTestUtils.waitForAll;
waitForPaint = InternalTestUtils.waitForPaint;
assertLog = InternalTestUtils.assertLog;
createMutableSource =
React.createMutableSource || React.unstable_createMutableSource;
useMutableSource = React.useMutableSource || React.unstable_useMutableSource;
}
describe('useMutableSource', () => {
const defaultGetSnapshot = source => source.value;
const defaultSubscribe = (source, callback) => source.subscribe(callback);
function createComplexSource(initialValueA, initialValueB) {
const callbacksA = [];
const callbacksB = [];
let revision = 0;
let valueA = initialValueA;
let valueB = initialValueB;
const subscribeHelper = (callbacks, callback) => {
if (callbacks.indexOf(callback) < 0) {
callbacks.push(callback);
}
return () => {
const index = callbacks.indexOf(callback);
if (index >= 0) {
callbacks.splice(index, 1);
}
};
};
return {
subscribeA(callback) {
return subscribeHelper(callbacksA, callback);
},
subscribeB(callback) {
return subscribeHelper(callbacksB, callback);
},
get listenerCountA() {
return callbacksA.length;
},
get listenerCountB() {
return callbacksB.length;
},
set valueA(newValue) {
revision++;
valueA = newValue;
callbacksA.forEach(callback => callback());
},
get valueA() {
return valueA;
},
set valueB(newValue) {
revision++;
valueB = newValue;
callbacksB.forEach(callback => callback());
},
get valueB() {
return valueB;
},
get version() {
return revision;
},
};
}
function createSource(initialValue) {
const callbacks = [];
let revision = 0;
let value = initialValue;
return {
subscribe(callback) {
if (callbacks.indexOf(callback) < 0) {
callbacks.push(callback);
}
return () => {
const index = callbacks.indexOf(callback);
if (index >= 0) {
callbacks.splice(index, 1);
}
};
},
get listenerCount() {
return callbacks.length;
},
set value(newValue) {
revision++;
value = newValue;
callbacks.forEach(callback => callback());
},
get value() {
return value;
},
get version() {
return revision;
},
};
}
function Component({getSnapshot, label, mutableSource, subscribe}) {
const snapshot = useMutableSource(mutableSource, getSnapshot, subscribe);
Scheduler.log(`${label}:${snapshot}`);
return <div>{`${label}:${snapshot}`}</div>;
}
beforeEach(loadModules);
it('should subscribe to a source and schedule updates when it changes', async () => {
const source = createSource('one');
const mutableSource = createMutableSource(source, param => param.version);
await act(async () => {
ReactNoop.renderToRootWithID(
<>
<Component
label="a"
getSnapshot={defaultGetSnapshot}
mutableSource={mutableSource}
subscribe={defaultSubscribe}
/>
<Component
label="b"
getSnapshot={defaultGetSnapshot}
mutableSource={mutableSource}
subscribe={defaultSubscribe}
/>
</>,
'root',
() => Scheduler.log('Sync effect'),
);
await waitFor(['a:one', 'b:one', 'Sync effect']);
expect(source.listenerCount).toBe(0);
ReactNoop.flushPassiveEffects();
expect(source.listenerCount).toBe(2);
source.value = 'two';
await waitFor(['a:two', 'b:two']);
ReactNoop.renderToRootWithID(
<>
<Component
label="a"
getSnapshot={defaultGetSnapshot}
mutableSource={mutableSource}
subscribe={defaultSubscribe}
/>
</>,
'root',
() => Scheduler.log('Sync effect'),
);
await waitForAll(['a:two', 'Sync effect']);
ReactNoop.flushPassiveEffects();
expect(source.listenerCount).toBe(1);
ReactNoop.unmountRootWithID('root');
await waitForAll([]);
ReactNoop.flushPassiveEffects();
expect(source.listenerCount).toBe(0);
source.value = 'three';
await waitForAll([]);
});
});
it('should restart work if a new source is mutated during render', async () => {
const source = createSource('one');
const mutableSource = createMutableSource(source, param => param.version);
await act(async () => {
React.startTransition(() => {
ReactNoop.render(
<>
<Component
label="a"
getSnapshot={defaultGetSnapshot}
mutableSource={mutableSource}
subscribe={defaultSubscribe}
/>
<Component
label="b"
getSnapshot={defaultGetSnapshot}
mutableSource={mutableSource}
subscribe={defaultSubscribe}
/>
</>,
() => Scheduler.log('Sync effect'),
);
});
await waitFor(['a:one']);
source.value = 'two';
await waitForAll(['a:two', 'b:two', 'Sync effect']);
});
});
it('should schedule an update if a new source is mutated between render and commit (subscription)', async () => {
const source = createSource('one');
const mutableSource = createMutableSource(source, param => param.version);
await act(async () => {
ReactNoop.render(
<>
<Component
label="a"
getSnapshot={defaultGetSnapshot}
mutableSource={mutableSource}
subscribe={defaultSubscribe}
/>
<Component
label="b"
getSnapshot={defaultGetSnapshot}
mutableSource={mutableSource}
subscribe={defaultSubscribe}
/>
</>,
() => Scheduler.log('Sync effect'),
);
await waitFor(['a:one', 'b:one', 'Sync effect']);
expect(source.listenerCount).toBe(0);
source.value = 'two';
await waitForAll(['a:two', 'b:two']);
});
});
it('should unsubscribe and resubscribe if a new source is used', async () => {
const sourceA = createSource('a-one');
const mutableSourceA = createMutableSource(
sourceA,
param => param.versionA,
);
const sourceB = createSource('b-one');
const mutableSourceB = createMutableSource(
sourceB,
param => param.versionB,
);
await act(async () => {
ReactNoop.render(
<Component
label="only"
getSnapshot={defaultGetSnapshot}
mutableSource={mutableSourceA}
subscribe={defaultSubscribe}
/>,
() => Scheduler.log('Sync effect'),
);
await waitForAll(['only:a-one', 'Sync effect']);
ReactNoop.flushPassiveEffects();
expect(sourceA.listenerCount).toBe(1);
sourceA.value = 'a-two';
await waitForAll(['only:a-two']);
ReactNoop.render(
<Component
label="only"
getSnapshot={defaultGetSnapshot}
mutableSource={mutableSourceB}
subscribe={defaultSubscribe}
/>,
() => Scheduler.log('Sync effect'),
);
await waitForAll(['only:b-one', 'Sync effect']);
ReactNoop.flushPassiveEffects();
expect(sourceA.listenerCount).toBe(0);
expect(sourceB.listenerCount).toBe(1);
sourceA.value = 'a-three';
await waitForAll([]);
sourceB.value = 'b-two';
await waitForAll(['only:b-two']);
});
});
it('should unsubscribe and resubscribe if a new subscribe function is provided', async () => {
const source = createSource('a-one');
const mutableSource = createMutableSource(source, param => param.version);
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();
};
});
await act(async () => {
ReactNoop.renderToRootWithID(
<Component
label="only"
getSnapshot={defaultGetSnapshot}
mutableSource={mutableSource}
subscribe={subscribeA}
/>,
'root',
() => Scheduler.log('Sync effect'),
);
await waitForAll(['only:a-one', 'Sync effect']);
ReactNoop.flushPassiveEffects();
expect(source.listenerCount).toBe(1);
expect(subscribeA).toHaveBeenCalledTimes(1);
ReactNoop.renderToRootWithID(
<Component
label="only"
getSnapshot={defaultGetSnapshot}
mutableSource={mutableSource}
subscribe={subscribeB}
/>,
'root',
() => Scheduler.log('Sync effect'),
);
await waitForAll(['only:a-one', 'Sync effect']);
ReactNoop.flushPassiveEffects();
expect(source.listenerCount).toBe(1);
expect(unsubscribeA).toHaveBeenCalledTimes(1);
expect(subscribeB).toHaveBeenCalledTimes(1);
ReactNoop.unmountRootWithID('root');
await waitForAll([]);
ReactNoop.flushPassiveEffects();
expect(source.listenerCount).toBe(0);
expect(unsubscribeB).toHaveBeenCalledTimes(1);
});
});
it('should re-use previously read snapshot value when reading is unsafe', async () => {
const source = createSource('one');
const mutableSource = createMutableSource(source, param => param.version);
await act(async () => {
ReactNoop.render(
<>
<Component
label="a"
getSnapshot={defaultGetSnapshot}
mutableSource={mutableSource}
subscribe={defaultSubscribe}
/>
<Component
label="b"
getSnapshot={defaultGetSnapshot}
mutableSource={mutableSource}
subscribe={defaultSubscribe}
/>
</>,
() => Scheduler.log('Sync effect'),
);
await waitForAll(['a:one', 'b:one', 'Sync effect']);
React.startTransition(() => {
source.value = 'two';
});
await waitFor(['a:two']);
ReactNoop.flushSync(() => {
ReactNoop.render(
<>
<Component
label="a"
getSnapshot={defaultGetSnapshot}
mutableSource={mutableSource}
subscribe={defaultSubscribe}
/>
<Component
label="b"
getSnapshot={defaultGetSnapshot}
mutableSource={mutableSource}
subscribe={defaultSubscribe}
/>
</>,
() => Scheduler.log('Sync effect'),
);
});
assertLog(['a:one', 'b:one', 'Sync effect']);
await waitForAll(['a:two', 'b:two']);
});
});
it('should read from source on newly mounted subtree if no pending updates are scheduled for source', async () => {
const source = createSource('one');
const mutableSource = createMutableSource(source, param => param.version);
await act(async () => {
ReactNoop.render(
<>
<Component
label="a"
getSnapshot={defaultGetSnapshot}
mutableSource={mutableSource}
subscribe={defaultSubscribe}
/>
</>,
() => Scheduler.log('Sync effect'),
);
await waitForAll(['a:one', 'Sync effect']);
ReactNoop.render(
<>
<Component
label="a"
getSnapshot={defaultGetSnapshot}
mutableSource={mutableSource}
subscribe={defaultSubscribe}
/>
<Component
label="b"
getSnapshot={defaultGetSnapshot}
mutableSource={mutableSource}
subscribe={defaultSubscribe}
/>
</>,
() => Scheduler.log('Sync effect'),
);
await waitForAll(['a:one', 'b:one', 'Sync effect']);
});
});
it('should throw and restart render if source and snapshot are unavailable during an update', async () => {
const source = createSource('one');
const mutableSource = createMutableSource(source, param => param.version);
await act(async () => {
ReactNoop.render(
<>
<Component
label="a"
getSnapshot={defaultGetSnapshot}
mutableSource={mutableSource}
subscribe={defaultSubscribe}
/>
<Component
label="b"
getSnapshot={defaultGetSnapshot}
mutableSource={mutableSource}
subscribe={defaultSubscribe}
/>
</>,
() => Scheduler.log('Sync effect'),
);
await waitForAll(['a:one', 'b:one', 'Sync effect']);
ReactNoop.flushPassiveEffects();
ReactNoop.idleUpdates(() => {
source.value = 'two';
});
await waitFor(['a:two']);
const newGetSnapshot = s => 'new:' + defaultGetSnapshot(s);
ReactNoop.flushSync(() => {
ReactNoop.render(
<>
<Component
label="a"
getSnapshot={newGetSnapshot}
mutableSource={mutableSource}
subscribe={defaultSubscribe}
/>
<Component
label="b"
getSnapshot={newGetSnapshot}
mutableSource={mutableSource}
subscribe={defaultSubscribe}
/>
</>,
() => Scheduler.log('Sync effect'),
);
});
assertLog(['a:new:two', 'b:new:two', 'Sync effect']);
});
});
it('should throw and restart render if source and snapshot are unavailable during a sync update', async () => {
const source = createSource('one');
const mutableSource = createMutableSource(source, param => param.version);
await act(async () => {
ReactNoop.render(
<>
<Component
label="a"
getSnapshot={defaultGetSnapshot}
mutableSource={mutableSource}
subscribe={defaultSubscribe}
/>
<Component
label="b"
getSnapshot={defaultGetSnapshot}
mutableSource={mutableSource}
subscribe={defaultSubscribe}
/>
</>,
() => Scheduler.log('Sync effect'),
);
await waitForAll(['a:one', 'b:one', 'Sync effect']);
ReactNoop.flushPassiveEffects();
ReactNoop.idleUpdates(() => {
source.value = 'two';
});
await waitFor(['a:two']);
const newGetSnapshot = s => 'new:' + defaultGetSnapshot(s);
ReactNoop.flushSync(() => {
ReactNoop.render(
<>
<Component
label="a"
getSnapshot={newGetSnapshot}
mutableSource={mutableSource}
subscribe={defaultSubscribe}
/>
<Component
label="b"
getSnapshot={newGetSnapshot}
mutableSource={mutableSource}
subscribe={defaultSubscribe}
/>
</>,
() => Scheduler.log('Sync effect'),
);
});
assertLog(['a:new:two', 'b:new:two', 'Sync effect']);
});
});
it('should only update components whose subscriptions fire', async () => {
const source = createComplexSource('a:one', 'b:one');
const mutableSource = createMutableSource(source, param => param.version);
const getSnapshotA = s => s.valueA;
const subscribeA = (s, callback) => s.subscribeA(callback);
const getSnapshotB = s => s.valueB;
const subscribeB = (s, callback) => s.subscribeB(callback);
await act(async () => {
ReactNoop.render(
<>
<Component
label="a"
getSnapshot={getSnapshotA}
mutableSource={mutableSource}
subscribe={subscribeA}
/>
<Component
label="b"
getSnapshot={getSnapshotB}
mutableSource={mutableSource}
subscribe={subscribeB}
/>
</>,
() => Scheduler.log('Sync effect'),
);
await waitForAll(['a:a:one', 'b:b:one', 'Sync effect']);
source.valueA = 'a:two';
await waitForAll(['a:a:two']);
source.valueB = 'b:two';
await waitForAll(['b:b:two']);
});
});
it('should detect tearing in part of the store not yet subscribed to', async () => {
const source = createComplexSource('a:one', 'b:one');
const mutableSource = createMutableSource(source, param => param.version);
const getSnapshotA = s => s.valueA;
const subscribeA = (s, callback) => s.subscribeA(callback);
const getSnapshotB = s => s.valueB;
const subscribeB = (s, callback) => s.subscribeB(callback);
await act(async () => {
ReactNoop.render(
<>
<Component
label="a"
getSnapshot={getSnapshotA}
mutableSource={mutableSource}
subscribe={subscribeA}
/>
</>,
() => Scheduler.log('Sync effect'),
);
await waitForAll(['a:a:one', 'Sync effect']);
React.startTransition(() => {
ReactNoop.render(
<>
<Component
label="a"
getSnapshot={getSnapshotA}
mutableSource={mutableSource}
subscribe={subscribeA}
/>
<Component
label="b"
getSnapshot={getSnapshotB}
mutableSource={mutableSource}
subscribe={subscribeB}
/>
<Component
label="c"
getSnapshot={getSnapshotB}
mutableSource={mutableSource}
subscribe={subscribeB}
/>
</>,
() => Scheduler.log('Sync effect'),
);
});
await waitFor(['a:a:one', 'b:b:one']);
source.valueB = 'b:two';
await waitForAll(['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', async () => {
const MockComponent = jest.fn(Component);
const source = createSource('one');
const mutableSource = createMutableSource(source, param => param.version);
await act(async () => {
ReactNoop.render(
<MockComponent
label="only"
getSnapshot={defaultGetSnapshot}
mutableSource={mutableSource}
subscribe={defaultSubscribe}
/>,
() => Scheduler.log('Sync effect'),
);
await waitFor(['only:one', 'Sync effect']);
ReactNoop.flushPassiveEffects();
expect(source.listenerCount).toBe(1);
source.value = 'one';
await waitForAll([]);
});
});
it('should throw and restart if getSnapshot changes between scheduled update and re-render', async () => {
const source = createSource('one');
const mutableSource = createMutableSource(source, param => param.version);
const newGetSnapshot = s => 'new:' + defaultGetSnapshot(s);
let updateGetSnapshot;
function WrapperWithState() {
const tuple = React.useState(() => defaultGetSnapshot);
updateGetSnapshot = tuple[1];
return (
<Component
label="only"
getSnapshot={tuple[0]}
mutableSource={mutableSource}
subscribe={defaultSubscribe}
/>
);
}
await act(async () => {
ReactNoop.render(<WrapperWithState />, () =>
Scheduler.log('Sync effect'),
);
await waitForAll(['only:one', 'Sync effect']);
ReactNoop.flushPassiveEffects();
source.value = 'two';
ReactNoop.flushSync(() => {
updateGetSnapshot(() => newGetSnapshot);
});
assertLog(['only:new:two']);
});
});
it('should recover from a mutation during yield when other work is scheduled', async () => {
const source = createSource('one');
const mutableSource = createMutableSource(source, param => param.version);
await act(async () => {
React.startTransition(() => {
ReactNoop.render(
<>
<Component
label="a"
getSnapshot={defaultGetSnapshot}
mutableSource={mutableSource}
subscribe={defaultSubscribe}
/>
<Component
label="b"
getSnapshot={defaultGetSnapshot}
mutableSource={mutableSource}
subscribe={defaultSubscribe}
/>
</>,
);
});
await waitFor(['a:one']);
source.value = 'two';
ReactNoop.render(<div />);
await waitForAll([]);
});
});
it('should not throw if the new getSnapshot returns the same snapshot value', async () => {
const source = createSource('one');
const mutableSource = createMutableSource(source, param => param.version);
const onRenderA = jest.fn();
const onRenderB = jest.fn();
let updateGetSnapshot;
function WrapperWithState() {
const tuple = React.useState(() => defaultGetSnapshot);
updateGetSnapshot = tuple[1];
return (
<Component
label="b"
getSnapshot={tuple[0]}
mutableSource={mutableSource}
subscribe={defaultSubscribe}
/>
);
}
await act(async () => {
ReactNoop.render(
<>
<React.Profiler id="a" onRender={onRenderA}>
<Component
label="a"
getSnapshot={defaultGetSnapshot}
mutableSource={mutableSource}
subscribe={defaultSubscribe}
/>
</React.Profiler>
<React.Profiler id="b" onRender={onRenderB}>
<WrapperWithState />
</React.Profiler>
</>,
() => Scheduler.log('Sync effect'),
);
await waitForAll(['a:one', 'b:one', 'Sync effect']);
ReactNoop.flushPassiveEffects();
expect(onRenderA).toHaveBeenCalledTimes(1);
expect(onRenderB).toHaveBeenCalledTimes(1);
updateGetSnapshot(() => s => defaultGetSnapshot(s));
await waitForAll(['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', async () => {
const source = createSource('one');
const mutableSource = createMutableSource(source, param => param.version);
const newGetSnapshot = s => 'new:' + defaultGetSnapshot(s);
let updateGetSnapshot;
function WrapperWithState() {
const tuple = React.useState(() => defaultGetSnapshot);
updateGetSnapshot = tuple[1];
return (
<Component
label="only"
getSnapshot={tuple[0]}
mutableSource={mutableSource}
subscribe={defaultSubscribe}
/>
);
}
await act(async () => {
ReactNoop.render(<WrapperWithState />, () =>
Scheduler.log('Sync effect'),
);
await waitForAll(['only:one', 'Sync effect']);
ReactNoop.flushPassiveEffects();
ReactNoop.batchedUpdates(() => {
source.value = 'two';
updateGetSnapshot(() => newGetSnapshot);
});
await waitForAll(['only:new:two']);
});
});
it('should still schedule an update if an eager selector throws after a mutation', async () => {
const source = createSource({
friends: [
{id: 1, name: 'Foo'},
{id: 2, name: 'Bar'},
],
});
const mutableSource = createMutableSource(source, param => param.version);
function FriendsList() {
const getSnapshot = React.useCallback(
({value}) => Array.from(value.friends),
[],
);
const friends = useMutableSource(
mutableSource,
getSnapshot,
defaultSubscribe,
);
return (
<ul>
{friends.map(friend => (
<Friend key={friend.id} id={friend.id} />
))}
</ul>
);
}
function Friend({id}) {
const getSnapshot = React.useCallback(
({value}) => {
return value.friends.find(friend => friend.id === id).name;
},
[id],
);
const name = useMutableSource(
mutableSource,
getSnapshot,
defaultSubscribe,
);
Scheduler.log(`${id}:${name}`);
return <li>{name}</li>;
}
await act(async () => {
ReactNoop.render(<FriendsList />, () => Scheduler.log('Sync effect'));
await waitForAll(['1:Foo', '2:Bar', 'Sync effect']);
source.value = {
friends: [
{id: 1, name: 'Foo'},
{id: 3, name: 'Baz'},
],
};
await waitForAll(['1:Foo', '3:Baz']);
});
});
it('should not warn about updates that fire between unmount and passive unsubscribe', async () => {
const source = createSource('one');
const mutableSource = createMutableSource(source, param => param.version);
function Wrapper() {
React.useLayoutEffect(() => () => {
Scheduler.log('layout unmount');
});
return (
<Component
label="only"
getSnapshot={defaultGetSnapshot}
mutableSource={mutableSource}
subscribe={defaultSubscribe}
/>
);
}
await act(async () => {
ReactNoop.renderToRootWithID(<Wrapper />, 'root', () =>
Scheduler.log('Sync effect'),
);
await waitForAll(['only:one', 'Sync effect']);
ReactNoop.flushPassiveEffects();
ReactNoop.unmountRootWithID('root');
await waitFor(['layout unmount']);
source.value = 'two';
await waitForAll([]);
});
});
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, param => param.version);
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(() => {
root.render(<App getSnapshot={getSnapshotA} />);
});
expect(root).toMatchRenderedOutput('initial');
await act(() => {
mutateB('Updated B');
root.render(<App getSnapshot={getSnapshotB} />);
});
expect(root).toMatchRenderedOutput('Updated B');
await act(() => {
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, param => param.version);
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(() => {
root.render(<App toggle={false} />);
});
expect(root).toMatchRenderedOutput('A: initial');
await act(() => {
ReactNoop.discreteUpdates(() => {
mutateA('Update');
mutateB('Update');
root.render(<App toggle={true} />);
});
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,
param => param.versionA,
);
const mutableSourceB = createMutableSource(
sourceB,
param => param.versionB,
);
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(() => {
root.render(<App toggle={false} />);
});
expect(root).toMatchRenderedOutput('A: initial');
await act(() => {
ReactNoop.discreteUpdates(() => {
sourceA.value = 'Update';
sourceB.value = 'Update';
root.render(<App toggle={true} />);
});
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, param => param.version);
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) {
if (first !== second) {
result = 'Oops, tearing!';
}
}
React.useEffect(() => {
Scheduler.log(result);
}, [result]);
return result;
}
const root = ReactNoop.createRoot();
await act(() => {
root.render(
<App
getSnapshotFirst={getSnapshotA}
getSnapshotSecond={getSnapshotB}
/>,
);
});
assertLog(['x: foo, y: bar']);
await act(() => {
ReactNoop.discreteUpdates(() => {
mutateA('baz');
root.render(
<App
getSnapshotFirst={getSnapshotA}
getSnapshotSecond={getSnapshotA}
/>,
);
});
mutateA('bar');
});
assertLog(['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, param => param.version);
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.log('Render: ' + value);
React.useEffect(() => {
Scheduler.log('Commit: ' + value);
}, [value]);
return value;
}
const root = ReactNoop.createRoot();
await act(() => {
root.render(<App getSnapshot={getSnapshotA} />);
});
assertLog(['Render: foo', 'Commit: foo']);
await act(async () => {
root.render(<App getSnapshot={getSnapshotB} />);
await waitForPaint(['Render: bar']);
mutateB('baz');
});
assertLog([
'Commit: bar',
'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: 'a0',
b: 'b0',
});
const mutableSource = createMutableSource(source, param => param.version);
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(() => {
root.render(
<App
getSnapshotFirst={getSnapshotA}
getSnapshotSecond={getSnapshotB}
/>,
);
});
expect(root.getChildrenAsJSX()).toEqual('first: a0, second: b0');
await act(async () => {
root.render(
<App
getSnapshotFirst={getSnapshotA}
getSnapshotSecond={getSnapshotA}
/>,
);
await waitForPaint([]);
await act(() => {
ReactNoop.discreteUpdates(() => {
mutateA('a1');
});
});
expect(root).toMatchRenderedOutput('first: a1, second: a1');
});
expect(root.getChildrenAsJSX()).toEqual('first: a1, second: a1');
});
it(
'if source is mutated after initial read but before subscription is set ' +
'up, should still entangle all pending mutations even if snapshot of ' +
'new subscription happens to match',
async () => {
const source = createSource({
a: 'a0',
b: 'b0',
});
const mutableSource = createMutableSource(source, param => param.version);
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 Read({getSnapshot}) {
const value = useMutableSource(
mutableSource,
getSnapshot,
defaultSubscribe,
);
Scheduler.log(value);
return value;
}
function Text({text}) {
Scheduler.log(text);
return text;
}
const root = ReactNoop.createRoot();
await act(() => {
root.render(
<>
<Read getSnapshot={getSnapshotA} />
</>,
);
});
assertLog(['a0']);
expect(root).toMatchRenderedOutput('a0');
await act(async () => {
React.startTransition(() => {
root.render(
<>
<Read getSnapshot={getSnapshotA} />
<Read getSnapshot={getSnapshotB} />
<Text text="c" />
</>,
);
});
await waitFor(['a0', 'b0']);
if (gate(flags => flags.enableUnifiedSyncLane)) {
React.startTransition(() => {
mutateA('a1');
mutateB('b1');
});
} else {
mutateA('a1');
mutateB('b1');
}
React.startTransition(() => {
mutateA('a0');
mutateB('b0');
});
await waitForPaint(['c']);
await waitForPaint(['a0']);
expect(root).toMatchRenderedOutput('a0b0c');
await waitForAll([]);
expect(root).toMatchRenderedOutput('a0b0c');
});
},
);
it('warns about functions being used as snapshot values', async () => {
const source = createSource(() => 'a');
const mutableSource = createMutableSource(source, param => param.version);
const getSnapshot = () => source.value;
function Read() {
const fn = useMutableSource(mutableSource, getSnapshot, defaultSubscribe);
const value = fn();
Scheduler.log(value);
return value;
}
const root = ReactNoop.createRoot();
root.render(
<>
<Read />
</>,
);
await expect(async () => await waitForAll(['a'])).toErrorDev(
'Mutable source should not return a function as the snapshot value.',
);
expect(root).toMatchRenderedOutput('a');
});
it('getSnapshot changes and then source is mutated during interleaved event', async () => {
const {useEffect} = React;
const source = createComplexSource('1', '2');
const mutableSource = createMutableSource(source, param => param.version);
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.log('Parent: ' + parentValue);
return (
<Child
parentConfig={parentConfig}
childConfig={childConfig}
parentValue={parentValue}
/>
);
}
function Child({parentConfig, childConfig, parentValue}) {
const [getSnapshot, subscribe] = childConfig;
const childValue = useMutableSource(
mutableSource,
getSnapshot,
subscribe,
);
Scheduler.log('Child: ' + childValue);
let result = `${parentValue}, ${childValue}`;
if (parentConfig === childConfig) {
if (parentValue !== childValue) {
result = 'Oops, tearing!';
}
}
useEffect(() => {
Scheduler.log('Commit: ' + result);
}, [result]);
return result;
}
const root = ReactNoop.createRoot();
await act(() => {
root.render(<App parentConfig={configA} childConfig={configB} />);
});
assertLog(['Parent: 1', 'Child: 2', 'Commit: 1, 2']);
await act(async () => {
React.startTransition(() => {
root.render(<App parentConfig={configB} childConfig={configB} />);
});
await waitFor(['Parent: 2']);
React.startTransition(() => {
source.valueB = '3';
});
await waitFor([
'Child: 2',
'Commit: 2, 2',
'Parent: 3',
'Child: 3',
]);
await waitForAll([
'Commit: 3, 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, param => param.version);
let committedA = null;
let committedB = null;
const onRender = () => {
if (committedB !== null) {
expect(committedA).toBe(committedB);
}
};
function ComponentA() {
const snapshot = useMutableSource(
mutableSource,
defaultGetSnapshot,
defaultSubscribe,
);
Scheduler.log(`a:${snapshot}`);
React.useEffect(() => {
committedA = snapshot;
}, [snapshot]);
return <div>{`a:${snapshot}`}</div>;
}
function ComponentB() {
const snapshot = useMutableSource(
mutableSource,
defaultGetSnapshot,
defaultSubscribe,
);
Scheduler.log(`b:${snapshot}`);
React.useEffect(() => {
committedB = snapshot;
}, [snapshot]);
return <div>{`b:${snapshot}`}</div>;
}
await act(() => {
ReactNoop.render(
<React.Profiler id="root" onRender={onRender}>
<ComponentA />
</React.Profiler>,
() => Scheduler.log('Sync effect'),
);
});
assertLog(['a:one', 'Sync effect']);
expect(source.listenerCount).toBe(1);
await act(async () => {
ReactNoop.render(
<React.Profiler id="root" onRender={onRender}>
<ComponentA />
<ComponentB />
</React.Profiler>,
() => Scheduler.log('Sync effect'),
);
await waitFor(['a:one', 'b:one', 'Sync effect']);
expect(source.listenerCount).toBe(1);
React.startTransition(() => {
source.value = 'two';
});
await waitForAll(['a:two', 'b:two']);
expect(source.listenerCount).toBe(2);
});
});
if (__DEV__) {
describe('dev warnings', () => {
it('should warn if the subscribe function does not return an unsubscribe function', async () => {
const source = createSource('one');
const mutableSource = createMutableSource(
source,
param => param.version,
);
const brokenSubscribe = () => {};
await expect(async () => {
await act(() => {
ReactNoop.render(
<Component
label="only"
getSnapshot={defaultGetSnapshot}
mutableSource={mutableSource}
subscribe={brokenSubscribe}
/>,
);
});
}).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', async () => {
const source = createSource('one');
const mutableSource = createMutableSource(
source,
param => param.version,
);
await act(async () => {
React.startTransition(() => {
ReactNoop.render(
<>
<Component
label="a"
getSnapshot={defaultGetSnapshot}
mutableSource={mutableSource}
subscribe={defaultSubscribe}
/>
<Component
label="b"
getSnapshot={defaultGetSnapshot}
mutableSource={mutableSource}
subscribe={defaultSubscribe}
/>
</>,
);
});
await waitFor(['a:one']);
const PrevScheduler = Scheduler;
loadModules();
spyOnDev(console, 'error').mockImplementation(() => {});
ReactNoop.render(
<Component
label="c"
getSnapshot={defaultGetSnapshot}
mutableSource={mutableSource}
subscribe={defaultSubscribe}
/>,
);
await waitFor(['c:one']);
expect(console.error.mock.calls[0][0]).toContain(
'Detected multiple renderers concurrently rendering the ' +
'same mutable source. This is currently unsupported.',
);
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', async () => {
const source = createSource('one');
const mutableSource = createMutableSource(
source,
param => param.version,
);
await act(async () => {
React.startTransition(() => {
ReactNoop.render(
<>
<Component
label="a"
getSnapshot={defaultGetSnapshot}
mutableSource={mutableSource}
subscribe={defaultSubscribe}
/>
<Component
label="b"
getSnapshot={defaultGetSnapshot}
mutableSource={mutableSource}
subscribe={defaultSubscribe}
/>
</>,
);
});
await waitFor(['a:one']);
const PrevScheduler = Scheduler;
loadModules();
spyOnDev(console, 'error').mockImplementation(() => {});
source.value = 'two';
ReactNoop.render(
<Component
label="c"
getSnapshot={defaultGetSnapshot}
mutableSource={mutableSource}
subscribe={defaultSubscribe}
/>,
);
await waitFor(['c:two']);
expect(console.error.mock.calls[0][0]).toContain(
'Detected multiple renderers concurrently rendering the ' +
'same mutable source. This is currently unsupported.',
);
expect(() =>
PrevScheduler.unstable_flushAllWithoutAsserting(),
).toThrow('Invalid hook call');
});
});
});
}
});