Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 163 additions & 0 deletions compat/test/browser/hydrationYield.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { setupRerender } from 'preact/test-utils';
import React, {
createElement,
hydrate,
Suspense,
useState,
useLayoutEffect
} from 'preact/compat';
import { options } from 'preact';
import { setupScratch, teardown } from '../../../test/_util/helpers';
import { createLazy } from './suspense-utils';

/* eslint-env browser */

/**
* Time-sliced hydration (options._yield, see #407) must compose with real
* suspensions: the slicing sentinel is intercepted by the scheduler's
* _catchError wrapper while genuine thenables (lazy/data suspensions) fall
* through to compat's Suspense handling.
*/
describe('hydration yielding + Suspense interop', () => {
/** @type {HTMLDivElement} */
let scratch, rerender;
let unhandledEvents = [];

let budget;
let queue;
let uninstall;

function onUnhandledRejection(event) {
unhandledEvents.push(event);
}

function installSlicing() {
// eslint-disable-next-line unicorn/no-thenable
const sentinel = { then() {} };
queue = [];
budget = Infinity;

const prevYield = options._yield;
const prevCatchError = options._catchError;

options._yield = vnode => {
if (vnode._parent && --budget < 0) throw sentinel;
};

options._catchError = (error, vnode, oldVNode, errorInfo) => {
if (error === sentinel) {
queue.push(vnode._component);
return;
}
prevCatchError(error, vnode, oldVNode, errorInfo);
};

uninstall = () => {
options._yield = prevYield;
options._catchError = prevCatchError;
};
}

function flushSlice(n) {
budget = n;
const pending = queue.splice(0);
pending.forEach(c => c.forceUpdate());
rerender();
}

beforeEach(() => {
scratch = setupScratch();
rerender = setupRerender();
installSlicing();

unhandledEvents = [];
if ('onunhandledrejection' in window) {
window.addEventListener('unhandledrejection', onUnhandledRejection);
}
});

afterEach(() => {
uninstall();
teardown(scratch);

if ('onunhandledrejection' in window) {
window.removeEventListener('unhandledrejection', onUnhandledRejection);
if (unhandledEvents.length) {
throw unhandledEvents[0].reason;
}
}
});

it('slices around a real lazy suspension without interfering', () => {
const clicks = [];
const [Lazy, resolve] = createLazy();
const Item = ({ name }) => (
<li onClick={() => clicks.push(name)}>{name}</li>
);
const App = () => (
<ul>
<Item name="a" />
<Suspense fallback={null}>
<Lazy />
</Suspense>
<Item name="c" />
</ul>
);

const html = '<ul><li>a</li><li>b</li><li>c</li></ul>';
scratch.innerHTML = html;
const lis = Array.from(scratch.querySelectorAll('li'));

// App + Item a mount; Suspense, Lazy and Item c all defer: Lazy via a
// real promise, the others via the slicing sentinel
budget = 2;
hydrate(<App />, scratch);
expect(scratch.innerHTML).to.equal(html);

// Resume the sliced components; the lazy subtree stays pending
flushSlice(Infinity);
expect(scratch.innerHTML).to.equal(html);
lis[0].click();
lis[2].click();
expect(clicks).to.deep.equal(['a', 'c']);
lis[1].click();
expect(clicks).to.deep.equal(['a', 'c']); // lazy still inert

return resolve(() => <Item name="b" />).then(() => {
rerender();
expect(scratch.innerHTML).to.equal(html);
expect(Array.from(scratch.querySelectorAll('li'))).to.deep.equal(lis);
lis[1].click();
expect(clicks).to.deep.equal(['a', 'c', 'b']);
});
});

it('runs hooks and effects exactly once for components resumed in a slice', () => {
let effects = 0;
function Counter() {
const [n, setN] = useState(0);
useLayoutEffect(() => {
effects++;
}, []);
return <button onClick={() => setN(n + 1)}>{n}</button>;
}

scratch.innerHTML = '<button>0</button>';
const button = scratch.firstChild;

budget = 0;
hydrate(<Counter />, scratch);
expect(queue.length).to.equal(1);
expect(effects).to.equal(0);

flushSlice(Infinity);
expect(effects).to.equal(1);
expect(scratch.firstChild).to.equal(button);

button.click();
rerender();
expect(effects).to.equal(1);
expect(scratch.firstChild).to.equal(button);
expect(button.textContent).to.equal('1');
});
});
3 changes: 2 additions & 1 deletion mangle.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,8 @@
"$_skipEffects": "__s",
"$_forwarded": "__f",
"$_isSuspended": "__i",
"$_bits": "__g"
"$_bits": "__g",
"$_yield": "__y"
}
}
}
24 changes: 23 additions & 1 deletion src/diff/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export function diff(
) {
/** @type {any} */
let tmp,
resumedExcess,
newType = newVNode.type;

// When passing through createElement it assigns the object
Expand All @@ -84,7 +85,7 @@ export function diff(
oldVNode._component._excess
) {
let excess = oldVNode._component._excess;
excessDomChildren = [];
resumedExcess = excessDomChildren = [];
if (excess.nodeType == 8) {
// Re-scan DOM from stored start marker for streamed hydration
for (
Expand Down Expand Up @@ -253,6 +254,13 @@ export function diff(
c._parentDom = parentDom;
c._bits &= ~COMPONENT_FORCE;

// Freshly mounting components during hydration may self-suspend to
// slice up the hydration walk; the hook throws a thenable to bail
// out here and resume from `_excess` later.
if (isHydrating && !oldVNode._component && (tmp = options._yield)) {
tmp(newVNode);
}

let renderHook = options._render,
count = 0;
if (isClassComponent) {
Expand Down Expand Up @@ -315,6 +323,15 @@ export function diff(
// We successfully rendered this VNode, unset any stored hydration/bailout state:
newVNode._flags &= RESET_MODE;

// The resume-owned excess array is not shared with any parent frame,
// so nodes the resumed subtree didn't adopt are SSR leftovers (e.g. a
// node claimed by a component that then rendered null) — remove them.
if (resumedExcess) {
for (tmp = resumedExcess.length; tmp--; ) {
removeNode(resumedExcess[tmp]);
}
}

if (c._renderCallbacks.length) {
commitQueue.push(c);
}
Expand All @@ -334,6 +351,11 @@ export function diff(
let commentMarkersToFind = 0,
startMarker;

// Components that suspend before their first render still have
// COMPONENT_DIRTY set from instantiation; clear it so that the
// resuming forceUpdate isn't ignored by enqueueRender.
newVNode._component._bits &= ~COMPONENT_DIRTY;

newVNode._flags |= isHydrating
? MODE_HYDRATE | MODE_SUSPENDED
: MODE_SUSPENDED;
Expand Down
6 changes: 6 additions & 0 deletions src/internal.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ export interface Options extends preact.Options {
vnode: VNode,
excessDomChildren: Array<PreactElement | null>
): void;
/**
* Attach a hook that is invoked before a freshly mounting component renders
* during hydration. Throwing a thenable from this hook suspends the
* component, allowing the hydration walk to be sliced into multiple tasks.
*/
_yield?(vnode: VNode): void;
}

export type ComponentChild =
Expand Down
Loading
Loading