From 4da03c9fbdaf955c637ea95679c32e9ac242992c Mon Sep 17 00:00:00 2001 From: salazarm Date: Tue, 21 Sep 2021 11:07:56 -0400 Subject: [PATCH] useSyncExternalStore React Native version (#22367) --- .../use-sync-external-store/index.native.js | 12 ++ .../npm/index.native.js | 7 + packages/use-sync-external-store/package.json | 1 + .../useSyncExternalStoreNative-test.js | 186 ++++++++++++++++++ .../src/useSyncExternalStore.js | 169 +--------------- .../src/useSyncExternalStoreClient.js | 154 +++++++++++++++ .../src/useSyncExternalStoreServer.js | 25 +++ scripts/rollup/bundles.js | 9 + 8 files changed, 397 insertions(+), 166 deletions(-) create mode 100644 packages/use-sync-external-store/index.native.js create mode 100644 packages/use-sync-external-store/npm/index.native.js create mode 100644 packages/use-sync-external-store/src/__tests__/useSyncExternalStoreNative-test.js create mode 100644 packages/use-sync-external-store/src/useSyncExternalStoreClient.js create mode 100644 packages/use-sync-external-store/src/useSyncExternalStoreServer.js diff --git a/packages/use-sync-external-store/index.native.js b/packages/use-sync-external-store/index.native.js new file mode 100644 index 0000000000000..cac5c1c2bf710 --- /dev/null +++ b/packages/use-sync-external-store/index.native.js @@ -0,0 +1,12 @@ +/** + * 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 + */ + +'use strict'; + +export * from './src/useSyncExternalStoreClient'; diff --git a/packages/use-sync-external-store/npm/index.native.js b/packages/use-sync-external-store/npm/index.native.js new file mode 100644 index 0000000000000..22546b9c0ebba --- /dev/null +++ b/packages/use-sync-external-store/npm/index.native.js @@ -0,0 +1,7 @@ +'use strict'; + +if (process.env.NODE_ENV === 'production') { + module.exports = require('./cjs/use-sync-external-store.native.production.min.js'); +} else { + module.exports = require('./cjs/use-sync-external-store.native.development.js'); +} diff --git a/packages/use-sync-external-store/package.json b/packages/use-sync-external-store/package.json index 7f5e5b0e8c0d3..b43b3a0ec67d2 100644 --- a/packages/use-sync-external-store/package.json +++ b/packages/use-sync-external-store/package.json @@ -13,6 +13,7 @@ "build-info.json", "index.js", "extra.js", + "index.native.js", "cjs/" ], "license": "MIT", diff --git a/packages/use-sync-external-store/src/__tests__/useSyncExternalStoreNative-test.js b/packages/use-sync-external-store/src/__tests__/useSyncExternalStoreNative-test.js new file mode 100644 index 0000000000000..0902e7554c450 --- /dev/null +++ b/packages/use-sync-external-store/src/__tests__/useSyncExternalStoreNative-test.js @@ -0,0 +1,186 @@ +/** + * 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 + * + * @jest-environment node + */ + +'use strict'; + +let React; +let ReactNoop; +let Scheduler; +let useSyncExternalStore; +let useSyncExternalStoreExtra; +let act; + +// This tests the userspace shim of `useSyncExternalStore` in a server-rendering +// (Node) environment +describe('useSyncExternalStore (userspace shim, server rendering)', () => { + beforeEach(() => { + jest.resetModules(); + + // 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: __, + // eslint-disable-next-line no-unused-vars + unstable_useSyncExternalStore: ___, + ...otherExports + } = jest.requireActual('react'); + return otherExports; + }); + + jest.mock('use-sync-external-store', () => + jest.requireActual('use-sync-external-store/index.native'), + ); + + React = require('react'); + ReactNoop = require('react-noop-renderer'); + Scheduler = require('scheduler'); + act = require('jest-react').act; + 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 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('native version', async () => { + const store = createExternalStore('client'); + + function App() { + const text = useSyncExternalStore( + store.subscribe, + store.getState, + () => 'server', + ); + return ; + } + + const root = ReactNoop.createRoot(); + await act(() => { + root.render(); + }); + expect(Scheduler).toHaveYielded(['client']); + expect(root).toMatchRenderedOutput('client'); + }); + + test('native version', async () => { + const store = createExternalStore('client'); + + function App() { + const text = useSyncExternalStore( + store.subscribe, + store.getState, + () => 'server', + ); + return ; + } + + const root = ReactNoop.createRoot(); + await act(() => { + root.render(); + }); + expect(Scheduler).toHaveYielded(['client']); + expect(root).toMatchRenderedOutput('client'); + }); + + // @gate !(enableUseRefAccessWarning && __DEV__) + test('Using isEqual to bailout', async () => { + const store = createExternalStore({a: 0, b: 0}); + + function A() { + const {a} = useSyncExternalStoreExtra( + store.subscribe, + store.getState, + null, + state => ({a: state.a}), + (state1, state2) => state1.a === state2.a, + ); + return ; + } + function B() { + const {b} = useSyncExternalStoreExtra( + store.subscribe, + store.getState, + null, + state => { + return {b: state.b}; + }, + (state1, state2) => state1.b === state2.b, + ); + return ; + } + + function App() { + return ( + <> + + + + ); + } + + const root = ReactNoop.createRoot(); + act(() => root.render()); + + expect(Scheduler).toHaveYielded(['A0', 'B0']); + expect(root).toMatchRenderedOutput('A0B0'); + + // Update b but not a + await act(() => { + store.set({a: 0, b: 1}); + }); + // Only b re-renders + expect(Scheduler).toHaveYielded(['B1']); + expect(root).toMatchRenderedOutput('A0B1'); + + // Update a but not b + await 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 6b30854aea03a..8a1a5c7191135 100644 --- a/packages/use-sync-external-store/src/useSyncExternalStore.js +++ b/packages/use-sync-external-store/src/useSyncExternalStore.js @@ -7,171 +7,8 @@ * @flow */ -import * as React from 'react'; -import is from 'shared/objectIs'; -import invariant from 'shared/invariant'; import {canUseDOM} from 'shared/ExecutionEnvironment'; +import {useSyncExternalStore as client} from './useSyncExternalStoreClient'; +import {useSyncExternalStore as server} from './useSyncExternalStoreServer'; -// Intentionally not using named imports because Rollup uses dynamic -// dispatch for CommonJS interop named imports. -const { - useState, - useEffect, - useLayoutEffect, - useDebugValue, - // The built-in API is still prefixed. - unstable_useSyncExternalStore: builtInAPI, -} = React; - -// TODO: This heuristic doesn't work in React Native. We'll need to provide a -// special build, using the `.native` extension. -const isServerEnvironment = !canUseDOM; - -// 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: any): typeof useSyncExternalStore_client) - : isServerEnvironment - ? useSyncExternalStore_server - : useSyncExternalStore_client; - -let didWarnOld18Alpha = false; -let didWarnUncachedGetSnapshot = false; - -function useSyncExternalStore_server( - subscribe: (() => void) => () => void, - getSnapshot: () => T, - getServerSnapshot?: () => T, -): T { - if (getServerSnapshot === undefined) { - invariant( - false, - 'Missing getServerSnapshot, which is required for server-' + - 'rendered content.', - ); - } - return getServerSnapshot(); -} - -// 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_client( - subscribe: (() => void) => () => void, - getSnapshot: () => T, - // Note: The client shim does not use getServerSnapshot, because pre-18 - // versions of React do not expose a way to check if we're hydrating. So - // users of the shim will need to track that themselves and return the - // correct value from `getSnapshot`. - getServerSnapshot?: () => 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(); - if (__DEV__) { - if (!didWarnUncachedGetSnapshot) { - if (value !== getSnapshot()) { - console.error( - 'The result of getSnapshot should be cached to avoid an infinite loop', - ); - didWarnUncachedGetSnapshot = true; - } - } - } - - // 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. - 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; - } -} +export const useSyncExternalStore = canUseDOM ? client : server; diff --git a/packages/use-sync-external-store/src/useSyncExternalStoreClient.js b/packages/use-sync-external-store/src/useSyncExternalStoreClient.js new file mode 100644 index 0000000000000..76e7fda36f831 --- /dev/null +++ b/packages/use-sync-external-store/src/useSyncExternalStoreClient.js @@ -0,0 +1,154 @@ +/** + * 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'; + +// Intentionally not using named imports because Rollup uses dynamic +// dispatch for CommonJS interop named imports. +const { + useState, + useEffect, + useLayoutEffect, + useDebugValue, + // The built-in API is still prefixed. + unstable_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: any): typeof useSyncExternalStore_client) + : useSyncExternalStore_client; + +let didWarnOld18Alpha = false; +let didWarnUncachedGetSnapshot = 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_client( + subscribe: (() => void) => () => void, + getSnapshot: () => T, + // Note: The client shim does not use getServerSnapshot, because pre-18 + // versions of React do not expose a way to check if we're hydrating. So + // users of the shim will need to track that themselves and return the + // correct value from `getSnapshot`. + getServerSnapshot?: () => 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(); + if (__DEV__) { + if (!didWarnUncachedGetSnapshot) { + if (value !== getSnapshot()) { + console.error( + 'The result of getSnapshot should be cached to avoid an infinite loop', + ); + didWarnUncachedGetSnapshot = true; + } + } + } + + // 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. + 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/useSyncExternalStoreServer.js b/packages/use-sync-external-store/src/useSyncExternalStoreServer.js new file mode 100644 index 0000000000000..1bf2a752273db --- /dev/null +++ b/packages/use-sync-external-store/src/useSyncExternalStoreServer.js @@ -0,0 +1,25 @@ +/** + * 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 invariant from 'shared/invariant'; + +export function useSyncExternalStore( + subscribe: (() => void) => () => void, + getSnapshot: () => T, + getServerSnapshot?: () => T, +): T { + if (getServerSnapshot === undefined) { + invariant( + false, + 'Missing getServerSnapshot, which is required for server-' + + 'rendered content.', + ); + } + return getServerSnapshot(); +} diff --git a/scripts/rollup/bundles.js b/scripts/rollup/bundles.js index 047d27e918070..b9ae507b1a305 100644 --- a/scripts/rollup/bundles.js +++ b/scripts/rollup/bundles.js @@ -702,6 +702,15 @@ const bundles = [ externals: ['react', 'use-sync-external-store'], }, + /******* Shim for useSyncExternalStore ReactNative *******/ + { + bundleTypes: [NODE_DEV, NODE_PROD], + moduleType: ISOMORPHIC, + entry: 'use-sync-external-store/index.native', + global: 'useSyncExternalStoreNative', + externals: ['react', 'ReactNativeInternalFeatureFlags'], + }, + /******* React Scheduler (experimental) *******/ { bundleTypes: [