From 56852900ad41bbbd50bcce8eea00d4fa214789e4 Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Thu, 2 Sep 2021 11:56:30 -0400 Subject: [PATCH] Wire up the native API for useSyncExternalStore Adds useSyncExternalStore to the internal dispatcher, and exports the native API from the React package without yet implementing it. --- .../react-debug-tools/src/ReactDebugHooks.js | 8 + .../src/server/ReactPartialRendererHooks.js | 8 + .../src/ReactFiberHooks.new.js | 77 +++++++++ .../src/ReactFiberHooks.old.js | 77 +++++++++ .../src/ReactInternalTypes.js | 5 + packages/react-server/src/ReactFizzHooks.js | 8 + .../react-server/src/ReactFlightServer.js | 1 + .../src/ReactSuspenseTestUtils.js | 1 + packages/react/index.classic.fb.js | 2 + packages/react/index.experimental.js | 1 + packages/react/index.js | 2 + packages/react/index.modern.fb.js | 2 + packages/react/src/React.js | 2 + packages/react/src/ReactHooks.js | 8 + .../useSyncExternalStoreShared-test.js | 162 +++++++++++------- .../src/useSyncExternalStore.js | 6 +- scripts/jest/TestFlags.js | 2 + 17 files changed, 311 insertions(+), 61 deletions(-) diff --git a/packages/react-debug-tools/src/ReactDebugHooks.js b/packages/react-debug-tools/src/ReactDebugHooks.js index 7d2679b319ce5..0e2364f672b70 100644 --- a/packages/react-debug-tools/src/ReactDebugHooks.js +++ b/packages/react-debug-tools/src/ReactDebugHooks.js @@ -265,6 +265,13 @@ function useMutableSource( return value; } +function useSyncExternalStore( + subscribe: (() => void) => () => void, + getSnapshot: () => T, +): T { + throw new Error('Not yet implemented'); +} + function useTransition(): [boolean, (() => void) => void] { // useTransition() composes multiple hooks internally. // Advance the current hook index the same number of times @@ -326,6 +333,7 @@ const Dispatcher: DispatcherType = { useState, useTransition, useMutableSource, + useSyncExternalStore, useDeferredValue, useOpaqueIdentifier, }; diff --git a/packages/react-dom/src/server/ReactPartialRendererHooks.js b/packages/react-dom/src/server/ReactPartialRendererHooks.js index 433ab9ae078b2..0712d9fee9285 100644 --- a/packages/react-dom/src/server/ReactPartialRendererHooks.js +++ b/packages/react-dom/src/server/ReactPartialRendererHooks.js @@ -462,6 +462,13 @@ function useMutableSource( return getSnapshot(source._source); } +function useSyncExternalStore( + subscribe: (() => void) => () => void, + getSnapshot: () => T, +): T { + throw new Error('Not yet implemented'); +} + function useDeferredValue(value: T): T { resolveCurrentlyRenderingComponent(); return value; @@ -514,6 +521,7 @@ export const Dispatcher: DispatcherType = { useOpaqueIdentifier, // Subscriptions are not setup in a server environment. useMutableSource, + useSyncExternalStore, }; if (enableCache) { diff --git a/packages/react-reconciler/src/ReactFiberHooks.new.js b/packages/react-reconciler/src/ReactFiberHooks.new.js index b19530e24b5d1..40beec7f8c742 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.new.js +++ b/packages/react-reconciler/src/ReactFiberHooks.new.js @@ -1242,6 +1242,20 @@ function updateMutableSource( return useMutableSource(hook, source, getSnapshot, subscribe); } +function mountSyncExternalStore( + subscribe: (() => void) => () => void, + getSnapshot: () => T, +): T { + throw new Error('Not yet implemented'); +} + +function updateSyncExternalStore( + subscribe: (() => void) => () => void, + getSnapshot: () => T, +): T { + throw new Error('Not yet implemented'); +} + function mountState( initialState: (() => S) | S, ): [S, Dispatch>] { @@ -2079,6 +2093,7 @@ export const ContextOnlyDispatcher: Dispatcher = { useDeferredValue: throwInvalidHookError, useTransition: throwInvalidHookError, useMutableSource: throwInvalidHookError, + useSyncExternalStore: throwInvalidHookError, useOpaqueIdentifier: throwInvalidHookError, unstable_isNewReconciler: enableNewReconciler, @@ -2104,6 +2119,7 @@ const HooksDispatcherOnMount: Dispatcher = { useDeferredValue: mountDeferredValue, useTransition: mountTransition, useMutableSource: mountMutableSource, + useSyncExternalStore: mountSyncExternalStore, useOpaqueIdentifier: mountOpaqueIdentifier, unstable_isNewReconciler: enableNewReconciler, @@ -2129,6 +2145,7 @@ const HooksDispatcherOnUpdate: Dispatcher = { useDeferredValue: updateDeferredValue, useTransition: updateTransition, useMutableSource: updateMutableSource, + useSyncExternalStore: updateSyncExternalStore, useOpaqueIdentifier: updateOpaqueIdentifier, unstable_isNewReconciler: enableNewReconciler, @@ -2154,6 +2171,7 @@ const HooksDispatcherOnRerender: Dispatcher = { useDeferredValue: rerenderDeferredValue, useTransition: rerenderTransition, useMutableSource: updateMutableSource, + useSyncExternalStore: mountSyncExternalStore, useOpaqueIdentifier: rerenderOpaqueIdentifier, unstable_isNewReconciler: enableNewReconciler, @@ -2302,6 +2320,14 @@ if (__DEV__) { mountHookTypesDev(); return mountMutableSource(source, getSnapshot, subscribe); }, + useSyncExternalStore( + subscribe: (() => void) => () => void, + getSnapshot: () => T, + ): T { + currentHookNameInDev = 'useSyncExternalStore'; + mountHookTypesDev(); + return mountSyncExternalStore(subscribe, getSnapshot); + }, useOpaqueIdentifier(): OpaqueIDType | void { currentHookNameInDev = 'useOpaqueIdentifier'; mountHookTypesDev(); @@ -2426,6 +2452,14 @@ if (__DEV__) { updateHookTypesDev(); return mountMutableSource(source, getSnapshot, subscribe); }, + useSyncExternalStore( + subscribe: (() => void) => () => void, + getSnapshot: () => T, + ): T { + currentHookNameInDev = 'useSyncExternalStore'; + updateHookTypesDev(); + return mountSyncExternalStore(subscribe, getSnapshot); + }, useOpaqueIdentifier(): OpaqueIDType | void { currentHookNameInDev = 'useOpaqueIdentifier'; updateHookTypesDev(); @@ -2550,6 +2584,14 @@ if (__DEV__) { updateHookTypesDev(); return updateMutableSource(source, getSnapshot, subscribe); }, + useSyncExternalStore( + subscribe: (() => void) => () => void, + getSnapshot: () => T, + ): T { + currentHookNameInDev = 'useSyncExternalStore'; + updateHookTypesDev(); + return updateSyncExternalStore(subscribe, getSnapshot); + }, useOpaqueIdentifier(): OpaqueIDType | void { currentHookNameInDev = 'useOpaqueIdentifier'; updateHookTypesDev(); @@ -2675,6 +2717,14 @@ if (__DEV__) { updateHookTypesDev(); return updateMutableSource(source, getSnapshot, subscribe); }, + useSyncExternalStore( + subscribe: (() => void) => () => void, + getSnapshot: () => T, + ): T { + currentHookNameInDev = 'useSyncExternalStore'; + updateHookTypesDev(); + return updateSyncExternalStore(subscribe, getSnapshot); + }, useOpaqueIdentifier(): OpaqueIDType | void { currentHookNameInDev = 'useOpaqueIdentifier'; updateHookTypesDev(); @@ -2813,6 +2863,15 @@ if (__DEV__) { mountHookTypesDev(); return mountMutableSource(source, getSnapshot, subscribe); }, + useSyncExternalStore( + subscribe: (() => void) => () => void, + getSnapshot: () => T, + ): T { + currentHookNameInDev = 'useSyncExternalStore'; + warnInvalidHookAccess(); + mountHookTypesDev(); + return mountSyncExternalStore(subscribe, getSnapshot); + }, useOpaqueIdentifier(): OpaqueIDType | void { currentHookNameInDev = 'useOpaqueIdentifier'; warnInvalidHookAccess(); @@ -2952,6 +3011,15 @@ if (__DEV__) { updateHookTypesDev(); return updateMutableSource(source, getSnapshot, subscribe); }, + useSyncExternalStore( + subscribe: (() => void) => () => void, + getSnapshot: () => T, + ): T { + currentHookNameInDev = 'useSyncExternalStore'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateSyncExternalStore(subscribe, getSnapshot); + }, useOpaqueIdentifier(): OpaqueIDType | void { currentHookNameInDev = 'useOpaqueIdentifier'; warnInvalidHookAccess(); @@ -3092,6 +3160,15 @@ if (__DEV__) { updateHookTypesDev(); return updateMutableSource(source, getSnapshot, subscribe); }, + useSyncExternalStore( + subscribe: (() => void) => () => void, + getSnapshot: () => T, + ): T { + currentHookNameInDev = 'useSyncExternalStore'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateSyncExternalStore(subscribe, getSnapshot); + }, useOpaqueIdentifier(): OpaqueIDType | void { currentHookNameInDev = 'useOpaqueIdentifier'; warnInvalidHookAccess(); diff --git a/packages/react-reconciler/src/ReactFiberHooks.old.js b/packages/react-reconciler/src/ReactFiberHooks.old.js index fb1e9b86883a9..08c61e9b162f6 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.old.js +++ b/packages/react-reconciler/src/ReactFiberHooks.old.js @@ -1242,6 +1242,20 @@ function updateMutableSource( return useMutableSource(hook, source, getSnapshot, subscribe); } +function mountSyncExternalStore( + subscribe: (() => void) => () => void, + getSnapshot: () => T, +): T { + throw new Error('Not yet implemented'); +} + +function updateSyncExternalStore( + subscribe: (() => void) => () => void, + getSnapshot: () => T, +): T { + throw new Error('Not yet implemented'); +} + function mountState( initialState: (() => S) | S, ): [S, Dispatch>] { @@ -2079,6 +2093,7 @@ export const ContextOnlyDispatcher: Dispatcher = { useDeferredValue: throwInvalidHookError, useTransition: throwInvalidHookError, useMutableSource: throwInvalidHookError, + useSyncExternalStore: throwInvalidHookError, useOpaqueIdentifier: throwInvalidHookError, unstable_isNewReconciler: enableNewReconciler, @@ -2104,6 +2119,7 @@ const HooksDispatcherOnMount: Dispatcher = { useDeferredValue: mountDeferredValue, useTransition: mountTransition, useMutableSource: mountMutableSource, + useSyncExternalStore: mountSyncExternalStore, useOpaqueIdentifier: mountOpaqueIdentifier, unstable_isNewReconciler: enableNewReconciler, @@ -2129,6 +2145,7 @@ const HooksDispatcherOnUpdate: Dispatcher = { useDeferredValue: updateDeferredValue, useTransition: updateTransition, useMutableSource: updateMutableSource, + useSyncExternalStore: updateSyncExternalStore, useOpaqueIdentifier: updateOpaqueIdentifier, unstable_isNewReconciler: enableNewReconciler, @@ -2154,6 +2171,7 @@ const HooksDispatcherOnRerender: Dispatcher = { useDeferredValue: rerenderDeferredValue, useTransition: rerenderTransition, useMutableSource: updateMutableSource, + useSyncExternalStore: mountSyncExternalStore, useOpaqueIdentifier: rerenderOpaqueIdentifier, unstable_isNewReconciler: enableNewReconciler, @@ -2302,6 +2320,14 @@ if (__DEV__) { mountHookTypesDev(); return mountMutableSource(source, getSnapshot, subscribe); }, + useSyncExternalStore( + subscribe: (() => void) => () => void, + getSnapshot: () => T, + ): T { + currentHookNameInDev = 'useSyncExternalStore'; + mountHookTypesDev(); + return mountSyncExternalStore(subscribe, getSnapshot); + }, useOpaqueIdentifier(): OpaqueIDType | void { currentHookNameInDev = 'useOpaqueIdentifier'; mountHookTypesDev(); @@ -2426,6 +2452,14 @@ if (__DEV__) { updateHookTypesDev(); return mountMutableSource(source, getSnapshot, subscribe); }, + useSyncExternalStore( + subscribe: (() => void) => () => void, + getSnapshot: () => T, + ): T { + currentHookNameInDev = 'useSyncExternalStore'; + updateHookTypesDev(); + return mountSyncExternalStore(subscribe, getSnapshot); + }, useOpaqueIdentifier(): OpaqueIDType | void { currentHookNameInDev = 'useOpaqueIdentifier'; updateHookTypesDev(); @@ -2550,6 +2584,14 @@ if (__DEV__) { updateHookTypesDev(); return updateMutableSource(source, getSnapshot, subscribe); }, + useSyncExternalStore( + subscribe: (() => void) => () => void, + getSnapshot: () => T, + ): T { + currentHookNameInDev = 'useSyncExternalStore'; + updateHookTypesDev(); + return updateSyncExternalStore(subscribe, getSnapshot); + }, useOpaqueIdentifier(): OpaqueIDType | void { currentHookNameInDev = 'useOpaqueIdentifier'; updateHookTypesDev(); @@ -2675,6 +2717,14 @@ if (__DEV__) { updateHookTypesDev(); return updateMutableSource(source, getSnapshot, subscribe); }, + useSyncExternalStore( + subscribe: (() => void) => () => void, + getSnapshot: () => T, + ): T { + currentHookNameInDev = 'useSyncExternalStore'; + updateHookTypesDev(); + return updateSyncExternalStore(subscribe, getSnapshot); + }, useOpaqueIdentifier(): OpaqueIDType | void { currentHookNameInDev = 'useOpaqueIdentifier'; updateHookTypesDev(); @@ -2813,6 +2863,15 @@ if (__DEV__) { mountHookTypesDev(); return mountMutableSource(source, getSnapshot, subscribe); }, + useSyncExternalStore( + subscribe: (() => void) => () => void, + getSnapshot: () => T, + ): T { + currentHookNameInDev = 'useSyncExternalStore'; + warnInvalidHookAccess(); + mountHookTypesDev(); + return mountSyncExternalStore(subscribe, getSnapshot); + }, useOpaqueIdentifier(): OpaqueIDType | void { currentHookNameInDev = 'useOpaqueIdentifier'; warnInvalidHookAccess(); @@ -2952,6 +3011,15 @@ if (__DEV__) { updateHookTypesDev(); return updateMutableSource(source, getSnapshot, subscribe); }, + useSyncExternalStore( + subscribe: (() => void) => () => void, + getSnapshot: () => T, + ): T { + currentHookNameInDev = 'useSyncExternalStore'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateSyncExternalStore(subscribe, getSnapshot); + }, useOpaqueIdentifier(): OpaqueIDType | void { currentHookNameInDev = 'useOpaqueIdentifier'; warnInvalidHookAccess(); @@ -3092,6 +3160,15 @@ if (__DEV__) { updateHookTypesDev(); return updateMutableSource(source, getSnapshot, subscribe); }, + useSyncExternalStore( + subscribe: (() => void) => () => void, + getSnapshot: () => T, + ): T { + currentHookNameInDev = 'useSyncExternalStore'; + warnInvalidHookAccess(); + updateHookTypesDev(); + return updateSyncExternalStore(subscribe, getSnapshot); + }, useOpaqueIdentifier(): OpaqueIDType | void { currentHookNameInDev = 'useOpaqueIdentifier'; warnInvalidHookAccess(); diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js index 5a4bc62374250..33bdb24af04db 100644 --- a/packages/react-reconciler/src/ReactInternalTypes.js +++ b/packages/react-reconciler/src/ReactInternalTypes.js @@ -41,6 +41,7 @@ export type HookType = | 'useDeferredValue' | 'useTransition' | 'useMutableSource' + | 'useSyncExternalStore' | 'useOpaqueIdentifier' | 'useCacheRefresh'; @@ -304,6 +305,10 @@ export type Dispatcher = {| getSnapshot: MutableSourceGetSnapshotFn, subscribe: MutableSourceSubscribeFn, ): Snapshot, + useSyncExternalStore( + subscribe: (() => void) => () => void, + getSnapshot: () => T, + ): T, useOpaqueIdentifier(): any, useCacheRefresh?: () => (?() => T, ?T) => void, diff --git a/packages/react-server/src/ReactFizzHooks.js b/packages/react-server/src/ReactFizzHooks.js index 23a35c1803f70..51ea84e8c43d1 100644 --- a/packages/react-server/src/ReactFizzHooks.js +++ b/packages/react-server/src/ReactFizzHooks.js @@ -461,6 +461,13 @@ function useMutableSource( return getSnapshot(source._source); } +function useSyncExternalStore( + subscribe: (() => void) => () => void, + getSnapshot: () => T, +): T { + throw new Error('Not yet implemented'); +} + function useDeferredValue(value: T): T { resolveCurrentlyRenderingComponent(); return value; @@ -509,6 +516,7 @@ export const Dispatcher: DispatcherType = { useOpaqueIdentifier, // Subscriptions are not setup in a server environment. useMutableSource, + useSyncExternalStore, }; if (enableCache) { diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index dd5b43df2d35e..efac391833e8b 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -827,6 +827,7 @@ const Dispatcher: DispatcherType = { useEffect: (unsupportedHook: any), useOpaqueIdentifier: (unsupportedHook: any), useMutableSource: (unsupportedHook: any), + useSyncExternalStore: (unsupportedHook: any), useCacheRefresh(): (?() => T, ?T) => void { return unsupportedRefresh; }, diff --git a/packages/react-suspense-test-utils/src/ReactSuspenseTestUtils.js b/packages/react-suspense-test-utils/src/ReactSuspenseTestUtils.js index 43c6c5184d2b9..ca898150b7cd2 100644 --- a/packages/react-suspense-test-utils/src/ReactSuspenseTestUtils.js +++ b/packages/react-suspense-test-utils/src/ReactSuspenseTestUtils.js @@ -44,6 +44,7 @@ export function waitForSuspense(fn: () => T): Promise { useTransition: unsupported, useOpaqueIdentifier: unsupported, useMutableSource: unsupported, + useSyncExternalStore: unsupported, useCacheRefresh: unsupported, }; // Not using async/await because we don't compile it. diff --git a/packages/react/index.classic.fb.js b/packages/react/index.classic.fb.js index 653013c7b0797..756f69f271336 100644 --- a/packages/react/index.classic.fb.js +++ b/packages/react/index.classic.fb.js @@ -51,6 +51,8 @@ export { useMemo, useMutableSource, useMutableSource as unstable_useMutableSource, + useSyncExternalStore, + useSyncExternalStore as unstable_useSyncExternalStore, useReducer, useRef, useState, diff --git a/packages/react/index.experimental.js b/packages/react/index.experimental.js index ab90ea66bc112..230e94f3a5280 100644 --- a/packages/react/index.experimental.js +++ b/packages/react/index.experimental.js @@ -45,6 +45,7 @@ export { useLayoutEffect, useMemo, useMutableSource as unstable_useMutableSource, + useSyncExternalStore as unstable_useSyncExternalStore, useReducer, useRef, useState, diff --git a/packages/react/index.js b/packages/react/index.js index 247d17ec01b8d..c2285c6b0b312 100644 --- a/packages/react/index.js +++ b/packages/react/index.js @@ -70,6 +70,8 @@ export { useLayoutEffect, useMemo, useMutableSource, + useSyncExternalStore, + useSyncExternalStore as unstable_useSyncExternalStore, useReducer, useRef, useState, diff --git a/packages/react/index.modern.fb.js b/packages/react/index.modern.fb.js index 4f316eacad8b7..d87d7c891ee88 100644 --- a/packages/react/index.modern.fb.js +++ b/packages/react/index.modern.fb.js @@ -50,6 +50,8 @@ export { useMemo, useMutableSource, useMutableSource as unstable_useMutableSource, + useSyncExternalStore, + useSyncExternalStore as unstable_useSyncExternalStore, useReducer, useRef, useState, diff --git a/packages/react/src/React.js b/packages/react/src/React.js index 2b87d18b6c81d..24421bd86c490 100644 --- a/packages/react/src/React.js +++ b/packages/react/src/React.js @@ -44,6 +44,7 @@ import { useLayoutEffect, useMemo, useMutableSource, + useSyncExternalStore, useReducer, useRef, useState, @@ -93,6 +94,7 @@ export { useLayoutEffect, useMemo, useMutableSource, + useSyncExternalStore, useReducer, useRef, useState, diff --git a/packages/react/src/ReactHooks.js b/packages/react/src/ReactHooks.js index 82bd886d82455..b284267b64e91 100644 --- a/packages/react/src/ReactHooks.js +++ b/packages/react/src/ReactHooks.js @@ -169,6 +169,14 @@ export function useMutableSource( return dispatcher.useMutableSource(source, getSnapshot, subscribe); } +export function useSyncExternalStore( + subscribe: (() => void) => () => void, + getSnapshot: () => T, +): T { + const dispatcher = resolveDispatcher(); + return dispatcher.useSyncExternalStore(subscribe, getSnapshot); +} + export function useCacheRefresh(): (?() => T, ?T) => void { const dispatcher = resolveDispatcher(); // $FlowFixMe This is unstable, thus optional diff --git a/packages/use-sync-external-store/src/__tests__/useSyncExternalStoreShared-test.js b/packages/use-sync-external-store/src/__tests__/useSyncExternalStoreShared-test.js index 3102087b8e90b..4bf0c4bc955af 100644 --- a/packages/use-sync-external-store/src/__tests__/useSyncExternalStoreShared-test.js +++ b/packages/use-sync-external-store/src/__tests__/useSyncExternalStoreShared-test.js @@ -29,11 +29,28 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { // 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; - }); + if (gate(flags => flags.variant)) { + // We'll use the variant flag to represent the native implementation + } else { + // and the non-variant tests for the shim. + // + // Remove useSyncExternalStore from the React imports so that we use the + // shim instead. Also removing startTransition, since we use that to + // detect outdated 18 alphas that don't yet include useSyncExternalStore. + // + // Longer term, we'll probably test this branch using an actual build + // of React 17. + jest.mock('react', () => { + const { + // eslint-disable-next-line no-unused-vars + startTransition: _, + // eslint-disable-next-line no-unused-vars + useSyncExternalStore: __, + ...otherExports + } = jest.requireActual('react'); + return otherExports; + }); + } React = require('react'); ReactNoop = require('react-noop-renderer'); @@ -63,14 +80,17 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { 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; + if (gate(flags => flags.variant)) { + // The native implementation only exists in 18+, so we test using + // concurrent mode. To test the legacy root behavior in the native + // implementation (which is supported in the sense that it needs to have + // the correct behavior, despite the fact that the legacy root API + // triggers a warning in 18), write a test that uses + // createLegacyRoot directly. + return ReactNoop.createRoot(); + } else { + return ReactNoop.createLegacyRoot(); + } } function createExternalStore(initialState) { @@ -96,6 +116,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { }; } + // @gate !variant test('basic usage', () => { const store = createExternalStore('Initial'); @@ -104,7 +125,8 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { return ; } - const root = createRoot(); + const root = createRoot(); + act(() => root.render()); expect(Scheduler).toHaveYielded(['Initial']); expect(root).toMatchRenderedOutput('Initial'); @@ -116,6 +138,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { expect(root).toMatchRenderedOutput('Updated'); }); + // @gate !variant test('skips re-rendering if nothing changes', () => { const store = createExternalStore('Initial'); @@ -124,7 +147,8 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { return ; } - const root = createRoot(); + const root = createRoot(); + act(() => root.render()); expect(Scheduler).toHaveYielded(['Initial']); expect(root).toMatchRenderedOutput('Initial'); @@ -138,6 +162,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { expect(root).toMatchRenderedOutput('Initial'); }); + // @gate !variant test('switch to a different store', () => { const storeA = createExternalStore(0); const storeB = createExternalStore(0); @@ -150,7 +175,8 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { return ; } - const root = createRoot(); + const root = createRoot(); + act(() => root.render()); expect(Scheduler).toHaveYielded([0]); expect(root).toMatchRenderedOutput('0'); @@ -187,6 +213,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { expect(root).toMatchRenderedOutput('1'); }); + // @gate !variant test('selecting a specific value inside getSnapshot', () => { const store = createExternalStore({a: 0, b: 0}); @@ -208,7 +235,8 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { ); } - const root = createRoot(); + const root = createRoot(); + act(() => root.render()); expect(Scheduler).toHaveYielded(['A0', 'B0']); expect(root).toMatchRenderedOutput('A0B0'); @@ -230,6 +258,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { expect(root).toMatchRenderedOutput('A1B1'); }); + // @gate !variant test( "compares to current state before bailing out, even when there's a " + 'mutation in between the sync and passive effects', @@ -244,7 +273,8 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { return ; } - const root = createRoot(); + const root = createRoot(); + act(() => root.render()); expect(Scheduler).toHaveYielded([0, 'Passive effect: 0']); // Schedule an update. We'll intentionally not use `act` so that we can @@ -272,6 +302,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { }, ); + // @gate !variant test('mutating the store in between render and commit when getSnapshot has changed', () => { const store = createExternalStore({a: 1, b: 1}); @@ -312,7 +343,8 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { ); } - const root = createRoot(); + const root = createRoot(); + act(() => root.render()); expect(Scheduler).toHaveYielded(['A1']); expect(root).toMatchRenderedOutput('A1'); @@ -330,6 +362,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { expect(root).toMatchRenderedOutput('B2'); }); + // @gate !variant 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}); @@ -368,7 +401,8 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { ); } - const root = createRoot(); + const root = createRoot(); + act(() => root.render()); expect(Scheduler).toHaveYielded(['A1']); expect(root).toMatchRenderedOutput('A1'); @@ -387,6 +421,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { expect(root).toMatchRenderedOutput('A1'); }); + // @gate !variant test("does not bail out if the previous update hasn't finished yet", () => { const store = createExternalStore(0); @@ -406,11 +441,14 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { return ; } - const root = createRoot( - <> - - - , + const root = createRoot(); + act(() => + root.render( + <> + + + , + ), ); expect(Scheduler).toHaveYielded([0, 0]); expect(root).toMatchRenderedOutput('00'); @@ -422,6 +460,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { expect(root).toMatchRenderedOutput('00'); }); + // @gate !variant test('uses the latest getSnapshot, even if it changed in the same batch as a store update', () => { const store = createExternalStore({a: 0, b: 0}); @@ -436,7 +475,8 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { return ; } - const root = createRoot(); + const root = createRoot(); + act(() => root.render()); expect(Scheduler).toHaveYielded([0]); // Update the store and getSnapshot at the same time @@ -449,6 +489,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { expect(root).toMatchRenderedOutput('2'); }); + // @gate !variant test('handles errors thrown by getSnapshot or isEqual', () => { class ErrorBoundary extends React.Component { state = {error: null}; @@ -492,10 +533,13 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { } const errorBoundary = React.createRef(null); - const root = createRoot( - - - , + const root = createRoot(); + act(() => + root.render( + + + , + ), ); expect(Scheduler).toHaveYielded([0]); expect(root).toMatchRenderedOutput('0'); @@ -524,7 +568,32 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { expect(root).toMatchRenderedOutput('1'); }); + // @gate !variant + test('Infinite loop if getSnapshot keeps returning new reference', () => { + const store = createExternalStore({}); + + function App() { + const text = useSyncExternalStore(store.subscribe, () => ({})); + return ; + } + + spyOnDev(console, 'error'); + const root = createRoot(); + + expect(() => act(() => root.render())).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.', + ); + if (__DEV__) { + expect(console.error.calls.argsFor(0)[0]).toMatch( + 'The result of getSnapshot should be cached to avoid an infinite loop', + ); + } + }); + describe('extra features implemented in user-space', () => { + // @gate !variant test('memoized selectors are only called once per update', () => { const store = createExternalStore({a: 0, b: 0}); @@ -543,7 +612,8 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { return ; } - const root = createRoot(); + const root = createRoot(); + act(() => root.render()); expect(Scheduler).toHaveYielded(['App', 'Selector', 'A0']); expect(root).toMatchRenderedOutput('A0'); @@ -563,6 +633,7 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { expect(root).toMatchRenderedOutput('A1'); }); + // @gate !variant test('Using isEqual to bailout', () => { const store = createExternalStore({a: 0, b: 0}); @@ -596,7 +667,8 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { ); } - const root = createRoot(); + const root = createRoot(); + act(() => root.render()); expect(Scheduler).toHaveYielded(['A0', 'B0']); expect(root).toMatchRenderedOutput('A0B0'); @@ -618,30 +690,4 @@ describe('Shared useSyncExternalStore behavior (shim and built-in)', () => { expect(root).toMatchRenderedOutput('A1B1'); }); }); - - test('Infinite loop if getSnapshot keeps returning new reference', () => { - const store = createExternalStore({}); - - function App() { - const text = useSyncExternalStore(store.subscribe, () => ({})); - return ; - } - - spyOnDev(console, 'error'); - - expect(() => { - act(() => { - createRoot(); - }); - }).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.', - ); - if (__DEV__) { - expect(console.error.calls.argsFor(0)[0]).toMatch( - 'The result of getSnapshot should be cached to avoid an infinite loop', - ); - } - }); }); diff --git a/packages/use-sync-external-store/src/useSyncExternalStore.js b/packages/use-sync-external-store/src/useSyncExternalStore.js index 8607c915eb72d..73cd7f8089893 100644 --- a/packages/use-sync-external-store/src/useSyncExternalStore.js +++ b/packages/use-sync-external-store/src/useSyncExternalStore.js @@ -17,8 +17,6 @@ const { useEffect, useLayoutEffect, useDebugValue, - - // $FlowFixMe - useSyncExternalStore not yet part of React Flow types useSyncExternalStore: builtInAPI, } = React; @@ -26,7 +24,9 @@ const { // 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; + builtInAPI !== undefined + ? ((builtInAPI: any): typeof useSyncExternalStore_shim) + : useSyncExternalStore_shim; let didWarnOld18Alpha = false; let didWarnUncachedGetSnapshot = false; diff --git a/scripts/jest/TestFlags.js b/scripts/jest/TestFlags.js index 9e9a531b0b3bb..9dbea6326ddec 100644 --- a/scripts/jest/TestFlags.js +++ b/scripts/jest/TestFlags.js @@ -42,6 +42,8 @@ const environmentFlags = { // Similarly, should stable imply "classic"? stable: !__EXPERIMENTAL__, + variant: __VARIANT__, + persistent: global.__PERSISTENT__ === true, // Use this for tests that are known to be broken.