mirror of
https://github.com/zebrajr/react.git
synced 2026-01-15 12:15:22 +00:00
[Partial Hydration] Dispatching events should not work until hydration commits (#16532)
* Refactor a bit to use less property access * Add test for invoking an event before mount * Add Hydration effect tag This is equivalent to a "Placement" effect in that it's a new insertion to the tree but it doesn't need an actual mutation. It is only used to determine if a subtree has actually mounted yet. * Use the Hydration flag for Roots Previous roots had a Placement flag on them as a hack for this case but since we have a special flag for it now, we can just use that. * Add Flare test
This commit is contained in:
committed by
GitHub
parent
8a01b50fc3
commit
05f5192e81
@@ -25,6 +25,7 @@ describe('ReactDOMServerPartialHydration', () => {
|
||||
ReactFeatureFlags = require('shared/ReactFeatureFlags');
|
||||
ReactFeatureFlags.enableSuspenseServerRenderer = true;
|
||||
ReactFeatureFlags.enableSuspenseCallback = true;
|
||||
ReactFeatureFlags.enableFlareAPI = true;
|
||||
|
||||
React = require('react');
|
||||
ReactDOM = require('react-dom');
|
||||
@@ -1729,4 +1730,169 @@ describe('ReactDOMServerPartialHydration', () => {
|
||||
// patched up the tree, which might mean we haven't patched the className.
|
||||
expect(newSpan.className).toBe('hi');
|
||||
});
|
||||
|
||||
it('does not invoke an event on a hydrated node until it commits', async () => {
|
||||
let suspend = false;
|
||||
let resolve;
|
||||
let promise = new Promise(resolvePromise => (resolve = resolvePromise));
|
||||
|
||||
function Sibling({text}) {
|
||||
if (suspend) {
|
||||
throw promise;
|
||||
} else {
|
||||
return 'Hello';
|
||||
}
|
||||
}
|
||||
|
||||
let clicks = 0;
|
||||
|
||||
function Button() {
|
||||
let [clicked, setClicked] = React.useState(false);
|
||||
if (clicked) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<a
|
||||
onClick={() => {
|
||||
setClicked(true);
|
||||
clicks++;
|
||||
}}>
|
||||
Click me
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div>
|
||||
<Suspense fallback="Loading...">
|
||||
<Button />
|
||||
<Sibling />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
suspend = false;
|
||||
let finalHTML = ReactDOMServer.renderToString(<App />);
|
||||
let container = document.createElement('div');
|
||||
container.innerHTML = finalHTML;
|
||||
|
||||
// We need this to be in the document since we'll dispatch events on it.
|
||||
document.body.appendChild(container);
|
||||
|
||||
let a = container.getElementsByTagName('a')[0];
|
||||
|
||||
// On the client we don't have all data yet but we want to start
|
||||
// hydrating anyway.
|
||||
suspend = true;
|
||||
let root = ReactDOM.unstable_createRoot(container, {hydrate: true});
|
||||
root.render(<App />);
|
||||
Scheduler.unstable_flushAll();
|
||||
jest.runAllTimers();
|
||||
|
||||
expect(container.textContent).toBe('Click meHello');
|
||||
|
||||
// We're now partially hydrated.
|
||||
a.click();
|
||||
expect(clicks).toBe(0);
|
||||
|
||||
// Resolving the promise so that rendering can complete.
|
||||
suspend = false;
|
||||
resolve();
|
||||
await promise;
|
||||
|
||||
Scheduler.unstable_flushAll();
|
||||
jest.runAllTimers();
|
||||
|
||||
// TODO: With selective hydration the event should've been replayed
|
||||
// but for now we'll have to issue it again.
|
||||
act(() => {
|
||||
a.click();
|
||||
});
|
||||
|
||||
expect(clicks).toBe(1);
|
||||
|
||||
expect(container.textContent).toBe('Hello');
|
||||
|
||||
document.body.removeChild(container);
|
||||
});
|
||||
|
||||
it('does not invoke an event on a hydrated EventResponder until it commits', async () => {
|
||||
let suspend = false;
|
||||
let resolve;
|
||||
let promise = new Promise(resolvePromise => (resolve = resolvePromise));
|
||||
|
||||
function Sibling({text}) {
|
||||
if (suspend) {
|
||||
throw promise;
|
||||
} else {
|
||||
return 'Hello';
|
||||
}
|
||||
}
|
||||
|
||||
const onEvent = jest.fn();
|
||||
const TestResponder = React.unstable_createResponder('TestEventResponder', {
|
||||
targetEventTypes: ['click'],
|
||||
onEvent,
|
||||
});
|
||||
|
||||
function Button() {
|
||||
let listener = React.unstable_useResponder(TestResponder, {});
|
||||
return <a listeners={listener}>Click me</a>;
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div>
|
||||
<Suspense fallback="Loading...">
|
||||
<Button />
|
||||
<Sibling />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
suspend = false;
|
||||
let finalHTML = ReactDOMServer.renderToString(<App />);
|
||||
let container = document.createElement('div');
|
||||
container.innerHTML = finalHTML;
|
||||
|
||||
// We need this to be in the document since we'll dispatch events on it.
|
||||
document.body.appendChild(container);
|
||||
|
||||
let a = container.getElementsByTagName('a')[0];
|
||||
|
||||
// On the client we don't have all data yet but we want to start
|
||||
// hydrating anyway.
|
||||
suspend = true;
|
||||
let root = ReactDOM.unstable_createRoot(container, {hydrate: true});
|
||||
root.render(<App />);
|
||||
Scheduler.unstable_flushAll();
|
||||
jest.runAllTimers();
|
||||
|
||||
// We're now partially hydrated.
|
||||
a.click();
|
||||
// We should not have invoked the event yet because we're not
|
||||
// yet hydrated.
|
||||
expect(onEvent).toHaveBeenCalledTimes(0);
|
||||
|
||||
// Resolving the promise so that rendering can complete.
|
||||
suspend = false;
|
||||
resolve();
|
||||
await promise;
|
||||
|
||||
Scheduler.unstable_flushAll();
|
||||
jest.runAllTimers();
|
||||
|
||||
// TODO: With selective hydration the event should've been replayed
|
||||
// but for now we'll have to issue it again.
|
||||
act(() => {
|
||||
a.click();
|
||||
});
|
||||
|
||||
expect(onEvent).toHaveBeenCalledTimes(1);
|
||||
|
||||
document.body.removeChild(container);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ let React;
|
||||
let ReactDOM;
|
||||
let ReactDOMServer;
|
||||
let Scheduler;
|
||||
let act;
|
||||
|
||||
// These tests rely both on ReactDOMServer and ReactDOM.
|
||||
// If a test only needs ReactDOMServer, put it in ReactServerRendering-test instead.
|
||||
@@ -23,6 +24,7 @@ describe('ReactDOMServerHydration', () => {
|
||||
ReactDOM = require('react-dom');
|
||||
ReactDOMServer = require('react-dom/server');
|
||||
Scheduler = require('scheduler');
|
||||
act = require('react-dom/test-utils').act;
|
||||
});
|
||||
|
||||
it('should have the correct mounting behavior (old hydrate API)', () => {
|
||||
@@ -499,4 +501,89 @@ describe('ReactDOMServerHydration', () => {
|
||||
Scheduler.unstable_flushAll();
|
||||
expect(element.textContent).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('does not invoke an event on a concurrent hydrating node until it commits', () => {
|
||||
function Sibling({text}) {
|
||||
Scheduler.unstable_yieldValue('Sibling');
|
||||
return <span>Sibling</span>;
|
||||
}
|
||||
|
||||
function Sibling2({text}) {
|
||||
Scheduler.unstable_yieldValue('Sibling2');
|
||||
return null;
|
||||
}
|
||||
|
||||
let clicks = 0;
|
||||
|
||||
function Button() {
|
||||
Scheduler.unstable_yieldValue('Button');
|
||||
let [clicked, setClicked] = React.useState(false);
|
||||
if (clicked) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<a
|
||||
onClick={() => {
|
||||
setClicked(true);
|
||||
clicks++;
|
||||
}}>
|
||||
Click me
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div>
|
||||
<Button />
|
||||
<Sibling />
|
||||
<Sibling2 />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let finalHTML = ReactDOMServer.renderToString(<App />);
|
||||
let container = document.createElement('div');
|
||||
container.innerHTML = finalHTML;
|
||||
expect(Scheduler).toHaveYielded(['Button', 'Sibling', 'Sibling2']);
|
||||
|
||||
// We need this to be in the document since we'll dispatch events on it.
|
||||
document.body.appendChild(container);
|
||||
|
||||
let a = container.getElementsByTagName('a')[0];
|
||||
|
||||
// Hydrate asynchronously.
|
||||
let root = ReactDOM.unstable_createRoot(container, {hydrate: true});
|
||||
root.render(<App />);
|
||||
// Flush part way through the render.
|
||||
if (__DEV__) {
|
||||
// In DEV effects gets double invoked.
|
||||
expect(Scheduler).toFlushAndYieldThrough(['Button', 'Button', 'Sibling']);
|
||||
} else {
|
||||
expect(Scheduler).toFlushAndYieldThrough(['Button', 'Sibling']);
|
||||
}
|
||||
|
||||
expect(container.textContent).toBe('Click meSibling');
|
||||
|
||||
// We're now partially hydrated.
|
||||
a.click();
|
||||
// Clicking should not invoke the event yet because we haven't committed
|
||||
// the hydration yet.
|
||||
expect(clicks).toBe(0);
|
||||
|
||||
// Finish the rest of the hydration.
|
||||
expect(Scheduler).toFlushAndYield(['Sibling2']);
|
||||
|
||||
// TODO: With selective hydration the event should've been replayed
|
||||
// but for now we'll have to issue it again.
|
||||
act(() => {
|
||||
a.click();
|
||||
});
|
||||
|
||||
expect(clicks).toBe(1);
|
||||
|
||||
expect(container.textContent).toBe('Sibling');
|
||||
|
||||
document.body.removeChild(container);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,26 +23,29 @@ export function precacheFiberNode(hostInst, node) {
|
||||
* ReactDOMTextComponent instance ancestor.
|
||||
*/
|
||||
export function getClosestInstanceFromNode(node) {
|
||||
if (node[internalInstanceKey]) {
|
||||
return node[internalInstanceKey];
|
||||
let inst = node[internalInstanceKey];
|
||||
if (inst) {
|
||||
return inst;
|
||||
}
|
||||
|
||||
while (!node[internalInstanceKey]) {
|
||||
if (node.parentNode) {
|
||||
node = node.parentNode;
|
||||
do {
|
||||
node = node.parentNode;
|
||||
if (node) {
|
||||
inst = node[internalInstanceKey];
|
||||
} else {
|
||||
// Top of the tree. This node must not be part of a React tree (or is
|
||||
// unmounted, potentially).
|
||||
return null;
|
||||
}
|
||||
}
|
||||
} while (!inst);
|
||||
|
||||
let inst = node[internalInstanceKey];
|
||||
if (inst.tag === HostComponent || inst.tag === HostText) {
|
||||
// In Fiber, this will always be the deepest root.
|
||||
return inst;
|
||||
let tag = inst.tag;
|
||||
switch (tag) {
|
||||
case HostComponent:
|
||||
case HostText:
|
||||
// In Fiber, this will always be the deepest root.
|
||||
return inst;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
NoEffect,
|
||||
PerformedWork,
|
||||
Placement,
|
||||
Hydrating,
|
||||
ContentReset,
|
||||
DidCapture,
|
||||
Update,
|
||||
@@ -944,11 +945,10 @@ function updateHostRoot(current, workInProgress, renderExpirationTime) {
|
||||
// be any children to hydrate which is effectively the same thing as
|
||||
// not hydrating.
|
||||
|
||||
// This is a bit of a hack. We track the host root as a placement to
|
||||
// know that we're currently in a mounting state. That way isMounted
|
||||
// works as expected. We must reset this before committing.
|
||||
// TODO: Delete this when we delete isMounted and findDOMNode.
|
||||
workInProgress.effectTag |= Placement;
|
||||
// Mark the host root with a Hydrating effect to know that we're
|
||||
// currently in a mounting state. That way isMounted, findDOMNode and
|
||||
// event replaying works as expected.
|
||||
workInProgress.effectTag |= Hydrating;
|
||||
|
||||
// Ensure that children mount into this root without tracking
|
||||
// side-effects. This ensures that we don't store Placement effects on
|
||||
@@ -2095,12 +2095,24 @@ function updateDehydratedSuspenseComponent(
|
||||
);
|
||||
const nextProps = workInProgress.pendingProps;
|
||||
const nextChildren = nextProps.children;
|
||||
workInProgress.child = mountChildFibers(
|
||||
const child = mountChildFibers(
|
||||
workInProgress,
|
||||
null,
|
||||
nextChildren,
|
||||
renderExpirationTime,
|
||||
);
|
||||
let node = child;
|
||||
while (node) {
|
||||
// Mark each child as hydrating. This is a fast path to know whether this
|
||||
// tree is part of a hydrating tree. This is used to determine if a child
|
||||
// node has fully mounted yet, and for scheduling event replaying.
|
||||
// Conceptually this is similar to Placement in that a new subtree is
|
||||
// inserted into the React tree here. It just happens to not need DOM
|
||||
// mutations because it already exists.
|
||||
node.effectTag |= Hydrating;
|
||||
node = node.sibling;
|
||||
}
|
||||
workInProgress.child = child;
|
||||
return workInProgress.child;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +56,6 @@ import {
|
||||
} from 'shared/ReactWorkTags';
|
||||
import {NoMode, BatchedMode} from './ReactTypeOfMode';
|
||||
import {
|
||||
Placement,
|
||||
Ref,
|
||||
Update,
|
||||
NoEffect,
|
||||
@@ -670,9 +669,6 @@ function completeWork(
|
||||
// If we hydrated, pop so that we can delete any remaining children
|
||||
// that weren't hydrated.
|
||||
popHydrationState(workInProgress);
|
||||
// This resets the hacky state to fix isMounted before committing.
|
||||
// TODO: Delete this when we delete isMounted and findDOMNode.
|
||||
workInProgress.effectTag &= ~Placement;
|
||||
}
|
||||
updateHostContainer(workInProgress);
|
||||
break;
|
||||
@@ -859,14 +855,13 @@ function completeWork(
|
||||
if ((workInProgress.effectTag & DidCapture) === NoEffect) {
|
||||
// This boundary did not suspend so it's now hydrated and unsuspended.
|
||||
workInProgress.memoizedState = null;
|
||||
if (enableSuspenseCallback) {
|
||||
// Notify the callback.
|
||||
workInProgress.effectTag |= Update;
|
||||
}
|
||||
} else {
|
||||
// Something suspended. Schedule an effect to attach retry listeners.
|
||||
workInProgress.effectTag |= Update;
|
||||
}
|
||||
// If nothing suspended, we need to schedule an effect to mark this boundary
|
||||
// as having hydrated so events know that they're free be invoked.
|
||||
// It's also a signal to replay events and the suspense callback.
|
||||
// If something suspended, schedule an effect to attach retry listeners.
|
||||
// So we might as well always mark this.
|
||||
workInProgress.effectTag |= Update;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
HostText,
|
||||
FundamentalComponent,
|
||||
} from 'shared/ReactWorkTags';
|
||||
import {NoEffect, Placement} from 'shared/ReactSideEffectTags';
|
||||
import {NoEffect, Placement, Hydrating} from 'shared/ReactSideEffectTags';
|
||||
import {enableFundamentalAPI} from 'shared/ReactFeatureFlags';
|
||||
|
||||
const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner;
|
||||
@@ -32,20 +32,21 @@ const MOUNTING = 1;
|
||||
const MOUNTED = 2;
|
||||
const UNMOUNTED = 3;
|
||||
|
||||
function isFiberMountedImpl(fiber: Fiber): number {
|
||||
type MountState = 1 | 2 | 3;
|
||||
|
||||
function isFiberMountedImpl(fiber: Fiber): MountState {
|
||||
let node = fiber;
|
||||
if (!fiber.alternate) {
|
||||
// If there is no alternate, this might be a new tree that isn't inserted
|
||||
// yet. If it is, then it will have a pending insertion effect on it.
|
||||
if ((node.effectTag & Placement) !== NoEffect) {
|
||||
return MOUNTING;
|
||||
}
|
||||
while (node.return) {
|
||||
node = node.return;
|
||||
if ((node.effectTag & Placement) !== NoEffect) {
|
||||
let nextNode = node;
|
||||
do {
|
||||
node = nextNode;
|
||||
if ((node.effectTag & (Placement | Hydrating)) !== NoEffect) {
|
||||
return MOUNTING;
|
||||
}
|
||||
}
|
||||
nextNode = node.return;
|
||||
} while (nextNode);
|
||||
} else {
|
||||
while (node.return) {
|
||||
node = node.return;
|
||||
|
||||
@@ -96,6 +96,8 @@ import {
|
||||
Passive,
|
||||
Incomplete,
|
||||
HostEffectMask,
|
||||
Hydrating,
|
||||
HydratingAndUpdate,
|
||||
} from 'shared/ReactSideEffectTags';
|
||||
import {
|
||||
NoWork,
|
||||
@@ -1860,7 +1862,8 @@ function commitMutationEffects(root: FiberRoot, renderPriorityLevel) {
|
||||
// updates, and deletions. To avoid needing to add a case for every possible
|
||||
// bitmap value, we remove the secondary effects from the effect tag and
|
||||
// switch on that value.
|
||||
let primaryEffectTag = effectTag & (Placement | Update | Deletion);
|
||||
let primaryEffectTag =
|
||||
effectTag & (Placement | Update | Deletion | Hydrating);
|
||||
switch (primaryEffectTag) {
|
||||
case Placement: {
|
||||
commitPlacement(nextEffect);
|
||||
@@ -1883,6 +1886,18 @@ function commitMutationEffects(root: FiberRoot, renderPriorityLevel) {
|
||||
commitWork(current, nextEffect);
|
||||
break;
|
||||
}
|
||||
case Hydrating: {
|
||||
nextEffect.effectTag &= ~Hydrating;
|
||||
break;
|
||||
}
|
||||
case HydratingAndUpdate: {
|
||||
nextEffect.effectTag &= ~Hydrating;
|
||||
|
||||
// Update
|
||||
const current = nextEffect.alternate;
|
||||
commitWork(current, nextEffect);
|
||||
break;
|
||||
}
|
||||
case Update: {
|
||||
const current = nextEffect.alternate;
|
||||
commitWork(current, nextEffect);
|
||||
|
||||
@@ -10,26 +10,28 @@
|
||||
export type SideEffectTag = number;
|
||||
|
||||
// Don't change these two values. They're used by React Dev Tools.
|
||||
export const NoEffect = /* */ 0b000000000000;
|
||||
export const PerformedWork = /* */ 0b000000000001;
|
||||
export const NoEffect = /* */ 0b0000000000000;
|
||||
export const PerformedWork = /* */ 0b0000000000001;
|
||||
|
||||
// You can change the rest (and add more).
|
||||
export const Placement = /* */ 0b000000000010;
|
||||
export const Update = /* */ 0b000000000100;
|
||||
export const PlacementAndUpdate = /* */ 0b000000000110;
|
||||
export const Deletion = /* */ 0b000000001000;
|
||||
export const ContentReset = /* */ 0b000000010000;
|
||||
export const Callback = /* */ 0b000000100000;
|
||||
export const DidCapture = /* */ 0b000001000000;
|
||||
export const Ref = /* */ 0b000010000000;
|
||||
export const Snapshot = /* */ 0b000100000000;
|
||||
export const Passive = /* */ 0b001000000000;
|
||||
export const Placement = /* */ 0b0000000000010;
|
||||
export const Update = /* */ 0b0000000000100;
|
||||
export const PlacementAndUpdate = /* */ 0b0000000000110;
|
||||
export const Deletion = /* */ 0b0000000001000;
|
||||
export const ContentReset = /* */ 0b0000000010000;
|
||||
export const Callback = /* */ 0b0000000100000;
|
||||
export const DidCapture = /* */ 0b0000001000000;
|
||||
export const Ref = /* */ 0b0000010000000;
|
||||
export const Snapshot = /* */ 0b0000100000000;
|
||||
export const Passive = /* */ 0b0001000000000;
|
||||
export const Hydrating = /* */ 0b0010000000000;
|
||||
export const HydratingAndUpdate = /* */ 0b0010000000100;
|
||||
|
||||
// Passive & Update & Callback & Ref & Snapshot
|
||||
export const LifecycleEffectMask = /* */ 0b001110100100;
|
||||
export const LifecycleEffectMask = /* */ 0b0001110100100;
|
||||
|
||||
// Union of all host effects
|
||||
export const HostEffectMask = /* */ 0b001111111111;
|
||||
export const HostEffectMask = /* */ 0b0011111111111;
|
||||
|
||||
export const Incomplete = /* */ 0b010000000000;
|
||||
export const ShouldCapture = /* */ 0b100000000000;
|
||||
export const Incomplete = /* */ 0b0100000000000;
|
||||
export const ShouldCapture = /* */ 0b1000000000000;
|
||||
|
||||
Reference in New Issue
Block a user