Skip to content

Commit

Permalink
[Partial Hydration] Dispatching events should not work until hydratio…
Browse files Browse the repository at this point in the history
…n 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
  • Loading branch information
sebmarkbage authored Aug 22, 2019
1 parent 8a01b50 commit 05f5192
Show file tree
Hide file tree
Showing 8 changed files with 335 additions and 54 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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)', () => {
Expand Down Expand Up @@ -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);
});
});
25 changes: 14 additions & 11 deletions packages/react-dom/src/client/ReactDOMComponentTree.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
24 changes: 18 additions & 6 deletions packages/react-reconciler/src/ReactFiberBeginWork.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
NoEffect,
PerformedWork,
Placement,
Hydrating,
ContentReset,
DidCapture,
Update,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
}
Expand Down
Loading

0 comments on commit 05f5192

Please sign in to comment.