@samitouri / QOS-React-2 / commits / ce883a19d8

useSubscription hook (#15022)

* Added use-subscription package with README

Brian Vaughn committed Jul 16, 2019 at 15:59 UTC ce883a19d845e1faf8d4e1587e7022feda66210a
6 files changed +861
packages/use-subscription/README.md new
+132
@@ -0,0 +1,132 @@
1 +# use-subscription
2 +
3 +React hook that safely manages subscriptions in concurrent mode.
4 +
5 +## When should you NOT use this?
6 +
7 +This utility should 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).
8 +
9 +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/master/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.
30 +
31 +# Installation
32 +
33 +```sh
34 +# Yarn
35 +yarn add use-subscription
36 +
37 +# NPM
38 +npm install use-subscription
39 +```
40 +
41 +# Usage
42 +
43 +To configure a subscription, you must provide two methods: `getCurrentValue` and `subscribe`.
44 +
45 +In order to avoid removing and re-adding subscriptions each time this hook is called, the parameters passed to this hook should be memoized. This can be done by wrapping the entire subscription with `useMemo()`, or by wrapping the individual callbacks with `useCallback()`.
46 +
47 +## Subscribing to event dispatchers
48 +
49 +Below is an example showing how `use-subscription` can be used to subscribe to event dispatchers such as DOM elements.
50 +
51 +```js
52 +import React, { useMemo } from "react";
53 +import { useSubscription } from "use-subscription";
54 +
55 +// In this example, "input" is an event dispatcher (e.g. an HTMLInputElement)
56 +// but it could be anything that emits an event and has a readable current value.
57 +function Example({ input }) {
58 +
59 + // Memoize to avoid removing and re-adding subscriptions each time this hook is called.
60 + const subscription = useMemo(
61 + () => ({
62 + getCurrentValue: () => input.value,
63 + subscribe: callback => {
64 + input.addEventListener("change", callback);
65 + return () => input.removeEventListener("change", callback);
66 + }
67 + }),
68 +
69 + // Re-subscribe any time our input changes
70 + // (e.g. we get a new HTMLInputElement prop to subscribe to)
71 + [input]
72 + );
73 +
74 + // The value returned by this hook reflects the input's current value.
75 + // Our component will automatically be re-rendered when that value changes.
76 + const value = useSubscription(subscription);
77 +
78 + // Your rendered output goes here ...
79 +}
80 +```
81 +
82 +## Subscribing to observables
83 +
84 +Below are examples showing how `use-subscription` can be used to subscribe to certain types of observables (e.g. RxJS `BehaviorSubject` and `ReplaySubject`).
85 +
86 +**Note** that it is not possible to support all observable types (e.g. RxJS `Subject` or `Observable`) because some provide no way to read the "current" value after it has been emitted.
87 +
88 +### `BehaviorSubject`
89 +```js
90 +const subscription = useMemo(
91 + () => ({
92 + getCurrentValue: () => behaviorSubject.getValue(),
93 + subscribe: callback => {
94 + const subscription = behaviorSubject.subscribe(callback);
95 + return () => subscription.unsubscribe();
96 + }
97 + }),
98 +
99 + // Re-subscribe any time the behaviorSubject changes
100 + [behaviorSubject]
101 +);
102 +
103 +const value = useSubscription(subscription);
104 +```
105 +
106 +### `ReplaySubject`
107 +```js
108 +const subscription = useMemo(
109 + () => ({
110 + getCurrentValue: () => {
111 + let currentValue;
112 + // ReplaySubject does not have a sync data getter,
113 + // So we need to temporarily subscribe to retrieve the most recent value.
114 + replaySubject
115 + .subscribe(value => {
116 + currentValue = value;
117 + })
118 + .unsubscribe();
119 + return currentValue;
120 + },
121 + subscribe: callback => {
122 + const subscription = replaySubject.subscribe(callback);
123 + return () => subscription.unsubscribe();
124 + }
125 + }),
126 +
127 + // Re-subscribe any time the replaySubject changes
128 + [replaySubject]
129 +);
130 +
131 +const value = useSubscription(subscription);
132 +```
packages/use-subscription/index.js new
+12
@@ -0,0 +1,12 @@
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 +'use strict';
11 +
12 +export * from './src/useSubscription';
packages/use-subscription/npm/index.js new
+7
@@ -0,0 +1,7 @@
1 +'use strict';
2 +
3 +if (process.env.NODE_ENV === 'production') {
4 + module.exports = require('./cjs/use-subscription.production.min.js');
5 +} else {
6 + module.exports = require('./cjs/use-subscription.development.js');
7 +}
packages/use-subscription/package.json new
+24
@@ -0,0 +1,24 @@
1 +{
2 + "private": true,
3 + "name": "use-subscription",
4 + "description": "Reusable hooks",
5 + "version": "0.0.0",
6 + "repository": {
7 + "type": "git",
8 + "url": "https://github.com/facebook/react.git",
9 + "directory": "packages/use-subscription"
10 + },
11 + "files": [
12 + "LICENSE",
13 + "README.md",
14 + "build-info.json",
15 + "index.js",
16 + "cjs/"
17 + ],
18 + "peerDependencies": {
19 + "react": "^16.8.0"
20 + },
21 + "devDependencies": {
22 + "rxjs": "^5.5.6"
23 + }
24 +}
packages/use-subscription/src/__tests__/useSubscription-test.internal.js new
+563
@@ -0,0 +1,563 @@
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 act;
13 +let useSubscription;
14 +let BehaviorSubject;
15 +let React;
16 +let ReactTestRenderer;
17 +let Scheduler;
18 +let ReplaySubject;
19 +
20 +describe('useSubscription', () => {
21 + beforeEach(() => {
22 + jest.resetModules();
23 + jest.mock('scheduler', () => require('scheduler/unstable_mock'));
24 +
25 + useSubscription = require('use-subscription').useSubscription;
26 + React = require('react');
27 + ReactTestRenderer = require('react-test-renderer');
28 + Scheduler = require('scheduler');
29 +
30 + act = ReactTestRenderer.act;
31 +
32 + BehaviorSubject = require('rxjs').BehaviorSubject;
33 + ReplaySubject = require('rxjs').ReplaySubject;
34 + });
35 +
36 + function createBehaviorSubject(initialValue) {
37 + const behaviorSubject = new BehaviorSubject();
38 + if (initialValue) {
39 + behaviorSubject.next(initialValue);
40 + }
41 + return behaviorSubject;
42 + }
43 +
44 + function createReplaySubject(initialValue) {
45 + const replaySubject = new ReplaySubject();
46 + if (initialValue) {
47 + replaySubject.next(initialValue);
48 + }
49 + return replaySubject;
50 + }
51 +
52 + it('supports basic subscription pattern', () => {
53 + function Child({value = 'default'}) {
54 + Scheduler.unstable_yieldValue(value);
55 + return null;
56 + }
57 +
58 + function Subscription({source}) {
59 + const value = useSubscription(
60 + React.useMemo(
61 + () => ({
62 + getCurrentValue: () => source.getValue(),
63 + subscribe: callback => {
64 + const subscription = source.subscribe(callback);
65 + return () => subscription.unsubscribe();
66 + },
67 + }),
68 + [source],
69 + ),
70 + );
71 + return <Child value={value} />;
72 + }
73 +
74 + const observable = createBehaviorSubject();
75 + let renderer;
76 + act(() => {
77 + renderer = ReactTestRenderer.create(
78 + <Subscription source={observable} />,
79 + {unstable_isConcurrent: true},
80 + );
81 + });
82 + expect(Scheduler).toHaveYielded(['default']);
83 +
84 + // Updates while subscribed should re-render the child component
85 + act(() => observable.next(123));
86 + expect(Scheduler).toHaveYielded([123]);
87 + act(() => observable.next('abc'));
88 + expect(Scheduler).toHaveYielded(['abc']);
89 +
90 + // Unmounting the subscriber should remove listeners
91 + act(() => renderer.update(<div />));
92 + act(() => observable.next(456));
93 + expect(Scheduler).toFlushAndYield([]);
94 + });
95 +
96 + it('should support observable types like RxJS ReplaySubject', () => {
97 + function Child({value = 'default'}) {
98 + Scheduler.unstable_yieldValue(value);
99 + return null;
100 + }
101 +
102 + function Subscription({source}) {
103 + const value = useSubscription(
104 + React.useMemo(
105 + () => ({
106 + getCurrentValue: () => {
107 + let currentValue;
108 + source
109 + .subscribe(tempValue => {
110 + currentValue = tempValue;
111 + })
112 + .unsubscribe();
113 + return currentValue;
114 + },
115 + subscribe: callback => {
116 + const subscription = source.subscribe(callback);
117 + return () => subscription.unsubscribe();
118 + },
119 + }),
120 + [source],
121 + ),
122 + );
123 + return <Child value={value} />;
124 + }
125 +
126 + let observable = createReplaySubject('initial');
127 + let renderer;
128 + act(() => {
129 + renderer = ReactTestRenderer.create(
130 + <Subscription source={observable} />,
131 + {unstable_isConcurrent: true},
132 + );
133 + });
134 + expect(Scheduler).toHaveYielded(['initial']);
135 + act(() => observable.next('updated'));
136 + expect(Scheduler).toHaveYielded(['updated']);
137 +
138 + Scheduler.unstable_flushAll();
139 +
140 + // Unsetting the subscriber prop should reset subscribed values
141 + observable = createReplaySubject(undefined);
142 + act(() => renderer.update(<Subscription source={observable} />));
143 + expect(Scheduler).toHaveYielded(['default']);
144 + });
145 +
146 + it('should unsubscribe from old sources and subscribe to new sources when memoized props change', () => {
147 + function Child({value = 'default'}) {
148 + Scheduler.unstable_yieldValue(value);
149 + return null;
150 + }
151 +
152 + let subscriptions = [];
153 +
154 + function Subscription({source}) {
155 + const value = useSubscription(
156 + React.useMemo(
157 + () => ({
158 + getCurrentValue: () => source.getValue(),
159 + subscribe: callback => {
160 + subscriptions.push(source);
161 + const subscription = source.subscribe(callback);
162 + return () => subscription.unsubscribe();
163 + },
164 + }),
165 + [source],
166 + ),
167 + );
168 + return <Child value={value} />;
169 + }
170 +
171 + const observableA = createBehaviorSubject('a-0');
172 + const observableB = createBehaviorSubject('b-0');
173 +
174 + expect(subscriptions).toHaveLength(0);
175 +
176 + let renderer;
177 + act(() => {
178 + renderer = ReactTestRenderer.create(
179 + <Subscription source={observableA} />,
180 + {unstable_isConcurrent: true},
181 + );
182 + });
183 +
184 + // Updates while subscribed should re-render the child component
185 + expect(Scheduler).toHaveYielded(['a-0']);
186 + expect(subscriptions).toHaveLength(1);
187 + expect(subscriptions[0]).toBe(observableA);
188 +
189 + // Unsetting the subscriber prop should reset subscribed values
190 + act(() => renderer.update(<Subscription source={observableB} />));
191 +
192 + expect(Scheduler).toHaveYielded(['b-0']);
193 + expect(subscriptions).toHaveLength(2);
194 + expect(subscriptions[1]).toBe(observableB);
195 +
196 + // Updates to the old subscribable should not re-render the child component
197 + act(() => observableA.next('a-1'));
198 + expect(Scheduler).toFlushAndYield([]);
199 +
200 + // Updates to the bew subscribable should re-render the child component
201 + act(() => observableB.next('b-1'));
202 + expect(Scheduler).toHaveYielded(['b-1']);
203 +
204 + expect(subscriptions).toHaveLength(2);
205 + });
206 +
207 + it('should unsubscribe from old sources and subscribe to new sources when useCallback functions change', () => {
208 + function Child({value = 'default'}) {
209 + Scheduler.unstable_yieldValue(value);
210 + return null;
211 + }
212 +
213 + let subscriptions = [];
214 +
215 + function Subscription({source}) {
216 + const value = useSubscription({
217 + getCurrentValue: React.useCallback(() => source.getValue(), [source]),
218 + subscribe: React.useCallback(
219 + callback => {
220 + subscriptions.push(source);
221 + const subscription = source.subscribe(callback);
222 + return () => subscription.unsubscribe();
223 + },
224 + [source],
225 + ),
226 + });
227 + return <Child value={value} />;
228 + }
229 +
230 + const observableA = createBehaviorSubject('a-0');
231 + const observableB = createBehaviorSubject('b-0');
232 +
233 + expect(subscriptions).toHaveLength(0);
234 +
235 + let renderer;
236 + act(() => {
237 + renderer = ReactTestRenderer.create(
238 + <Subscription source={observableA} />,
239 + {unstable_isConcurrent: true},
240 + );
241 + });
242 +
243 + // Updates while subscribed should re-render the child component
244 + expect(Scheduler).toHaveYielded(['a-0']);
245 + expect(subscriptions).toHaveLength(1);
246 + expect(subscriptions[0]).toBe(observableA);
247 +
248 + // Unsetting the subscriber prop should reset subscribed values
249 + act(() => renderer.update(<Subscription source={observableB} />));
250 + expect(Scheduler).toHaveYielded(['b-0']);
251 + expect(subscriptions).toHaveLength(2);
252 + expect(subscriptions[1]).toBe(observableB);
253 +
254 + // Updates to the old subscribable should not re-render the child component
255 + act(() => observableA.next('a-1'));
256 + expect(Scheduler).toFlushAndYield([]);
257 +
258 + // Updates to the bew subscribable should re-render the child component
259 + act(() => observableB.next('b-1'));
260 + expect(Scheduler).toHaveYielded(['b-1']);
261 +
262 + expect(subscriptions).toHaveLength(2);
263 + });
264 +
265 + it('should ignore values emitted by a new subscribable until the commit phase', () => {
266 + const log = [];
267 +
268 + function Grandchild({value}) {
269 + Scheduler.unstable_yieldValue('Grandchild: ' + value);
270 + return null;
271 + }
272 +
273 + function Child({value = 'default'}) {
274 + Scheduler.unstable_yieldValue('Child: ' + value);
275 + return <Grandchild value={value} />;
276 + }
277 +
278 + function Subscription({source}) {
279 + const value = useSubscription(
280 + React.useMemo(
281 + () => ({
282 + getCurrentValue: () => source.getValue(),
283 + subscribe: callback => {
284 + const subscription = source.subscribe(callback);
285 + return () => subscription.unsubscribe();
286 + },
287 + }),
288 + [source],
289 + ),
290 + );
291 + return <Child value={value} />;
292 + }
293 +
294 + class Parent extends React.Component {
295 + state = {};
296 +
297 + static getDerivedStateFromProps(nextProps, prevState) {
298 + if (nextProps.observed !== prevState.observed) {
299 + return {
300 + observed: nextProps.observed,
301 + };
302 + }
303 +
304 + return null;
305 + }
306 +
307 + componentDidMount() {
308 + log.push('Parent.componentDidMount');
309 + }
310 +
311 + componentDidUpdate() {
312 + log.push('Parent.componentDidUpdate');
313 + }
314 +
315 + render() {
316 + return <Subscription source={this.state.observed} />;
317 + }
318 + }
319 +
320 + const observableA = createBehaviorSubject('a-0');
321 + const observableB = createBehaviorSubject('b-0');
322 +
323 + let renderer;
324 + act(() => {
325 + renderer = ReactTestRenderer.create(<Parent observed={observableA} />, {
326 + unstable_isConcurrent: true,
327 + });
328 + });
329 + expect(Scheduler).toHaveYielded(['Child: a-0', 'Grandchild: a-0']);
330 + expect(log).toEqual(['Parent.componentDidMount']);
331 +
332 + // Start React update, but don't finish
333 + act(() => {
334 + renderer.update(<Parent observed={observableB} />);
335 + expect(Scheduler).toFlushAndYieldThrough(['Child: b-0']);
336 + expect(log).toEqual(['Parent.componentDidMount']);
337 +
338 + // Emit some updates from the uncommitted subscribable
339 + observableB.next('b-1');
340 + observableB.next('b-2');
341 + observableB.next('b-3');
342 + });
343 +
344 + // Update again
345 + act(() => renderer.update(<Parent observed={observableA} />));
346 +
347 + // Flush everything and ensure that the correct subscribable is used
348 + // We expect the last emitted update to be rendered (because of the commit phase value check)
349 + // But the intermediate ones should be ignored,
350 + // And the final rendered output should be the higher-priority observable.
351 + expect(Scheduler).toHaveYielded([
352 + 'Grandchild: b-0',
353 + 'Child: b-3',
354 + 'Grandchild: b-3',
355 + 'Child: a-0',
356 + 'Grandchild: a-0',
357 + ]);
358 + expect(log).toEqual([
359 + 'Parent.componentDidMount',
360 + 'Parent.componentDidUpdate',
361 + 'Parent.componentDidUpdate',
362 + ]);
363 + });
364 +
365 + it('should not drop values emitted between updates', () => {
366 + const log = [];
367 +
368 + function Grandchild({value}) {
369 + Scheduler.unstable_yieldValue('Grandchild: ' + value);
370 + return null;
371 + }
372 +
373 + function Child({value = 'default'}) {
374 + Scheduler.unstable_yieldValue('Child: ' + value);
375 + return <Grandchild value={value} />;
376 + }
377 +
378 + function Subscription({source}) {
379 + const value = useSubscription(
380 + React.useMemo(
381 + () => ({
382 + getCurrentValue: () => source.getValue(),
383 + subscribe: callback => {
384 + const subscription = source.subscribe(callback);
385 + return () => subscription.unsubscribe();
386 + },
387 + }),
388 + [source],
389 + ),
390 + );
391 + return <Child value={value} />;
392 + }
393 +
394 + class Parent extends React.Component {
395 + state = {};
396 +
397 + static getDerivedStateFromProps(nextProps, prevState) {
398 + if (nextProps.observed !== prevState.observed) {
399 + return {
400 + observed: nextProps.observed,
401 + };
402 + }
403 +
404 + return null;
405 + }
406 +
407 + componentDidMount() {
408 + log.push('Parent.componentDidMount:' + this.props.observed.value);
409 + }
410 +
411 + componentDidUpdate() {
412 + log.push('Parent.componentDidUpdate:' + this.props.observed.value);
413 + }
414 +
415 + render() {
416 + return <Subscription source={this.state.observed} />;
417 + }
418 + }
419 +
420 + const observableA = createBehaviorSubject('a-0');
421 + const observableB = createBehaviorSubject('b-0');
422 +
423 + let renderer;
424 + act(() => {
425 + renderer = ReactTestRenderer.create(<Parent observed={observableA} />, {
426 + unstable_isConcurrent: true,
427 + });
428 + });
429 + expect(Scheduler).toHaveYielded(['Child: a-0', 'Grandchild: a-0']);
430 + expect(log).toEqual(['Parent.componentDidMount:a-0']);
431 + log.splice(0);
432 +
433 + // Start React update, but don't finish
434 + act(() => {
435 + renderer.update(<Parent observed={observableB} />);
436 + expect(Scheduler).toFlushAndYieldThrough(['Child: b-0']);
437 + expect(log).toEqual([]);
438 +
439 + // Emit some updates from the old subscribable
440 + observableA.next('a-1');
441 + observableA.next('a-2');
442 +
443 + // Update again
444 + renderer.update(<Parent observed={observableA} />);
445 +
446 + // Flush everything and ensure that the correct subscribable is used
447 + // We expect the new subscribable to finish rendering,
448 + // But then the updated values from the old subscribable should be used.
449 + expect(Scheduler).toFlushAndYield([
450 + 'Grandchild: b-0',
451 + 'Child: a-2',
452 + 'Grandchild: a-2',
453 + ]);
454 + expect(log).toEqual([
455 + 'Parent.componentDidUpdate:b-0',
456 + 'Parent.componentDidUpdate:a-2',
457 + ]);
458 + });
459 +
460 + // Updates from the new subscribable should be ignored.
461 + log.splice(0);
462 + act(() => observableB.next('b-1'));
463 + expect(Scheduler).toFlushAndYield([]);
464 + expect(log).toEqual([]);
465 + });
466 +
467 + it('should guard against updates that happen after unmounting', () => {
468 + function Child({value = 'default'}) {
469 + Scheduler.unstable_yieldValue(value);
470 + return null;
471 + }
472 +
473 + function Subscription({source}) {
474 + const value = useSubscription(
475 + React.useMemo(
476 + () => ({
477 + getCurrentValue: () => source.getValue(),
478 + subscribe: callback => {
479 + return source.subscribe(callback);
480 + },
481 + }),
482 + [source],
483 + ),
484 + );
485 + return <Child value={value} />;
486 + }
487 +
488 + const eventHandler = {
489 + _callbacks: [],
490 + _value: true,
491 + change(value) {
492 + eventHandler._value = value;
493 + const _callbacks = eventHandler._callbacks.slice(0);
494 + _callbacks.forEach(callback => callback(value));
495 + },
496 + getValue() {
497 + return eventHandler._value;
498 + },
499 + subscribe(callback) {
500 + eventHandler._callbacks.push(callback);
501 + return () => {
502 + eventHandler._callbacks.splice(
503 + eventHandler._callbacks.indexOf(callback),
504 + 1,
505 + );
506 + };
507 + },
508 + };
509 +
510 + eventHandler.subscribe(value => {
511 + if (value === false) {
512 + renderer.unmount();
513 + expect(Scheduler).toFlushAndYield([]);
514 + }
515 + });
516 +
517 + let renderer;
518 + act(() => {
519 + renderer = ReactTestRenderer.create(
520 + <Subscription source={eventHandler} />,
521 + {unstable_isConcurrent: true},
522 + );
523 + });
524 + expect(Scheduler).toHaveYielded([true]);
525 +
526 + // This event should unmount
527 + eventHandler.change(false);
528 + });
529 +
530 + it('does not return a value from the previous subscription if the source is updated', () => {
531 + const subscription1 = {
532 + getCurrentValue: () => 'one',
533 + subscribe: () => () => {},
534 + };
535 +
536 + const subscription2 = {
537 + getCurrentValue: () => 'two',
538 + subscribe: () => () => {},
539 + };
540 +
541 + function Subscription({subscription}) {
542 + const value = useSubscription(subscription);
543 + if (value !== subscription.getCurrentValue()) {
544 + throw Error(
545 + `expected value "${subscription.getCurrentValue()}" but got value "${value}"`,
546 + );
547 + }
548 + return null;
549 + }
550 +
551 + let renderer;
552 + act(() => {
553 + renderer = ReactTestRenderer.create(
554 + <Subscription subscription={subscription1} />,
555 + {unstable_isConcurrent: true},
556 + );
557 + });
558 + Scheduler.unstable_flushAll();
559 +
560 + act(() => renderer.update(<Subscription subscription={subscription2} />));
561 + Scheduler.unstable_flushAll();
562 + });
563 +});
packages/use-subscription/src/useSubscription.js new
+123
@@ -0,0 +1,123 @@
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 {useDebugValue, useEffect, useState} from 'react';
11 +
12 +// Hook used for safely managing subscriptions in concurrent mode.
13 +//
14 +// In order to avoid removing and re-adding subscriptions each time this hook is called,
15 +// the parameters passed to this hook should be memoized in some way–
16 +// either by wrapping the entire params object with useMemo()
17 +// or by wrapping the individual callbacks with useCallback().
18 +export function useSubscription<Value>({
19 + // (Synchronously) returns the current value of our subscription.
20 + getCurrentValue,
21 +
22 + // This function is passed an event handler to attach to the subscription.
23 + // It should return an unsubscribe function that removes the handler.
24 + subscribe,
25 +}: {|
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 + () => {
72 + let didUnsubscribe = false;
73 +
74 + const checkForUpdates = () => {
75 + // It's possible that this callback will be invoked even after being unsubscribed,
76 + // if it's removed as a result of a subscription event/update.
77 + // In this case, React will log a DEV warning about an update from an unmounted component.
78 + // We can avoid triggering that warning with this check.
79 + if (didUnsubscribe) {
80 + return;
81 + }
82 +
83 + setState(prevState => {
84 + // Ignore values from stale sources!
85 + // Since we subscribe an unsubscribe in a passive effect,
86 + // it's possible that this callback will be invoked for a stale (previous) subscription.
87 + // This check avoids scheduling an update for that stale subscription.
88 + if (
89 + prevState.getCurrentValue !== getCurrentValue ||
90 + prevState.subscribe !== subscribe
91 + ) {
92 + return prevState;
93 + }
94 +
95 + // Some subscriptions will auto-invoke the handler, even if the value hasn't changed.
96 + // If the value hasn't changed, no update is needed.
97 + // Return state as-is so React can bail out and avoid an unnecessary render.
98 + const value = getCurrentValue();
99 + if (prevState.value === value) {
100 + return prevState;
101 + }
102 +
103 + return {...prevState, value};
104 + });
105 + };
106 + const unsubscribe = subscribe(checkForUpdates);
107 +
108 + // Because we're subscribing in a passive effect,
109 + // it's possible that an update has occurred between render and our effect handler.
110 + // Check for this and schedule an update if work has occurred.
111 + checkForUpdates();
112 +
113 + return () => {
114 + didUnsubscribe = true;
115 + unsubscribe();
116 + };
117 + },
118 + [getCurrentValue, subscribe],
119 + );
120 +
121 + // Return the current value for our caller to use while rendering.
122 + return valueToReturn;
123 +}