@samitouri / QOS-React-2 / commits / 4997515b96

Point useSubscription to useSyncExternalStore shim (#24289)

* Point useSubscription to useSyncExternalStore shim * Update tests * Update README * Ad hoc case

dan committed Apr 11, 2022 at 21:15 UTC 4997515b96eede5ab1ca622e0439a0707f8d4afd
6 files changed +22 -139
packages/use-subscription/README.md
+2 -26
@@ -1,32 +1,8 @@
1 # use-subscription
2
3 -React hook that safely manages subscriptions in concurrent mode.
3 +React Hook for subscribing to external data sources.
4
5 -This utility can be used for subscriptions to a single value that are typically only read in one place and may update frequently (e.g. a component that subscribes to a geolocation API to show a dot on a map).
6 -
7 -## When should you NOT use this?
8 -
9 -Most other cases have **better long-term solutions**:
10 -* Redux/Flux stores should use the [context API](https://reactjs.org/docs/context.html) instead.
11 -* I/O subscriptions (e.g. notifications) that update infrequently should use a mechanism like [`react-cache`](https://github.com/facebook/react/blob/main/packages/react-cache/README.md) instead.
12 -* Complex libraries like Relay/Apollo should manage subscriptions manually with the same techniques which this library uses under the hood (as referenced [here](https://gist.github.com/bvaughn/d569177d70b50b58bff69c3c4a5353f3)) in a way that is most optimized for their library usage.
13 -
14 -## Limitations in concurrent mode
15 -
16 -`use-subscription` is safe to use in concurrent mode. However, [it achieves correctness by sometimes de-opting to synchronous mode](https://github.com/facebook/react/issues/13186#issuecomment-403959161), obviating the benefits of concurrent rendering. This is an inherent limitation of storing state outside of React's managed state queue and rendering in response to a change event.
17 -
18 -The effect of de-opting to sync mode is that the main thread may periodically be blocked (in the case of CPU-bound work), and placeholders may appear earlier than desired (in the case of IO-bound work).
19 -
20 -For **full compatibility** with concurrent rendering, including both **time-slicing** and **React Suspense**, the suggested longer-term solution is to move to one of the patterns described in the previous section.
21 -
22 -## What types of subscriptions can this support?
23 -
24 -This abstraction can handle a variety of subscription types, including:
25 -* Event dispatchers like `HTMLInputElement`.
26 -* Custom pub/sub components like Relay's `FragmentSpecResolver`.
27 -* Observable types like RxJS `BehaviorSubject` and `ReplaySubject`. (Types like RxJS `Subject` or `Observable` are not supported, because they provide no way to read the "current" value after it has been emitted.)
28 -
29 -Note that JavaScript promises are also **not supported** because they provide no way to synchronously read the "current" value.
5 +**You may now migrate to [`use-sync-external-store`](https://www.npmjs.com/package/use-sync-external-store) directly instead, which has the same API as [`React.useSyncExternalStore`](https://reactjs.org/docs/hooks-reference.html#usesyncexternalstore). The `use-subscription` package is now a thin wrapper over `use-sync-external-store` and will not be updated further.**
6
7 # Installation
8
packages/use-subscription/package.json
+4 -1
@@ -15,9 +15,12 @@
15 ],
16 "license": "MIT",
17 "peerDependencies": {
18 - "react": "^18.0.0"
18 + "react": "^16.8.0 || ^17.0.0 || ^18.0.0"
19 },
20 "devDependencies": {
21 "rxjs": "^5.5.6"
22 + },
23 + "dependencies": {
24 + "use-sync-external-store": "^1.0.0"
25 }
26 }
packages/use-subscription/src/__tests__/useSubscription-test.js
+8 -13
@@ -457,17 +457,13 @@ describe('useSubscription', () => {
457 renderer.update(<Parent observed={observableA} />);
458
459 // Flush everything and ensure that the correct subscribable is used
460 - // We expect the new subscribable to finish rendering,
461 - // But then the updated values from the old subscribable should be used.
460 expect(Scheduler).toFlushAndYield([
463 - 'Grandchild: b-0',
461 + 'Child: a-2',
462 + 'Grandchild: a-2',
463 'Child: a-2',
464 'Grandchild: a-2',
465 ]);
467 - expect(log).toEqual([
468 - 'Parent.componentDidUpdate:b-0',
469 - 'Parent.componentDidUpdate:a-2',
470 - ]);
466 + expect(log).toEqual(['Parent.componentDidUpdate:a-2']);
467 });
468
469 // Updates from the new subscribable should be ignored.
@@ -628,7 +624,10 @@ describe('useSubscription', () => {
624 } else {
625 mutate('C');
626 }
631 - expect(Scheduler).toFlushAndYieldThrough(['render:first:C']);
627 + expect(Scheduler).toFlushAndYieldThrough([
628 + 'render:first:C',
629 + 'render:second:C',
630 + ]);
631 if (gate(flags => flags.enableSyncDefaultUpdates)) {
632 React.startTransition(() => {
633 mutate('D');
@@ -636,11 +635,7 @@ describe('useSubscription', () => {
635 } else {
636 mutate('D');
637 }
639 - expect(Scheduler).toFlushAndYield([
640 - 'render:second:C',
641 - 'render:first:D',
642 - 'render:second:D',
643 - ]);
638 + expect(Scheduler).toFlushAndYield(['render:first:D', 'render:second:D']);
639
640 // No more pending updates
641 jest.runAllTimers();
packages/use-subscription/src/useSubscription.js
+2 -97
@@ -7,7 +7,7 @@
7 * @flow
8 */
9
10 -import {useDebugValue, useEffect, useState} from 'react';
10 +import {useSyncExternalStore} from 'use-sync-external-store/shim';
11
12 // Hook used for safely managing subscriptions in concurrent mode.
13 //
@@ -26,100 +26,5 @@ export function useSubscription<Value>({
26 getCurrentValue: () => Value,
27 subscribe: (callback: Function) => () => void,
28 |}): Value {
29 - // Read the current value from our subscription.
30 - // When this value changes, we'll schedule an update with React.
31 - // It's important to also store the hook params so that we can check for staleness.
32 - // (See the comment in checkForUpdates() below for more info.)
33 - const [state, setState] = useState(() => ({
34 - getCurrentValue,
35 - subscribe,
36 - value: getCurrentValue(),
37 - }));
38 -
39 - let valueToReturn = state.value;
40 -
41 - // If parameters have changed since our last render, schedule an update with its current value.
42 - if (
43 - state.getCurrentValue !== getCurrentValue ||
44 - state.subscribe !== subscribe
45 - ) {
46 - // If the subscription has been updated, we'll schedule another update with React.
47 - // React will process this update immediately, so the old subscription value won't be committed.
48 - // It is still nice to avoid returning a mismatched value though, so let's override the return value.
49 - valueToReturn = getCurrentValue();
50 -
51 - setState({
52 - getCurrentValue,
53 - subscribe,
54 - value: valueToReturn,
55 - });
56 - }
57 -
58 - // Display the current value for this hook in React DevTools.
59 - useDebugValue(valueToReturn);
60 -
61 - // It is important not to subscribe while rendering because this can lead to memory leaks.
62 - // (Learn more at reactjs.org/docs/strict-mode.html#detecting-unexpected-side-effects)
63 - // Instead, we wait until the commit phase to attach our handler.
64 - //
65 - // We intentionally use a passive effect (useEffect) rather than a synchronous one (useLayoutEffect)
66 - // so that we don't stretch the commit phase.
67 - // This also has an added benefit when multiple components are subscribed to the same source:
68 - // It allows each of the event handlers to safely schedule work without potentially removing an another handler.
69 - // (Learn more at https://codesandbox.io/s/k0yvr5970o)
70 - useEffect(() => {
71 - let didUnsubscribe = false;
72 -
73 - const checkForUpdates = () => {
74 - // It's possible that this callback will be invoked even after being unsubscribed,
75 - // if it's removed as a result of a subscription event/update.
76 - // In this case, React will log a DEV warning about an update from an unmounted component.
77 - // We can avoid triggering that warning with this check.
78 - if (didUnsubscribe) {
79 - return;
80 - }
81 -
82 - // We use a state updater function to avoid scheduling work for a stale source.
83 - // However it's important to eagerly read the currently value,
84 - // so that all scheduled work shares the same value (in the event of multiple subscriptions).
85 - // This avoids visual "tearing" when a mutation happens during a (concurrent) render.
86 - const value = getCurrentValue();
87 -
88 - setState(prevState => {
89 - // Ignore values from stale sources!
90 - // Since we subscribe an unsubscribe in a passive effect,
91 - // it's possible that this callback will be invoked for a stale (previous) subscription.
92 - // This check avoids scheduling an update for that stale subscription.
93 - if (
94 - prevState.getCurrentValue !== getCurrentValue ||
95 - prevState.subscribe !== subscribe
96 - ) {
97 - return prevState;
98 - }
99 -
100 - // Some subscriptions will auto-invoke the handler, even if the value hasn't changed.
101 - // If the value hasn't changed, no update is needed.
102 - // Return state as-is so React can bail out and avoid an unnecessary render.
103 - if (prevState.value === value) {
104 - return prevState;
105 - }
106 -
107 - return {...prevState, value};
108 - });
109 - };
110 - const unsubscribe = subscribe(checkForUpdates);
111 -
112 - // Because we're subscribing in a passive effect,
113 - // it's possible that an update has occurred between render and our effect handler.
114 - // Check for this and schedule an update if work has occurred.
115 - checkForUpdates();
116 -
117 - return () => {
118 - didUnsubscribe = true;
119 - unsubscribe();
120 - };
121 - }, [getCurrentValue, subscribe]);
122 -
123 - // Return the current value for our caller to use while rendering.
124 - return valueToReturn;
29 + return useSyncExternalStore(subscribe, getCurrentValue);
30 }
packages/use-sync-external-store/package.json
+1 -1
@@ -19,6 +19,6 @@
19 ],
20 "license": "MIT",
21 "peerDependencies": {
22 - "react": "^16.8.0 || ^17.0.0 || ^18.0.0-rc"
22 + "react": "^16.8.0 || ^17.0.0 || ^18.0.0"
23 }
24 }
scripts/rollup/build-all-release-channels.js
+5 -1
@@ -251,7 +251,11 @@ function updatePackageVersions(
251 }
252 }
253 if (packageInfo.peerDependencies) {
254 - if (!pinToExactVersion && moduleName === 'use-sync-external-store') {
254 + if (
255 + !pinToExactVersion &&
256 + (moduleName === 'use-sync-external-store' ||
257 + moduleName === 'use-subscription')
258 + ) {
259 // use-sync-external-store supports older versions of React, too, so
260 // we don't override to the latest version. We should figure out some
261 // better way to handle this.