diff --git a/packages/use-sync-external-store/src/__tests__/useSyncExternalStore-test.js b/packages/use-sync-external-store/extra.js similarity index 69% rename from packages/use-sync-external-store/src/__tests__/useSyncExternalStore-test.js rename to packages/use-sync-external-store/extra.js index be6eeb5caf8c..90d48eb4641b 100644 --- a/packages/use-sync-external-store/src/__tests__/useSyncExternalStore-test.js +++ b/packages/use-sync-external-store/extra.js @@ -4,11 +4,9 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @emails react-core + * @flow */ 'use strict'; -describe('useSyncExternalStore', () => { - test('TODO', () => {}); -}); +export * from './src/useSyncExternalStoreExtra'; diff --git a/packages/use-sync-external-store/npm/extra.js b/packages/use-sync-external-store/npm/extra.js new file mode 100644 index 000000000000..468dd518ac1f --- /dev/null +++ b/packages/use-sync-external-store/npm/extra.js @@ -0,0 +1,7 @@ +'use strict'; + +if (process.env.NODE_ENV === 'production') { + module.exports = require('./cjs/use-sync-external-store-extra.production.min.js'); +} else { + module.exports = require('./cjs/use-sync-external-store-extra.development.js'); +} diff --git a/packages/use-sync-external-store/package.json b/packages/use-sync-external-store/package.json index cc9a7c3a393c..7f5e5b0e8c0d 100644 --- a/packages/use-sync-external-store/package.json +++ b/packages/use-sync-external-store/package.json @@ -12,6 +12,7 @@ "README.md", "build-info.json", "index.js", + "extra.js", "cjs/" ], "license": "MIT", diff --git a/packages/use-sync-external-store/src/__tests__/useSyncExternalStoreShared-test.js b/packages/use-sync-external-store/src/__tests__/useSyncExternalStoreShared-test.js new file mode 100644 index 000000000000..8db73a8e8c77 --- /dev/null +++ b/packages/use-sync-external-store/src/__tests__/useSyncExternalStoreShared-test.js @@ -0,0 +1,621 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + */ + +'use strict'; + +let useSyncExternalStore; +let useSyncExternalStoreExtra; +let React; +let ReactNoop; +let Scheduler; +let act; +let useState; +let useEffect; +let useLayoutEffect; + +// This tests shared behavior between the built-in and shim implementations of +// of useSyncExternalStore. +describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { + beforeEach(() => { + jest.resetModules(); + + // Remove the built-in API from the React exports to force the package to + // use the shim. + // TODO: Don't do this during a variant test run. That way these tests run + // against both the shim and the built-in implementation. + jest.mock('react', () => { + // eslint-disable-next-line no-unused-vars + const {startTransition, ...otherExports} = jest.requireActual('react'); + return otherExports; + }); + + React = require('react'); + ReactNoop = require('react-noop-renderer'); + Scheduler = require('scheduler'); + useState = React.useState; + useEffect = React.useEffect; + useLayoutEffect = React.useLayoutEffect; + + const internalAct = require('jest-react').act; + + // The internal act implementation doesn't batch updates by default, since + // it's mostly used to test concurrent mode. But since these tests run + // in both concurrent and legacy mode, I'm adding batching here. + act = cb => internalAct(() => ReactNoop.batchedUpdates(cb)); + + useSyncExternalStore = require('use-sync-external-store') + .useSyncExternalStore; + useSyncExternalStoreExtra = require('use-sync-external-store/extra') + .useSyncExternalStoreExtra; + }); + + function Text({text}) { + Scheduler.unstable_yieldValue(text); + return text; + } + + function createRoot(element) { + // This wrapper function exists so we can test both legacy roots and + // concurrent roots. + // + // TODO: Once the built-in API exists, conditionally test the concurrent + // root API, too. + const root = ReactNoop.createLegacyRoot(); + act(() => { + root.render(element); + }); + return root; + } + + 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; + }, + }; + } + + test('basic usage', () => { + const store = createExternalStore('Initial'); + + function App() { + const text = useSyncExternalStore(store.subscribe, store.getState); + return ; + } + + const root = createRoot(); + + expect(Scheduler).toHaveYielded(['Initial']); + expect(root).toMatchRenderedOutput('Initial'); + + act(() => { + store.set('Updated'); + }); + expect(Scheduler).toHaveYielded(['Updated']); + expect(root).toMatchRenderedOutput('Updated'); + }); + + test('skips re-rendering if nothing changes', () => { + const store = createExternalStore('Initial'); + + function App() { + const text = useSyncExternalStore(store.subscribe, store.getState); + return ; + } + + const root = createRoot(); + + expect(Scheduler).toHaveYielded(['Initial']); + expect(root).toMatchRenderedOutput('Initial'); + + // Update to the same value + act(() => { + store.set('Initial'); + }); + // Should not re-render + expect(Scheduler).toHaveYielded([]); + expect(root).toMatchRenderedOutput('Initial'); + }); + + test('switch to a different store', () => { + 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 ; + } + + const root = createRoot(); + + expect(Scheduler).toHaveYielded([0]); + expect(root).toMatchRenderedOutput('0'); + + act(() => { + storeA.set(1); + }); + expect(Scheduler).toHaveYielded([1]); + expect(root).toMatchRenderedOutput('1'); + + // Switch stores + act(() => { + // This update will be disregarded + storeA.set(2); + setStore(storeB); + }); + // Now reading from B instead of A + expect(Scheduler).toHaveYielded([0]); + expect(root).toMatchRenderedOutput('0'); + + // Update A + act(() => { + storeA.set(3); + }); + // Nothing happened, because we're no longer subscribed to A + expect(Scheduler).toHaveYielded([]); + expect(root).toMatchRenderedOutput('0'); + + // Update B + act(() => { + storeB.set(1); + }); + expect(Scheduler).toHaveYielded([1]); + expect(root).toMatchRenderedOutput('1'); + }); + + test('selecting a specific value inside getSnapshot', () => { + const store = createExternalStore({a: 0, b: 0}); + + function A() { + const a = useSyncExternalStore(store.subscribe, () => store.getState().a); + return ; + } + function B() { + const b = useSyncExternalStore(store.subscribe, () => store.getState().b); + return ; + } + + function App() { + return ( + <> + + + + ); + } + + const root = createRoot(); + + expect(Scheduler).toHaveYielded(['A0', 'B0']); + expect(root).toMatchRenderedOutput('A0B0'); + + // Update b but not a + act(() => { + store.set({a: 0, b: 1}); + }); + // Only b re-renders + expect(Scheduler).toHaveYielded(['B1']); + expect(root).toMatchRenderedOutput('A0B1'); + + // Update a but not b + act(() => { + store.set({a: 1, b: 1}); + }); + // Only a re-renders + expect(Scheduler).toHaveYielded(['A1']); + expect(root).toMatchRenderedOutput('A1B1'); + }); + + test( + "compares to current state before bailing out, even when there's a " + + 'mutation in between the sync and passive effects', + () => { + const store = createExternalStore(0); + + function App() { + const value = useSyncExternalStore(store.subscribe, store.getState); + useEffect(() => { + Scheduler.unstable_yieldValue('Passive effect: ' + value); + }, [value]); + return ; + } + + const root = createRoot(); + expect(Scheduler).toHaveYielded([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); + expect(Scheduler).toHaveYielded([ + 1, + // Passive effect hasn't fired yet + ]); + expect(root).toMatchRenderedOutput('1'); + + // Flip the store state back to the previous value. + store.set(0); + expect(Scheduler).toHaveYielded([ + '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(root).toMatchRenderedOutput('0'); + }, + ); + + test('mutating the store in between render and commit when getSnapshot has changed', () => { + 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.unstable_yieldValue('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 ; + } + + let setStep; + function App() { + const [step, _setStep] = useState(0); + setStep = _setStep; + return ( + <> + + + + ); + } + + const root = createRoot(); + expect(Scheduler).toHaveYielded(['A1']); + expect(root).toMatchRenderedOutput('A1'); + + act(() => { + // Change getSnapshot and update the store in the same batch + setStep(1); + }); + expect(Scheduler).toHaveYielded([ + '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(root).toMatchRenderedOutput('B2'); + }); + + test('mutating the store in between render and commit when getSnapshot has _not_ changed', () => { + // 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.unstable_yieldValue('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 ; + } + + let setStep; + function App() { + const [step, _setStep] = useState(0); + setStep = _setStep; + return ( + <> + + + + ); + } + + const root = createRoot(); + expect(Scheduler).toHaveYielded(['A1']); + expect(root).toMatchRenderedOutput('A1'); + + // This will cause a layout effect, and in the layout effect we'll update + // the store + act(() => { + setStep(1); + }); + expect(Scheduler).toHaveYielded([ + '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(root).toMatchRenderedOutput('A1'); + }); + + test("does not bail out if the previous update hasn't finished yet", () => { + const store = createExternalStore(0); + + function Child1() { + const value = useSyncExternalStore(store.subscribe, store.getState); + useLayoutEffect(() => { + if (value === 1) { + Scheduler.unstable_yieldValue('Reset back to 0'); + store.set(0); + } + }, [value]); + return ; + } + + function Child2() { + const value = useSyncExternalStore(store.subscribe, store.getState); + return ; + } + + const root = createRoot( + <> + + + , + ); + expect(Scheduler).toHaveYielded([0, 0]); + expect(root).toMatchRenderedOutput('00'); + + act(() => { + store.set(1); + }); + expect(Scheduler).toHaveYielded([1, 1, 'Reset back to 0', 0, 0]); + expect(root).toMatchRenderedOutput('00'); + }); + + test('uses the latest getSnapshot, even if it changed in the same batch as a store update', () => { + 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 ; + } + + const root = createRoot(); + expect(Scheduler).toHaveYielded([0]); + + // Update the store and getSnapshot at the same time + act(() => { + setGetSnapshot(() => getSnapshotB); + store.set({a: 1, b: 2}); + }); + // It should read from B instead of A + expect(Scheduler).toHaveYielded([2]); + expect(root).toMatchRenderedOutput('2'); + }); + + test('handles errors thrown by getSnapshot or isEqual', () => { + class ErrorBoundary extends React.Component { + state = {error: null}; + static getDerivedStateFromError(error) { + return {error}; + } + render() { + if (this.state.error) { + return ; + } + 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; + }, + { + isEqual: (a, b) => { + if (a.throwInIsEqual || b.throwInIsEqual) { + throw new Error('Error in isEqual'); + } + return a.value === b.value; + }, + }, + ); + return ; + } + + const errorBoundary = React.createRef(null); + const root = createRoot( + + + , + ); + expect(Scheduler).toHaveYielded([0]); + expect(root).toMatchRenderedOutput('0'); + + // Update that throws in a getSnapshot. We can catch it with an error boundary. + act(() => { + store.set({value: 1, throwInGetSnapshot: true, throwInIsEqual: false}); + }); + expect(Scheduler).toHaveYielded(['Error in getSnapshot']); + expect(root).toMatchRenderedOutput('Error in getSnapshot'); + + // Clear the error. + act(() => { + store.set({value: 1, throwInGetSnapshot: false, throwInIsEqual: false}); + errorBoundary.current.setState({error: null}); + }); + expect(Scheduler).toHaveYielded([1]); + expect(root).toMatchRenderedOutput('1'); + + // Update that throws in isEqual. Since isEqual only prevents a bail out, + // we don't need to surface an error. But we do have to re-render. + act(() => { + store.set({value: 1, throwInGetSnapshot: false, throwInIsEqual: true}); + }); + expect(Scheduler).toHaveYielded([1]); + expect(root).toMatchRenderedOutput('1'); + }); + + describe('extra features implemented in user-space', () => { + test('memoized selectors are only called once per update', () => { + const store = createExternalStore({a: 0, b: 0}); + + function selector(state) { + Scheduler.unstable_yieldValue('Selector'); + return state.a; + } + + function App() { + Scheduler.unstable_yieldValue('App'); + const a = useSyncExternalStoreExtra( + store.subscribe, + store.getState, + selector, + ); + return ; + } + + const root = createRoot(); + + expect(Scheduler).toHaveYielded(['App', 'Selector', 'A0']); + expect(root).toMatchRenderedOutput('A0'); + + // Update the store + act(() => { + store.set({a: 1, b: 0}); + }); + expect(Scheduler).toHaveYielded([ + // The selector runs before React starts rendering + 'Selector', + 'App', + // And because the selector didn't change during render, we can reuse + // the previous result without running the selector again + 'A1', + ]); + expect(root).toMatchRenderedOutput('A1'); + }); + + test('Using isEqual to bailout', () => { + const store = createExternalStore({a: 0, b: 0}); + + function A() { + const {a} = useSyncExternalStoreExtra( + store.subscribe, + store.getState, + state => ({a: state.a}), + (state1, state2) => state1.a === state2.a, + ); + return ; + } + function B() { + const {b} = useSyncExternalStoreExtra( + store.subscribe, + store.getState, + state => { + return {b: state.b}; + }, + (state1, state2) => state1.b === state2.b, + ); + return ; + } + + function App() { + return ( + <> + + + + ); + } + + const root = createRoot(); + + expect(Scheduler).toHaveYielded(['A0', 'B0']); + expect(root).toMatchRenderedOutput('A0B0'); + + // Update b but not a + act(() => { + store.set({a: 0, b: 1}); + }); + // Only b re-renders + expect(Scheduler).toHaveYielded(['B1']); + expect(root).toMatchRenderedOutput('A0B1'); + + // Update a but not b + act(() => { + store.set({a: 1, b: 1}); + }); + // Only a re-renders + expect(Scheduler).toHaveYielded(['A1']); + expect(root).toMatchRenderedOutput('A1B1'); + }); + }); +}); diff --git a/packages/use-sync-external-store/src/useSyncExternalStore.js b/packages/use-sync-external-store/src/useSyncExternalStore.js index 8967bff10d33..55853f6e9b8a 100644 --- a/packages/use-sync-external-store/src/useSyncExternalStore.js +++ b/packages/use-sync-external-store/src/useSyncExternalStore.js @@ -7,6 +7,132 @@ * @flow */ -export function useSyncExternalStore() { - throw new Error('Not yet implemented'); +import * as React from 'react'; +import is from 'shared/objectIs'; + +// Intentionally not using named imports because Rollup uses dynamic +// dispatch for CommonJS interop named imports. +const { + useState, + useEffect, + useLayoutEffect, + useDebugValue, + + // $FlowFixMe - useSyncExternalStore not yet part of React Flow types + useSyncExternalStore: builtInAPI, +} = React; + +// Prefer the built-in API, if it exists. If it doesn't exist, then we assume +// we're in version 16 or 17, so rendering is always synchronous. The shim +// does not support concurrent rendering, only the built-in API. +export const useSyncExternalStore = + builtInAPI !== undefined ? builtInAPI : useSyncExternalStore_shim; + +let didWarnOld18Alpha = false; + +// Disclaimer: This shim breaks many of the rules of React, and only works +// because of a very particular set of implementation details and assumptions +// -- change any one of them and it will break. The most important assumption +// is that updates are always synchronous, because concurrent rendering is +// only available in versions of React that also have a built-in +// useSyncExternalStore API. And we only use this shim when the built-in API +// does not exist. +// +// Do not assume that the clever hacks used by this hook also work in general. +// The point of this shim is to replace the need for hacks by other libraries. +function useSyncExternalStore_shim( + subscribe: (() => void) => () => void, + getSnapshot: () => T, +): T { + if (__DEV__) { + if (!didWarnOld18Alpha) { + if (React.startTransition !== undefined) { + didWarnOld18Alpha = true; + console.error( + 'You are using an outdated, pre-release alpha of React 18 that ' + + 'does not support useSyncExternalStore. The ' + + 'use-sync-external-store shim will not work correctly. Upgrade ' + + 'to a newer pre-release.', + ); + } + } + } + + // Read the current snapshot from the store on every render. Again, this + // breaks the rules of React, and only works here because of specific + // implementation details, most importantly that updates are + // always synchronous. + const value = getSnapshot(); + + // Because updates are synchronous, we don't queue them. Instead we force a + // re-render whenever the subscribed state changes by updating an some + // arbitrary useState hook. Then, during render, we call getSnapshot to read + // the current value. + // + // Because we don't actually use the state returned by the useState hook, we + // can save a bit of memory by storing other stuff in that slot. + // + // To implement the early bailout, we need to track some things on a mutable + // object. Usually, we would put that in a useRef hook, but we can stash it in + // our useState hook instead. + // + // To force a re-render, we call forceUpdate({inst}). That works because the + // new object always fails an equality check. + const [{inst}, forceUpdate] = useState({inst: {value, getSnapshot}}); + + // Track the latest getSnapshot function with a ref. This needs to be updated + // in the layout phase so we can access it during the tearing check that + // happens on subscribe. + // TODO: Circumvent SSR warning + useLayoutEffect(() => { + inst.value = value; + inst.getSnapshot = getSnapshot; + + // Whenever getSnapshot or subscribe changes, we need to check in the + // commit phase if there was an interleaved mutation. In concurrent mode + // this can happen all the time, but even in synchronous mode, an earlier + // effect may have mutated the store. + if (checkIfSnapshotChanged(inst)) { + // Force a re-render. + forceUpdate({inst}); + } + }, [subscribe, value, getSnapshot]); + + useEffect(() => { + // Check for changes right before subscribing. Subsequent changes will be + // detected in the subscription handler. + if (checkIfSnapshotChanged(inst)) { + // Force a re-render. + forceUpdate({inst}); + } + const handleStoreChange = () => { + // TODO: Because there is no cross-renderer API for batching updates, it's + // up to the consumer of this library to wrap their subscription event + // with unstable_batchedUpdates. Should we try to detect when this isn't + // the case and print a warning in development? + + // The store changed. Check if the snapshot changed since the last time we + // read from the store. + if (checkIfSnapshotChanged(inst)) { + // Force a re-render. + forceUpdate({inst}); + } + }; + // Subscribe to the store and return a clean-up function. + return subscribe(handleStoreChange); + }, [subscribe]); + + useDebugValue(value); + return value; +} + +function checkIfSnapshotChanged(inst) { + const latestGetSnapshot = inst.getSnapshot; + const prevValue = inst.value; + try { + const nextValue = latestGetSnapshot(); + return !is(prevValue, nextValue); + } catch (error) { + return true; + } } diff --git a/packages/use-sync-external-store/src/useSyncExternalStoreExtra.js b/packages/use-sync-external-store/src/useSyncExternalStoreExtra.js new file mode 100644 index 000000000000..905bc67c462b --- /dev/null +++ b/packages/use-sync-external-store/src/useSyncExternalStoreExtra.js @@ -0,0 +1,76 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import * as React from 'react'; +import is from 'shared/objectIs'; +import {useSyncExternalStore} from 'use-sync-external-store'; + +// Intentionally not using named imports because Rollup uses dynamic +// dispatch for CommonJS interop named imports. +const {useMemo, useDebugValue} = React; + +// Same as useSyncExternalStore, but supports selector and isEqual arguments. +export function useSyncExternalStoreExtra( + subscribe: (() => void) => () => void, + getSnapshot: () => Snapshot, + selector: (snapshot: Snapshot) => Selection, + isEqual?: (a: Selection, b: Selection) => boolean, +): Selection { + const getSnapshotWithMemoizedSelector = useMemo(() => { + // Track the memoized state using closure variables that are local to this + // memoized instance of a getSnapshot function. Intentionally not using a + // useRef hook, because that state would be shared across all concurrent + // copies of the hook/component. + let hasMemo = false; + let memoizedSnapshot; + let memoizedSelection; + return () => { + const nextSnapshot = getSnapshot(); + + if (!hasMemo) { + // The first time the hook is called, there is no memoized result. + hasMemo = true; + memoizedSnapshot = nextSnapshot; + const nextSelection = selector(nextSnapshot); + memoizedSelection = nextSelection; + return nextSelection; + } + + // We may be able to reuse the previous invocation's result. + const prevSnapshot: Snapshot = (memoizedSnapshot: any); + const prevSelection: Selection = (memoizedSelection: any); + + if (is(prevSnapshot, nextSnapshot)) { + // The snapshot is the same as last time. Reuse the previous selection. + return prevSelection; + } + + // The snapshot has changed, so we need to compute a new selection. + memoizedSnapshot = nextSnapshot; + const nextSelection = selector(nextSnapshot); + + // If a custom isEqual function is provided, use that to check if the data + // has changed. If it hasn't, return the previous selection. That signals + // to React that the selections are conceptually equal, and we can bail + // out of rendering. + if (isEqual !== undefined && isEqual(prevSelection, nextSelection)) { + return prevSelection; + } + + memoizedSelection = nextSelection; + return nextSelection; + }; + }, [getSnapshot, selector, isEqual]); + const value = useSyncExternalStore( + subscribe, + getSnapshotWithMemoizedSelector, + ); + useDebugValue(value); + return value; +} diff --git a/scripts/rollup/bundles.js b/scripts/rollup/bundles.js index 7e87f4cc355e..047d27e91807 100644 --- a/scripts/rollup/bundles.js +++ b/scripts/rollup/bundles.js @@ -684,7 +684,7 @@ const bundles = [ externals: ['react'], }, - /******* Shim for useSyncExternalState *******/ + /******* Shim for useSyncExternalStore *******/ { bundleTypes: [NODE_DEV, NODE_PROD], moduleType: ISOMORPHIC, @@ -693,6 +693,15 @@ const bundles = [ externals: ['react'], }, + /******* Shim for useSyncExternalStore (+ extra user-space features) *******/ + { + bundleTypes: [NODE_DEV, NODE_PROD], + moduleType: ISOMORPHIC, + entry: 'use-sync-external-store/extra', + global: 'useSyncExternalStoreExtra', + externals: ['react', 'use-sync-external-store'], + }, + /******* React Scheduler (experimental) *******/ { bundleTypes: [