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
+}