'use strict';
let useSyncExternalStore;
let useSyncExternalStoreWithSelector;
let React;
let ReactDOM;
let ReactDOMClient;
let Scheduler;
let act;
let useState;
let useEffect;
let useLayoutEffect;
let assertLog;
let assertConsoleErrorDev;
describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
beforeEach(() => {
jest.resetModules();
if (gate(flags => flags.enableUseSyncExternalStoreShim)) {
jest.mock('react', () => {
return jest.requireActual(
__DEV__
? 'react-17/umd/react.development.js'
: 'react-17/umd/react.production.min.js',
);
});
jest.mock('react-dom', () =>
jest.requireActual(
__DEV__
? 'react-dom-17/umd/react-dom.development.js'
: 'react-dom-17/umd/react-dom.production.min.js',
),
);
jest.mock('react-dom/client', () =>
jest.requireActual(
__DEV__
? 'react-dom-17/umd/react-dom.development.js'
: 'react-dom-17/umd/react-dom.production.min.js',
),
);
}
React = require('react');
ReactDOM = require('react-dom');
ReactDOMClient = require('react-dom/client');
Scheduler = require('scheduler');
useState = React.useState;
useEffect = React.useEffect;
useLayoutEffect = React.useLayoutEffect;
const InternalTestUtils = require('internal-test-utils');
assertLog = InternalTestUtils.assertLog;
assertConsoleErrorDev = InternalTestUtils.assertConsoleErrorDev;
const internalAct = require('internal-test-utils').act;
act = cb => internalAct(() => ReactDOM.unstable_batchedUpdates(cb));
if (gate(flags => flags.source)) {
jest.mock('use-sync-external-store/src/useSyncExternalStore', () =>
jest.requireActual('use-sync-external-store/shim'),
);
}
useSyncExternalStore =
require('use-sync-external-store/shim').useSyncExternalStore;
useSyncExternalStoreWithSelector =
require('use-sync-external-store/shim/with-selector').useSyncExternalStoreWithSelector;
});
function Text({text}) {
Scheduler.log(text);
return text;
}
function createRoot(container) {
if (gate(flags => !flags.enableUseSyncExternalStoreShim)) {
return ReactDOMClient.createRoot(container);
} else {
ReactDOM.render(null, container);
return {
render(children) {
ReactDOM.render(children, container);
},
};
}
}
function createExternalStore(initialState) {
const listeners = new Set();
let currentState = initialState;
return {
set(text) {
currentState = text;
ReactDOM.unstable_batchedUpdates(() => {
listeners.forEach(listener => listener());
});
},
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
getState() {
return currentState;
},
getSubscriberCount() {
return listeners.size;
},
};
}
it('basic usage', async () => {
const store = createExternalStore('Initial');
function App() {
const text = useSyncExternalStore(store.subscribe, store.getState);
return React.createElement(Text, {
text: text,
});
}
const container = document.createElement('div');
const root = createRoot(container);
await act(() => root.render(React.createElement(App, null)));
assertLog(['Initial']);
expect(container.textContent).toEqual('Initial');
await act(() => {
store.set('Updated');
});
assertLog(['Updated']);
expect(container.textContent).toEqual('Updated');
});
it('skips re-rendering if nothing changes', async () => {
const store = createExternalStore('Initial');
function App() {
const text = useSyncExternalStore(store.subscribe, store.getState);
return React.createElement(Text, {
text: text,
});
}
const container = document.createElement('div');
const root = createRoot(container);
await act(() => root.render(React.createElement(App, null)));
assertLog(['Initial']);
expect(container.textContent).toEqual('Initial');
await act(() => {
store.set('Initial');
});
assertLog([]);
expect(container.textContent).toEqual('Initial');
});
it('switch to a different store', async () => {
const storeA = createExternalStore(0);
const storeB = createExternalStore(0);
let setStore;
function App() {
const [store, _setStore] = useState(storeA);
setStore = _setStore;
const value = useSyncExternalStore(store.subscribe, store.getState);
return React.createElement(Text, {
text: value,
});
}
const container = document.createElement('div');
const root = createRoot(container);
await act(() => root.render(React.createElement(App, null)));
assertLog([0]);
expect(container.textContent).toEqual('0');
await act(() => {
storeA.set(1);
});
assertLog([1]);
expect(container.textContent).toEqual('1');
await act(() => {
ReactDOM.flushSync(() => {
storeA.set(2);
setStore(storeB);
});
});
assertLog([0]);
expect(container.textContent).toEqual('0');
await act(() => {
storeA.set(3);
});
assertLog([]);
expect(container.textContent).toEqual('0');
await act(() => {
storeB.set(1);
});
assertLog([1]);
expect(container.textContent).toEqual('1');
});
it('selecting a specific value inside getSnapshot', async () => {
const store = createExternalStore({
a: 0,
b: 0,
});
function A() {
const a = useSyncExternalStore(store.subscribe, () => store.getState().a);
return React.createElement(Text, {
text: 'A' + a,
});
}
function B() {
const b = useSyncExternalStore(store.subscribe, () => store.getState().b);
return React.createElement(Text, {
text: 'B' + b,
});
}
function App() {
return React.createElement(
React.Fragment,
null,
React.createElement(A, null),
React.createElement(B, null),
);
}
const container = document.createElement('div');
const root = createRoot(container);
await act(() => root.render(React.createElement(App, null)));
assertLog(['A0', 'B0']);
expect(container.textContent).toEqual('A0B0');
await act(() => {
store.set({
a: 0,
b: 1,
});
});
assertLog(['B1']);
expect(container.textContent).toEqual('A0B1');
await act(() => {
store.set({
a: 1,
b: 1,
});
});
assertLog(['A1']);
expect(container.textContent).toEqual('A1B1');
});
it(
"compares to current state before bailing out, even when there's a " +
'mutation in between the sync and passive effects',
async () => {
const store = createExternalStore(0);
function App() {
const value = useSyncExternalStore(store.subscribe, store.getState);
useEffect(() => {
Scheduler.log('Passive effect: ' + value);
}, [value]);
return React.createElement(Text, {
text: value,
});
}
const container = document.createElement('div');
const root = createRoot(container);
await act(() => root.render(React.createElement(App, null)));
assertLog([0, 'Passive effect: 0']);
store.set(1);
assertLog([
1,
]);
expect(container.textContent).toEqual('1');
store.set(0);
assertLog([
'Passive effect: 1',
0,
]);
expect(container.textContent).toEqual('0');
},
);
it('mutating the store in between render and commit when getSnapshot has changed', async () => {
const store = createExternalStore({
a: 1,
b: 1,
});
const getSnapshotA = () => store.getState().a;
const getSnapshotB = () => store.getState().b;
function Child1({step}) {
const value = useSyncExternalStore(store.subscribe, store.getState);
useLayoutEffect(() => {
if (step === 1) {
Scheduler.log('Update B in commit phase');
store.set({
a: value.a,
b: 2,
});
}
}, [step]);
return null;
}
function Child2({step}) {
const label = step === 0 ? 'A' : 'B';
const getSnapshot = step === 0 ? getSnapshotA : getSnapshotB;
const value = useSyncExternalStore(store.subscribe, getSnapshot);
return React.createElement(Text, {
text: label + value,
});
}
let setStep;
function App() {
const [step, _setStep] = useState(0);
setStep = _setStep;
return React.createElement(
React.Fragment,
null,
React.createElement(Child1, {
step: step,
}),
React.createElement(Child2, {
step: step,
}),
);
}
const container = document.createElement('div');
const root = createRoot(container);
await act(() => root.render(React.createElement(App, null)));
assertLog(['A1']);
expect(container.textContent).toEqual('A1');
await act(() => {
setStep(1);
});
assertLog([
'B1',
'Update B in commit phase',
'B2',
]);
expect(container.textContent).toEqual('B2');
});
it('mutating the store in between render and commit when getSnapshot has _not_ changed', async () => {
const store = createExternalStore({
a: 1,
b: 1,
});
const getSnapshotA = () => store.getState().a;
function Child1({step}) {
const value = useSyncExternalStore(store.subscribe, store.getState);
useLayoutEffect(() => {
if (step === 1) {
Scheduler.log('Update B in commit phase');
store.set({
a: value.a,
b: 2,
});
}
}, [step]);
return null;
}
function Child2({step}) {
const value = useSyncExternalStore(store.subscribe, getSnapshotA);
return React.createElement(Text, {
text: 'A' + value,
});
}
let setStep;
function App() {
const [step, _setStep] = useState(0);
setStep = _setStep;
return React.createElement(
React.Fragment,
null,
React.createElement(Child1, {
step: step,
}),
React.createElement(Child2, {
step: step,
}),
);
}
const container = document.createElement('div');
const root = createRoot(container);
await act(() => root.render(React.createElement(App, null)));
assertLog(['A1']);
expect(container.textContent).toEqual('A1');
await act(() => {
setStep(1);
});
assertLog([
'A1',
'Update B in commit phase',
]);
expect(container.textContent).toEqual('A1');
});
it("does not bail out if the previous update hasn't finished yet", async () => {
const store = createExternalStore(0);
function Child1() {
const value = useSyncExternalStore(store.subscribe, store.getState);
useLayoutEffect(() => {
if (value === 1) {
Scheduler.log('Reset back to 0');
store.set(0);
}
}, [value]);
return React.createElement(Text, {
text: value,
});
}
function Child2() {
const value = useSyncExternalStore(store.subscribe, store.getState);
return React.createElement(Text, {
text: value,
});
}
const container = document.createElement('div');
const root = createRoot(container);
await act(() =>
root.render(
React.createElement(
React.Fragment,
null,
React.createElement(Child1, null),
React.createElement(Child2, null),
),
),
);
assertLog([0, 0]);
expect(container.textContent).toEqual('00');
await act(() => {
store.set(1);
});
assertLog([1, 1, 'Reset back to 0', 0, 0]);
expect(container.textContent).toEqual('00');
});
it('uses the latest getSnapshot, even if it changed in the same batch as a store update', async () => {
const store = createExternalStore({
a: 0,
b: 0,
});
const getSnapshotA = () => store.getState().a;
const getSnapshotB = () => store.getState().b;
let setGetSnapshot;
function App() {
const [getSnapshot, _setGetSnapshot] = useState(() => getSnapshotA);
setGetSnapshot = _setGetSnapshot;
const text = useSyncExternalStore(store.subscribe, getSnapshot);
return React.createElement(Text, {
text: text,
});
}
const container = document.createElement('div');
const root = createRoot(container);
await act(() => root.render(React.createElement(App, null)));
assertLog([0]);
await act(() => {
ReactDOM.flushSync(() => {
setGetSnapshot(() => getSnapshotB);
store.set({
a: 1,
b: 2,
});
});
});
assertLog([2]);
expect(container.textContent).toEqual('2');
});
it('handles errors thrown by getSnapshot', async () => {
class ErrorBoundary extends React.Component {
state = {
error: null,
};
static getDerivedStateFromError(error) {
return {
error,
};
}
render() {
if (this.state.error) {
return React.createElement(Text, {
text: this.state.error.message,
});
}
return this.props.children;
}
}
const store = createExternalStore({
value: 0,
throwInGetSnapshot: false,
throwInIsEqual: false,
});
function App() {
const {value} = useSyncExternalStore(store.subscribe, () => {
const state = store.getState();
if (state.throwInGetSnapshot) {
throw new Error('Error in getSnapshot');
}
return state;
});
return React.createElement(Text, {
text: value,
});
}
const errorBoundary = React.createRef(null);
const container = document.createElement('div');
const root = createRoot(container);
await act(() =>
root.render(
React.createElement(
ErrorBoundary,
{
ref: errorBoundary,
},
React.createElement(App, null),
),
),
);
assertLog([0]);
expect(container.textContent).toEqual('0');
if (__DEV__ && gate(flags => flags.enableUseSyncExternalStoreShim)) {
await expect(async () => {
await act(() => {
store.set({
value: 1,
throwInGetSnapshot: true,
throwInIsEqual: false,
});
});
}).rejects.toThrow('Error in getSnapshot');
} else {
await act(() => {
store.set({
value: 1,
throwInGetSnapshot: true,
throwInIsEqual: false,
});
});
}
assertLog(
gate(flags => flags.enableUseSyncExternalStoreShim)
? ['Error in getSnapshot']
: [
'Error in getSnapshot',
'Error in getSnapshot',
],
);
expect(container.textContent).toEqual('Error in getSnapshot');
});
it('Infinite loop if getSnapshot keeps returning new reference', async () => {
const store = createExternalStore({});
function App() {
const text = useSyncExternalStore(store.subscribe, () => ({}));
return React.createElement(Text, {
text: JSON.stringify(text),
});
}
const container = document.createElement('div');
const root = createRoot(container);
await expect(async () => {
await act(() => {
ReactDOM.flushSync(async () =>
root.render(React.createElement(App, null)),
);
});
}).rejects.toThrow(
'Maximum update depth exceeded. This can happen when a component repeatedly ' +
'calls setState inside componentWillUpdate or componentDidUpdate. React limits ' +
'the number of nested updates to prevent infinite loops.',
);
assertConsoleErrorDev(
gate(flags => flags.enableUseSyncExternalStoreShim)
? [
[
'The result of getSnapshot should be cached to avoid an infinite loop',
{withoutStack: true},
],
'Error: Maximum update depth exceeded',
'The above error occurred i',
]
: [
'The result of getSnapshot should be cached to avoid an infinite loop',
],
);
});
it('getSnapshot can return NaN without infinite loop warning', async () => {
const store = createExternalStore('not a number');
function App() {
const value = useSyncExternalStore(store.subscribe, () =>
parseInt(store.getState(), 10),
);
return React.createElement(Text, {
text: value,
});
}
const container = document.createElement('div');
const root = createRoot(container);
await act(() => root.render(React.createElement(App, null)));
expect(container.textContent).toEqual('NaN');
assertLog([NaN]);
await act(() => store.set(123));
expect(container.textContent).toEqual('123');
assertLog([123]);
await act(() => store.set('not a number'));
expect(container.textContent).toEqual('NaN');
assertLog([NaN]);
});
describe('extra features implemented in user-space', () => {
it('memoized selectors are only called once per update', async () => {
const store = createExternalStore({
a: 0,
b: 0,
});
function selector(state) {
Scheduler.log('Selector');
return state.a;
}
function App() {
Scheduler.log('App');
const a = useSyncExternalStoreWithSelector(
store.subscribe,
store.getState,
null,
selector,
);
return React.createElement(Text, {
text: 'A' + a,
});
}
const container = document.createElement('div');
const root = createRoot(container);
await act(() => root.render(React.createElement(App, null)));
assertLog(['App', 'Selector', 'A0']);
expect(container.textContent).toEqual('A0');
await act(() => {
store.set({
a: 1,
b: 0,
});
});
assertLog([
'Selector',
'App',
'A1',
]);
expect(container.textContent).toEqual('A1');
});
it('Using isEqual to bailout', async () => {
const store = createExternalStore({
a: 0,
b: 0,
});
function A() {
const {a} = useSyncExternalStoreWithSelector(
store.subscribe,
store.getState,
null,
state => ({
a: state.a,
}),
(state1, state2) => state1.a === state2.a,
);
return React.createElement(Text, {
text: 'A' + a,
});
}
function B() {
const {b} = useSyncExternalStoreWithSelector(
store.subscribe,
store.getState,
null,
state => {
return {
b: state.b,
};
},
(state1, state2) => state1.b === state2.b,
);
return React.createElement(Text, {
text: 'B' + b,
});
}
function App() {
return React.createElement(
React.Fragment,
null,
React.createElement(A, null),
React.createElement(B, null),
);
}
const container = document.createElement('div');
const root = createRoot(container);
await act(() => root.render(React.createElement(App, null)));
assertLog(['A0', 'B0']);
expect(container.textContent).toEqual('A0B0');
await act(() => {
store.set({
a: 0,
b: 1,
});
});
assertLog(['B1']);
expect(container.textContent).toEqual('A0B1');
await act(() => {
store.set({
a: 1,
b: 1,
});
});
assertLog(['A1']);
expect(container.textContent).toEqual('A1B1');
});
it('basic server hydration', async () => {
const store = createExternalStore('client');
const ref = React.createRef();
function App() {
const text = useSyncExternalStore(
store.subscribe,
store.getState,
() => 'server',
);
useEffect(() => {
Scheduler.log('Passive effect: ' + text);
}, [text]);
return React.createElement(
'div',
{
ref: ref,
},
React.createElement(Text, {
text: text,
}),
);
}
const container = document.createElement('div');
container.innerHTML = '<div>server</div>';
const serverRenderedDiv = container.getElementsByTagName('div')[0];
if (gate(flags => !flags.enableUseSyncExternalStoreShim)) {
await act(() => {
ReactDOMClient.hydrateRoot(container, React.createElement(App, null));
});
assertLog([
'server',
'Passive effect: server',
'client',
'Passive effect: client',
]);
} else {
await act(() => {
ReactDOM.hydrate(React.createElement(App, null), container);
});
assertConsoleErrorDev(['Text content did not match']);
assertLog(['client', 'Passive effect: client']);
}
expect(container.textContent).toEqual('client');
expect(ref.current).toEqual(serverRenderedDiv);
});
});
it('regression test for #23150', async () => {
const store = createExternalStore('Initial');
function App() {
const text = useSyncExternalStore(store.subscribe, store.getState);
const [derivedText, setDerivedText] = useState(text);
useEffect(() => {}, []);
if (derivedText !== text.toUpperCase()) {
setDerivedText(text.toUpperCase());
}
return React.createElement(Text, {
text: derivedText,
});
}
const container = document.createElement('div');
const root = createRoot(container);
await act(() => root.render(React.createElement(App, null)));
assertLog(['INITIAL']);
expect(container.textContent).toEqual('INITIAL');
await act(() => {
store.set('Updated');
});
assertLog(['UPDATED']);
expect(container.textContent).toEqual('UPDATED');
});
it('compares selection to rendered selection even if selector changes', async () => {
const store = createExternalStore({
items: ['A', 'B'],
});
const shallowEqualArray = (a, b) => {
if (a.length !== b.length) {
return false;
}
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) {
return false;
}
}
return true;
};
const List = React.memo(({items}) => {
return React.createElement(
'ul',
null,
items.map(text =>
React.createElement(
'li',
{
key: text,
},
React.createElement(Text, {
key: text,
text: text,
}),
),
),
);
});
function App({step}) {
const inlineSelector = state => {
Scheduler.log('Inline selector');
return [...state.items, 'C'];
};
const items = useSyncExternalStoreWithSelector(
store.subscribe,
store.getState,
null,
inlineSelector,
shallowEqualArray,
);
return React.createElement(
React.Fragment,
null,
React.createElement(List, {
items: items,
}),
React.createElement(Text, {
text: 'Sibling: ' + step,
}),
);
}
const container = document.createElement('div');
const root = createRoot(container);
await act(() => {
root.render(
React.createElement(App, {
step: 0,
}),
);
});
assertLog(['Inline selector', 'A', 'B', 'C', 'Sibling: 0']);
await act(() => {
root.render(
React.createElement(App, {
step: 1,
}),
);
});
assertLog([
'Inline selector',
'Sibling: 1',
]);
});
describe('selector and isEqual error handling in extra', () => {
let ErrorBoundary;
beforeEach(() => {
ErrorBoundary = class extends React.Component {
state = {
error: null,
};
static getDerivedStateFromError(error) {
return {
error,
};
}
render() {
if (this.state.error) {
return React.createElement(Text, {
text: this.state.error.message,
});
}
return this.props.children;
}
};
});
it('selector can throw on update', async () => {
const store = createExternalStore({
a: 'a',
});
const selector = state => {
if (typeof state.a !== 'string') {
throw new TypeError('Malformed state');
}
return state.a.toUpperCase();
};
function App() {
const a = useSyncExternalStoreWithSelector(
store.subscribe,
store.getState,
null,
selector,
);
return React.createElement(Text, {
text: a,
});
}
const container = document.createElement('div');
const root = createRoot(container);
await act(() =>
root.render(
React.createElement(
ErrorBoundary,
null,
React.createElement(App, null),
),
),
);
assertLog(['A']);
expect(container.textContent).toEqual('A');
if (__DEV__ && gate(flags => flags.enableUseSyncExternalStoreShim)) {
await expect(async () => {
await act(() => {
store.set({});
});
}).rejects.toThrow('Malformed state');
} else {
await act(() => {
store.set({});
});
}
expect(container.textContent).toEqual('Malformed state');
});
it('isEqual can throw on update', async () => {
const store = createExternalStore({
a: 'A',
});
const selector = state => state.a;
const isEqual = (left, right) => {
if (typeof left.a !== 'string' || typeof right.a !== 'string') {
throw new TypeError('Malformed state');
}
return left.a.trim() === right.a.trim();
};
function App() {
const a = useSyncExternalStoreWithSelector(
store.subscribe,
store.getState,
null,
selector,
isEqual,
);
return React.createElement(Text, {
text: a,
});
}
const container = document.createElement('div');
const root = createRoot(container);
await act(() =>
root.render(
React.createElement(
ErrorBoundary,
null,
React.createElement(App, null),
),
),
);
assertLog(['A']);
expect(container.textContent).toEqual('A');
if (__DEV__ && gate(flags => flags.enableUseSyncExternalStoreShim)) {
await expect(async () => {
await act(() => {
store.set({});
});
}).rejects.toThrow('Malformed state');
} else {
await act(() => {
store.set({});
});
}
expect(container.textContent).toEqual('Malformed state');
});
});
});