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.