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: [