[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:
Sebastian Markbåge
2019-08-22 08:46:20 -07:00
committed by GitHub
parent 8a01b50fc3
commit 05f5192e81
8 changed files with 335 additions and 54 deletions

View File

@@ -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);
});
});

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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);

View File

@@ -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;