main
js 149 lines 5.62 KB
Raw
1 /**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 *
7 * @flow
8 */
9
10 import * as React from 'react';
11 import is from 'shared/objectIs';
12
13 // Intentionally not using named imports because Rollup uses dynamic
14 // dispatch for CommonJS interop named imports.
15 const {useState, useEffect, useLayoutEffect, useDebugValue} = React;
16
17 let didWarnOld18Alpha = false;
18 let didWarnUncachedGetSnapshot = false;
19
20 // Disclaimer: This shim breaks many of the rules of React, and only works
21 // because of a very particular set of implementation details and assumptions
22 // -- change any one of them and it will break. The most important assumption
23 // is that updates are always synchronous, because concurrent rendering is
24 // only available in versions of React that also have a built-in
25 // useSyncExternalStore API. And we only use this shim when the built-in API
26 // does not exist.
27 //
28 // Do not assume that the clever hacks used by this hook also work in general.
29 // The point of this shim is to replace the need for hacks by other libraries.
30 export function useSyncExternalStore<T>(
31 subscribe: (() => void) => () => void,
32 getSnapshot: () => T,
33 // Note: The shim does not use getServerSnapshot, because pre-18 versions of
34 // React do not expose a way to check if we're hydrating. So users of the shim
35 // will need to track that themselves and return the correct value
36 // from `getSnapshot`.
37 getServerSnapshot?: () => T,
38 ): T {
39 if (__DEV__) {
40 if (!didWarnOld18Alpha) {
41 if (React.startTransition !== undefined) {
42 didWarnOld18Alpha = true;
43 // Avoid transforming the `console.error` call as it would cause the built artifact
44 // to access React internals, which exist under different paths depending on the
45 // React version.
46 console['error'](
47 'You are using an outdated, pre-release alpha of React 18 that ' +
48 'does not support useSyncExternalStore. The ' +
49 'use-sync-external-store shim will not work correctly. Upgrade ' +
50 'to a newer pre-release.',
51 );
52 }
53 }
54 }
55
56 // Read the current snapshot from the store on every render. Again, this
57 // breaks the rules of React, and only works here because of specific
58 // implementation details, most importantly that updates are
59 // always synchronous.
60 const value = getSnapshot();
61 if (__DEV__) {
62 if (!didWarnUncachedGetSnapshot) {
63 const cachedValue = getSnapshot();
64 if (!is(value, cachedValue)) {
65 // Avoid transforming the `console.error` call as it would cause the built artifact
66 // to access React internals, which exist under different paths depending on the
67 // React version.
68 console['error'](
69 'The result of getSnapshot should be cached to avoid an infinite loop',
70 );
71 didWarnUncachedGetSnapshot = true;
72 }
73 }
74 }
75
76 // Because updates are synchronous, we don't queue them. Instead we force a
77 // re-render whenever the subscribed state changes by updating an some
78 // arbitrary useState hook. Then, during render, we call getSnapshot to read
79 // the current value.
80 //
81 // Because we don't actually use the state returned by the useState hook, we
82 // can save a bit of memory by storing other stuff in that slot.
83 //
84 // To implement the early bailout, we need to track some things on a mutable
85 // object. Usually, we would put that in a useRef hook, but we can stash it in
86 // our useState hook instead.
87 //
88 // To force a re-render, we call forceUpdate({inst}). That works because the
89 // new object always fails an equality check.
90 const [{inst}, forceUpdate] = useState({inst: {value, getSnapshot}});
91
92 // Track the latest getSnapshot function with a ref. This needs to be updated
93 // in the layout phase so we can access it during the tearing check that
94 // happens on subscribe.
95 useLayoutEffect(() => {
96 inst.value = value;
97 inst.getSnapshot = getSnapshot;
98
99 // Whenever getSnapshot or subscribe changes, we need to check in the
100 // commit phase if there was an interleaved mutation. In concurrent mode
101 // this can happen all the time, but even in synchronous mode, an earlier
102 // effect may have mutated the store.
103 if (checkIfSnapshotChanged(inst)) {
104 // Force a re-render.
105 forceUpdate({inst});
106 }
107 }, [subscribe, value, getSnapshot]);
108
109 useEffect(() => {
110 // Check for changes right before subscribing. Subsequent changes will be
111 // detected in the subscription handler.
112 if (checkIfSnapshotChanged(inst)) {
113 // Force a re-render.
114 forceUpdate({inst});
115 }
116 const handleStoreChange = () => {
117 // TODO: Because there is no cross-renderer API for batching updates, it's
118 // up to the consumer of this library to wrap their subscription event
119 // with unstable_batchedUpdates. Should we try to detect when this isn't
120 // the case and print a warning in development?
121
122 // The store changed. Check if the snapshot changed since the last time we
123 // read from the store.
124 if (checkIfSnapshotChanged(inst)) {
125 // Force a re-render.
126 forceUpdate({inst});
127 }
128 };
129 // Subscribe to the store and return a clean-up function.
130 return subscribe(handleStoreChange);
131 }, [subscribe]);
132
133 useDebugValue(value);
134 return value;
135 }
136
137 function checkIfSnapshotChanged<T>(inst: {
138 value: T,
139 getSnapshot: () => T,
140 }): boolean {
141 const latestGetSnapshot = inst.getSnapshot;
142 const prevValue = inst.value;
143 try {
144 const nextValue = latestGetSnapshot();
145 return !is(prevValue, nextValue);
146 } catch (error) {
147 return true;
148 }
149 }