/
githubmirror
/
preact
Обзор
Документация
Войти
/
githubmirror
/
preact
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
compat/test/browser/useSyncExternalStore.test.jsx
881 строка
23 KB
ssssota
fix(compat): fall back to getSnapshot when server snapshot is missing
20 июл 2026, 09:03
20 июл 2026, 09:03
7670785
Код
Авторство
О чём код?
import React, { createElement, Fragment, useSyncExternalStore, render, hydrate, useState, useCallback, useEffect, useLayoutEffect } from 'preact/compat'; import ReactDOMServer from 'preact/compat/server'; import { setupRerender, act } from 'preact/test-utils'; import { setupScratch, teardown } from '../../../test/_util/helpers'; import { vi } from 'vitest'; const ReactDOM = React; describe('useSyncExternalStore', () => { /** @type {HTMLDivElement} */ let scratch; /** @type {() => void} */ let rerender; /** @type {{ logs: string[], log(arg: string): void; }} */ const Scheduler = { logs: [], log(arg) { this.logs.push(arg); } }; beforeEach(() => { scratch = setupScratch(); rerender = setupRerender(); }); afterEach(() => { teardown(scratch); Scheduler.logs = []; }); function defer(cb) { return Promise.resolve().then(cb); } function assertLog(expected) { expect(Scheduler.logs).to.deep.equal(expected); Scheduler.logs = []; } function Text({ text }) { Scheduler.log(text); return text; } /** @type {(container: Element) => { render(children: React.JSX.Element): void}} */ function createRoot(container) { return { render(children) { render(children, container); } }; } function createExternalStore(initialState) { const listeners = new Set(); let currentState = initialState; return { listeners, set(text) { currentState = text; listeners.forEach(listener => listener()); }, subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); }, getState() { return currentState; }, getSubscriberCount() { return listeners.size; } }; } it('subscribes and follows effects', () => { const subscribe = vi.fn(() => () => {}); const getSnapshot = vi.fn(() => 'hello world'); const App = () => { const value = useSyncExternalStore(subscribe, getSnapshot); return <p>{value}</p>; }; act(() => { render(<App />, scratch); }); expect(scratch.innerHTML).to.equal('<p>hello world</p>'); expect(subscribe).toHaveBeenCalledOnce(); expect(getSnapshot).toHaveBeenCalledTimes(3); }); it('subscribes and rerenders when called', () => { /** @type {() => void} */ let flush; const subscribe = vi.fn(cb => { flush = cb; return () => {}; }); let called = false; const getSnapshot = vi.fn(() => { if (called) { return 'hello new world'; } return 'hello world'; }); const App = () => { const value = useSyncExternalStore(subscribe, getSnapshot); return <p>{value}</p>; }; act(() => { render(<App />, scratch); }); expect(scratch.innerHTML).to.equal('<p>hello world</p>'); expect(subscribe).toHaveBeenCalledOnce(); expect(getSnapshot).toHaveBeenCalledTimes(3); called = true; flush(); rerender(); expect(scratch.innerHTML).to.equal('<p>hello new world</p>'); }); it('getSnapshot can return NaN without causing infinite loop', () => { /** @type {() => void} */ let flush; const subscribe = vi.fn(cb => { flush = cb; return () => {}; }); let called = false; const getSnapshot = vi.fn(() => { if (called) { return NaN; } return 1; }); const App = () => { const value = useSyncExternalStore(subscribe, getSnapshot); return <p>{value}</p>; }; act(() => { render(<App />, scratch); }); expect(scratch.innerHTML).to.equal('<p>1</p>'); expect(subscribe).toHaveBeenCalledOnce(); expect(getSnapshot).toHaveBeenCalledTimes(3); called = true; flush(); rerender(); expect(scratch.innerHTML).to.equal('<p>NaN</p>'); }); it('should not call function values on subscription', () => { /** @type {() => void} */ let flush; const subscribe = vi.fn(cb => { flush = cb; return () => {}; }); const func = () => 'value: ' + i++; let i = 0; const getSnapshot = vi.fn(() => { return func; }); const App = () => { const value = useSyncExternalStore(subscribe, getSnapshot); return <p>{value()}</p>; }; act(() => { render(<App />, scratch); }); expect(scratch.innerHTML).to.equal('<p>value: 0</p>'); expect(subscribe).toHaveBeenCalledOnce(); expect(getSnapshot).toHaveBeenCalledTimes(3); flush(); rerender(); expect(scratch.innerHTML).to.equal('<p>value: 0</p>'); }); it('should work with changing getSnapshot', () => { /** @type {() => void} */ let flush; const subscribe = vi.fn(cb => { flush = cb; return () => {}; }); let i = 0; const App = () => { const value = useSyncExternalStore(subscribe, () => { return i; }); return <p>value: {value}</p>; }; act(() => { render(<App />, scratch); }); expect(scratch.innerHTML).to.equal('<p>value: 0</p>'); expect(subscribe).toHaveBeenCalledOnce(); i++; flush(); rerender(); expect(scratch.innerHTML).to.equal('<p>value: 1</p>'); }); it('works with useCallback', () => { /** @type {() => void} */ let toggle; const App = () => { const [state, setState] = useState(true); toggle = setState.bind(this, () => false); const value = useSyncExternalStore( useCallback(() => { return () => {}; }, [state]), () => (state ? 'yep' : 'nope') ); return <p>{value}</p>; }; act(() => { render(<App />, scratch); }); expect(scratch.innerHTML).to.equal('<p>yep</p>'); toggle(); rerender(); expect(scratch.innerHTML).to.equal('<p>nope</p>'); }); it('handles store updates before subscribing', async () => { // This test is testing scheduling mechanics, so teardown the manual // rerender test setup to rely on Preact's built-in scheduling and verify // this behavior works. We still need a DOM container to render into so set // that back up. teardown(scratch); scratch = setupScratch(); const store = createExternalStore(0); function App() { const value = useSyncExternalStore(store.subscribe, store.getState); useEffect(() => { Scheduler.log('Passive effect: ' + value); }, [value]); return <Text text={value} />; } const container = document.createElement('div'); const root = createRoot(container); // Schedule a mutation in the next microtask after the initial render but // before subscribing to the store const mutation = defer(() => { // Assert we are running this mutation before subscribing to the store expect(store.listeners.size).to.equal(0); store.set(1); }); root.render(<App />); expect(container.textContent).to.equal('0'); assertLog([0]); // Wait for the mutation to occur. Then wait for the passive effects that // subscribe to the store and log the new value. await mutation; await new Promise(r => setTimeout(r, 32)); expect(container.textContent).to.equal('1'); expect(store.listeners.size).to.equal(1); assertLog(['Passive effect: 0', 1, 'Passive effect: 1']); }); // The following tests are taken from the React test suite: // https://github.com/facebook/react/blob/3e09c27b880e1fecdb1eca5db510ecce37ea6be2/packages/use-sync-external-store/src/__tests__/useSyncExternalStoreShared-test.js describe('React useSyncExternalStore test suite', () => { it('uses the server snapshot during server rendering', () => { const getSnapshot = vi.fn(() => 'client'); const getServerSnapshot = vi.fn(() => 'server'); function App() { return useSyncExternalStore( () => () => {}, getSnapshot, getServerSnapshot ); } expect(ReactDOMServer.renderToString(<App />)).to.equal('server'); expect(getSnapshot).not.toHaveBeenCalled(); expect(getServerSnapshot).toHaveBeenCalledOnce(); }); it('falls back to the snapshot during server rendering', () => { const getSnapshot = vi.fn(() => 'client'); function App() { return useSyncExternalStore(() => () => {}, getSnapshot); } expect(ReactDOMServer.renderToString(<App />)).to.equal('client'); expect(getSnapshot).toHaveBeenCalledOnce(); }); it('basic usage', async () => { const store = createExternalStore('Initial'); function App() { const text = useSyncExternalStore(store.subscribe, store.getState); return <Text text={text} />; } const container = document.createElement('div'); const root = createRoot(container); await act(() => root.render(<App />)); assertLog(['Initial']); expect(container.textContent).to.equal('Initial'); await act(() => { store.set('Updated'); }); assertLog(['Updated']); expect(container.textContent).to.equal('Updated'); }); it('skips re-rendering if nothing changes', async () => { const store = createExternalStore('Initial'); function App() { const text = useSyncExternalStore(store.subscribe, store.getState); return <Text text={text} />; } const container = document.createElement('div'); const root = createRoot(container); await act(() => root.render(<App />)); assertLog(['Initial']); expect(container.textContent).to.equal('Initial'); // Update to the same value await act(() => { store.set('Initial'); }); // Should not re-render assertLog([]); expect(container.textContent).to.equal('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 <Text text={value} />; } const container = document.createElement('div'); const root = createRoot(container); await act(() => root.render(<App />)); assertLog([0]); expect(container.textContent).to.equal('0'); await act(() => { storeA.set(1); }); assertLog([1]); expect(container.textContent).to.equal('1'); // Switch stores and update in the same batch await act(() => { // This update will be disregarded storeA.set(2); setStore(storeB); }); // Now reading from B instead of A assertLog([0]); expect(container.textContent).to.equal('0'); // Update A await act(() => { storeA.set(3); }); // Nothing happened, because we're no longer subscribed to A assertLog([]); expect(container.textContent).to.equal('0'); // Update B await act(() => { storeB.set(1); }); assertLog([1]); expect(container.textContent).to.equal('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 <Text text={'A' + a} />; } function B() { const b = useSyncExternalStore( store.subscribe, () => store.getState().b ); return <Text text={'B' + b} />; } function App() { return ( <Fragment> <A /> <B /> </Fragment> ); } const container = document.createElement('div'); const root = createRoot(container); await act(() => root.render(<App />)); assertLog(['A0', 'B0']); expect(container.textContent).to.equal('A0B0'); // Update b but not a await act(() => { store.set({ a: 0, b: 1 }); }); // Only b re-renders assertLog(['B1']); expect(container.textContent).to.equal('A0B1'); // Update a but not b await act(() => { store.set({ a: 1, b: 1 }); }); // Only a re-renders assertLog(['A1']); expect(container.textContent).to.equal('A1B1'); }); // In React 18, you can't observe in between a sync render and its // passive effects, so this is only relevant to legacy roots // @gate enableUseSyncExternalStoreShim 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 <Text text={value} />; } const container = document.createElement('div'); const root = createRoot(container); await act(() => root.render(<App />)); assertLog([0, 'Passive effect: 0']); // Schedule an update. We'll intentionally not use `act` so that we can // insert a mutation before React subscribes to the store in a // passive effect. store.set(1); rerender(); assertLog([ 1 // Passive effect hasn't fired yet ]); expect(container.textContent).to.equal('1'); // Flip the store state back to the previous value. store.set(0); rerender(); assertLog([ 'Passive effect: 1', // Re-render. If the current state were tracked by updating a ref in a // passive effect, then this would break because the previous render's // passive effect hasn't fired yet, so we'd incorrectly think that // the state hasn't changed. 0 ]); // Should flip back to 0 expect(container.textContent).to.equal('0'); // Preact: Wait for 'Passive effect: 0' to flush from the rAF so it doesn't impact other tests await new Promise(r => setTimeout(r, 32)); }); 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) { // Update B in a layout effect. This happens in the same commit // that changed the getSnapshot in Child2. Child2's effects haven't // fired yet, so it doesn't have access to the latest getSnapshot. So // it can't use the getSnapshot to bail out. 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 <Text text={label + value} />; } let setStep; function App() { const [step, _setStep] = useState(0); setStep = _setStep; return ( <> <Child1 step={step} /> <Child2 step={step} /> </> ); } const container = document.createElement('div'); const root = createRoot(container); await act(() => root.render(<App />)); assertLog(['A1']); expect(container.textContent).to.equal('A1'); await act(() => { // Change getSnapshot and update the store in the same batch setStep(1); }); assertLog([ 'B1', 'Update B in commit phase', // If Child2 had used the old getSnapshot to bail out, then it would have // incorrectly bailed out here instead of re-rendering. 'B2' ]); expect(container.textContent).to.equal('B2'); }); it('mutating the store in between render and commit when getSnapshot has _not_ changed', async () => { // Same as previous test, but `getSnapshot` does not change 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) { // Update B in a layout effect. This happens in the same commit // that changed the getSnapshot in Child2. Child2's effects haven't // fired yet, so it doesn't have access to the latest getSnapshot. So // it can't use the getSnapshot to bail out. 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 <Text text={'A' + value} />; } let setStep; function App() { const [step, _setStep] = useState(0); setStep = _setStep; return ( <> <Child1 step={step} /> <Child2 step={step} /> </> ); } const container = document.createElement('div'); const root = createRoot(container); await act(() => root.render(<App />)); assertLog(['A1']); expect(container.textContent).to.equal('A1'); // This will cause a layout effect, and in the layout effect we'll update // the store await act(() => { setStep(1); }); assertLog([ 'A1', // This updates B, but since Child2 doesn't subscribe to B, it doesn't // need to re-render. 'Update B in commit phase' // No re-render ]); expect(container.textContent).to.equal('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 <Text text={value} />; } function Child2() { const value = useSyncExternalStore(store.subscribe, store.getState); return <Text text={value} />; } const container = document.createElement('div'); const root = createRoot(container); await act(() => root.render( <> <Child1 /> <Child2 /> </> ) ); assertLog([0, 0]); expect(container.textContent).to.equal('00'); await act(() => { store.set(1); }); // Preact logs differ from React here cuz of how we do rerendering. We // rerender subtrees and then commit effects so Child2 never sees the // update to 1 cuz Child1 rerenders and runs its layout effects first. assertLog([1, /*1,*/ 'Reset back to 0', 0, 0]); expect(container.textContent).to.equal('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 <Text text={text} />; } const container = document.createElement('div'); const root = createRoot(container); await act(() => root.render(<App />)); assertLog([0]); // Update the store and getSnapshot at the same time await act(() => { setGetSnapshot(() => getSnapshotB); store.set({ a: 1, b: 2 }); }); // It should read from B instead of A assertLog([2]); expect(container.textContent).to.equal('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 <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 <Text text={value} />; } const errorBoundary = React.createRef(); const container = document.createElement('div'); const root = createRoot(container); await act(() => root.render( <ErrorBoundary ref={errorBoundary}> <App /> </ErrorBoundary> ) ); assertLog([0]); expect(container.textContent).to.equal('0'); // Update that throws in a getSnapshot. We can catch it with an error boundary. await act(() => { store.set({ value: 1, throwInGetSnapshot: true, throwInIsEqual: false }); }); assertLog(['Error in getSnapshot']); expect(container.textContent).to.equal('Error in getSnapshot'); }); 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 <Text text={value} />; } const container = document.createElement('div'); const root = createRoot(container); // Initial render that reads a snapshot of NaN. This is OK because we use // Object.is algorithm to compare values. await act(() => root.render(<App />)); expect(container.textContent).to.equal('NaN'); // Update to real number await act(() => store.set(123)); expect(container.textContent).to.equal('123'); // Update back to NaN await act(() => store.set('not a number')); expect(container.textContent).to.equal('NaN'); }); it('basic server hydration', async () => { const store = createExternalStore('client'); const ref = React.createRef(); function Child() { const text = useSyncExternalStore( store.subscribe, store.getState, () => 'server' ); useEffect(() => { Scheduler.log('Passive effect: ' + text); }, [text]); return ( <div ref={ref}> <Text text={text} /> </div> ); } function App() { return <Child />; } const container = document.createElement('div'); container.innerHTML = '<div>server</div>'; const serverRenderedDiv = container.getElementsByTagName('div')[0]; await act(() => { hydrate(<App />, container); }); assertLog([ // First it hydrates the server rendered HTML 'server', 'Passive effect: server', // Then in a second paint, it re-renders with the client state 'client', 'Passive effect: client' ]); expect(container.textContent).toEqual('client'); expect(ref.current).toEqual(serverRenderedDiv); }); it('regression test for facebook/react#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 <Text text={derivedText} />; } const container = document.createElement('div'); const root = createRoot(container); await act(() => root.render(<App />)); assertLog(['INITIAL']); expect(container.textContent).to.equal('INITIAL'); await act(() => { store.set('Updated'); }); assertLog(['UPDATED']); expect(container.textContent).to.equal('UPDATED'); }); }); });