'use strict';
let useSyncExternalStore;
let React;
let ReactNoop;
let Scheduler;
let act;
let useLayoutEffect;
let forwardRef;
let useImperativeHandle;
let useRef;
let useState;
let use;
let startTransition;
let waitFor;
let waitForAll;
let assertLog;
let Suspense;
let useMemo;
let textCache;
describe('useSyncExternalStore', () => {
beforeEach(() => {
jest.resetModules();
React = require('react');
ReactNoop = require('react-noop-renderer');
Scheduler = require('scheduler');
useLayoutEffect = React.useLayoutEffect;
useImperativeHandle = React.useImperativeHandle;
forwardRef = React.forwardRef;
useRef = React.useRef;
useState = React.useState;
use = React.use;
useSyncExternalStore = React.useSyncExternalStore;
startTransition = React.startTransition;
Suspense = React.Suspense;
useMemo = React.useMemo;
textCache = new Map();
const InternalTestUtils = require('internal-test-utils');
waitFor = InternalTestUtils.waitFor;
waitForAll = InternalTestUtils.waitForAll;
assertLog = InternalTestUtils.assertLog;
act = require('internal-test-utils').act;
});
function resolveText(text) {
const record = textCache.get(text);
if (record === undefined) {
const newRecord = {
status: 'resolved',
value: text,
};
textCache.set(text, newRecord);
} else if (record.status === 'pending') {
const thenable = record.value;
record.status = 'resolved';
record.value = text;
thenable.pings.forEach(t => t());
}
}
function readText(text) {
const record = textCache.get(text);
if (record !== undefined) {
switch (record.status) {
case 'pending':
throw record.value;
case 'rejected':
throw record.value;
case 'resolved':
return record.value;
}
} else {
const thenable = {
pings: [],
then(resolve) {
if (newRecord.status === 'pending') {
thenable.pings.push(resolve);
} else {
Promise.resolve().then(() => resolve(newRecord.value));
}
},
};
const newRecord = {
status: 'pending',
value: thenable,
};
textCache.set(text, newRecord);
throw thenable;
}
}
function AsyncText({text}) {
const result = readText(text);
Scheduler.log(text);
return result;
}
function Text({text}) {
Scheduler.log(text);
return text;
}
function createExternalStore(initialState) {
const listeners = new Set();
let currentState = initialState;
return {
set(text) {
currentState = text;
ReactNoop.batchedUpdates(() => {
listeners.forEach(listener => listener());
});
},
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
getState() {
return currentState;
},
getSubscriberCount() {
return listeners.size;
},
};
}
it(
'detects interleaved mutations during a concurrent read before ' +
'layout effects fire',
async () => {
const store1 = createExternalStore(0);
const store2 = createExternalStore(0);
const Child = forwardRef(({store, label}, ref) => {
const value = useSyncExternalStore(store.subscribe, store.getState);
useImperativeHandle(ref, () => {
return value;
}, []);
return <Text text={label + value} />;
});
function App({store}) {
const refA = useRef(null);
const refB = useRef(null);
const refC = useRef(null);
useLayoutEffect(() => {
const aText = refA.current;
const bText = refB.current;
const cText = refC.current;
Scheduler.log(
`Children observed during layout: A${aText}B${bText}C${cText}`,
);
});
return (
<>
<Child store={store} ref={refA} label="A" />
<Child store={store} ref={refB} label="B" />
<Child store={store} ref={refC} label="C" />
</>
);
}
const root = ReactNoop.createRoot();
await act(async () => {
startTransition(() => {
root.render(<App store={store1} />);
});
await waitFor(['A0', 'B0']);
store1.set(1);
await waitForAll([
'C1',
'A1',
'B1',
'C1',
'Children observed during layout: A1B1C1',
]);
});
await act(async () => {
startTransition(() => {
root.render(<App store={store2} />);
});
await waitFor(['A0', 'B0']);
store2.set(1);
await waitForAll([
'C1',
'A1',
'B1',
'C1',
'Children observed during layout: A1B1C1',
]);
});
},
);
it('next value is correctly cached when state is dispatched in render phase', async () => {
const store = createExternalStore('value:initial');
function App() {
const value = useSyncExternalStore(store.subscribe, store.getState);
const [sameValue, setSameValue] = useState(value);
if (value !== sameValue) setSameValue(value);
return <Text text={value} />;
}
const root = ReactNoop.createRoot();
await act(() => {
root.render(<App />);
});
assertLog(['value:initial']);
await act(() => {
store.set('value:changed');
});
assertLog(['value:changed']);
await act(() => {
store.set('value:initial');
});
assertLog(['value:initial']);
});
it(
'regression: suspending in shell after synchronously patching ' +
'up store mutation',
async () => {
const store = createExternalStore('Initial');
let resolve;
const promise = new Promise(r => {
resolve = r;
});
function A() {
const value = useSyncExternalStore(store.subscribe, store.getState);
if (value === 'Updated') {
try {
use(promise);
} catch (x) {
Scheduler.log('Suspend A');
throw x;
}
}
return <Text text={'A: ' + value} />;
}
function B() {
const value = useSyncExternalStore(store.subscribe, store.getState);
return <Text text={'B: ' + value} />;
}
function App() {
return (
<>
<span>
<A />
</span>
<span>
<B />
</span>
</>
);
}
const root = ReactNoop.createRoot();
await act(async () => {
startTransition(() => root.render(<App />));
await waitFor(['A: Initial']);
store.set('Updated');
});
assertLog([
'B: Updated',
'Suspend A',
'B: Updated',
]);
expect(root).toMatchRenderedOutput(null);
await act(() => resolve());
assertLog(['A: Updated', 'B: Updated']);
expect(root).toMatchRenderedOutput(
<>
<span>A: Updated</span>
<span>B: Updated</span>
</>,
);
},
);
it('regression: does not infinite loop for only changing store reference in render', async () => {
let store = {value: {}};
let listeners = [];
const ExternalStore = {
set(value) {
store = {...store};
setTimeout(() => {
store = {value};
emitChange();
}, 100);
emitChange();
},
subscribe(listener) {
listeners = [...listeners, listener];
return () => {
listeners = listeners.filter(l => l !== listener);
};
},
getSnapshot() {
return store;
},
};
function emitChange() {
listeners.forEach(l => l());
}
function StoreText() {
const {value} = useSyncExternalStore(
ExternalStore.subscribe,
ExternalStore.getSnapshot,
);
useMemo(() => {
const newValue = {text: 'B'};
if (value == null || newValue !== value) {
ExternalStore.set(newValue);
}
}, []);
return <Text text={value.text || '(not set)'} />;
}
function App() {
return (
<>
<Suspense fallback={'Loading...'}>
<AsyncText text={'A'} />
<StoreText />
</Suspense>
</>
);
}
const root = ReactNoop.createRoot();
await act(async () => {
root.render(<App />);
});
assertLog(['(not set)']);
expect(root).toMatchRenderedOutput('Loading...');
await act(() => {
resolveText('A');
});
assertLog([
'A',
'B',
'A',
'B',
'B',
...(gate('alwaysThrottleRetries') ? [] : ['B']),
]);
expect(root).toMatchRenderedOutput('AB');
});
});