@samitouri / QOS-React-2 / commits / 1314299c7f

Initial shim of useSyncExternalStore (#22211)

This sets up an initial shim implementation of useSyncExternalStore, via the use-sync-external-store package. It's designed to mimic the behavior of the built-in API, but is backwards compatible to any version of React that supports hooks. I have not yet implemented the built-in API, but once it exists, the use-sync-external-store package will always prefer that one. Library authors can depend on the shim and trust that their users get the correct implementation. See https://github.com/reactwg/react-18/discussions/86 for background on the API. The tests I've added here are designed to run against both the shim and built-in implementation, using our variant test flag feature. Tests that only apply to concurrent roots will live in a separate suite.

Andrew Clark committed Sep 1, 2021 at 20:52 UTC 1314299c7f70914d61d8e1cef56767f112110674
7 files changed +845 -7
packages/use-sync-external-store/extra.js renamed
+2 -4
@@ -4,11 +4,9 @@
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 - * @emails react-core
7 + * @flow
8 */
9
10 'use strict';
11
12 -describe('useSyncExternalStore', () => {
13 - test('TODO', () => {});
14 -});
12 +export * from './src/useSyncExternalStoreExtra';
packages/use-sync-external-store/npm/extra.js new
+7
@@ -0,0 +1,7 @@
1 +'use strict';
2 +
3 +if (process.env.NODE_ENV === 'production') {
4 + module.exports = require('./cjs/use-sync-external-store-extra.production.min.js');
5 +} else {
6 + module.exports = require('./cjs/use-sync-external-store-extra.development.js');
7 +}
packages/use-sync-external-store/package.json
+1
@@ -12,6 +12,7 @@
12 "README.md",
13 "build-info.json",
14 "index.js",
15 + "extra.js",
16 "cjs/"
17 ],
18 "license": "MIT",
packages/use-sync-external-store/src/__tests__/useSyncExternalStoreShared-test.js new
+621
@@ -0,0 +1,621 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its 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 + * @emails react-core
8 + */
9 +
10 +'use strict';
11 +
12 +let useSyncExternalStore;
13 +let useSyncExternalStoreExtra;
14 +let React;
15 +let ReactNoop;
16 +let Scheduler;
17 +let act;
18 +let useState;
19 +let useEffect;
20 +let useLayoutEffect;
21 +
22 +// This tests shared behavior between the built-in and shim implementations of
23 +// of useSyncExternalStore.
24 +describe('Shared useSyncExternalStore behavior (shim and built-in)', () => {
25 + beforeEach(() => {
26 + jest.resetModules();
27 +
28 + // Remove the built-in API from the React exports to force the package to
29 + // use the shim.
30 + // TODO: Don't do this during a variant test run. That way these tests run
31 + // against both the shim and the built-in implementation.
32 + jest.mock('react', () => {
33 + // eslint-disable-next-line no-unused-vars
34 + const {startTransition, ...otherExports} = jest.requireActual('react');
35 + return otherExports;
36 + });
37 +
38 + React = require('react');
39 + ReactNoop = require('react-noop-renderer');
40 + Scheduler = require('scheduler');
41 + useState = React.useState;
42 + useEffect = React.useEffect;
43 + useLayoutEffect = React.useLayoutEffect;
44 +
45 + const internalAct = require('jest-react').act;
46 +
47 + // The internal act implementation doesn't batch updates by default, since
48 + // it's mostly used to test concurrent mode. But since these tests run
49 + // in both concurrent and legacy mode, I'm adding batching here.
50 + act = cb => internalAct(() => ReactNoop.batchedUpdates(cb));
51 +
52 + useSyncExternalStore = require('use-sync-external-store')
53 + .useSyncExternalStore;
54 + useSyncExternalStoreExtra = require('use-sync-external-store/extra')
55 + .useSyncExternalStoreExtra;
56 + });
57 +
58 + function Text({text}) {
59 + Scheduler.unstable_yieldValue(text);
60 + return text;
61 + }
62 +
63 + function createRoot(element) {
64 + // This wrapper function exists so we can test both legacy roots and
65 + // concurrent roots.
66 + //
67 + // TODO: Once the built-in API exists, conditionally test the concurrent
68 + // root API, too.
69 + const root = ReactNoop.createLegacyRoot();
70 + act(() => {
71 + root.render(element);
72 + });
73 + return root;
74 + }
75 +
76 + function createExternalStore(initialState) {
77 + const listeners = new Set();
78 + let currentState = initialState;
79 + return {
80 + set(text) {
81 + currentState = text;
82 + ReactNoop.batchedUpdates(() => {
83 + listeners.forEach(listener => listener());
84 + });
85 + },
86 + subscribe(listener) {
87 + listeners.add(listener);
88 + return () => listeners.delete(listener);
89 + },
90 + getState() {
91 + return currentState;
92 + },
93 + getSubscriberCount() {
94 + return listeners.size;
95 + },
96 + };
97 + }
98 +
99 + test('basic usage', () => {
100 + const store = createExternalStore('Initial');
101 +
102 + function App() {
103 + const text = useSyncExternalStore(store.subscribe, store.getState);
104 + return <Text text={text} />;
105 + }
106 +
107 + const root = createRoot(<App />);
108 +
109 + expect(Scheduler).toHaveYielded(['Initial']);
110 + expect(root).toMatchRenderedOutput('Initial');
111 +
112 + act(() => {
113 + store.set('Updated');
114 + });
115 + expect(Scheduler).toHaveYielded(['Updated']);
116 + expect(root).toMatchRenderedOutput('Updated');
117 + });
118 +
119 + test('skips re-rendering if nothing changes', () => {
120 + const store = createExternalStore('Initial');
121 +
122 + function App() {
123 + const text = useSyncExternalStore(store.subscribe, store.getState);
124 + return <Text text={text} />;
125 + }
126 +
127 + const root = createRoot(<App />);
128 +
129 + expect(Scheduler).toHaveYielded(['Initial']);
130 + expect(root).toMatchRenderedOutput('Initial');
131 +
132 + // Update to the same value
133 + act(() => {
134 + store.set('Initial');
135 + });
136 + // Should not re-render
137 + expect(Scheduler).toHaveYielded([]);
138 + expect(root).toMatchRenderedOutput('Initial');
139 + });
140 +
141 + test('switch to a different store', () => {
142 + const storeA = createExternalStore(0);
143 + const storeB = createExternalStore(0);
144 +
145 + let setStore;
146 + function App() {
147 + const [store, _setStore] = useState(storeA);
148 + setStore = _setStore;
149 + const value = useSyncExternalStore(store.subscribe, store.getState);
150 + return <Text text={value} />;
151 + }
152 +
153 + const root = createRoot(<App />);
154 +
155 + expect(Scheduler).toHaveYielded([0]);
156 + expect(root).toMatchRenderedOutput('0');
157 +
158 + act(() => {
159 + storeA.set(1);
160 + });
161 + expect(Scheduler).toHaveYielded([1]);
162 + expect(root).toMatchRenderedOutput('1');
163 +
164 + // Switch stores
165 + act(() => {
166 + // This update will be disregarded
167 + storeA.set(2);
168 + setStore(storeB);
169 + });
170 + // Now reading from B instead of A
171 + expect(Scheduler).toHaveYielded([0]);
172 + expect(root).toMatchRenderedOutput('0');
173 +
174 + // Update A
175 + act(() => {
176 + storeA.set(3);
177 + });
178 + // Nothing happened, because we're no longer subscribed to A
179 + expect(Scheduler).toHaveYielded([]);
180 + expect(root).toMatchRenderedOutput('0');
181 +
182 + // Update B
183 + act(() => {
184 + storeB.set(1);
185 + });
186 + expect(Scheduler).toHaveYielded([1]);
187 + expect(root).toMatchRenderedOutput('1');
188 + });
189 +
190 + test('selecting a specific value inside getSnapshot', () => {
191 + const store = createExternalStore({a: 0, b: 0});
192 +
193 + function A() {
194 + const a = useSyncExternalStore(store.subscribe, () => store.getState().a);
195 + return <Text text={'A' + a} />;
196 + }
197 + function B() {
198 + const b = useSyncExternalStore(store.subscribe, () => store.getState().b);
199 + return <Text text={'B' + b} />;
200 + }
201 +
202 + function App() {
203 + return (
204 + <>
205 + <A />
206 + <B />
207 + </>
208 + );
209 + }
210 +
211 + const root = createRoot(<App />);
212 +
213 + expect(Scheduler).toHaveYielded(['A0', 'B0']);
214 + expect(root).toMatchRenderedOutput('A0B0');
215 +
216 + // Update b but not a
217 + act(() => {
218 + store.set({a: 0, b: 1});
219 + });
220 + // Only b re-renders
221 + expect(Scheduler).toHaveYielded(['B1']);
222 + expect(root).toMatchRenderedOutput('A0B1');
223 +
224 + // Update a but not b
225 + act(() => {
226 + store.set({a: 1, b: 1});
227 + });
228 + // Only a re-renders
229 + expect(Scheduler).toHaveYielded(['A1']);
230 + expect(root).toMatchRenderedOutput('A1B1');
231 + });
232 +
233 + test(
234 + "compares to current state before bailing out, even when there's a " +
235 + 'mutation in between the sync and passive effects',
236 + () => {
237 + const store = createExternalStore(0);
238 +
239 + function App() {
240 + const value = useSyncExternalStore(store.subscribe, store.getState);
241 + useEffect(() => {
242 + Scheduler.unstable_yieldValue('Passive effect: ' + value);
243 + }, [value]);
244 + return <Text text={value} />;
245 + }
246 +
247 + const root = createRoot(<App />);
248 + expect(Scheduler).toHaveYielded([0, 'Passive effect: 0']);
249 +
250 + // Schedule an update. We'll intentionally not use `act` so that we can
251 + // insert a mutation before React subscribes to the store in a
252 + // passive effect.
253 + store.set(1);
254 + expect(Scheduler).toHaveYielded([
255 + 1,
256 + // Passive effect hasn't fired yet
257 + ]);
258 + expect(root).toMatchRenderedOutput('1');
259 +
260 + // Flip the store state back to the previous value.
261 + store.set(0);
262 + expect(Scheduler).toHaveYielded([
263 + 'Passive effect: 1',
264 + // Re-render. If the current state were tracked by updating a ref in a
265 + // passive effect, then this would break because the previous render's
266 + // passive effect hasn't fired yet, so we'd incorrectly think that
267 + // the state hasn't changed.
268 + 0,
269 + ]);
270 + // Should flip back to 0
271 + expect(root).toMatchRenderedOutput('0');
272 + },
273 + );
274 +
275 + test('mutating the store in between render and commit when getSnapshot has changed', () => {
276 + const store = createExternalStore({a: 1, b: 1});
277 +
278 + const getSnapshotA = () => store.getState().a;
279 + const getSnapshotB = () => store.getState().b;
280 +
281 + function Child1({step}) {
282 + const value = useSyncExternalStore(store.subscribe, store.getState);
283 + useLayoutEffect(() => {
284 + if (step === 1) {
285 + // Update B in a layout effect. This happens in the same commit
286 + // that changed the getSnapshot in Child2. Child2's effects haven't
287 + // fired yet, so it doesn't have access to the latest getSnapshot. So
288 + // it can't use the getSnapshot to bail out.
289 + Scheduler.unstable_yieldValue('Update B in commit phase');
290 + store.set({a: value.a, b: 2});
291 + }
292 + }, [step]);
293 + return null;
294 + }
295 +
296 + function Child2({step}) {
297 + const label = step === 0 ? 'A' : 'B';
298 + const getSnapshot = step === 0 ? getSnapshotA : getSnapshotB;
299 + const value = useSyncExternalStore(store.subscribe, getSnapshot);
300 + return <Text text={label + value} />;
301 + }
302 +
303 + let setStep;
304 + function App() {
305 + const [step, _setStep] = useState(0);
306 + setStep = _setStep;
307 + return (
308 + <>
309 + <Child1 step={step} />
310 + <Child2 step={step} />
311 + </>
312 + );
313 + }
314 +
315 + const root = createRoot(<App />);
316 + expect(Scheduler).toHaveYielded(['A1']);
317 + expect(root).toMatchRenderedOutput('A1');
318 +
319 + act(() => {
320 + // Change getSnapshot and update the store in the same batch
321 + setStep(1);
322 + });
323 + expect(Scheduler).toHaveYielded([
324 + 'B1',
325 + 'Update B in commit phase',
326 + // If Child2 had used the old getSnapshot to bail out, then it would have
327 + // incorrectly bailed out here instead of re-rendering.
328 + 'B2',
329 + ]);
330 + expect(root).toMatchRenderedOutput('B2');
331 + });
332 +
333 + test('mutating the store in between render and commit when getSnapshot has _not_ changed', () => {
334 + // Same as previous test, but `getSnapshot` does not change
335 + const store = createExternalStore({a: 1, b: 1});
336 +
337 + const getSnapshotA = () => store.getState().a;
338 +
339 + function Child1({step}) {
340 + const value = useSyncExternalStore(store.subscribe, store.getState);
341 + useLayoutEffect(() => {
342 + if (step === 1) {
343 + // Update B in a layout effect. This happens in the same commit
344 + // that changed the getSnapshot in Child2. Child2's effects haven't
345 + // fired yet, so it doesn't have access to the latest getSnapshot. So
346 + // it can't use the getSnapshot to bail out.
347 + Scheduler.unstable_yieldValue('Update B in commit phase');
348 + store.set({a: value.a, b: 2});
349 + }
350 + }, [step]);
351 + return null;
352 + }
353 +
354 + function Child2({step}) {
355 + const value = useSyncExternalStore(store.subscribe, getSnapshotA);
356 + return <Text text={'A' + value} />;
357 + }
358 +
359 + let setStep;
360 + function App() {
361 + const [step, _setStep] = useState(0);
362 + setStep = _setStep;
363 + return (
364 + <>
365 + <Child1 step={step} />
366 + <Child2 step={step} />
367 + </>
368 + );
369 + }
370 +
371 + const root = createRoot(<App />);
372 + expect(Scheduler).toHaveYielded(['A1']);
373 + expect(root).toMatchRenderedOutput('A1');
374 +
375 + // This will cause a layout effect, and in the layout effect we'll update
376 + // the store
377 + act(() => {
378 + setStep(1);
379 + });
380 + expect(Scheduler).toHaveYielded([
381 + 'A1',
382 + // This updates B, but since Child2 doesn't subscribe to B, it doesn't
383 + // need to re-render.
384 + 'Update B in commit phase',
385 + // No re-render
386 + ]);
387 + expect(root).toMatchRenderedOutput('A1');
388 + });
389 +
390 + test("does not bail out if the previous update hasn't finished yet", () => {
391 + const store = createExternalStore(0);
392 +
393 + function Child1() {
394 + const value = useSyncExternalStore(store.subscribe, store.getState);
395 + useLayoutEffect(() => {
396 + if (value === 1) {
397 + Scheduler.unstable_yieldValue('Reset back to 0');
398 + store.set(0);
399 + }
400 + }, [value]);
401 + return <Text text={value} />;
402 + }
403 +
404 + function Child2() {
405 + const value = useSyncExternalStore(store.subscribe, store.getState);
406 + return <Text text={value} />;
407 + }
408 +
409 + const root = createRoot(
410 + <>
411 + <Child1 />
412 + <Child2 />
413 + </>,
414 + );
415 + expect(Scheduler).toHaveYielded([0, 0]);
416 + expect(root).toMatchRenderedOutput('00');
417 +
418 + act(() => {
419 + store.set(1);
420 + });
421 + expect(Scheduler).toHaveYielded([1, 1, 'Reset back to 0', 0, 0]);
422 + expect(root).toMatchRenderedOutput('00');
423 + });
424 +
425 + test('uses the latest getSnapshot, even if it changed in the same batch as a store update', () => {
426 + const store = createExternalStore({a: 0, b: 0});
427 +
428 + const getSnapshotA = () => store.getState().a;
429 + const getSnapshotB = () => store.getState().b;
430 +
431 + let setGetSnapshot;
432 + function App() {
433 + const [getSnapshot, _setGetSnapshot] = useState(() => getSnapshotA);
434 + setGetSnapshot = _setGetSnapshot;
435 + const text = useSyncExternalStore(store.subscribe, getSnapshot);
436 + return <Text text={text} />;
437 + }
438 +
439 + const root = createRoot(<App />);
440 + expect(Scheduler).toHaveYielded([0]);
441 +
442 + // Update the store and getSnapshot at the same time
443 + act(() => {
444 + setGetSnapshot(() => getSnapshotB);
445 + store.set({a: 1, b: 2});
446 + });
447 + // It should read from B instead of A
448 + expect(Scheduler).toHaveYielded([2]);
449 + expect(root).toMatchRenderedOutput('2');
450 + });
451 +
452 + test('handles errors thrown by getSnapshot or isEqual', () => {
453 + class ErrorBoundary extends React.Component {
454 + state = {error: null};
455 + static getDerivedStateFromError(error) {
456 + return {error};
457 + }
458 + render() {
459 + if (this.state.error) {
460 + return <Text text={this.state.error.message} />;
461 + }
462 + return this.props.children;
463 + }
464 + }
465 +
466 + const store = createExternalStore({
467 + value: 0,
468 + throwInGetSnapshot: false,
469 + throwInIsEqual: false,
470 + });
471 +
472 + function App() {
473 + const {value} = useSyncExternalStore(
474 + store.subscribe,
475 + () => {
476 + const state = store.getState();
477 + if (state.throwInGetSnapshot) {
478 + throw new Error('Error in getSnapshot');
479 + }
480 + return state;
481 + },
482 + {
483 + isEqual: (a, b) => {
484 + if (a.throwInIsEqual || b.throwInIsEqual) {
485 + throw new Error('Error in isEqual');
486 + }
487 + return a.value === b.value;
488 + },
489 + },
490 + );
491 + return <Text text={value} />;
492 + }
493 +
494 + const errorBoundary = React.createRef(null);
495 + const root = createRoot(
496 + <ErrorBoundary ref={errorBoundary}>
497 + <App />
498 + </ErrorBoundary>,
499 + );
500 + expect(Scheduler).toHaveYielded([0]);
501 + expect(root).toMatchRenderedOutput('0');
502 +
503 + // Update that throws in a getSnapshot. We can catch it with an error boundary.
504 + act(() => {
505 + store.set({value: 1, throwInGetSnapshot: true, throwInIsEqual: false});
506 + });
507 + expect(Scheduler).toHaveYielded(['Error in getSnapshot']);
508 + expect(root).toMatchRenderedOutput('Error in getSnapshot');
509 +
510 + // Clear the error.
511 + act(() => {
512 + store.set({value: 1, throwInGetSnapshot: false, throwInIsEqual: false});
513 + errorBoundary.current.setState({error: null});
514 + });
515 + expect(Scheduler).toHaveYielded([1]);
516 + expect(root).toMatchRenderedOutput('1');
517 +
518 + // Update that throws in isEqual. Since isEqual only prevents a bail out,
519 + // we don't need to surface an error. But we do have to re-render.
520 + act(() => {
521 + store.set({value: 1, throwInGetSnapshot: false, throwInIsEqual: true});
522 + });
523 + expect(Scheduler).toHaveYielded([1]);
524 + expect(root).toMatchRenderedOutput('1');
525 + });
526 +
527 + describe('extra features implemented in user-space', () => {
528 + test('memoized selectors are only called once per update', () => {
529 + const store = createExternalStore({a: 0, b: 0});
530 +
531 + function selector(state) {
532 + Scheduler.unstable_yieldValue('Selector');
533 + return state.a;
534 + }
535 +
536 + function App() {
537 + Scheduler.unstable_yieldValue('App');
538 + const a = useSyncExternalStoreExtra(
539 + store.subscribe,
540 + store.getState,
541 + selector,
542 + );
543 + return <Text text={'A' + a} />;
544 + }
545 +
546 + const root = createRoot(<App />);
547 +
548 + expect(Scheduler).toHaveYielded(['App', 'Selector', 'A0']);
549 + expect(root).toMatchRenderedOutput('A0');
550 +
551 + // Update the store
552 + act(() => {
553 + store.set({a: 1, b: 0});
554 + });
555 + expect(Scheduler).toHaveYielded([
556 + // The selector runs before React starts rendering
557 + 'Selector',
558 + 'App',
559 + // And because the selector didn't change during render, we can reuse
560 + // the previous result without running the selector again
561 + 'A1',
562 + ]);
563 + expect(root).toMatchRenderedOutput('A1');
564 + });
565 +
566 + test('Using isEqual to bailout', () => {
567 + const store = createExternalStore({a: 0, b: 0});
568 +
569 + function A() {
570 + const {a} = useSyncExternalStoreExtra(
571 + store.subscribe,
572 + store.getState,
573 + state => ({a: state.a}),
574 + (state1, state2) => state1.a === state2.a,
575 + );
576 + return <Text text={'A' + a} />;
577 + }
578 + function B() {
579 + const {b} = useSyncExternalStoreExtra(
580 + store.subscribe,
581 + store.getState,
582 + state => {
583 + return {b: state.b};
584 + },
585 + (state1, state2) => state1.b === state2.b,
586 + );
587 + return <Text text={'B' + b} />;
588 + }
589 +
590 + function App() {
591 + return (
592 + <>
593 + <A />
594 + <B />
595 + </>
596 + );
597 + }
598 +
599 + const root = createRoot(<App />);
600 +
601 + expect(Scheduler).toHaveYielded(['A0', 'B0']);
602 + expect(root).toMatchRenderedOutput('A0B0');
603 +
604 + // Update b but not a
605 + act(() => {
606 + store.set({a: 0, b: 1});
607 + });
608 + // Only b re-renders
609 + expect(Scheduler).toHaveYielded(['B1']);
610 + expect(root).toMatchRenderedOutput('A0B1');
611 +
612 + // Update a but not b
613 + act(() => {
614 + store.set({a: 1, b: 1});
615 + });
616 + // Only a re-renders
617 + expect(Scheduler).toHaveYielded(['A1']);
618 + expect(root).toMatchRenderedOutput('A1B1');
619 + });
620 + });
621 +});
packages/use-sync-external-store/src/useSyncExternalStore.js
+128 -2
@@ -7,6 +7,132 @@
7 * @flow
8 */
9
10 -export function useSyncExternalStore() {
11 - throw new Error('Not yet implemented');
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 {
16 + useState,
17 + useEffect,
18 + useLayoutEffect,
19 + useDebugValue,
20 +
21 + // $FlowFixMe - useSyncExternalStore not yet part of React Flow types
22 + useSyncExternalStore: builtInAPI,
23 +} = React;
24 +
25 +// Prefer the built-in API, if it exists. If it doesn't exist, then we assume
26 +// we're in version 16 or 17, so rendering is always synchronous. The shim
27 +// does not support concurrent rendering, only the built-in API.
28 +export const useSyncExternalStore =
29 + builtInAPI !== undefined ? builtInAPI : useSyncExternalStore_shim;
30 +
31 +let didWarnOld18Alpha = false;
32 +
33 +// Disclaimer: This shim breaks many of the rules of React, and only works
34 +// because of a very particular set of implementation details and assumptions
35 +// -- change any one of them and it will break. The most important assumption
36 +// is that updates are always synchronous, because concurrent rendering is
37 +// only available in versions of React that also have a built-in
38 +// useSyncExternalStore API. And we only use this shim when the built-in API
39 +// does not exist.
40 +//
41 +// Do not assume that the clever hacks used by this hook also work in general.
42 +// The point of this shim is to replace the need for hacks by other libraries.
43 +function useSyncExternalStore_shim<T>(
44 + subscribe: (() => void) => () => void,
45 + getSnapshot: () => T,
46 +): T {
47 + if (__DEV__) {
48 + if (!didWarnOld18Alpha) {
49 + if (React.startTransition !== undefined) {
50 + didWarnOld18Alpha = true;
51 + console.error(
52 + 'You are using an outdated, pre-release alpha of React 18 that ' +
53 + 'does not support useSyncExternalStore. The ' +
54 + 'use-sync-external-store shim will not work correctly. Upgrade ' +
55 + 'to a newer pre-release.',
56 + );
57 + }
58 + }
59 + }
60 +
61 + // Read the current snapshot from the store on every render. Again, this
62 + // breaks the rules of React, and only works here because of specific
63 + // implementation details, most importantly that updates are
64 + // always synchronous.
65 + const value = getSnapshot();
66 +
67 + // Because updates are synchronous, we don't queue them. Instead we force a
68 + // re-render whenever the subscribed state changes by updating an some
69 + // arbitrary useState hook. Then, during render, we call getSnapshot to read
70 + // the current value.
71 + //
72 + // Because we don't actually use the state returned by the useState hook, we
73 + // can save a bit of memory by storing other stuff in that slot.
74 + //
75 + // To implement the early bailout, we need to track some things on a mutable
76 + // object. Usually, we would put that in a useRef hook, but we can stash it in
77 + // our useState hook instead.
78 + //
79 + // To force a re-render, we call forceUpdate({inst}). That works because the
80 + // new object always fails an equality check.
81 + const [{inst}, forceUpdate] = useState({inst: {value, getSnapshot}});
82 +
83 + // Track the latest getSnapshot function with a ref. This needs to be updated
84 + // in the layout phase so we can access it during the tearing check that
85 + // happens on subscribe.
86 + // TODO: Circumvent SSR warning
87 + useLayoutEffect(() => {
88 + inst.value = value;
89 + inst.getSnapshot = getSnapshot;
90 +
91 + // Whenever getSnapshot or subscribe changes, we need to check in the
92 + // commit phase if there was an interleaved mutation. In concurrent mode
93 + // this can happen all the time, but even in synchronous mode, an earlier
94 + // effect may have mutated the store.
95 + if (checkIfSnapshotChanged(inst)) {
96 + // Force a re-render.
97 + forceUpdate({inst});
98 + }
99 + }, [subscribe, value, getSnapshot]);
100 +
101 + useEffect(() => {
102 + // Check for changes right before subscribing. Subsequent changes will be
103 + // detected in the subscription handler.
104 + if (checkIfSnapshotChanged(inst)) {
105 + // Force a re-render.
106 + forceUpdate({inst});
107 + }
108 + const handleStoreChange = () => {
109 + // TODO: Because there is no cross-renderer API for batching updates, it's
110 + // up to the consumer of this library to wrap their subscription event
111 + // with unstable_batchedUpdates. Should we try to detect when this isn't
112 + // the case and print a warning in development?
113 +
114 + // The store changed. Check if the snapshot changed since the last time we
115 + // read from the store.
116 + if (checkIfSnapshotChanged(inst)) {
117 + // Force a re-render.
118 + forceUpdate({inst});
119 + }
120 + };
121 + // Subscribe to the store and return a clean-up function.
122 + return subscribe(handleStoreChange);
123 + }, [subscribe]);
124 +
125 + useDebugValue(value);
126 + return value;
127 +}
128 +
129 +function checkIfSnapshotChanged(inst) {
130 + const latestGetSnapshot = inst.getSnapshot;
131 + const prevValue = inst.value;
132 + try {
133 + const nextValue = latestGetSnapshot();
134 + return !is(prevValue, nextValue);
135 + } catch (error) {
136 + return true;
137 + }
138 }
packages/use-sync-external-store/src/useSyncExternalStoreExtra.js new
+76
@@ -0,0 +1,76 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its 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 +import {useSyncExternalStore} from 'use-sync-external-store';
13 +
14 +// Intentionally not using named imports because Rollup uses dynamic
15 +// dispatch for CommonJS interop named imports.
16 +const {useMemo, useDebugValue} = React;
17 +
18 +// Same as useSyncExternalStore, but supports selector and isEqual arguments.
19 +export function useSyncExternalStoreExtra<Snapshot, Selection>(
20 + subscribe: (() => void) => () => void,
21 + getSnapshot: () => Snapshot,
22 + selector: (snapshot: Snapshot) => Selection,
23 + isEqual?: (a: Selection, b: Selection) => boolean,
24 +): Selection {
25 + const getSnapshotWithMemoizedSelector = useMemo(() => {
26 + // Track the memoized state using closure variables that are local to this
27 + // memoized instance of a getSnapshot function. Intentionally not using a
28 + // useRef hook, because that state would be shared across all concurrent
29 + // copies of the hook/component.
30 + let hasMemo = false;
31 + let memoizedSnapshot;
32 + let memoizedSelection;
33 + return () => {
34 + const nextSnapshot = getSnapshot();
35 +
36 + if (!hasMemo) {
37 + // The first time the hook is called, there is no memoized result.
38 + hasMemo = true;
39 + memoizedSnapshot = nextSnapshot;
40 + const nextSelection = selector(nextSnapshot);
41 + memoizedSelection = nextSelection;
42 + return nextSelection;
43 + }
44 +
45 + // We may be able to reuse the previous invocation's result.
46 + const prevSnapshot: Snapshot = (memoizedSnapshot: any);
47 + const prevSelection: Selection = (memoizedSelection: any);
48 +
49 + if (is(prevSnapshot, nextSnapshot)) {
50 + // The snapshot is the same as last time. Reuse the previous selection.
51 + return prevSelection;
52 + }
53 +
54 + // The snapshot has changed, so we need to compute a new selection.
55 + memoizedSnapshot = nextSnapshot;
56 + const nextSelection = selector(nextSnapshot);
57 +
58 + // If a custom isEqual function is provided, use that to check if the data
59 + // has changed. If it hasn't, return the previous selection. That signals
60 + // to React that the selections are conceptually equal, and we can bail
61 + // out of rendering.
62 + if (isEqual !== undefined && isEqual(prevSelection, nextSelection)) {
63 + return prevSelection;
64 + }
65 +
66 + memoizedSelection = nextSelection;
67 + return nextSelection;
68 + };
69 + }, [getSnapshot, selector, isEqual]);
70 + const value = useSyncExternalStore(
71 + subscribe,
72 + getSnapshotWithMemoizedSelector,
73 + );
74 + useDebugValue(value);
75 + return value;
76 +}
scripts/rollup/bundles.js
+10 -1
@@ -684,7 +684,7 @@ const bundles = [
684 externals: ['react'],
685 },
686
687 - /******* Shim for useSyncExternalState *******/
687 + /******* Shim for useSyncExternalStore *******/
688 {
689 bundleTypes: [NODE_DEV, NODE_PROD],
690 moduleType: ISOMORPHIC,
@@ -693,6 +693,15 @@ const bundles = [
693 externals: ['react'],
694 },
695
696 + /******* Shim for useSyncExternalStore (+ extra user-space features) *******/
697 + {
698 + bundleTypes: [NODE_DEV, NODE_PROD],
699 + moduleType: ISOMORPHIC,
700 + entry: 'use-sync-external-store/extra',
701 + global: 'useSyncExternalStoreExtra',
702 + externals: ['react', 'use-sync-external-store'],
703 + },
704 +
705 /******* React Scheduler (experimental) *******/
706 {
707 bundleTypes: [