Skip to content

Commit e98797f

Browse files
authored
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 #17513
1 parent 1662035 commit e98797f

File tree

2 files changed

+251
-4
lines changed

2 files changed

+251
-4
lines changed

packages/react-dom/src/__tests__/ReactDOMServerSelectiveHydration-test.internal.js

Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,14 @@
99

1010
'use strict';
1111

12+
import {createEventTarget} from 'dom-event-testing-library';
13+
1214
let React;
1315
let ReactDOM;
1416
let ReactDOMServer;
1517
let Scheduler;
1618
let Suspense;
19+
let usePress;
1720

1821
function dispatchMouseHoverEvent(to, from) {
1922
if (!to) {
@@ -92,11 +95,16 @@ describe('ReactDOMServerSelectiveHydration', () => {
9295
beforeEach(() => {
9396
jest.resetModuleRegistry();
9497

98+
let ReactFeatureFlags = require('shared/ReactFeatureFlags');
99+
ReactFeatureFlags.enableDeprecatedFlareAPI = true;
100+
ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false;
101+
95102
React = require('react');
96103
ReactDOM = require('react-dom');
97104
ReactDOMServer = require('react-dom/server');
98105
Scheduler = require('scheduler');
99106
Suspense = React.Suspense;
107+
usePress = require('react-interactions/events/press').usePress;
100108
});
101109

102110
if (!__EXPERIMENTAL__) {
@@ -342,6 +350,241 @@ describe('ReactDOMServerSelectiveHydration', () => {
342350
document.body.removeChild(container);
343351
});
344352

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+
345588
it('hydrates the last target as higher priority for continuous events', async () => {
346589
let suspend = false;
347590
let resolve;

packages/react-dom/src/events/ReactDOMEventReplaying.js

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -221,14 +221,18 @@ function trapReplayableEvent(
221221
if (enableDeprecatedFlareAPI) {
222222
// Trap events for the responder system.
223223
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)) {
226230
const listener = addResponderEventSystemEvent(
227231
document,
228232
topLevelTypeString,
229-
true,
233+
false,
230234
);
231-
listenerMap.set(passiveEventKey, listener);
235+
listenerMap.set(activeEventKey, listener);
232236
}
233237
}
234238
}

0 commit comments

Comments
 (0)