main
js 610 lines 17.3 KB
Raw
1 /**
2 * Copyright (c) Meta Platforms, Inc. and 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 ReactDOMClient;
17 let Scheduler;
18 let ReplaySubject;
19 let assertLog;
20 let waitForAll;
21 let waitFor;
22 let waitForPaint;
23
24 describe('useSubscription', () => {
25 beforeEach(() => {
26 jest.resetModules();
27 jest.mock('scheduler', () => require('scheduler/unstable_mock'));
28
29 useSubscription = require('use-subscription').useSubscription;
30 React = require('react');
31 ReactDOMClient = require('react-dom/client');
32 Scheduler = require('scheduler');
33
34 act = require('internal-test-utils').act;
35
36 BehaviorSubject = require('rxjs').BehaviorSubject;
37 ReplaySubject = require('rxjs').ReplaySubject;
38
39 const InternalTestUtils = require('internal-test-utils');
40 waitForAll = InternalTestUtils.waitForAll;
41 waitForPaint = InternalTestUtils.waitForPaint;
42 assertLog = InternalTestUtils.assertLog;
43 waitFor = InternalTestUtils.waitFor;
44 });
45
46 function createBehaviorSubject(initialValue) {
47 const behaviorSubject = new BehaviorSubject();
48 if (initialValue) {
49 behaviorSubject.next(initialValue);
50 }
51 return behaviorSubject;
52 }
53
54 function createReplaySubject(initialValue) {
55 const replaySubject = new ReplaySubject();
56 if (initialValue) {
57 replaySubject.next(initialValue);
58 }
59 return replaySubject;
60 }
61
62 it('supports basic subscription pattern', async () => {
63 function Child({value = 'default'}) {
64 Scheduler.log(value);
65 return null;
66 }
67
68 function Subscription({source}) {
69 const value = useSubscription(
70 React.useMemo(
71 () => ({
72 getCurrentValue: () => source.getValue(),
73 subscribe: callback => {
74 const subscription = source.subscribe(callback);
75 return () => subscription.unsubscribe();
76 },
77 }),
78 [source],
79 ),
80 );
81 return <Child value={value} />;
82 }
83
84 const observable = createBehaviorSubject();
85 const root = ReactDOMClient.createRoot(document.createElement('div'));
86 await act(() => {
87 root.render(<Subscription source={observable} />);
88 });
89 assertLog(['default']);
90
91 // Updates while subscribed should re-render the child component
92 await act(() => observable.next(123));
93 assertLog([123]);
94 await act(() => observable.next('abc'));
95 assertLog(['abc']);
96
97 // Unmounting the subscriber should remove listeners
98 await act(() => root.render(<div />));
99 await act(() => observable.next(456));
100 await waitForAll([]);
101 });
102
103 it('should support observable types like RxJS ReplaySubject', async () => {
104 function Child({value = 'default'}) {
105 Scheduler.log(value);
106 return null;
107 }
108
109 function Subscription({source}) {
110 const value = useSubscription(
111 React.useMemo(
112 () => ({
113 getCurrentValue: () => {
114 let currentValue;
115 source
116 .subscribe(tempValue => {
117 currentValue = tempValue;
118 })
119 .unsubscribe();
120 return currentValue;
121 },
122 subscribe: callback => {
123 const subscription = source.subscribe(callback);
124 return () => subscription.unsubscribe();
125 },
126 }),
127 [source],
128 ),
129 );
130 return <Child value={value} />;
131 }
132
133 let observable = createReplaySubject('initial');
134 const root = ReactDOMClient.createRoot(document.createElement('div'));
135 await act(() => {
136 root.render(<Subscription source={observable} />);
137 });
138 assertLog(['initial']);
139 await act(() => observable.next('updated'));
140 assertLog(['updated']);
141
142 await waitForAll([]);
143
144 // Unsetting the subscriber prop should reset subscribed values
145 observable = createReplaySubject(undefined);
146 await act(() => root.render(<Subscription source={observable} />));
147 assertLog(['default']);
148 });
149
150 it('should unsubscribe from old sources and subscribe to new sources when memoized props change', async () => {
151 function Child({value = 'default'}) {
152 Scheduler.log(value);
153 return null;
154 }
155
156 const subscriptions = [];
157
158 function Subscription({source}) {
159 const value = useSubscription(
160 React.useMemo(
161 () => ({
162 getCurrentValue: () => source.getValue(),
163 subscribe: callback => {
164 subscriptions.push(source);
165 const subscription = source.subscribe(callback);
166 return () => subscription.unsubscribe();
167 },
168 }),
169 [source],
170 ),
171 );
172 return <Child value={value} />;
173 }
174
175 const observableA = createBehaviorSubject('a-0');
176 const observableB = createBehaviorSubject('b-0');
177
178 expect(subscriptions).toHaveLength(0);
179
180 const root = ReactDOMClient.createRoot(document.createElement('div'));
181 await act(() => {
182 root.render(<Subscription source={observableA} />);
183 });
184
185 // Updates while subscribed should re-render the child component
186 assertLog(['a-0']);
187 expect(subscriptions).toHaveLength(1);
188 expect(subscriptions[0]).toBe(observableA);
189
190 // Unsetting the subscriber prop should reset subscribed values
191 await act(() => root.render(<Subscription source={observableB} />));
192
193 assertLog(['b-0']);
194 expect(subscriptions).toHaveLength(2);
195 expect(subscriptions[1]).toBe(observableB);
196
197 // Updates to the old subscribable should not re-render the child component
198 await act(() => observableA.next('a-1'));
199 await waitForAll([]);
200
201 // Updates to the bew subscribable should re-render the child component
202 await act(() => observableB.next('b-1'));
203 assertLog(['b-1']);
204
205 expect(subscriptions).toHaveLength(2);
206 });
207
208 it('should unsubscribe from old sources and subscribe to new sources when useCallback functions change', async () => {
209 function Child({value = 'default'}) {
210 Scheduler.log(value);
211 return null;
212 }
213
214 const subscriptions = [];
215
216 function Subscription({source}) {
217 const value = useSubscription({
218 getCurrentValue: React.useCallback(() => source.getValue(), [source]),
219 subscribe: React.useCallback(
220 callback => {
221 subscriptions.push(source);
222 const subscription = source.subscribe(callback);
223 return () => subscription.unsubscribe();
224 },
225 [source],
226 ),
227 });
228 return <Child value={value} />;
229 }
230
231 const observableA = createBehaviorSubject('a-0');
232 const observableB = createBehaviorSubject('b-0');
233
234 expect(subscriptions).toHaveLength(0);
235
236 const root = ReactDOMClient.createRoot(document.createElement('div'));
237 await act(() => {
238 root.render(<Subscription source={observableA} />);
239 });
240
241 // Updates while subscribed should re-render the child component
242 assertLog(['a-0']);
243 expect(subscriptions).toHaveLength(1);
244 expect(subscriptions[0]).toBe(observableA);
245
246 // Unsetting the subscriber prop should reset subscribed values
247 await act(() => root.render(<Subscription source={observableB} />));
248 assertLog(['b-0']);
249 expect(subscriptions).toHaveLength(2);
250 expect(subscriptions[1]).toBe(observableB);
251
252 // Updates to the old subscribable should not re-render the child component
253 await act(() => observableA.next('a-1'));
254 await waitForAll([]);
255
256 // Updates to the bew subscribable should re-render the child component
257 await act(() => observableB.next('b-1'));
258 assertLog(['b-1']);
259
260 expect(subscriptions).toHaveLength(2);
261 });
262
263 it('should ignore values emitted by a new subscribable until the commit phase', async () => {
264 const log = [];
265
266 function Grandchild({value}) {
267 Scheduler.log('Grandchild: ' + value);
268 return null;
269 }
270
271 function Child({value = 'default'}) {
272 Scheduler.log('Child: ' + value);
273 return <Grandchild value={value} />;
274 }
275
276 function Subscription({source}) {
277 const value = useSubscription(
278 React.useMemo(
279 () => ({
280 getCurrentValue: () => source.getValue(),
281 subscribe: callback => {
282 const subscription = source.subscribe(callback);
283 return () => subscription.unsubscribe();
284 },
285 }),
286 [source],
287 ),
288 );
289 return <Child value={value} />;
290 }
291
292 class Parent extends React.Component {
293 state = {};
294
295 static getDerivedStateFromProps(nextProps, prevState) {
296 if (nextProps.observed !== prevState.observed) {
297 return {
298 observed: nextProps.observed,
299 };
300 }
301
302 return null;
303 }
304
305 componentDidMount() {
306 log.push('Parent.componentDidMount');
307 }
308
309 componentDidUpdate() {
310 log.push('Parent.componentDidUpdate');
311 }
312
313 render() {
314 return <Subscription source={this.state.observed} />;
315 }
316 }
317
318 const observableA = createBehaviorSubject('a-0');
319 const observableB = createBehaviorSubject('b-0');
320
321 const root = ReactDOMClient.createRoot(document.createElement('div'));
322 await act(() => {
323 root.render(<Parent observed={observableA} />);
324 });
325 assertLog(['Child: a-0', 'Grandchild: a-0']);
326 expect(log).toEqual(['Parent.componentDidMount']);
327
328 // Start React update, but don't finish
329 await act(async () => {
330 React.startTransition(() => {
331 root.render(<Parent observed={observableB} />);
332 });
333
334 await waitFor(['Child: b-0']);
335 expect(log).toEqual(['Parent.componentDidMount']);
336
337 // Emit some updates from the uncommitted subscribable
338 observableB.next('b-1');
339 observableB.next('b-2');
340 observableB.next('b-3');
341 });
342
343 assertLog(['Grandchild: b-0', 'Child: b-3', 'Grandchild: b-3']);
344
345 // Update again
346 await act(() => root.render(<Parent observed={observableA} />));
347
348 // Flush everything and ensure that the correct subscribable is used
349 // We expect the last emitted update to be rendered (because of the commit phase value check)
350 // But the intermediate ones should be ignored,
351 // And the final rendered output should be the higher-priority observable.
352 assertLog(['Child: a-0', 'Grandchild: a-0']);
353 expect(log).toEqual([
354 'Parent.componentDidMount',
355 'Parent.componentDidUpdate',
356 'Parent.componentDidUpdate',
357 ]);
358 });
359
360 it('should not drop values emitted between updates', async () => {
361 const log = [];
362
363 function Grandchild({value}) {
364 Scheduler.log('Grandchild: ' + value);
365 return null;
366 }
367
368 function Child({value = 'default'}) {
369 Scheduler.log('Child: ' + value);
370 return <Grandchild value={value} />;
371 }
372
373 function Subscription({source}) {
374 const value = useSubscription(
375 React.useMemo(
376 () => ({
377 getCurrentValue: () => source.getValue(),
378 subscribe: callback => {
379 const subscription = source.subscribe(callback);
380 return () => subscription.unsubscribe();
381 },
382 }),
383 [source],
384 ),
385 );
386 return <Child value={value} />;
387 }
388
389 class Parent extends React.Component {
390 state = {};
391
392 static getDerivedStateFromProps(nextProps, prevState) {
393 if (nextProps.observed !== prevState.observed) {
394 return {
395 observed: nextProps.observed,
396 };
397 }
398
399 return null;
400 }
401
402 componentDidMount() {
403 log.push('Parent.componentDidMount:' + this.props.observed.value);
404 }
405
406 componentDidUpdate() {
407 log.push('Parent.componentDidUpdate:' + this.props.observed.value);
408 }
409
410 render() {
411 return <Subscription source={this.state.observed} />;
412 }
413 }
414
415 const observableA = createBehaviorSubject('a-0');
416 const observableB = createBehaviorSubject('b-0');
417
418 const root = ReactDOMClient.createRoot(document.createElement('div'));
419 await act(() => {
420 root.render(<Parent observed={observableA} />);
421 });
422 assertLog(['Child: a-0', 'Grandchild: a-0']);
423 expect(log).toEqual(['Parent.componentDidMount:a-0']);
424 log.splice(0);
425
426 // Start React update, but don't finish
427 await act(async () => {
428 React.startTransition(() => {
429 root.render(<Parent observed={observableB} />);
430 });
431 await waitFor(['Child: b-0']);
432 expect(log).toEqual([]);
433
434 // Emit some updates from the old subscribable
435 observableA.next('a-1');
436 observableA.next('a-2');
437
438 // Update again
439 React.startTransition(() => {
440 root.render(<Parent observed={observableA} />);
441 });
442
443 // Flush everything and ensure that the correct subscribable is used
444 await waitForAll([
445 'Child: a-2',
446 'Grandchild: a-2',
447 'Child: a-2',
448 'Grandchild: a-2',
449 ]);
450 expect(log).toEqual(['Parent.componentDidUpdate:a-2']);
451 });
452
453 // Updates from the new subscribable should be ignored.
454 log.splice(0);
455 await act(() => observableB.next('b-1'));
456 await waitForAll([]);
457 expect(log).toEqual([]);
458 });
459
460 it('should guard against updates that happen after unmounting', async () => {
461 function Child({value = 'default'}) {
462 Scheduler.log(value);
463 return null;
464 }
465
466 function Subscription({source}) {
467 const value = useSubscription(
468 React.useMemo(
469 () => ({
470 getCurrentValue: () => source.getValue(),
471 subscribe: callback => {
472 return source.subscribe(callback);
473 },
474 }),
475 [source],
476 ),
477 );
478 return <Child value={value} />;
479 }
480
481 const eventHandler = {
482 _callbacks: [],
483 _value: true,
484 change(value) {
485 eventHandler._value = value;
486 const _callbacks = eventHandler._callbacks.slice(0);
487 _callbacks.forEach(callback => callback(value));
488 },
489 getValue() {
490 return eventHandler._value;
491 },
492 subscribe(callback) {
493 eventHandler._callbacks.push(callback);
494 return () => {
495 eventHandler._callbacks.splice(
496 eventHandler._callbacks.indexOf(callback),
497 1,
498 );
499 };
500 },
501 };
502
503 eventHandler.subscribe(async value => {
504 if (value === false) {
505 root.unmount();
506 }
507 });
508
509 const root = ReactDOMClient.createRoot(document.createElement('div'));
510 await act(() => {
511 root.render(<Subscription source={eventHandler} />);
512 });
513 assertLog([true]);
514
515 // This event should unmount
516 eventHandler.change(false);
517 });
518
519 it('does not return a value from the previous subscription if the source is updated', async () => {
520 const subscription1 = {
521 getCurrentValue: () => 'one',
522 subscribe: () => () => {},
523 };
524
525 const subscription2 = {
526 getCurrentValue: () => 'two',
527 subscribe: () => () => {},
528 };
529
530 function Subscription({subscription}) {
531 const value = useSubscription(subscription);
532 if (value !== subscription.getCurrentValue()) {
533 throw Error(
534 `expected value "${subscription.getCurrentValue()}" but got value "${value}"`,
535 );
536 }
537 return null;
538 }
539
540 const root = ReactDOMClient.createRoot(document.createElement('div'));
541 await act(() => {
542 root.render(<Subscription subscription={subscription1} />);
543 });
544 await waitForAll([]);
545
546 await act(() => root.render(<Subscription subscription={subscription2} />));
547 await waitForAll([]);
548 });
549
550 it('should not tear if a mutation occurs during a concurrent update', async () => {
551 const input = document.createElement('input');
552
553 const mutate = value => {
554 input.value = value;
555 input.dispatchEvent(new Event('change'));
556 };
557
558 const subscription = {
559 getCurrentValue: () => input.value,
560 subscribe: callback => {
561 input.addEventListener('change', callback);
562 return () => input.removeEventListener('change', callback);
563 },
564 };
565
566 const Subscriber = ({id}) => {
567 const value = useSubscription(subscription);
568 Scheduler.log(`render:${id}:${value}`);
569 return value;
570 };
571
572 const root = ReactDOMClient.createRoot(document.createElement('div'));
573 await act(async () => {
574 // Initial render of "A"
575 mutate('A');
576 root.render(
577 <React.Fragment>
578 <Subscriber id="first" />
579 <Subscriber id="second" />
580 </React.Fragment>,
581 );
582 await waitForAll(['render:first:A', 'render:second:A']);
583
584 // Update state "A" -> "B"
585 // This update will be eagerly evaluated,
586 // so the tearing case this test is guarding against would not happen.
587 mutate('B');
588 await waitForAll(['render:first:B', 'render:second:B']);
589
590 // No more pending updates
591 jest.runAllTimers();
592
593 // Partial update "B" -> "C"
594 // Interrupt with a second mutation "C" -> "D".
595 // This update will not be eagerly evaluated,
596 // but useSubscription() should eagerly close over the updated value to avoid tearing.
597 React.startTransition(() => {
598 mutate('C');
599 });
600 await waitForPaint(['render:first:C', 'render:second:C']);
601 React.startTransition(() => {
602 mutate('D');
603 });
604 await waitForAll(['render:first:D', 'render:second:D']);
605
606 // No more pending updates
607 jest.runAllTimers();
608 });
609 });
610 });