Skip to content
Merged
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
2 changes: 1 addition & 1 deletion compat/src/internal.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ export interface FunctionComponent<P = {}> extends PreactFunctionComponent<P> {
export interface VNode<T = any> extends PreactVNode<T> {
$$typeof?: symbol;
preactCompatNormalized?: boolean;
_mask?: [number, number];
}

export interface SuspenseState {
Expand All @@ -45,4 +44,5 @@ export interface SuspenseComponent
_pendingSuspensionCount: number;
_suspenders: Component[];
_detachOnNextRender: null | VNode<any>;
_mask?: [number, number];
}
15 changes: 3 additions & 12 deletions compat/src/suspense.js
Original file line number Diff line number Diff line change
Expand Up @@ -191,23 +191,14 @@ Suspense.prototype.componentWillUnmount = function () {
* @param {import('./internal').SuspenseState} state
*/
Suspense.prototype.render = function (props, state) {
let vnode = this._vnode;
if (!vnode._mask) {
let root = vnode;
while (root._parent) root = root._parent;

root = root._mask || (root._mask = [0, 0]);
vnode._mask = [root[1]++, 0];
}

if (this._detachOnNextRender) {
// When the Suspense's _vnode was created by a call to createVNode
// (i.e. due to a setState further up in the tree)
// it's _children prop is null, in this case we "forget" about the parked vnodes to detach
if (vnode._children) {
if (this._vnode._children) {
const detachedParent = document.createElement('div');
const detachedComponent = vnode._children[0]._component;
vnode._children[0] = detachedClone(
const detachedComponent = this._vnode._children[0]._component;
this._vnode._children[0] = detachedClone(
this._detachOnNextRender,
detachedParent,
(detachedComponent._originalParentDom = detachedComponent._parentDom)
Expand Down
282 changes: 95 additions & 187 deletions compat/test/browser/suspense-hydration.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,7 @@ import React, {
hydrate,
Fragment,
Suspense,
lazy,
memo,
useId,
useState
} from 'preact/compat';
import { logCall, getLog, clearLog } from '../../../test/_util/logCall';
Expand All @@ -17,7 +15,6 @@ import {
} from '../../../test/_util/helpers';
import { ul, li, div } from '../../../test/_util/dom';
import { createLazy, createSuspenseLoader } from './suspense-utils';
import { renderToString, renderToStringAsync } from 'preact-render-to-string';
import { vi } from 'vitest';

/* eslint-env browser */
Expand Down Expand Up @@ -77,218 +74,129 @@ describe('suspense hydration', () => {
}
});

it('is stable for async Suspense siblings resolving in different orders', async () => {
const getIds = html =>
Object.fromEntries(
[...html.matchAll(/<span id="([^"]+)">([AB])<\/span>/g)].map(
([, id, name]) => [name, id]
)
);

async function renderWithResolveOrder(order) {
const loaders = {};

function Field({ name }) {
const id = useId();
return <span id={id}>{name}</span>;
}

const createLazy = name =>
lazy(
() =>
new Promise(resolve => {
loaders[name] = () =>
resolve({ default: () => <Field name={name} /> });
})
);

const A = createLazy('A');
const B = createLazy('B');
const rendered = renderToStringAsync(
<div>
<Suspense fallback={null}>
<A />
</Suspense>
<Suspense fallback={null}>
<B />
</Suspense>
</div>
);

await Promise.resolve();
order.some(name => loaders[name]());

return getIds(await rendered);
}
it('should leave DOM untouched when suspending while hydrating', () => {
scratch.innerHTML = '<div>Hello</div>';
clearLog();

const ordered = await renderWithResolveOrder(['A', 'B']);
const reversed = await renderWithResolveOrder(['B', 'A']);
const [Lazy, resolve] = createLazy();
hydrate(
<Suspense>
<Lazy />
</Suspense>,
scratch
);
rerender(); // Flush rerender queue to mimic what preact will really do
expect(scratch.innerHTML).to.equal('<div>Hello</div>');
expect(getLog()).to.deep.equal([]);
clearLog();

expect(new Set(Object.values(ordered)).size).to.equal(2);
expect(new Set(Object.values(reversed)).size).to.equal(2);
expect(reversed).to.deep.equal(ordered);
return resolve(() => <div>Hello</div>).then(() => {
rerender();
expect(scratch.innerHTML).to.equal('<div>Hello</div>');
expect(getLog()).to.deep.equal([]);
clearLog();
});
});

it('is stable for nested async Suspense siblings resolving in different orders', async () => {
const getIds = html =>
Object.fromEntries(
[...html.matchAll(/<span id="([^"]+)">([AB])<\/span>/g)].map(
([, id, name]) => [name, id]
)
);
it('Should not crash when oldVNode._children is null during shouldComponentUpdate optimization', () => {
const originalHtml = '<div>Hello</div>';
scratch.innerHTML = originalHtml;
clearLog();

async function renderWithResolveOrder(order) {
const loaders = {};
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false };
}

function Field({ name }) {
const id = useId();
return <span id={id}>{name}</span>;
static getDerivedStateFromError() {
return { hasError: true };
}

const createLazy = name =>
lazy(
() =>
new Promise(resolve => {
loaders[name] = () =>
resolve({ default: () => <Field name={name} /> });
})
);
render() {
return this.props.children;
}
}

const A = createLazy('A');
const B = createLazy('B');
const rendered = renderToStringAsync(
<Suspense fallback={null}>
<Suspense fallback={null}>
<A />
</Suspense>
<Suspense fallback={null}>
<B />
</Suspense>
const [Lazy, resolve] = createLazy();
function App() {
return (
<Suspense>
<ErrorBoundary>
<Lazy />
</ErrorBoundary>
</Suspense>
);

await Promise.resolve();
order.some(name => loaders[name]());

return getIds(await rendered);
}

const ordered = await renderWithResolveOrder(['A', 'B']);
const reversed = await renderWithResolveOrder(['B', 'A']);

expect(ordered).to.deep.equal({ A: 'P1-0', B: 'P2-0' });
expect(reversed).to.deep.equal(ordered);
});

it('does not leak Suspense useId masks across abandoned renderToString attempts', () => {
const idsIn = html => [...html.matchAll(/P\d+-\d+/g)].map(([id]) => id);

function Field() {
return <i>{useId()}</i>;
}
hydrate(<App />, scratch);
rerender(); // Flush rerender queue to mimic what preact will really do
expect(scratch.innerHTML).to.equal(originalHtml);
expect(getLog()).to.deep.equal([]);
clearLog();

function Suspends() {
throw Promise.resolve();
let i = 0;
class ThrowOrRender extends React.Component {
shouldComponentUpdate() {
return i === 0;
}
render() {
if (i === 0) {
i++;
throw new Error('Test error');
}
return <div>Hello</div>;
}
}

const tree = () => (
<>
<Suspense fallback={null}>
<Field />
</Suspense>
<Suspense fallback={null}>
<Field />
</Suspense>
</>
);

const first = idsIn(renderToString(tree()));
expect(first).to.deep.equal(['P0-0', 'P1-0']);

expect(() =>
renderToString(
<Suspense fallback={null}>
<Suspends />
</Suspense>
)
).to.throw(/renderToStringAsync/);

expect(idsIn(renderToString(tree()))).to.deep.equal(first);
return resolve(ThrowOrRender).then(() => {
rerender();
expect(scratch.innerHTML).to.equal(originalHtml);
clearLog();
});
});

it('keeps deeply nested Suspense useId masks compact', async () => {
function Field() {
const id = useId();
return <span id={id}>field</span>;
}

const Wrapper = ({ children }) => children;
let child = (
<Suspense fallback={null}>
<Field />
</Suspense>
);

for (let i = 0; i < 10; i++) {
child = <Wrapper>{child}</Wrapper>;
}

const html = await renderToStringAsync(
<Suspense fallback={null}>{child}</Suspense>
);

expect(html).to.equal('<span id="P1-0">field</span>');
});
it('does not crash when a hydrated suspended component bails out with shouldComponentUpdate', () => {
scratch.innerHTML = '<div>ssr</div>';
clearLog();

it('keeps nested Suspense ids distinct from parent useId calls', async () => {
const ids = [];
const promise = new Promise(() => {});
let update;

function Field() {
ids.push(useId());
return <span id={ids[1]}>field</span>;
}
class Suspender extends React.Component {
shouldComponentUpdate() {
return false;
}

function Wrapper() {
ids.push(useId());
return (
<Suspense fallback={null}>
<Field />
</Suspense>
);
render() {
throw promise;
}
}

await renderToStringAsync(
<Suspense fallback={null}>
<Wrapper />
</Suspense>
);
class App extends React.Component {
constructor(props) {
super(props);
this.state = { tick: 0 };
update = () => this.setState({ tick: this.state.tick + 1 });
}

expect(ids[0]).to.equal('P0-0');
expect(ids[1]).to.equal('P1-0');
});
render() {
return (
<Suspense fallback={<div>loading</div>}>
<Suspender tick={this.state.tick} />
</Suspense>
);
}
}

it('should leave DOM untouched when suspending while hydrating', () => {
scratch.innerHTML = '<div>Hello</div>';
clearLog();
hydrate(<App />, scratch);
expect(scratch.innerHTML).to.equal('<div>ssr</div>');

const [Lazy, resolve] = createLazy();
hydrate(
<Suspense>
<Lazy />
</Suspense>,
scratch
);
rerender(); // Flush rerender queue to mimic what preact will really do
expect(scratch.innerHTML).to.equal('<div>Hello</div>');
update();
expect(() => rerender()).not.to.throw();
expect(scratch.innerHTML).to.equal('<div>ssr</div>');
expect(getLog()).to.deep.equal([]);
clearLog();

return resolve(() => <div>Hello</div>).then(() => {
rerender();
expect(scratch.innerHTML).to.equal('<div>Hello</div>');
expect(getLog()).to.deep.equal([]);
clearLog();
});
});

it('should leave DOM untouched when suspending while hydrating', () => {
Expand Down
Loading
Loading