Skip to content

Commit 05f5192

Browse files
authored
[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
1 parent 8a01b50 commit 05f5192

File tree

8 files changed

+335
-54
lines changed

8 files changed

+335
-54
lines changed

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

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ describe('ReactDOMServerPartialHydration', () => {
2525
ReactFeatureFlags = require('shared/ReactFeatureFlags');
2626
ReactFeatureFlags.enableSuspenseServerRenderer = true;
2727
ReactFeatureFlags.enableSuspenseCallback = true;
28+
ReactFeatureFlags.enableFlareAPI = true;
2829

2930
React = require('react');
3031
ReactDOM = require('react-dom');
@@ -1729,4 +1730,169 @@ describe('ReactDOMServerPartialHydration', () => {
17291730
// patched up the tree, which might mean we haven't patched the className.
17301731
expect(newSpan.className).toBe('hi');
17311732
});
1733+
1734+
it('does not invoke an event on a hydrated node until it commits', async () => {
1735+
let suspend = false;
1736+
let resolve;
1737+
let promise = new Promise(resolvePromise => (resolve = resolvePromise));
1738+
1739+
function Sibling({text}) {
1740+
if (suspend) {
1741+
throw promise;
1742+
} else {
1743+
return 'Hello';
1744+
}
1745+
}
1746+
1747+
let clicks = 0;
1748+
1749+
function Button() {
1750+
let [clicked, setClicked] = React.useState(false);
1751+
if (clicked) {
1752+
return null;
1753+
}
1754+
return (
1755+
<a
1756+
onClick={() => {
1757+
setClicked(true);
1758+
clicks++;
1759+
}}>
1760+
Click me
1761+
</a>
1762+
);
1763+
}
1764+
1765+
function App() {
1766+
return (
1767+
<div>
1768+
<Suspense fallback="Loading...">
1769+
<Button />
1770+
<Sibling />
1771+
</Suspense>
1772+
</div>
1773+
);
1774+
}
1775+
1776+
suspend = false;
1777+
let finalHTML = ReactDOMServer.renderToString(<App />);
1778+
let container = document.createElement('div');
1779+
container.innerHTML = finalHTML;
1780+
1781+
// We need this to be in the document since we'll dispatch events on it.
1782+
document.body.appendChild(container);
1783+
1784+
let a = container.getElementsByTagName('a')[0];
1785+
1786+
// On the client we don't have all data yet but we want to start
1787+
// hydrating anyway.
1788+
suspend = true;
1789+
let root = ReactDOM.unstable_createRoot(container, {hydrate: true});
1790+
root.render(<App />);
1791+
Scheduler.unstable_flushAll();
1792+
jest.runAllTimers();
1793+
1794+
expect(container.textContent).toBe('Click meHello');
1795+
1796+
// We're now partially hydrated.
1797+
a.click();
1798+
expect(clicks).toBe(0);
1799+
1800+
// Resolving the promise so that rendering can complete.
1801+
suspend = false;
1802+
resolve();
1803+
await promise;
1804+
1805+
Scheduler.unstable_flushAll();
1806+
jest.runAllTimers();
1807+
1808+
// TODO: With selective hydration the event should've been replayed
1809+
// but for now we'll have to issue it again.
1810+
act(() => {
1811+
a.click();
1812+
});
1813+
1814+
expect(clicks).toBe(1);
1815+
1816+
expect(container.textContent).toBe('Hello');
1817+
1818+
document.body.removeChild(container);
1819+
});
1820+
1821+
it('does not invoke an event on a hydrated EventResponder until it commits', async () => {
1822+
let suspend = false;
1823+
let resolve;
1824+
let promise = new Promise(resolvePromise => (resolve = resolvePromise));
1825+
1826+
function Sibling({text}) {
1827+
if (suspend) {
1828+
throw promise;
1829+
} else {
1830+
return 'Hello';
1831+
}
1832+
}
1833+
1834+
const onEvent = jest.fn();
1835+
const TestResponder = React.unstable_createResponder('TestEventResponder', {
1836+
targetEventTypes: ['click'],
1837+
onEvent,
1838+
});
1839+
1840+
function Button() {
1841+
let listener = React.unstable_useResponder(TestResponder, {});
1842+
return <a listeners={listener}>Click me</a>;
1843+
}
1844+
1845+
function App() {
1846+
return (
1847+
<div>
1848+
<Suspense fallback="Loading...">
1849+
<Button />
1850+
<Sibling />
1851+
</Suspense>
1852+
</div>
1853+
);
1854+
}
1855+
1856+
suspend = false;
1857+
let finalHTML = ReactDOMServer.renderToString(<App />);
1858+
let container = document.createElement('div');
1859+
container.innerHTML = finalHTML;
1860+
1861+
// We need this to be in the document since we'll dispatch events on it.
1862+
document.body.appendChild(container);
1863+
1864+
let a = container.getElementsByTagName('a')[0];
1865+
1866+
// On the client we don't have all data yet but we want to start
1867+
// hydrating anyway.
1868+
suspend = true;
1869+
let root = ReactDOM.unstable_createRoot(container, {hydrate: true});
1870+
root.render(<App />);
1871+
Scheduler.unstable_flushAll();
1872+
jest.runAllTimers();
1873+
1874+
// We're now partially hydrated.
1875+
a.click();
1876+
// We should not have invoked the event yet because we're not
1877+
// yet hydrated.
1878+
expect(onEvent).toHaveBeenCalledTimes(0);
1879+
1880+
// Resolving the promise so that rendering can complete.
1881+
suspend = false;
1882+
resolve();
1883+
await promise;
1884+
1885+
Scheduler.unstable_flushAll();
1886+
jest.runAllTimers();
1887+
1888+
// TODO: With selective hydration the event should've been replayed
1889+
// but for now we'll have to issue it again.
1890+
act(() => {
1891+
a.click();
1892+
});
1893+
1894+
expect(onEvent).toHaveBeenCalledTimes(1);
1895+
1896+
document.body.removeChild(container);
1897+
});
17321898
});

packages/react-dom/src/__tests__/ReactServerRenderingHydration-test.js

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ let React;
1313
let ReactDOM;
1414
let ReactDOMServer;
1515
let Scheduler;
16+
let act;
1617

1718
// These tests rely both on ReactDOMServer and ReactDOM.
1819
// If a test only needs ReactDOMServer, put it in ReactServerRendering-test instead.
@@ -23,6 +24,7 @@ describe('ReactDOMServerHydration', () => {
2324
ReactDOM = require('react-dom');
2425
ReactDOMServer = require('react-dom/server');
2526
Scheduler = require('scheduler');
27+
act = require('react-dom/test-utils').act;
2628
});
2729

2830
it('should have the correct mounting behavior (old hydrate API)', () => {
@@ -499,4 +501,89 @@ describe('ReactDOMServerHydration', () => {
499501
Scheduler.unstable_flushAll();
500502
expect(element.textContent).toBe('Hello world');
501503
});
504+
505+
it('does not invoke an event on a concurrent hydrating node until it commits', () => {
506+
function Sibling({text}) {
507+
Scheduler.unstable_yieldValue('Sibling');
508+
return <span>Sibling</span>;
509+
}
510+
511+
function Sibling2({text}) {
512+
Scheduler.unstable_yieldValue('Sibling2');
513+
return null;
514+
}
515+
516+
let clicks = 0;
517+
518+
function Button() {
519+
Scheduler.unstable_yieldValue('Button');
520+
let [clicked, setClicked] = React.useState(false);
521+
if (clicked) {
522+
return null;
523+
}
524+
return (
525+
<a
526+
onClick={() => {
527+
setClicked(true);
528+
clicks++;
529+
}}>
530+
Click me
531+
</a>
532+
);
533+
}
534+
535+
function App() {
536+
return (
537+
<div>
538+
<Button />
539+
<Sibling />
540+
<Sibling2 />
541+
</div>
542+
);
543+
}
544+
545+
let finalHTML = ReactDOMServer.renderToString(<App />);
546+
let container = document.createElement('div');
547+
container.innerHTML = finalHTML;
548+
expect(Scheduler).toHaveYielded(['Button', 'Sibling', 'Sibling2']);
549+
550+
// We need this to be in the document since we'll dispatch events on it.
551+
document.body.appendChild(container);
552+
553+
let a = container.getElementsByTagName('a')[0];
554+
555+
// Hydrate asynchronously.
556+
let root = ReactDOM.unstable_createRoot(container, {hydrate: true});
557+
root.render(<App />);
558+
// Flush part way through the render.
559+
if (__DEV__) {
560+
// In DEV effects gets double invoked.
561+
expect(Scheduler).toFlushAndYieldThrough(['Button', 'Button', 'Sibling']);
562+
} else {
563+
expect(Scheduler).toFlushAndYieldThrough(['Button', 'Sibling']);
564+
}
565+
566+
expect(container.textContent).toBe('Click meSibling');
567+
568+
// We're now partially hydrated.
569+
a.click();
570+
// Clicking should not invoke the event yet because we haven't committed
571+
// the hydration yet.
572+
expect(clicks).toBe(0);
573+
574+
// Finish the rest of the hydration.
575+
expect(Scheduler).toFlushAndYield(['Sibling2']);
576+
577+
// TODO: With selective hydration the event should've been replayed
578+
// but for now we'll have to issue it again.
579+
act(() => {
580+
a.click();
581+
});
582+
583+
expect(clicks).toBe(1);
584+
585+
expect(container.textContent).toBe('Sibling');
586+
587+
document.body.removeChild(container);
588+
});
502589
});

packages/react-dom/src/client/ReactDOMComponentTree.js

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,26 +23,29 @@ export function precacheFiberNode(hostInst, node) {
2323
* ReactDOMTextComponent instance ancestor.
2424
*/
2525
export function getClosestInstanceFromNode(node) {
26-
if (node[internalInstanceKey]) {
27-
return node[internalInstanceKey];
26+
let inst = node[internalInstanceKey];
27+
if (inst) {
28+
return inst;
2829
}
2930

30-
while (!node[internalInstanceKey]) {
31-
if (node.parentNode) {
32-
node = node.parentNode;
31+
do {
32+
node = node.parentNode;
33+
if (node) {
34+
inst = node[internalInstanceKey];
3335
} else {
3436
// Top of the tree. This node must not be part of a React tree (or is
3537
// unmounted, potentially).
3638
return null;
3739
}
38-
}
40+
} while (!inst);
3941

40-
let inst = node[internalInstanceKey];
41-
if (inst.tag === HostComponent || inst.tag === HostText) {
42-
// In Fiber, this will always be the deepest root.
43-
return inst;
42+
let tag = inst.tag;
43+
switch (tag) {
44+
case HostComponent:
45+
case HostText:
46+
// In Fiber, this will always be the deepest root.
47+
return inst;
4448
}
45-
4649
return null;
4750
}
4851

packages/react-reconciler/src/ReactFiberBeginWork.js

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import {
4646
NoEffect,
4747
PerformedWork,
4848
Placement,
49+
Hydrating,
4950
ContentReset,
5051
DidCapture,
5152
Update,
@@ -944,11 +945,10 @@ function updateHostRoot(current, workInProgress, renderExpirationTime) {
944945
// be any children to hydrate which is effectively the same thing as
945946
// not hydrating.
946947

947-
// This is a bit of a hack. We track the host root as a placement to
948-
// know that we're currently in a mounting state. That way isMounted
949-
// works as expected. We must reset this before committing.
950-
// TODO: Delete this when we delete isMounted and findDOMNode.
951-
workInProgress.effectTag |= Placement;
948+
// Mark the host root with a Hydrating effect to know that we're
949+
// currently in a mounting state. That way isMounted, findDOMNode and
950+
// event replaying works as expected.
951+
workInProgress.effectTag |= Hydrating;
952952

953953
// Ensure that children mount into this root without tracking
954954
// side-effects. This ensures that we don't store Placement effects on
@@ -2095,12 +2095,24 @@ function updateDehydratedSuspenseComponent(
20952095
);
20962096
const nextProps = workInProgress.pendingProps;
20972097
const nextChildren = nextProps.children;
2098-
workInProgress.child = mountChildFibers(
2098+
const child = mountChildFibers(
20992099
workInProgress,
21002100
null,
21012101
nextChildren,
21022102
renderExpirationTime,
21032103
);
2104+
let node = child;
2105+
while (node) {
2106+
// Mark each child as hydrating. This is a fast path to know whether this
2107+
// tree is part of a hydrating tree. This is used to determine if a child
2108+
// node has fully mounted yet, and for scheduling event replaying.
2109+
// Conceptually this is similar to Placement in that a new subtree is
2110+
// inserted into the React tree here. It just happens to not need DOM
2111+
// mutations because it already exists.
2112+
node.effectTag |= Hydrating;
2113+
node = node.sibling;
2114+
}
2115+
workInProgress.child = child;
21042116
return workInProgress.child;
21052117
}
21062118
}

0 commit comments

Comments
 (0)