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

export interface SuspenseState {
Expand All @@ -47,5 +48,5 @@ export interface SuspenseComponent extends PreactComponent<
_pendingSuspensionCount: number;
_suspenders: Component[];
_detachOnNextRender: null | VNode<any>;
_mask?: [number, number];
_mask?: [string | number, number];
}
2 changes: 2 additions & 0 deletions compat/src/suspense.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export function lazy<T>(
export interface SuspenseProps {
children?: ComponentChildren;
fallback: ComponentChildren;
/** A stable, unique name for useId calls during resumed hydration. */
name?: string;
}

export class Suspense extends Component<SuspenseProps> {
Expand Down
11 changes: 8 additions & 3 deletions compat/src/suspense.js
Original file line number Diff line number Diff line change
Expand Up @@ -198,14 +198,19 @@ Suspense.prototype.componentWillUnmount = function () {
* @param {import('./internal').SuspenseState} state
*/
Suspense.prototype.render = function (props, state) {
let vnode = this._vnode;
if (props.name) {
vnode._mask = this._mask || (this._mask = ['S' + props.name, 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 (this._vnode._children) {
if (vnode._children) {
const detachedParent = document.createElement('div');
const detachedComponent = this._vnode._children[0]._component;
this._vnode._children[0] = detachedClone(
const detachedComponent = vnode._children[0]._component;
vnode._children[0] = detachedClone(
this._detachOnNextRender,
detachedParent,
(detachedComponent._originalParentDom = detachedComponent._parentDom)
Expand Down
115 changes: 115 additions & 0 deletions compat/test/browser/suspense-hydration.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import React, {
hydrate,
Fragment,
Suspense,
lazy,
memo,
useId,
useState
} from 'preact/compat';
import { logCall, getLog, clearLog } from '../../../test/_util/logCall';
Expand All @@ -15,6 +17,7 @@ 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';

/* eslint-env browser, mocha */
describe('suspense hydration', () => {
Expand Down Expand Up @@ -73,6 +76,118 @@ describe('suspense hydration', () => {
}
});

it('keeps named Suspense ids stable across async resolution order', 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 }) {
return <span id={useId()}>{name}</span>;
}

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

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

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: 'PSA-0', B: 'PSB-0' });
expect(reversed).to.deep.equal(ordered);
});

it('keeps named Suspense ids stable when client-only work renders during hydration', async () => {
let renderedIds;
function Field({ name }) {
const id = useId();
if (renderedIds) renderedIds[name] = id;
return <span id={id}>{name}</span>;
}

function ResolvedContent() {
return (
<>
<Field name="outer" />
<Suspense name="nested" fallback={null}>
<Field name="nested" />
</Suspense>
</>
);
}

scratch.innerHTML = renderToString(
<Suspense name="outer" fallback={null}>
<ResolvedContent />
</Suspense>
);
expect(scratch.innerHTML).to.equal(
'<span id="PSouter-0">outer</span><span id="PSnested-0">nested</span>'
);

const [Lazy, resolve] = createLazy();
renderedIds = {};
let showClientContent;
function App() {
const [showClient, setShowClient] = useState(false);
showClientContent = () => setShowClient(true);
return (
<>
{showClient && (
<Suspense name="client" fallback={null}>
<Field name="client" />
</Suspense>
)}
<Suspense name="outer" fallback={null}>
<Lazy />
</Suspense>
</>
);
}

hydrate(<App />, scratch);
rerender();
showClientContent();
rerender();
await resolve(ResolvedContent);
rerender();

expect(renderedIds).to.deep.equal({
client: 'PSclient-0',
outer: 'PSouter-0',
nested: 'PSnested-0'
});
});

it('should leave DOM untouched when suspending while hydrating', () => {
scratch.innerHTML = '<div>Hello</div>';
clearLog();
Expand Down
2 changes: 1 addition & 1 deletion compat/test/ts/suspense.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const IsLazyFunc = React.lazy(() => componentPromise);
class ReactSuspensefulFunc extends React.Component {
render() {
return (
<React.Suspense fallback={<FallBack />}>
<React.Suspense name="lazy-function" fallback={<FallBack />}>
<IsLazyFunc isProp={false} />
</React.Suspense>
);
Expand Down
2 changes: 1 addition & 1 deletion hooks/src/internal.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export interface Component extends Omit<
}

export interface VNode extends Omit<PreactVNode, '_component'> {
_mask?: [number, number];
_mask?: [string | number, number];
_component?: Component; // Override with our specific Component type
}

Expand Down
Loading