Fix Event Replaying in Flare by Eagerly Adding Active Listeners (#17933)
* Add test of Event Replaying using Flare * Fix Event Replaying in Flare by Eagerly Adding Active Listeners This effectively reverts part of https://github.com/facebook/react/pull/17513
Sebastian Markbåge committed
Jan 30, 2020 at 09:32 UTC
e98797f7b98c827fb691e519bc2c445977a0546c
2 files changed
+251
-4
packages/react-dom/src/__tests__/ReactDOMServerSelectiveHydration-test.internal.js
+243
@@ -9,11 +9,14 @@
9
10
'use strict';
11
12
+import {createEventTarget} from 'dom-event-testing-library';
13
+
14
let React;
15
let ReactDOM;
16
let ReactDOMServer;
17
let Scheduler;
18
let Suspense;
19
+let usePress;
20
21
function dispatchMouseHoverEvent(to, from) {
22
if (!to) {
@@ -92,11 +95,16 @@ describe('ReactDOMServerSelectiveHydration', () => {
95
beforeEach(() => {
96
jest.resetModuleRegistry();
97
98
+ let ReactFeatureFlags = require('shared/ReactFeatureFlags');
99
+ ReactFeatureFlags.enableDeprecatedFlareAPI = true;
100
+ ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false;
101
+
102
React = require('react');
103
ReactDOM = require('react-dom');
104
ReactDOMServer = require('react-dom/server');
105
Scheduler = require('scheduler');
106
Suspense = React.Suspense;
107
+ usePress = require('react-interactions/events/press').usePress;
108
});
109
110
if (!__EXPERIMENTAL__) {
@@ -342,6 +350,241 @@ describe('ReactDOMServerSelectiveHydration', () => {
350
document.body.removeChild(container);
351
});
352
353
+ it('hydrates the target boundary synchronously during a click (flare)', async () => {
354
+ function Child({text}) {
355
+ Scheduler.unstable_yieldValue(text);
356
+ const listener = usePress({
357
+ onPress() {
358
+ Scheduler.unstable_yieldValue('Clicked ' + text);
359
+ },
360
+ });
361
+
362
+ return <span DEPRECATED_flareListeners={listener}>{text}</span>;
363
+ }
364
+
365
+ function App() {
366
+ Scheduler.unstable_yieldValue('App');
367
+ return (
368
+ <div>
369
+ <Suspense fallback="Loading...">
370
+ <Child text="A" />
371
+ </Suspense>
372
+ <Suspense fallback="Loading...">
373
+ <Child text="B" />
374
+ </Suspense>
375
+ </div>
376
+ );
377
+ }
378
+
379
+ let finalHTML = ReactDOMServer.renderToString(<App />);
380
+
381
+ expect(Scheduler).toHaveYielded(['App', 'A', 'B']);
382
+
383
+ let container = document.createElement('div');
384
+ // We need this to be in the document since we'll dispatch events on it.
385
+ document.body.appendChild(container);
386
+
387
+ container.innerHTML = finalHTML;
388
+
389
+ let root = ReactDOM.createRoot(container, {hydrate: true});
390
+ root.render(<App />);
391
+
392
+ // Nothing has been hydrated so far.
393
+ expect(Scheduler).toHaveYielded([]);
394
+
395
+ let span = container.getElementsByTagName('span')[1];
396
+
397
+ let target = createEventTarget(span);
398
+
399
+ // This should synchronously hydrate the root App and the second suspense
400
+ // boundary.
401
+ let preventDefault = jest.fn();
402
+ target.virtualclick({preventDefault});
403
+
404
+ // The event should have been canceled because we called preventDefault.
405
+ expect(preventDefault).toHaveBeenCalled();
406
+
407
+ // We rendered App, B and then invoked the event without rendering A.
408
+ expect(Scheduler).toHaveYielded(['App', 'B', 'Clicked B']);
409
+
410
+ // After continuing the scheduler, we finally hydrate A.
411
+ expect(Scheduler).toFlushAndYield(['A']);
412
+
413
+ document.body.removeChild(container);
414
+ });
415
+
416
+ it('hydrates at higher pri if sync did not work first time (flare)', async () => {
417
+ let suspend = false;
418
+ let resolve;
419
+ let promise = new Promise(resolvePromise => (resolve = resolvePromise));
420
+
421
+ function Child({text}) {
422
+ if ((text === 'A' || text === 'D') && suspend) {
423
+ throw promise;
424
+ }
425
+ Scheduler.unstable_yieldValue(text);
426
+
427
+ const listener = usePress({
428
+ onPress() {
429
+ Scheduler.unstable_yieldValue('Clicked ' + text);
430
+ },
431
+ });
432
+ return <span DEPRECATED_flareListeners={listener}>{text}</span>;
433
+ }
434
+
435
+ function App() {
436
+ Scheduler.unstable_yieldValue('App');
437
+ return (
438
+ <div>
439
+ <Suspense fallback="Loading...">
440
+ <Child text="A" />
441
+ </Suspense>
442
+ <Suspense fallback="Loading...">
443
+ <Child text="B" />
444
+ </Suspense>
445
+ <Suspense fallback="Loading...">
446
+ <Child text="C" />
447
+ </Suspense>
448
+ <Suspense fallback="Loading...">
449
+ <Child text="D" />
450
+ </Suspense>
451
+ </div>
452
+ );
453
+ }
454
+
455
+ let finalHTML = ReactDOMServer.renderToString(<App />);
456
+
457
+ expect(Scheduler).toHaveYielded(['App', 'A', 'B', 'C', 'D']);
458
+
459
+ let container = document.createElement('div');
460
+ // We need this to be in the document since we'll dispatch events on it.
461
+ document.body.appendChild(container);
462
+
463
+ container.innerHTML = finalHTML;
464
+
465
+ let spanD = container.getElementsByTagName('span')[3];
466
+
467
+ suspend = true;
468
+
469
+ // A and D will be suspended. We'll click on D which should take
470
+ // priority, after we unsuspend.
471
+ let root = ReactDOM.createRoot(container, {hydrate: true});
472
+ root.render(<App />);
473
+
474
+ // Nothing has been hydrated so far.
475
+ expect(Scheduler).toHaveYielded([]);
476
+
477
+ // This click target cannot be hydrated yet because it's suspended.
478
+ let result = dispatchClickEvent(spanD);
479
+
480
+ expect(Scheduler).toHaveYielded(['App']);
481
+
482
+ expect(result).toBe(true);
483
+
484
+ // Continuing rendering will render B next.
485
+ expect(Scheduler).toFlushAndYield(['B', 'C']);
486
+
487
+ suspend = false;
488
+ resolve();
489
+ await promise;
490
+
491
+ // After the click, we should prioritize D and the Click first,
492
+ // and only after that render A and C.
493
+ expect(Scheduler).toFlushAndYield(['D', 'Clicked D', 'A']);
494
+
495
+ document.body.removeChild(container);
496
+ });
497
+
498
+ it('hydrates at higher pri for secondary discrete events (flare)', async () => {
499
+ let suspend = false;
500
+ let resolve;
501
+ let promise = new Promise(resolvePromise => (resolve = resolvePromise));
502
+
503
+ function Child({text}) {
504
+ if ((text === 'A' || text === 'D') && suspend) {
505
+ throw promise;
506
+ }
507
+ Scheduler.unstable_yieldValue(text);
508
+
509
+ const listener = usePress({
510
+ onPress() {
511
+ Scheduler.unstable_yieldValue('Clicked ' + text);
512
+ },
513
+ });
514
+ return <span DEPRECATED_flareListeners={listener}>{text}</span>;
515
+ }
516
+
517
+ function App() {
518
+ Scheduler.unstable_yieldValue('App');
519
+ return (
520
+ <div>
521
+ <Suspense fallback="Loading...">
522
+ <Child text="A" />
523
+ </Suspense>
524
+ <Suspense fallback="Loading...">
525
+ <Child text="B" />
526
+ </Suspense>
527
+ <Suspense fallback="Loading...">
528
+ <Child text="C" />
529
+ </Suspense>
530
+ <Suspense fallback="Loading...">
531
+ <Child text="D" />
532
+ </Suspense>
533
+ </div>
534
+ );
535
+ }
536
+
537
+ let finalHTML = ReactDOMServer.renderToString(<App />);
538
+
539
+ expect(Scheduler).toHaveYielded(['App', 'A', 'B', 'C', 'D']);
540
+
541
+ let container = document.createElement('div');
542
+ // We need this to be in the document since we'll dispatch events on it.
543
+ document.body.appendChild(container);
544
+
545
+ container.innerHTML = finalHTML;
546
+
547
+ let spanA = container.getElementsByTagName('span')[0];
548
+ let spanC = container.getElementsByTagName('span')[2];
549
+ let spanD = container.getElementsByTagName('span')[3];
550
+
551
+ suspend = true;
552
+
553
+ // A and D will be suspended. We'll click on D which should take
554
+ // priority, after we unsuspend.
555
+ let root = ReactDOM.createRoot(container, {hydrate: true});
556
+ root.render(<App />);
557
+
558
+ // Nothing has been hydrated so far.
559
+ expect(Scheduler).toHaveYielded([]);
560
+
561
+ // This click target cannot be hydrated yet because the first is Suspended.
562
+ dispatchClickEvent(spanA);
563
+ dispatchClickEvent(spanC);
564
+ dispatchClickEvent(spanD);
565
+
566
+ expect(Scheduler).toHaveYielded(['App']);
567
+
568
+ suspend = false;
569
+ resolve();
570
+ await promise;
571
+
572
+ // We should prioritize hydrating A, C and D first since we clicked in
573
+ // them. Only after they're done will we hydrate B.
574
+ expect(Scheduler).toFlushAndYield([
575
+ 'A',
576
+ 'Clicked A',
577
+ 'C',
578
+ 'Clicked C',
579
+ 'D',
580
+ 'Clicked D',
581
+ // B should render last since it wasn't clicked.
582
+ 'B',
583
+ ]);
584
+
585
+ document.body.removeChild(container);
586
+ });
587
+
588
it('hydrates the last target as higher priority for continuous events', async () => {
589
let suspend = false;
590
let resolve;
packages/react-dom/src/events/ReactDOMEventReplaying.js
+8
-4
@@ -221,14 +221,18 @@ function trapReplayableEvent(
221
if (enableDeprecatedFlareAPI) {
222
// Trap events for the responder system.
223
const topLevelTypeString = unsafeCastDOMTopLevelTypeToString(topLevelType);
224
- const passiveEventKey = topLevelTypeString + '_passive';
225
- if (!listenerMap.has(passiveEventKey)) {
224
+ // TODO: Ideally we shouldn't need these to be active but
225
+ // if we only have a passive listener, we at least need it
226
+ // to still pretend to be active so that Flare gets those
227
+ // events.
228
+ const activeEventKey = topLevelTypeString + '_active';
229
+ if (!listenerMap.has(activeEventKey)) {
230
const listener = addResponderEventSystemEvent(
231
document,
232
topLevelTypeString,
229
- true,
233
+ false,
234
);
231
- listenerMap.set(passiveEventKey, listener);
235
+ listenerMap.set(activeEventKey, listener);
236
}
237
}
238
}