Replay capture phase for continuous events (#22856)

Co-authored-by: Dan Abramov <dan.abramov@me.com>

Co-authored-by: Marco Salazar <salazarm@fb.com>
This commit is contained in:
Dan Abramov
2021-12-02 14:33:44 +00:00
committed by GitHub
co-authored by Dan Abramov Marco Salazar
parent 44f99d75fa
commit 71d16750c5
3 changed files with 560 additions and 62 deletions
@@ -684,7 +684,6 @@ describe('ReactDOMServerSelectiveHydration', () => {
let suspend = false;
let resolve;
const promise = new Promise(resolvePromise => (resolve = resolvePromise));
function Child({text}) {
if ((text === 'A' || text === 'D') && suspend) {
throw promise;
@@ -724,11 +723,8 @@ describe('ReactDOMServerSelectiveHydration', () => {
</div>
);
}
const finalHTML = ReactDOMServer.renderToString(<App />);
expect(Scheduler).toHaveYielded(['App', 'A', 'B', 'C', 'D']);
const container = document.createElement('div');
// We need this to be in the document since we'll dispatch events on it.
document.body.appendChild(container);
@@ -748,22 +744,18 @@ describe('ReactDOMServerSelectiveHydration', () => {
// Nothing has been hydrated so far.
expect(Scheduler).toHaveYielded([]);
// Click D
dispatchMouseHoverEvent(spanD, null);
dispatchClickEvent(spanD);
// Hover over B and then C.
dispatchMouseHoverEvent(spanB, spanD);
dispatchMouseHoverEvent(spanC, spanB);
expect(Scheduler).toHaveYielded(['App']);
await act(async () => {
suspend = false;
resolve();
await promise;
});
if (
gate(
flags =>
@@ -799,6 +791,527 @@ describe('ReactDOMServerSelectiveHydration', () => {
document.body.removeChild(container);
});
it('replays capture phase for continuous events and respects stopPropagation', async () => {
let suspend = false;
let resolve;
const promise = new Promise(resolvePromise => (resolve = resolvePromise));
function Child({text}) {
if ((text === 'A' || text === 'D') && suspend) {
throw promise;
}
Scheduler.unstable_yieldValue(text);
return (
<span
id={text}
onClickCapture={e => {
e.preventDefault();
Scheduler.unstable_yieldValue('Capture Clicked ' + text);
}}
onClick={e => {
e.preventDefault();
Scheduler.unstable_yieldValue('Clicked ' + text);
}}
onMouseEnter={e => {
e.preventDefault();
Scheduler.unstable_yieldValue('Mouse Enter ' + text);
}}
onMouseOut={e => {
e.preventDefault();
Scheduler.unstable_yieldValue('Mouse Out ' + text);
}}
onMouseOutCapture={e => {
e.preventDefault();
e.stopPropagation();
Scheduler.unstable_yieldValue('Mouse Out Capture ' + text);
}}
onMouseOverCapture={e => {
e.preventDefault();
e.stopPropagation();
Scheduler.unstable_yieldValue('Mouse Over Capture ' + text);
}}
onMouseOver={e => {
e.preventDefault();
Scheduler.unstable_yieldValue('Mouse Over ' + text);
}}>
<div
onMouseOverCapture={e => {
e.preventDefault();
Scheduler.unstable_yieldValue('Mouse Over Capture Inner ' + text);
}}>
{text}
</div>
</span>
);
}
function App() {
Scheduler.unstable_yieldValue('App');
return (
<div
onClickCapture={e => {
e.preventDefault();
Scheduler.unstable_yieldValue('Capture Clicked Parent');
}}
onMouseOverCapture={e => {
Scheduler.unstable_yieldValue('Mouse Over Capture Parent');
}}>
<Suspense fallback="Loading...">
<Child text="A" />
</Suspense>
<Suspense fallback="Loading...">
<Child text="B" />
</Suspense>
<Suspense fallback="Loading...">
<Child text="C" />
</Suspense>
<Suspense fallback="Loading...">
<Child text="D" />
</Suspense>
</div>
);
}
const finalHTML = ReactDOMServer.renderToString(<App />);
expect(Scheduler).toHaveYielded(['App', 'A', 'B', 'C', 'D']);
const container = document.createElement('div');
// We need this to be in the document since we'll dispatch events on it.
document.body.appendChild(container);
container.innerHTML = finalHTML;
const spanB = document.getElementById('B').firstChild;
const spanC = document.getElementById('C').firstChild;
const spanD = document.getElementById('D').firstChild;
suspend = true;
// A and D will be suspended. We'll click on D which should take
// priority, after we unsuspend.
ReactDOM.hydrateRoot(container, <App />);
// Nothing has been hydrated so far.
expect(Scheduler).toHaveYielded([]);
// Click D
dispatchMouseHoverEvent(spanD, null);
dispatchClickEvent(spanD);
// Hover over B and then C.
dispatchMouseHoverEvent(spanB, spanD);
dispatchMouseHoverEvent(spanC, spanB);
expect(Scheduler).toHaveYielded(['App']);
await act(async () => {
suspend = false;
resolve();
await promise;
});
if (
gate(
flags =>
flags.enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay,
)
) {
// We should prioritize hydrating D first because we clicked it.
// but event isnt replayed
expect(Scheduler).toHaveYielded([
'D',
'B', // Ideally this should be later.
'C',
// Mouse out events aren't replayed
// 'Mouse Out Capture B',
// 'Mouse Out B',
'Mouse Over Capture Parent',
'Mouse Over Capture C',
// Stop propagation stops these
// 'Mouse Over Capture Inner C',
// 'Mouse Over C',
'A',
]);
} else {
// We should prioritize hydrating D first because we clicked it.
// Next we should hydrate C since that's the current hover target.
// To simplify implementation details we hydrate both B and C at
// the same time since B was already scheduled.
// This is ok because it will at least not continue for nested
// boundary. See the next test below.
expect(Scheduler).toHaveYielded([
'D',
'Clicked D',
'B', // Ideally this should be later.
'C',
// Capture phase isn't replayed
// Mouseout isn't replayed
'Mouse Over C',
'Mouse Enter C',
'A',
]);
}
// This test shows existing quirk where stopPropagation on mouseout
// prevents mouseEnter from firing
dispatchMouseHoverEvent(spanC, spanB);
expect(Scheduler).toHaveYielded([
'Mouse Out Capture B',
// stopPropagation stops these
// 'Mouse Out B',
// 'Mouse Enter C',
'Mouse Over Capture Parent',
'Mouse Over Capture C',
// Stop propagation stops these
// 'Mouse Over Capture Inner C',
// 'Mouse Over C',
]);
document.body.removeChild(container);
});
describe('can handle replaying events as part of multiple instances of React', () => {
let resolveInner;
let resolveOuter;
let innerPromise;
let outerPromise;
let OuterScheduler;
let InnerScheduler;
let innerDiv;
beforeEach(async () => {
document.body.innerHTML = '';
jest.resetModuleRegistry();
let OuterReactDOM;
let InnerReactDOM;
jest.isolateModules(() => {
OuterReactDOM = require('react-dom');
OuterScheduler = require('scheduler');
});
jest.isolateModules(() => {
InnerReactDOM = require('react-dom');
InnerScheduler = require('scheduler');
});
expect(OuterReactDOM).not.toBe(InnerReactDOM);
expect(OuterScheduler).not.toBe(InnerScheduler);
const outerContainer = document.createElement('div');
const innerContainer = document.createElement('div');
let suspendOuter = false;
outerPromise = new Promise(res => {
resolveOuter = () => {
suspendOuter = false;
res();
};
});
function Outer() {
if (suspendOuter) {
OuterScheduler.unstable_yieldValue('Suspend Outer');
throw outerPromise;
}
OuterScheduler.unstable_yieldValue('Outer');
const innerRoot = outerContainer.querySelector('#inner-root');
return (
<div
id="inner-root"
onMouseEnter={() => {
Scheduler.unstable_yieldValue('Outer Mouse Enter');
}}
dangerouslySetInnerHTML={{
__html: innerRoot ? innerRoot.innerHTML : '',
}}
/>
);
}
const OuterApp = () => {
return (
<Suspense fallback={<div>Loading</div>}>
<Outer />
</Suspense>
);
};
let suspendInner = false;
innerPromise = new Promise(res => {
resolveInner = () => {
suspendInner = false;
res();
};
});
function Inner() {
if (suspendInner) {
InnerScheduler.unstable_yieldValue('Suspend Inner');
throw innerPromise;
}
InnerScheduler.unstable_yieldValue('Inner');
return (
<div
id="inner"
onMouseEnter={() => {
Scheduler.unstable_yieldValue('Inner Mouse Enter');
}}
/>
);
}
const InnerApp = () => {
return (
<Suspense fallback={<div>Loading</div>}>
<Inner />
</Suspense>
);
};
document.body.appendChild(outerContainer);
const outerHTML = ReactDOMServer.renderToString(<OuterApp />);
outerContainer.innerHTML = outerHTML;
const innerWrapper = document.querySelector('#inner-root');
innerWrapper.appendChild(innerContainer);
const innerHTML = ReactDOMServer.renderToString(<InnerApp />);
innerContainer.innerHTML = innerHTML;
expect(OuterScheduler).toHaveYielded(['Outer']);
expect(InnerScheduler).toHaveYielded(['Inner']);
suspendOuter = true;
suspendInner = true;
OuterReactDOM.hydrateRoot(outerContainer, <OuterApp />);
InnerReactDOM.hydrateRoot(innerContainer, <InnerApp />);
expect(OuterScheduler).toFlushAndYield(['Suspend Outer']);
expect(InnerScheduler).toFlushAndYield(['Suspend Inner']);
innerDiv = document.querySelector('#inner');
dispatchClickEvent(innerDiv);
await act(async () => {
jest.runAllTimers();
Scheduler.unstable_flushAllWithoutAsserting();
OuterScheduler.unstable_flushAllWithoutAsserting();
InnerScheduler.unstable_flushAllWithoutAsserting();
});
expect(OuterScheduler).toHaveYielded(['Suspend Outer']);
if (
gate(
flags =>
flags.enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay,
)
) {
// InnerApp doesn't see the event because OuterApp calls stopPropagation in
// capture phase since the event is blocked on suspended component
expect(InnerScheduler).toHaveYielded([]);
} else {
// no stopPropagation
expect(InnerScheduler).toHaveYielded(['Suspend Inner']);
}
expect(Scheduler).toHaveYielded([]);
});
afterEach(async () => {
document.body.innerHTML = '';
});
// @gate enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay
it('Inner hydrates first then Outer', async () => {
dispatchMouseHoverEvent(innerDiv);
await act(async () => {
resolveInner();
await innerPromise;
jest.runAllTimers();
Scheduler.unstable_flushAllWithoutAsserting();
OuterScheduler.unstable_flushAllWithoutAsserting();
InnerScheduler.unstable_flushAllWithoutAsserting();
});
expect(OuterScheduler).toHaveYielded(['Suspend Outer']);
// Inner App renders because it is unblocked
expect(InnerScheduler).toHaveYielded(['Inner']);
// No event is replayed yet
expect(Scheduler).toHaveYielded([]);
dispatchMouseHoverEvent(innerDiv);
expect(OuterScheduler).toHaveYielded([]);
expect(InnerScheduler).toHaveYielded([]);
// No event is replayed yet
expect(Scheduler).toHaveYielded([]);
await act(async () => {
resolveOuter();
await outerPromise;
jest.runAllTimers();
Scheduler.unstable_flushAllWithoutAsserting();
OuterScheduler.unstable_flushAllWithoutAsserting();
InnerScheduler.unstable_flushAllWithoutAsserting();
});
// Nothing happens to inner app yet.
// Its blocked on the outer app replaying the event
expect(InnerScheduler).toHaveYielded([]);
// Outer hydrates and schedules Replay
expect(OuterScheduler).toHaveYielded(['Outer']);
// No event is replayed yet
expect(Scheduler).toHaveYielded([]);
// fire scheduled Replay
await act(async () => {
jest.runAllTimers();
Scheduler.unstable_flushAllWithoutAsserting();
OuterScheduler.unstable_flushAllWithoutAsserting();
InnerScheduler.unstable_flushAllWithoutAsserting();
});
// First Inner Mouse Enter fires then Outer Mouse Enter
expect(Scheduler).toHaveYielded([
'Inner Mouse Enter',
'Outer Mouse Enter',
]);
});
// @gate enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay
it('Outer hydrates first then Inner', async () => {
dispatchMouseHoverEvent(innerDiv);
await act(async () => {
resolveOuter();
await outerPromise;
Scheduler.unstable_flushAllWithoutAsserting();
OuterScheduler.unstable_flushAllWithoutAsserting();
InnerScheduler.unstable_flushAllWithoutAsserting();
});
// Outer resolves and scheduled replay
expect(OuterScheduler).toHaveYielded(['Outer']);
// Inner App is still blocked
expect(InnerScheduler).toHaveYielded([]);
// Replay outer event
await act(async () => {
Scheduler.unstable_flushAllWithoutAsserting();
OuterScheduler.unstable_flushAllWithoutAsserting();
InnerScheduler.unstable_flushAllWithoutAsserting();
});
// Inner is still blocked so when Outer replays the event in capture phase
// inner ends up caling stopPropagation
expect(Scheduler).toHaveYielded([]);
expect(OuterScheduler).toHaveYielded([]);
expect(InnerScheduler).toHaveYielded(['Suspend Inner']);
dispatchMouseHoverEvent(innerDiv);
expect(OuterScheduler).toHaveYielded([]);
expect(InnerScheduler).toHaveYielded([]);
expect(Scheduler).toHaveYielded([]);
await act(async () => {
resolveInner();
await innerPromise;
Scheduler.unstable_flushAllWithoutAsserting();
OuterScheduler.unstable_flushAllWithoutAsserting();
InnerScheduler.unstable_flushAllWithoutAsserting();
});
// Inner hydrates
expect(InnerScheduler).toHaveYielded(['Inner']);
// Outer was hydrated earlier
expect(OuterScheduler).toHaveYielded([]);
await act(async () => {
Scheduler.unstable_flushAllWithoutAsserting();
OuterScheduler.unstable_flushAllWithoutAsserting();
InnerScheduler.unstable_flushAllWithoutAsserting();
});
// First Inner Mouse Enter fires then Outer Mouse Enter
expect(Scheduler).toHaveYielded([
'Inner Mouse Enter',
'Outer Mouse Enter',
]);
});
});
// @gate enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay
it('replays event with null target when tree is dismounted', async () => {
let suspend = false;
let resolve;
const promise = new Promise(resolvePromise => {
resolve = () => {
suspend = false;
resolvePromise();
};
});
function Child() {
if (suspend) {
throw promise;
}
Scheduler.unstable_yieldValue('Child');
return (
<div
onMouseOver={() => {
Scheduler.unstable_yieldValue('on mouse over');
}}>
Child
</div>
);
}
function App() {
return (
<Suspense>
<Child />
</Suspense>
);
}
const finalHTML = ReactDOMServer.renderToString(<App />);
expect(Scheduler).toHaveYielded(['Child']);
const container = document.createElement('div');
document.body.appendChild(container);
container.innerHTML = finalHTML;
suspend = true;
ReactDOM.hydrateRoot(container, <App />);
const childDiv = container.firstElementChild;
dispatchMouseHoverEvent(childDiv);
// Not hydrated so event is saved for replay and stopPropagation is called
expect(Scheduler).toHaveYielded([]);
resolve();
Scheduler.unstable_flushNumberOfYields(1);
expect(Scheduler).toHaveYielded(['Child']);
Scheduler.unstable_scheduleCallback(
Scheduler.unstable_ImmediatePriority,
() => {
container.removeChild(childDiv);
const container2 = document.createElement('div');
container2.addEventListener('mouseover', () => {
Scheduler.unstable_yieldValue('container2 mouse over');
});
container2.appendChild(childDiv);
},
);
Scheduler.unstable_flushAllWithoutAsserting();
// Even though the tree is remove the event is still dispatched with native event handler
// on the container firing.
expect(Scheduler).toHaveYielded(['container2 mouse over']);
document.body.removeChild(container);
});
it('hydrates the last target path first for continuous events', async () => {
let suspend = false;
let resolve;
+17 -45
View File
@@ -267,31 +267,6 @@ function dispatchEventWithEnableCapturePhaseSelectiveHydrationWithoutDiscreteEve
targetContainer: EventTarget,
nativeEvent: AnyNativeEvent,
) {
// TODO: replaying capture phase events is currently broken
// because we used to do it during top-level native bubble handlers
// but now we use different bubble and capture handlers.
// In eager mode, we attach capture listeners early, so we need
// to filter them out until we fix the logic to handle them correctly.
const allowReplay = (eventSystemFlags & IS_CAPTURE_PHASE) === 0;
if (
allowReplay &&
hasQueuedDiscreteEvents() &&
isDiscreteEventThatRequiresHydration(domEventName)
) {
// If we already have a queue of discrete events, and this is another discrete
// event, then we can't dispatch it regardless of its target, since they
// need to dispatch in order.
queueDiscreteEvent(
null, // Flags that we're not actually blocked on anything as far as we know.
domEventName,
eventSystemFlags,
targetContainer,
nativeEvent,
);
return;
}
let blockedOn = findInstanceBlockingEvent(
domEventName,
eventSystemFlags,
@@ -306,28 +281,25 @@ function dispatchEventWithEnableCapturePhaseSelectiveHydrationWithoutDiscreteEve
return_targetInst,
targetContainer,
);
if (allowReplay) {
clearIfContinuousEvent(domEventName, nativeEvent);
}
clearIfContinuousEvent(domEventName, nativeEvent);
return;
}
if (allowReplay) {
if (
queueIfContinuousEvent(
blockedOn,
domEventName,
eventSystemFlags,
targetContainer,
nativeEvent,
)
) {
return;
}
// We need to clear only if we didn't queue because
// queueing is accumulative.
clearIfContinuousEvent(domEventName, nativeEvent);
if (
queueIfContinuousEvent(
blockedOn,
domEventName,
eventSystemFlags,
targetContainer,
nativeEvent,
)
) {
nativeEvent.stopPropagation();
return;
}
// We need to clear only if we didn't queue because
// queueing is accumulative.
clearIfContinuousEvent(domEventName, nativeEvent);
if (
eventSystemFlags & IS_CAPTURE_PHASE &&
@@ -358,10 +330,10 @@ function dispatchEventWithEnableCapturePhaseSelectiveHydrationWithoutDiscreteEve
}
blockedOn = nextBlockedOn;
}
if (blockedOn) {
if (blockedOn !== null) {
nativeEvent.stopPropagation();
return;
}
return;
}
// This is not replayable so we'll invoke it but without a target,
+22 -9
View File
@@ -472,15 +472,26 @@ function attemptReplayContinuousQueuedEvent(
queuedEvent.nativeEvent,
);
if (nextBlockedOn === null) {
setReplayingEvent(queuedEvent.nativeEvent);
dispatchEventForPluginEventSystem(
queuedEvent.domEventName,
queuedEvent.eventSystemFlags,
queuedEvent.nativeEvent,
return_targetInst,
targetContainer,
);
resetReplayingEvent();
if (enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay) {
const nativeEvent = queuedEvent.nativeEvent;
const nativeEventClone = new nativeEvent.constructor(
nativeEvent.type,
(nativeEvent: any),
);
setReplayingEvent(nativeEventClone);
nativeEvent.target.dispatchEvent(nativeEventClone);
resetReplayingEvent();
} else {
setReplayingEvent(queuedEvent.nativeEvent);
dispatchEventForPluginEventSystem(
queuedEvent.domEventName,
queuedEvent.eventSystemFlags,
queuedEvent.nativeEvent,
return_targetInst,
targetContainer,
);
resetReplayingEvent();
}
} else {
// We're still blocked. Try again later.
const fiber = getInstanceFromNode(nextBlockedOn);
@@ -532,6 +543,8 @@ function replayUnblockedEvents() {
nextDiscreteEvent.nativeEvent,
);
if (nextBlockedOn === null) {
// This whole function is in !enableCapturePhaseSelectiveHydrationWithoutDiscreteEventReplay,
// so we don't need the new replay behavior code branch.
setReplayingEvent(nextDiscreteEvent.nativeEvent);
dispatchEventForPluginEventSystem(
nextDiscreteEvent.domEventName,