From 37833161cc7a79935af00eb8dcfae6e043dd0e68 Mon Sep 17 00:00:00 2001 From: Jovi De Croock Date: Mon, 14 Sep 2026 16:42:24 +0200 Subject: [PATCH 1/7] Support recoverable rendering with use(browser()) --- compat/mangle.json | 1 + compat/src/index.d.ts | 6 + compat/src/index.js | 3 + compat/src/render.js | 35 +++++- compat/test/browser/recoverable.test.jsx | 154 +++++++++++++++++++++++ compat/test/ts/recoverable.tsx | 6 + src/diff/children.js | 6 +- src/diff/index.js | 42 ++++++- src/internal.d.ts | 1 + 9 files changed, 248 insertions(+), 6 deletions(-) create mode 100644 compat/test/browser/recoverable.test.jsx create mode 100644 compat/test/ts/recoverable.tsx diff --git a/compat/mangle.json b/compat/mangle.json index 8352f38e63..792b2b244f 100644 --- a/compat/mangle.json +++ b/compat/mangle.json @@ -12,6 +12,7 @@ "__REACT_DEVTOOLS_GLOBAL_HOOK__", "__PREACT_DEVTOOLS__", "_renderers", + "_reason", "__source", "__self" ] diff --git a/compat/src/index.d.ts b/compat/src/index.d.ts index 71374f8f0e..080605facd 100644 --- a/compat/src/index.d.ts +++ b/compat/src/index.d.ts @@ -133,6 +133,12 @@ declare namespace React { ): T; export function useEffectEvent(cb: T): T; // React 19 hooks + export interface ReactRecoverable { + $$typeof: symbol; + _reason?: string | (() => unknown); + } + export function browser(reason?: string | (() => unknown)): ReactRecoverable; + export function use(resource: ReactRecoverable): undefined; export function use(resource: Promise | _preact.Context): T; // Preact Defaults diff --git a/compat/src/index.js b/compat/src/index.js index 26ad94c46a..e683f01164 100644 --- a/compat/src/index.js +++ b/compat/src/index.js @@ -36,6 +36,7 @@ import { createPortal } from './portals'; import { REACT_ELEMENT_TYPE, __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED, + browser, hydrate, render, use, @@ -171,6 +172,7 @@ export { version, Children, render, + browser, hydrate, unmountComponentAtNode, createPortal, @@ -226,6 +228,7 @@ export default { version, Children, render, + browser, hydrate, unmountComponentAtNode, createPortal, diff --git a/compat/src/render.js b/compat/src/render.js index f17f0d2d3c..432957b501 100644 --- a/compat/src/render.js +++ b/compat/src/render.js @@ -22,6 +22,16 @@ import { useDeferredValue, useInsertionEffect, useTransition } from './index'; import { assign, IS_NON_DIMENSIONAL } from './util'; export const REACT_ELEMENT_TYPE = Symbol.for('react.element'); +const RECOVERABLE_TYPE = Symbol.for('react.recoverable'); + +/** + * Create a value that defers server rendering to the nearest Suspense boundary + * when passed to `use`. The reason is only evaluated by the server renderer. + * @param {string | (() => any)} [reason] + */ +export function browser(reason) { + return { $$typeof: RECOVERABLE_TYPE, _reason: reason }; +} const MODE_HYDRATE = 1 << 5; let currentComponent, hydrationRoot, renderTrackingInitialized; @@ -333,11 +343,11 @@ function initRenderTracking(value) { } /** - * Read the value of a Promise (suspending while pending) or a Context. + * Read a Promise or Context, or defer a recoverable to a downstream renderer. * Unlike other hooks, `use` may be called conditionally. * @template T - * @param {(Promise & { status?: string, value?: T, reason?: any }) | import('../../src/internal').PreactContext} resource - * @returns {T} + * @param {(Promise & { status?: string, value?: T, reason?: any }) | import('../../src/internal').PreactContext | { $$typeof: symbol, _reason?: any }} resource + * @returns {T | undefined} */ export const use = /* @__PURE__ */ initRenderTracking(function use(resource) { // A Context is a function without a `then`, a thenable has one. @@ -360,6 +370,25 @@ export const use = /* @__PURE__ */ initRenderTracking(function use(resource) { throw resource; } + if (resource.$$typeof === RECOVERABLE_TYPE) { + if (options._skipEffects) { + let reason = resource._reason; + if (typeof reason == 'function') { + try { + reason = reason(); + } catch { + reason = + 'The browser-only rendering reason could not be initialized.'; + } + } + const error = new Error('Browser-only rendering was requested.'); + if (resource._reason !== undefined) error.cause = reason; + Object.defineProperty(error, RECOVERABLE_TYPE, { value: true }); + throw error; + } + return; + } + const id = resource._id; const provider = currentComponent._globalContext[id]; if (!provider) return resource._defaultValue; diff --git a/compat/test/browser/recoverable.test.jsx b/compat/test/browser/recoverable.test.jsx new file mode 100644 index 0000000000..6eb1e333eb --- /dev/null +++ b/compat/test/browser/recoverable.test.jsx @@ -0,0 +1,154 @@ +import { + createElement, + Fragment, + hydrate, + render, + Suspense, + browser, + use +} from 'preact/compat'; +import { options } from 'preact'; +import { setupRerender } from 'preact/test-utils'; +import { setupScratch, teardown } from '../../../test/_util/helpers'; +import { vi } from 'vitest'; +import { renderToString } from 'preact-render-to-string'; + +describe('recoverable rendering', () => { + let scratch, rerender; + beforeEach(() => { + scratch = setupScratch(); + rerender = setupRerender(); + }); + afterEach(() => teardown(scratch)); + + it('continues through a recoverable without evaluating its reason', () => { + const reason = vi.fn(() => { + throw new Error('unused'); + }); + const value = Object.freeze(browser(reason)); + function App() { + expect(use(value)).to.equal(undefined); + return
content
; + } + render(, scratch); + expect(scratch.innerHTML).to.equal('
content
'); + expect(reason).not.toHaveBeenCalled(); + expect(value.$$typeof).to.equal(Symbol.for('react.recoverable')); + expect(value._reason).to.equal(reason); + }); + + it('creates a branded error at each server use site', () => { + const cause = new Error('requires a browser'); + const reason = vi.fn(() => cause); + const value = browser(reason); + function App() { + use(value); + return
unreachable
; + } + const errors = []; + for (let i = 0; i < 2; i++) { + try { + renderToString(); + } catch (error) { + errors.push(error); + } + } + expect(errors).to.have.length(2); + expect(errors[0]).not.to.equal(errors[1]); + expect(errors[0].cause).to.equal(cause); + expect(errors[0][Symbol.for('react.recoverable')]).to.equal(true); + expect(reason).toHaveBeenCalledTimes(2); + expect(options._skipEffects).not.to.equal(true); + }); + + it('still defers if the reason initializer throws', () => { + function App() { + use( + browser(() => { + throw new Error('diagnostic'); + }) + ); + } + let caught; + try { + renderToString(); + } catch (error) { + caught = error; + } + expect(caught[Symbol.for('react.recoverable')]).to.equal(true); + expect(caught.cause).to.equal( + 'The browser-only rendering reason could not be initialized.' + ); + }); + + it('freshly renders a marked fallback without claiming adjacent siblings', () => { + scratch.innerHTML = + '

before

fallback
after
'; + const sibling = scratch.lastChild; + hydrate( + +

before

+ +
+ content +
+
+
after
+
, + scratch + ); + expect(scratch.innerHTML).to.equal( + '

before

content
after
' + ); + expect(scratch.lastChild).to.equal(sibling); + }); + + it('removes nested fallback markers and handles empty primary content', () => { + scratch.innerHTML = + 'nestedafter'; + const sibling = scratch.lastChild; + hydrate( + + {null} + after + , + scratch + ); + expect(scratch.innerHTML).to.equal('after'); + expect(scratch.lastChild).to.equal(sibling); + }); + + it('freshly renders a boundary marked after hydration suspended', async () => { + let resolve; + const promise = new Promise(r => { + resolve = r; + }); + function App() { + use(promise); + return ( +
+ content +
+ ); + } + scratch.innerHTML = + '
fallback
after'; + hydrate( + + + + + after + , + scratch + ); + scratch.firstChild.data = '$s!:1'; + resolve(); + await promise; + rerender(); + rerender(); + expect(scratch.querySelector('div').className).to.equal('primary'); + expect(scratch.querySelector('div').title).to.equal('new'); + expect(scratch.lastChild.textContent).to.equal('after'); + }); +}); diff --git a/compat/test/ts/recoverable.tsx b/compat/test/ts/recoverable.tsx new file mode 100644 index 0000000000..0351059bf5 --- /dev/null +++ b/compat/test/ts/recoverable.tsx @@ -0,0 +1,6 @@ +import React, { browser, use } from '../../'; +export const value: undefined = use(browser()); +export const reason: undefined = use(browser(() => new Error('browser only'))); +export const fromDefault: undefined = React.use(React.browser('browser only')); +// @ts-expect-error A reason must be a string or an initializer. +browser(123); diff --git a/src/diff/children.js b/src/diff/children.js index 1a56f70932..75021e0eda 100644 --- a/src/diff/children.js +++ b/src/diff/children.js @@ -432,7 +432,11 @@ function insert(parentVNode, oldDom, parentDom, isMounting) { oldDom = parentVNode._dom; } - while ((oldDom = oldDom && oldDom.nextSibling) && oldDom.nodeType == 8); + while ( + (oldDom = oldDom && oldDom.nextSibling) && + oldDom.nodeType == 8 && + !oldDom.data.startsWith('$s!') + ); return oldDom; } diff --git a/src/diff/index.js b/src/diff/index.js index a23d80586b..80f80331d9 100644 --- a/src/diff/index.js +++ b/src/diff/index.js @@ -84,7 +84,11 @@ export function diff( ) { newVNode._flags |= MODE_HYDRATE; excessDomChildren = []; - if (tmp.nodeType == 8) { + if (tmp.nodeType == 8 && tmp.data.startsWith('$s!')) { + oldDom = clearClientBoundary(tmp, excessDomChildren); + newVNode._flags &= RESET_MODE; + isHydrating = false; + } else if (tmp.nodeType == 8) { // Re-scan DOM from stored start marker for streamed hydration. // `depth` only ever reaches 0 through the `break` below, so it // doesn't need to be re-tested in the loop condition. @@ -102,7 +106,7 @@ export function diff( } else { excessDomChildren.push(tmp); } - oldDom = excessDomChildren[0]; + if (isHydrating) oldDom = excessDomChildren[0]; oldVNode._component._excess = NULL; } @@ -335,6 +339,19 @@ export function diff( oldDom = oldVNode._children ? getDomSibling(oldVNode, 0) : NULL; } + // The server left a fallback here. It is not the primary tree's DOM. + if ( + isHydrating && + c._childDidSuspend && + oldDom && + oldDom.nodeType == 8 && + oldDom.data.startsWith('$s!') + ) { + oldDom = clearClientBoundary(oldDom, excessDomChildren); + excessDomChildren = NULL; + isHydrating = false; + } + oldDom = diffChildren( parentDom, isArray(renderResult) ? renderResult : [renderResult], @@ -804,3 +821,24 @@ export function unmount(vnode, parentVNode, skipRemove) { function doRender(props, state, context) { return this.constructor(props, context); } + +/** + * Remove only this boundary's fallback, including nested boundary markers. + * @param {any} node + * @param {any[]} excess + * @returns {any} + */ +function clearClientBoundary(node, excess) { + let depth = 0; + do { + if (node.nodeType == 8) { + if (node.data.startsWith('$s')) depth++; + else if (node.data.startsWith('/$s')) depth--; + } + const next = node.nextSibling; + if (excess) excess[excess.indexOf(node)] = NULL; + removeNode(node); + node = next; + } while (node && depth); + return node; +} diff --git a/src/internal.d.ts b/src/internal.d.ts index d00bc0f302..a20a4b6c62 100644 --- a/src/internal.d.ts +++ b/src/internal.d.ts @@ -165,6 +165,7 @@ export interface Component

extends Omit< state: S; // Override Component["state"] to not be readonly for internal use, specifically Hooks _excess?: PreactElement; + _childDidSuspend?: (promise: any, vnode: VNode) => void; _renderCallbacks: Array<() => void>; // Only class components _stateCallbacks: Array<() => void>; // Only class components _globalContext?: any; From b1b3a92a756a33ad9ef8a6fe600180116faebc2e Mon Sep 17 00:00:00 2001 From: Jovi De Croock Date: Mon, 14 Sep 2026 16:58:22 +0200 Subject: [PATCH 2/7] Throw recoverable values without browser diagnostics --- compat/src/render.js | 18 ++----------- compat/test/browser/recoverable.test.jsx | 34 +++++------------------- 2 files changed, 8 insertions(+), 44 deletions(-) diff --git a/compat/src/render.js b/compat/src/render.js index 432957b501..78ecb9872b 100644 --- a/compat/src/render.js +++ b/compat/src/render.js @@ -26,7 +26,7 @@ const RECOVERABLE_TYPE = Symbol.for('react.recoverable'); /** * Create a value that defers server rendering to the nearest Suspense boundary - * when passed to `use`. The reason is only evaluated by the server renderer. + * when passed to `use`. The renderer receives the optional reason unchanged. * @param {string | (() => any)} [reason] */ export function browser(reason) { @@ -371,21 +371,7 @@ export const use = /* @__PURE__ */ initRenderTracking(function use(resource) { } if (resource.$$typeof === RECOVERABLE_TYPE) { - if (options._skipEffects) { - let reason = resource._reason; - if (typeof reason == 'function') { - try { - reason = reason(); - } catch { - reason = - 'The browser-only rendering reason could not be initialized.'; - } - } - const error = new Error('Browser-only rendering was requested.'); - if (resource._reason !== undefined) error.cause = reason; - Object.defineProperty(error, RECOVERABLE_TYPE, { value: true }); - throw error; - } + if (options._skipEffects) throw resource; return; } diff --git a/compat/test/browser/recoverable.test.jsx b/compat/test/browser/recoverable.test.jsx index 6eb1e333eb..1948306d34 100644 --- a/compat/test/browser/recoverable.test.jsx +++ b/compat/test/browser/recoverable.test.jsx @@ -37,10 +37,9 @@ describe('recoverable rendering', () => { expect(value._reason).to.equal(reason); }); - it('creates a branded error at each server use site', () => { - const cause = new Error('requires a browser'); - const reason = vi.fn(() => cause); - const value = browser(reason); + it('throws the recoverable to the server renderer', () => { + const reason = vi.fn(); + const value = Object.freeze(browser(reason)); function App() { use(value); return

unreachable
; @@ -54,33 +53,12 @@ describe('recoverable rendering', () => { } } expect(errors).to.have.length(2); - expect(errors[0]).not.to.equal(errors[1]); - expect(errors[0].cause).to.equal(cause); - expect(errors[0][Symbol.for('react.recoverable')]).to.equal(true); - expect(reason).toHaveBeenCalledTimes(2); + expect(errors[0]).to.equal(value); + expect(errors[1]).to.equal(value); + expect(reason).not.toHaveBeenCalled(); expect(options._skipEffects).not.to.equal(true); }); - it('still defers if the reason initializer throws', () => { - function App() { - use( - browser(() => { - throw new Error('diagnostic'); - }) - ); - } - let caught; - try { - renderToString(); - } catch (error) { - caught = error; - } - expect(caught[Symbol.for('react.recoverable')]).to.equal(true); - expect(caught.cause).to.equal( - 'The browser-only rendering reason could not be initialized.' - ); - }); - it('freshly renders a marked fallback without claiming adjacent siblings', () => { scratch.innerHTML = '

before

fallback
after
'; From 87960a014851313a3189e9aa1ddab6e196975b25 Mon Sep 17 00:00:00 2001 From: Jovi De Croock Date: Mon, 14 Sep 2026 17:33:49 +0200 Subject: [PATCH 3/7] Hydrate recovered fallbacks before retrying client content --- compat/src/index.d.ts | 5 +- compat/src/internal.d.ts | 1 + compat/src/render.js | 24 +-- compat/src/suspense.js | 74 +++++++-- compat/test/browser/recoverable.test.jsx | 188 +++++++++++++++++++++-- mangle.json | 1 + src/diff/index.js | 50 ++---- src/internal.d.ts | 5 +- 8 files changed, 264 insertions(+), 84 deletions(-) diff --git a/compat/src/index.d.ts b/compat/src/index.d.ts index 080605facd..892cb5f5d6 100644 --- a/compat/src/index.d.ts +++ b/compat/src/index.d.ts @@ -133,10 +133,7 @@ declare namespace React { ): T; export function useEffectEvent(cb: T): T; // React 19 hooks - export interface ReactRecoverable { - $$typeof: symbol; - _reason?: string | (() => unknown); - } + export interface ReactRecoverable extends PromiseLike {} export function browser(reason?: string | (() => unknown)): ReactRecoverable; export function use(resource: ReactRecoverable): undefined; export function use(resource: Promise | _preact.Context): T; diff --git a/compat/src/internal.d.ts b/compat/src/internal.d.ts index 28a3135fdc..c5b6d264f8 100644 --- a/compat/src/internal.d.ts +++ b/compat/src/internal.d.ts @@ -44,6 +44,7 @@ export interface SuspenseComponent extends PreactComponent< SuspenseState > { _pendingSuspensionCount: number; + _hydrateFallback?: number | null; _suspenders: Component[]; _detachOnNextRender: null | VNode; _mask?: [number, number]; diff --git a/compat/src/render.js b/compat/src/render.js index 78ecb9872b..538c83e7cc 100644 --- a/compat/src/render.js +++ b/compat/src/render.js @@ -22,15 +22,20 @@ import { useDeferredValue, useInsertionEffect, useTransition } from './index'; import { assign, IS_NON_DIMENSIONAL } from './util'; export const REACT_ELEMENT_TYPE = Symbol.for('react.element'); -const RECOVERABLE_TYPE = Symbol.for('react.recoverable'); /** - * Create a value that defers server rendering to the nearest Suspense boundary - * when passed to `use`. The renderer receives the optional reason unchanged. + * Create a thenable that resolves in the browser and defers server rendering. * @param {string | (() => any)} [reason] */ export function browser(reason) { - return { $$typeof: RECOVERABLE_TYPE, _reason: reason }; + return { + // oxlint-disable-next-line unicorn/no-thenable -- `use` consumes instrumented thenables. + then() {}, + get status() { + return options._skipEffects ? 'rejected' : 'fulfilled'; + }, + reason: { $$typeof: Symbol.for('react.recoverable'), _reason: reason } + }; } const MODE_HYDRATE = 1 << 5; @@ -343,11 +348,11 @@ function initRenderTracking(value) { } /** - * Read a Promise or Context, or defer a recoverable to a downstream renderer. + * Read the value of a Promise (suspending while pending) or a Context. * Unlike other hooks, `use` may be called conditionally. * @template T - * @param {(Promise & { status?: string, value?: T, reason?: any }) | import('../../src/internal').PreactContext | { $$typeof: symbol, _reason?: any }} resource - * @returns {T | undefined} + * @param {(Promise & { status?: string, value?: T, reason?: any }) | import('../../src/internal').PreactContext} resource + * @returns {T} */ export const use = /* @__PURE__ */ initRenderTracking(function use(resource) { // A Context is a function without a `then`, a thenable has one. @@ -370,11 +375,6 @@ export const use = /* @__PURE__ */ initRenderTracking(function use(resource) { throw resource; } - if (resource.$$typeof === RECOVERABLE_TYPE) { - if (options._skipEffects) throw resource; - return; - } - const id = resource._id; const provider = currentComponent._globalContext[id]; if (!provider) return resource._defaultValue; diff --git a/compat/src/suspense.js b/compat/src/suspense.js index 85b377d588..b521eedd4b 100644 --- a/compat/src/suspense.js +++ b/compat/src/suspense.js @@ -129,6 +129,35 @@ function createSuspense() { // - do not set `Suspense.prototype.constructor` to `Suspense` Suspense.prototype = new Component(); + Suspense.prototype._hydrate = function (oldDom, excess) { + oldDom = this._excess || (excess && oldDom); + if (oldDom && oldDom.nodeType == 8 && oldDom.data.startsWith('$s!')) { + this._excess = null; + this._hydrateFallback = 1; + let children = [], + depth = 1, + start = oldDom; + if (excess) excess[excess.indexOf(oldDom)] = null; + while ((oldDom = oldDom.nextSibling)) { + if (excess) excess[excess.indexOf(oldDom)] = null; + if (oldDom.nodeType == 8) { + if (oldDom.data.startsWith('$s')) depth++; + else if (oldDom.data.startsWith('/$s') && !--depth) { + oldDom.remove(); + break; + } + } + children.push(oldDom); + } + start.remove(); + this._renderCallbacks.push(() => { + children.forEach(node => node && node.remove()); + this.forceUpdate(); + }); + return children; + } + }; + /** * @this {import('./internal').SuspenseComponent} * @param {Promise} promise The thrown promise @@ -136,6 +165,14 @@ function createSuspense() { */ Suspense.prototype._childDidSuspend = function (promise, suspendingVNode) { const suspendingComponent = suspendingVNode._component; + // Streamed recovery wakes this boundary after marking its fallback. + const marker = suspendingComponent._excess; + if (marker && marker.nodeType == 8) { + marker.__r = () => { + this._excess = marker; + this.forceUpdate(); + }; + } if (this._suspenders == null) { this._suspenders = []; @@ -150,6 +187,7 @@ function createSuspense() { suspendingComponent._onResolve = null; onSuspensionComplete(); + if (marker && !this._pendingSuspensionCount) marker.__r = null; }; suspendingComponent._onResolve = onResolved; @@ -210,26 +248,36 @@ function createSuspense() { * @param {import('./internal').SuspenseState} state */ Suspense.prototype.render = function (props, state) { + // Keep the fallback in slot 1 while trying the primary tree. + if (this._hydrateFallback == 1) { + this._hydrateFallback = 2; + return [null, createElement(Fragment, null, props.fallback)]; + } + if (this._hydrateFallback) { + this._renderCallbacks.push(function () { + this._hydrateFallback = null; + if (!this._pendingSuspensionCount) this.forceUpdate(); + }); + } + 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) { - const detachedParent = document.createElement('div'); - const detachedComponent = this._vnode._children[0]._component; - this._vnode._children[0] = detachedClone( - this._detachOnNextRender, - detachedParent, - (detachedComponent._originalParentDom = detachedComponent._parentDom) - ); - } + const parked = this._detachOnNextRender; + const detachedParent = document.createElement('div'); + const detachedComponent = parked._component; + // A parent update may have already replaced this boundary's vnode. + parked._parent._children[0] = detachedClone( + parked, + detachedParent, + (detachedComponent._originalParentDom = detachedComponent._parentDom) + ); this._detachOnNextRender = null; } return [ createElement(Fragment, null, state._suspended ? null : props.children), - state._suspended && createElement(Fragment, null, props.fallback) + (state._suspended || this._hydrateFallback) && + createElement(Fragment, null, props.fallback) ]; }; diff --git a/compat/test/browser/recoverable.test.jsx b/compat/test/browser/recoverable.test.jsx index 1948306d34..28bee4a0e2 100644 --- a/compat/test/browser/recoverable.test.jsx +++ b/compat/test/browser/recoverable.test.jsx @@ -5,7 +5,8 @@ import { render, Suspense, browser, - use + use, + useLayoutEffect } from 'preact/compat'; import { options } from 'preact'; import { setupRerender } from 'preact/test-utils'; @@ -33,8 +34,8 @@ describe('recoverable rendering', () => { render(, scratch); expect(scratch.innerHTML).to.equal('
content
'); expect(reason).not.toHaveBeenCalled(); - expect(value.$$typeof).to.equal(Symbol.for('react.recoverable')); - expect(value._reason).to.equal(reason); + expect(value.reason.$$typeof).to.equal(Symbol.for('react.recoverable')); + expect(value.reason._reason).to.equal(reason); }); it('throws the recoverable to the server renderer', () => { @@ -53,31 +54,47 @@ describe('recoverable rendering', () => { } } expect(errors).to.have.length(2); - expect(errors[0]).to.equal(value); - expect(errors[1]).to.equal(value); + expect(errors[0]).to.equal(value.reason); + expect(errors[1]).to.equal(value.reason); expect(reason).not.toHaveBeenCalled(); expect(options._skipEffects).not.to.equal(true); }); - it('freshly renders a marked fallback without claiming adjacent siblings', () => { + it('hydrates a marked fallback until its primary content resolves', async () => { + let resolve; + const promise = new Promise(r => { + resolve = r; + }); + const onClick = vi.fn(); + function App() { + use(promise); + return
content
; + } scratch.innerHTML = - '

before

fallback
after
'; + '

before

after
'; + const fallback = scratch.querySelector('button'); const sibling = scratch.lastChild; hydrate(

before

- -
- content -
+ fallback}> +
after
, scratch ); - expect(scratch.innerHTML).to.equal( - '

before

content
after
' - ); + expect(scratch.querySelector('button')).to.equal(fallback); + expect(scratch.lastChild).to.equal(sibling); + fallback.click(); + expect(onClick).toHaveBeenCalledOnce(); + rerender(); + expect(scratch.querySelector('button')).to.equal(fallback); + + resolve(); + await promise; + rerender(); + expect(scratch.querySelector('.primary').textContent).to.equal('content'); expect(scratch.lastChild).to.equal(sibling); }); @@ -121,6 +138,8 @@ describe('recoverable rendering', () => { scratch ); scratch.firstChild.data = '$s!:1'; + scratch.firstChild.__r(); + rerender(); resolve(); await promise; rerender(); @@ -129,4 +148,145 @@ describe('recoverable rendering', () => { expect(scratch.querySelector('div').title).to.equal('new'); expect(scratch.lastChild.textContent).to.equal('after'); }); + + for (const late of [false, true]) { + it(`preserves fallback effects and refs through ${late ? 'late' : 'immediate'} recovery`, async () => { + let resolve; + const promise = new Promise(r => { + resolve = r; + }); + const mount = vi.fn(), + cleanup = vi.fn(), + ref = vi.fn(), + click = vi.fn(); + const mismatch = vi.fn(); + const previousMismatch = options._hydrationMismatch; + options._hydrationMismatch = mismatch; + function Fallback() { + useLayoutEffect(() => { + mount(); + return cleanup; + }, []); + return ( + + ); + } + function Primary() { + use(promise); + return ( + + ); + } + try { + scratch.innerHTML = ``; + const [before, fallback, after] = scratch.querySelectorAll('button'); + hydrate( + + + }> + + + + , + scratch + ); + if (late) { + const marker = fallback.previousSibling; + marker.data = '$s!:1'; + marker.__r(); + } + rerender(); + expect(scratch.querySelector('.fallback')).to.equal(fallback); + fallback.click(); + expect(click).toHaveBeenCalledOnce(); + expect(mount).toHaveBeenCalledOnce(); + expect(cleanup).not.toHaveBeenCalled(); + expect(ref).toHaveBeenCalledExactlyOnceWith(fallback); + resolve(); + await promise; + rerender(); + expect(scratch.querySelector('.fallback')).to.equal(null); + expect(scratch.querySelector('.primary').title).to.equal('ready'); + expect(scratch.firstChild).to.equal(before); + expect(scratch.lastChild).to.equal(after); + expect(mount).toHaveBeenCalledOnce(); + expect(cleanup).toHaveBeenCalledOnce(); + expect(ref).toHaveBeenCalledTimes(2); + expect(ref).toHaveBeenLastCalledWith(null); + expect(mismatch).not.toHaveBeenCalled(); + } finally { + options._hydrationMismatch = previousMismatch; + } + }); + } + + it('hydrates nested fallback boundaries without claiming their siblings', async () => { + let resolveOuter, resolveInner; + const outer = new Promise(r => { + resolveOuter = r; + }); + const inner = new Promise(r => { + resolveInner = r; + }); + const click = vi.fn(); + function Outer() { + use(outer); + return ; + } + function Inner() { + use(inner); + return ; + } + scratch.innerHTML = + ''; + const [nested, extra, after] = scratch.querySelectorAll('button'); + hydrate( + + + + nested + + } + > + + + + + } + > + + + + , + scratch + ); + expect(scratch.querySelector('.nested'), 'initial nested').to.equal(nested); + rerender(); + expect(scratch.querySelector('.nested'), 'pending nested').to.equal(nested); + expect(scratch.querySelector('.extra')).to.equal(extra); + expect(scratch.lastChild).to.equal(after); + nested.click(); + expect(click).toHaveBeenCalledOnce(); + resolveOuter(); + await outer; + rerender(); + expect(scratch.innerHTML).to.equal( + '' + ); + resolveInner(); + await inner; + rerender(); + expect(scratch.innerHTML).to.equal( + '' + ); + expect(scratch.lastChild).to.equal(after); + }); }); diff --git a/mangle.json b/mangle.json index 5193d8cbf6..fd9bb196d7 100644 --- a/mangle.json +++ b/mangle.json @@ -48,6 +48,7 @@ "$_children": "__k", "$_pendingSuspensionCount": "__u", "$_childDidSuspend": "__c", + "$_hydrate": "__y", "$_unmounted": "__z", "$_onResolve": "__R", "$_suspended": "__a", diff --git a/src/diff/index.js b/src/diff/index.js index 80f80331d9..c70fc8ed56 100644 --- a/src/diff/index.js +++ b/src/diff/index.js @@ -84,11 +84,7 @@ export function diff( ) { newVNode._flags |= MODE_HYDRATE; excessDomChildren = []; - if (tmp.nodeType == 8 && tmp.data.startsWith('$s!')) { - oldDom = clearClientBoundary(tmp, excessDomChildren); - newVNode._flags &= RESET_MODE; - isHydrating = false; - } else if (tmp.nodeType == 8) { + if (tmp.nodeType == 8) { // Re-scan DOM from stored start marker for streamed hydration. // `depth` only ever reaches 0 through the `break` below, so it // doesn't need to be re-tested in the loop condition. @@ -106,7 +102,7 @@ export function diff( } else { excessDomChildren.push(tmp); } - if (isHydrating) oldDom = excessDomChildren[0]; + oldDom = excessDomChildren[0]; oldVNode._component._excess = NULL; } @@ -263,6 +259,14 @@ export function diff( c.props = newProps; c._parentDom = parentDom; c._bits &= ~COMPONENT_FORCE; + if ( + c._hydrate && + (tmp = c._hydrate(oldDom, isHydrating && excessDomChildren)) + ) { + oldDom = tmp[0]; + excessDomChildren = tmp; + isHydrating = true; + } let renderHook = options._render, count = 0; @@ -339,19 +343,6 @@ export function diff( oldDom = oldVNode._children ? getDomSibling(oldVNode, 0) : NULL; } - // The server left a fallback here. It is not the primary tree's DOM. - if ( - isHydrating && - c._childDidSuspend && - oldDom && - oldDom.nodeType == 8 && - oldDom.data.startsWith('$s!') - ) { - oldDom = clearClientBoundary(oldDom, excessDomChildren); - excessDomChildren = NULL; - isHydrating = false; - } - oldDom = diffChildren( parentDom, isArray(renderResult) ? renderResult : [renderResult], @@ -821,24 +812,3 @@ export function unmount(vnode, parentVNode, skipRemove) { function doRender(props, state, context) { return this.constructor(props, context); } - -/** - * Remove only this boundary's fallback, including nested boundary markers. - * @param {any} node - * @param {any[]} excess - * @returns {any} - */ -function clearClientBoundary(node, excess) { - let depth = 0; - do { - if (node.nodeType == 8) { - if (node.data.startsWith('$s')) depth++; - else if (node.data.startsWith('/$s')) depth--; - } - const next = node.nextSibling; - if (excess) excess[excess.indexOf(node)] = NULL; - removeNode(node); - node = next; - } while (node && depth); - return node; -} diff --git a/src/internal.d.ts b/src/internal.d.ts index a20a4b6c62..5d3a35dd57 100644 --- a/src/internal.d.ts +++ b/src/internal.d.ts @@ -165,7 +165,10 @@ export interface Component

extends Omit< state: S; // Override Component["state"] to not be readonly for internal use, specifically Hooks _excess?: PreactElement; - _childDidSuspend?: (promise: any, vnode: VNode) => void; + _hydrate?: ( + oldDom: PreactElement, + excess: PreactElement[] | false + ) => PreactElement[]; _renderCallbacks: Array<() => void>; // Only class components _stateCallbacks: Array<() => void>; // Only class components _globalContext?: any; From 9552037df0b3af03725bee9d082da2ec225f4026 Mon Sep 17 00:00:00 2001 From: Jovi De Croock Date: Mon, 14 Sep 2026 18:35:37 +0200 Subject: [PATCH 4/7] Reconcile recovered boundaries without hydrating fallbacks --- compat/src/index.d.ts | 7 +- compat/src/internal.d.ts | 1 - compat/src/render.js | 5 +- compat/src/suspense.js | 74 +---- compat/test/browser/recoverable.test.jsx | 331 ++++++++++------------- compat/test/ts/recoverable.tsx | 5 + mangle.json | 1 - src/diff/index.js | 86 +++--- src/internal.d.ts | 5 +- 9 files changed, 225 insertions(+), 290 deletions(-) diff --git a/compat/src/index.d.ts b/compat/src/index.d.ts index 892cb5f5d6..58993b692a 100644 --- a/compat/src/index.d.ts +++ b/compat/src/index.d.ts @@ -133,7 +133,12 @@ declare namespace React { ): T; export function useEffectEvent(cb: T): T; // React 19 hooks - export interface ReactRecoverable extends PromiseLike {} + export interface ReactRecoverable { + then( + resolve: (value: undefined) => void, + reject: (reason: unknown) => void + ): void; + } export function browser(reason?: string | (() => unknown)): ReactRecoverable; export function use(resource: ReactRecoverable): undefined; export function use(resource: Promise | _preact.Context): T; diff --git a/compat/src/internal.d.ts b/compat/src/internal.d.ts index c5b6d264f8..28a3135fdc 100644 --- a/compat/src/internal.d.ts +++ b/compat/src/internal.d.ts @@ -44,7 +44,6 @@ export interface SuspenseComponent extends PreactComponent< SuspenseState > { _pendingSuspensionCount: number; - _hydrateFallback?: number | null; _suspenders: Component[]; _detachOnNextRender: null | VNode; _mask?: [number, number]; diff --git a/compat/src/render.js b/compat/src/render.js index 538c83e7cc..99ce049f72 100644 --- a/compat/src/render.js +++ b/compat/src/render.js @@ -30,7 +30,10 @@ export const REACT_ELEMENT_TYPE = Symbol.for('react.element'); export function browser(reason) { return { // oxlint-disable-next-line unicorn/no-thenable -- `use` consumes instrumented thenables. - then() {}, + then(resolve, reject) { + if (options._skipEffects) reject(this.reason); + else resolve(); + }, get status() { return options._skipEffects ? 'rejected' : 'fulfilled'; }, diff --git a/compat/src/suspense.js b/compat/src/suspense.js index b521eedd4b..85b377d588 100644 --- a/compat/src/suspense.js +++ b/compat/src/suspense.js @@ -129,35 +129,6 @@ function createSuspense() { // - do not set `Suspense.prototype.constructor` to `Suspense` Suspense.prototype = new Component(); - Suspense.prototype._hydrate = function (oldDom, excess) { - oldDom = this._excess || (excess && oldDom); - if (oldDom && oldDom.nodeType == 8 && oldDom.data.startsWith('$s!')) { - this._excess = null; - this._hydrateFallback = 1; - let children = [], - depth = 1, - start = oldDom; - if (excess) excess[excess.indexOf(oldDom)] = null; - while ((oldDom = oldDom.nextSibling)) { - if (excess) excess[excess.indexOf(oldDom)] = null; - if (oldDom.nodeType == 8) { - if (oldDom.data.startsWith('$s')) depth++; - else if (oldDom.data.startsWith('/$s') && !--depth) { - oldDom.remove(); - break; - } - } - children.push(oldDom); - } - start.remove(); - this._renderCallbacks.push(() => { - children.forEach(node => node && node.remove()); - this.forceUpdate(); - }); - return children; - } - }; - /** * @this {import('./internal').SuspenseComponent} * @param {Promise} promise The thrown promise @@ -165,14 +136,6 @@ function createSuspense() { */ Suspense.prototype._childDidSuspend = function (promise, suspendingVNode) { const suspendingComponent = suspendingVNode._component; - // Streamed recovery wakes this boundary after marking its fallback. - const marker = suspendingComponent._excess; - if (marker && marker.nodeType == 8) { - marker.__r = () => { - this._excess = marker; - this.forceUpdate(); - }; - } if (this._suspenders == null) { this._suspenders = []; @@ -187,7 +150,6 @@ function createSuspense() { suspendingComponent._onResolve = null; onSuspensionComplete(); - if (marker && !this._pendingSuspensionCount) marker.__r = null; }; suspendingComponent._onResolve = onResolved; @@ -248,36 +210,26 @@ function createSuspense() { * @param {import('./internal').SuspenseState} state */ Suspense.prototype.render = function (props, state) { - // Keep the fallback in slot 1 while trying the primary tree. - if (this._hydrateFallback == 1) { - this._hydrateFallback = 2; - return [null, createElement(Fragment, null, props.fallback)]; - } - if (this._hydrateFallback) { - this._renderCallbacks.push(function () { - this._hydrateFallback = null; - if (!this._pendingSuspensionCount) this.forceUpdate(); - }); - } - if (this._detachOnNextRender) { - const parked = this._detachOnNextRender; - const detachedParent = document.createElement('div'); - const detachedComponent = parked._component; - // A parent update may have already replaced this boundary's vnode. - parked._parent._children[0] = detachedClone( - parked, - detachedParent, - (detachedComponent._originalParentDom = detachedComponent._parentDom) - ); + // 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) { + const detachedParent = document.createElement('div'); + const detachedComponent = this._vnode._children[0]._component; + this._vnode._children[0] = detachedClone( + this._detachOnNextRender, + detachedParent, + (detachedComponent._originalParentDom = detachedComponent._parentDom) + ); + } this._detachOnNextRender = null; } return [ createElement(Fragment, null, state._suspended ? null : props.children), - (state._suspended || this._hydrateFallback) && - createElement(Fragment, null, props.fallback) + state._suspended && createElement(Fragment, null, props.fallback) ]; }; diff --git a/compat/test/browser/recoverable.test.jsx b/compat/test/browser/recoverable.test.jsx index 28bee4a0e2..d9e53ebbae 100644 --- a/compat/test/browser/recoverable.test.jsx +++ b/compat/test/browser/recoverable.test.jsx @@ -5,8 +5,7 @@ import { render, Suspense, browser, - use, - useLayoutEffect + use } from 'preact/compat'; import { options } from 'preact'; import { setupRerender } from 'preact/test-utils'; @@ -22,10 +21,8 @@ describe('recoverable rendering', () => { }); afterEach(() => teardown(scratch)); - it('continues through a recoverable without evaluating its reason', () => { - const reason = vi.fn(() => { - throw new Error('unused'); - }); + it('resolves in the browser without evaluating its reason', () => { + const reason = vi.fn(); const value = Object.freeze(browser(reason)); function App() { expect(use(value)).to.equal(undefined); @@ -34,100 +31,106 @@ describe('recoverable rendering', () => { render(, scratch); expect(scratch.innerHTML).to.equal('

content
'); expect(reason).not.toHaveBeenCalled(); - expect(value.reason.$$typeof).to.equal(Symbol.for('react.recoverable')); - expect(value.reason._reason).to.equal(reason); }); - it('throws the recoverable to the server renderer', () => { + it('settles synchronously without retaining callbacks', async () => { + const value = browser(); + expect(await value).to.equal(undefined); + let error; + const previous = options._skipEffects; + try { + options._skipEffects = true; + value.then(null, caught => { + error = caught; + }); + } finally { + options._skipEffects = previous; + } + expect(error).to.equal(value.reason); + }); + + it('throws its recoverable reason to the server renderer', () => { const reason = vi.fn(); const value = Object.freeze(browser(reason)); function App() { use(value); - return
unreachable
; } - const errors = []; - for (let i = 0; i < 2; i++) { - try { - renderToString(); - } catch (error) { - errors.push(error); - } + let error; + try { + renderToString(); + } catch (caught) { + error = caught; } - expect(errors).to.have.length(2); - expect(errors[0]).to.equal(value.reason); - expect(errors[1]).to.equal(value.reason); + expect(error).to.equal(value.reason); expect(reason).not.toHaveBeenCalled(); expect(options._skipEffects).not.to.equal(true); }); - it('hydrates a marked fallback until its primary content resolves', async () => { + it('replaces a marked fallback without claiming adjacent siblings', () => { + scratch.innerHTML = + '

before

fallback
after
'; + const sibling = scratch.lastChild; + hydrate( + +

before

+ +
+ content +
+
+
after
+
, + scratch + ); + expect(scratch.innerHTML).to.equal( + '

before

content
after
' + ); + expect(scratch.lastChild).to.equal(sibling); + }); + + it('leaves the server fallback inert while primary content is pending', async () => { let resolve; const promise = new Promise(r => { resolve = r; }); - const onClick = vi.fn(); function App() { use(promise); return
content
; } scratch.innerHTML = - '

before

after
'; - const fallback = scratch.querySelector('button'); - const sibling = scratch.lastChild; + 'after'; + const serverFallback = scratch.querySelector('button'); hydrate( -

before

- fallback}> + client fallback}> -
after
+ after
, scratch ); - expect(scratch.querySelector('button')).to.equal(fallback); - expect(scratch.lastChild).to.equal(sibling); - fallback.click(); - expect(onClick).toHaveBeenCalledOnce(); rerender(); - expect(scratch.querySelector('button')).to.equal(fallback); - + const clientFallback = scratch.querySelector('button'); + expect(clientFallback).to.equal(serverFallback); + expect(clientFallback.title).to.equal('server'); resolve(); await promise; rerender(); expect(scratch.querySelector('.primary').textContent).to.equal('content'); - expect(scratch.lastChild).to.equal(sibling); + expect(scratch.lastElementChild.textContent).to.equal('after'); }); - it('removes nested fallback markers and handles empty primary content', () => { - scratch.innerHTML = - 'nestedafter'; - const sibling = scratch.lastChild; - hydrate( - - {null} - after - , - scratch - ); - expect(scratch.innerHTML).to.equal('after'); - expect(scratch.lastChild).to.equal(sibling); - }); - - it('freshly renders a boundary marked after hydration suspended', async () => { + it('replaces nested and late marked fallback ranges', async () => { let resolve; const promise = new Promise(r => { resolve = r; }); function App() { use(promise); - return ( -
- content -
- ); + return ready; } scratch.innerHTML = - '
fallback
after'; + 'nestedafter'; hydrate( @@ -138,154 +141,112 @@ describe('recoverable rendering', () => { scratch ); scratch.firstChild.data = '$s!:1'; - scratch.firstChild.__r(); - rerender(); resolve(); await promise; rerender(); rerender(); - expect(scratch.querySelector('div').className).to.equal('primary'); - expect(scratch.querySelector('div').title).to.equal('new'); - expect(scratch.lastChild.textContent).to.equal('after'); + expect(scratch.innerHTML).to.equal('readyafter'); }); - for (const late of [false, true]) { - it(`preserves fallback effects and refs through ${late ? 'late' : 'immediate'} recovery`, async () => { - let resolve; - const promise = new Promise(r => { - resolve = r; - }); - const mount = vi.fn(), - cleanup = vi.fn(), - ref = vi.fn(), - click = vi.fn(); - const mismatch = vi.fn(); - const previousMismatch = options._hydrationMismatch; - options._hydrationMismatch = mismatch; - function Fallback() { - useLayoutEffect(() => { - mount(); - return cleanup; - }, []); - return ( - - ); - } - function Primary() { - use(promise); - return ( - - ); - } - try { - scratch.innerHTML = ``; - const [before, fallback, after] = scratch.querySelectorAll('button'); - hydrate( - - - }> - - - - , - scratch - ); - if (late) { - const marker = fallback.previousSibling; - marker.data = '$s!:1'; - marker.__r(); - } - rerender(); - expect(scratch.querySelector('.fallback')).to.equal(fallback); - fallback.click(); - expect(click).toHaveBeenCalledOnce(); - expect(mount).toHaveBeenCalledOnce(); - expect(cleanup).not.toHaveBeenCalled(); - expect(ref).toHaveBeenCalledExactlyOnceWith(fallback); - resolve(); - await promise; - rerender(); - expect(scratch.querySelector('.fallback')).to.equal(null); - expect(scratch.querySelector('.primary').title).to.equal('ready'); - expect(scratch.firstChild).to.equal(before); - expect(scratch.lastChild).to.equal(after); - expect(mount).toHaveBeenCalledOnce(); - expect(cleanup).toHaveBeenCalledOnce(); - expect(ref).toHaveBeenCalledTimes(2); - expect(ref).toHaveBeenLastCalledWith(null); - expect(mismatch).not.toHaveBeenCalled(); - } finally { - options._hydrationMismatch = previousMismatch; - } - }); - } + it('removes a marked last-child fallback for empty primary content', () => { + scratch.innerHTML = 'fallback'; + hydrate({null}, scratch); + expect(scratch.innerHTML).to.equal(''); + }); - it('hydrates nested fallback boundaries without claiming their siblings', async () => { - let resolveOuter, resolveInner; - const outer = new Promise(r => { - resolveOuter = r; - }); - const inner = new Promise(r => { - resolveInner = r; - }); + it('does not claim an adjacent same-tag sibling', () => { + scratch.innerHTML = + 'fallbackafter'; + const after = scratch.lastChild; + hydrate( + + + ready + + after + , + scratch + ); + expect(scratch.querySelector('.primary')).not.to.equal(after); + expect(scratch.lastChild).to.equal(after); + expect(scratch.innerHTML).to.equal( + 'readyafter' + ); + }); + + it('does not steal adjacent DOM for additional primary siblings', () => { const click = vi.fn(); - function Outer() { - use(outer); - return ; + const mismatch = vi.fn(); + const oldMismatch = options._hydrationMismatch; + options._hydrationMismatch = mismatch; + scratch.innerHTML = + 'fallbackafter'; + const after = scratch.lastElementChild; + try { + hydrate( + + + + one + + two + + after + , + scratch + ); + const elements = [...scratch.querySelectorAll('i')]; + expect(elements.map(node => node.className)).to.deep.equal([ + 'one', + 'two', + 'after' + ]); + expect(elements[0].title).to.equal('fresh'); + elements[0].click(); + expect(click).toHaveBeenCalledOnce(); + expect(elements[2]).to.equal(after); + expect(mismatch).not.toHaveBeenCalled(); + } finally { + options._hydrationMismatch = oldMismatch; } - function Inner() { - use(inner); - return ; + }); + + it('keeps the recovery range through repeated suspension', async () => { + let resolveFirst, resolveSecond; + const first = new Promise(r => { + resolveFirst = r; + }); + const second = new Promise(r => { + resolveSecond = r; + }); + function Primary() { + use(first); + use(second); + return ready; } scratch.innerHTML = - ''; - const [nested, extra, after] = scratch.querySelectorAll('button'); + 'loadingafter'; + const fallback = scratch.querySelector('.fallback'), + after = scratch.lastChild; hydrate( - - - nested - - } - > - - - - - } - > - + + - + after , scratch ); - expect(scratch.querySelector('.nested'), 'initial nested').to.equal(nested); + resolveFirst(); + await first; rerender(); - expect(scratch.querySelector('.nested'), 'pending nested').to.equal(nested); - expect(scratch.querySelector('.extra')).to.equal(extra); - expect(scratch.lastChild).to.equal(after); - nested.click(); - expect(click).toHaveBeenCalledOnce(); - resolveOuter(); - await outer; - rerender(); - expect(scratch.innerHTML).to.equal( - '' - ); - resolveInner(); - await inner; + expect(scratch.querySelector('.fallback')).to.equal(fallback); + expect(scratch.lastElementChild).to.equal(after); + resolveSecond(); + await second; rerender(); expect(scratch.innerHTML).to.equal( - '' + 'readyafter' ); expect(scratch.lastChild).to.equal(after); }); diff --git a/compat/test/ts/recoverable.tsx b/compat/test/ts/recoverable.tsx index 0351059bf5..2ef87fa4cf 100644 --- a/compat/test/ts/recoverable.tsx +++ b/compat/test/ts/recoverable.tsx @@ -4,3 +4,8 @@ export const reason: undefined = use(browser(() => new Error('browser only'))); export const fromDefault: undefined = React.use(React.browser('browser only')); // @ts-expect-error A reason must be a string or an initializer. browser(123); + +export async function consumeRecoverable() { + const value: undefined = await browser(); + return value; +} diff --git a/mangle.json b/mangle.json index fd9bb196d7..5193d8cbf6 100644 --- a/mangle.json +++ b/mangle.json @@ -48,7 +48,6 @@ "$_children": "__k", "$_pendingSuspensionCount": "__u", "$_childDidSuspend": "__c", - "$_hydrate": "__y", "$_unmounted": "__z", "$_onResolve": "__R", "$_suspended": "__a", diff --git a/src/diff/index.js b/src/diff/index.js index c70fc8ed56..40ca029205 100644 --- a/src/diff/index.js +++ b/src/diff/index.js @@ -44,7 +44,7 @@ import { setProperty } from './props'; * @param {object} globalContext The current context object. Modified by * getChildContext * @param {string} namespace Current namespace of the DOM node (HTML, SVG, or MathML) - * @param {Array} excessDomChildren + * @param {Array & { _flags?: boolean }} excessDomChildren * @param {Array} commitQueue List of components which have callbacks * to invoke in commitRoot * @param {PreactElement} oldDom The current attached DOM element any new dom @@ -68,6 +68,7 @@ export function diff( ) { /** @type {any} */ let tmp, + recovery, newType = newVNode.type; // When passing through createElement it assigns the object @@ -76,32 +77,17 @@ export function diff( // If the previous diff bailed out, resume creating/hydrating. // `tmp` holds the stored excess node until the options._diff call below. - if ( - oldVNode._flags & MODE_SUSPENDED && + if (oldVNode._flags & MODE_SUSPENDED && (tmp = oldVNode._component._excess)) { // @ts-expect-error This is 1 or 0 (true or false) - (isHydrating = oldVNode._flags & MODE_HYDRATE) && - (tmp = oldVNode._component._excess) - ) { - newVNode._flags |= MODE_HYDRATE; - excessDomChildren = []; + isHydrating = oldVNode._flags & MODE_HYDRATE; + if (isHydrating) newVNode._flags |= MODE_HYDRATE; if (tmp.nodeType == 8) { - // Re-scan DOM from stored start marker for streamed hydration. - // `depth` only ever reaches 0 through the `break` below, so it - // doesn't need to be re-tested in the loop condition. - for ( - let depth = 1, node = tmp.nextSibling; - node; - node = node.nextSibling - ) { - if (node.nodeType == 8) { - if (node.data.startsWith('$s')) depth++; - else if (node.data.startsWith('/$s') && !--depth) break; - } - excessDomChildren.push(node); - } - } else { - excessDomChildren.push(tmp); - } + excessDomChildren = collectSuspenseBoundary(tmp); + if (tmp.data.startsWith('$s!')) { + recovery = excessDomChildren; + isHydrating = false; + } else excessDomChildren = excessDomChildren.slice(1, -1); + } else excessDomChildren = [tmp]; oldDom = excessDomChildren[0]; oldVNode._component._excess = NULL; } @@ -259,14 +245,6 @@ export function diff( c.props = newProps; c._parentDom = parentDom; c._bits &= ~COMPONENT_FORCE; - if ( - c._hydrate && - (tmp = c._hydrate(oldDom, isHydrating && excessDomChildren)) - ) { - oldDom = tmp[0]; - excessDomChildren = tmp; - isHydrating = true; - } let renderHook = options._render, count = 0; @@ -343,6 +321,22 @@ export function diff( oldDom = oldVNode._children ? getDomSibling(oldVNode, 0) : NULL; } + // Reconcile the server fallback within this boundary. + if ( + isHydrating && + c._childDidSuspend && + oldDom && + oldDom.nodeType == 8 && + oldDom.data.startsWith('$s!') + ) { + excessDomChildren = recovery = collectSuspenseBoundary( + oldDom, + excessDomChildren + ); + oldDom = recovery[0]; + isHydrating = false; + } + oldDom = diffChildren( parentDom, isArray(renderResult) ? renderResult : [renderResult], @@ -356,6 +350,7 @@ export function diff( isHydrating, refQueue ); + if (recovery) recovery.some(removeNode); // When we exit a portal we // change up the oldDom @@ -388,9 +383,10 @@ export function diff( let commentMarkersToFind = 0, startMarker; - newVNode._flags |= isHydrating - ? MODE_HYDRATE | MODE_SUSPENDED - : MODE_SUSPENDED; + newVNode._flags |= + isHydrating || (excessDomChildren && excessDomChildren._flags) + ? MODE_HYDRATE | MODE_SUSPENDED + : MODE_SUSPENDED; if (excessDomChildren) { for (let i = 0; i < excessDomChildren.length; i++) { @@ -812,3 +808,21 @@ export function unmount(vnode, parentVNode, skipRemove) { function doRender(props, state, context) { return this.constructor(props, context); } + +/** Collect candidates without claiming DOM from neighboring boundaries. */ +function collectSuspenseBoundary(node, parent) { + let nodes = /** @type {PreactElement[] & { _flags?: boolean }} */ ([]), + depth = 0; + do { + nodes.push(node); + if (parent) parent[parent.indexOf(node)] = NULL; + if (node.nodeType == 8) { + if (node.data.startsWith('$s')) depth++; + else if (node.data.startsWith('/$s')) depth--; + } + node = node.nextSibling; + } while (node && depth); + // A further suspension must preserve this range for another client retry. + nodes._flags = true; + return nodes; +} diff --git a/src/internal.d.ts b/src/internal.d.ts index 5d3a35dd57..a20a4b6c62 100644 --- a/src/internal.d.ts +++ b/src/internal.d.ts @@ -165,10 +165,7 @@ export interface Component

extends Omit< state: S; // Override Component["state"] to not be readonly for internal use, specifically Hooks _excess?: PreactElement; - _hydrate?: ( - oldDom: PreactElement, - excess: PreactElement[] | false - ) => PreactElement[]; + _childDidSuspend?: (promise: any, vnode: VNode) => void; _renderCallbacks: Array<() => void>; // Only class components _stateCallbacks: Array<() => void>; // Only class components _globalContext?: any; From 21eeffc44bcc1172ced477b2315127cf3c9e3870 Mon Sep 17 00:00:00 2001 From: Jovi De Croock Date: Mon, 14 Sep 2026 19:10:23 +0200 Subject: [PATCH 5/7] Share recovered boundary reconciliation across retries --- src/diff/index.js | 33 +++++++++++++++------------------ 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/src/diff/index.js b/src/diff/index.js index 40ca029205..6667a1d772 100644 --- a/src/diff/index.js +++ b/src/diff/index.js @@ -44,7 +44,7 @@ import { setProperty } from './props'; * @param {object} globalContext The current context object. Modified by * getChildContext * @param {string} namespace Current namespace of the DOM node (HTML, SVG, or MathML) - * @param {Array & { _flags?: boolean }} excessDomChildren + * @param {Array} excessDomChildren * @param {Array} commitQueue List of components which have callbacks * to invoke in commitRoot * @param {PreactElement} oldDom The current attached DOM element any new dom @@ -77,16 +77,15 @@ export function diff( // If the previous diff bailed out, resume creating/hydrating. // `tmp` holds the stored excess node until the options._diff call below. - if (oldVNode._flags & MODE_SUSPENDED && (tmp = oldVNode._component._excess)) { + if ( + oldVNode._flags & MODE_SUSPENDED && // @ts-expect-error This is 1 or 0 (true or false) - isHydrating = oldVNode._flags & MODE_HYDRATE; - if (isHydrating) newVNode._flags |= MODE_HYDRATE; - if (tmp.nodeType == 8) { - excessDomChildren = collectSuspenseBoundary(tmp); - if (tmp.data.startsWith('$s!')) { - recovery = excessDomChildren; - isHydrating = false; - } else excessDomChildren = excessDomChildren.slice(1, -1); + (isHydrating = oldVNode._flags & MODE_HYDRATE) && + (tmp = oldVNode._component._excess) + ) { + newVNode._flags |= MODE_HYDRATE; + if (tmp.nodeType == 8 && !tmp.data.startsWith('$s!')) { + excessDomChildren = collectSuspenseBoundary(tmp).slice(1, -1); } else excessDomChildren = [tmp]; oldDom = excessDomChildren[0]; oldVNode._component._excess = NULL; @@ -324,7 +323,7 @@ export function diff( // Reconcile the server fallback within this boundary. if ( isHydrating && - c._childDidSuspend && + (c._childDidSuspend || oldVNode._flags & MODE_SUSPENDED) && oldDom && oldDom.nodeType == 8 && oldDom.data.startsWith('$s!') @@ -383,10 +382,9 @@ export function diff( let commentMarkersToFind = 0, startMarker; - newVNode._flags |= - isHydrating || (excessDomChildren && excessDomChildren._flags) - ? MODE_HYDRATE | MODE_SUSPENDED - : MODE_SUSPENDED; + newVNode._flags |= isHydrating + ? MODE_HYDRATE | MODE_SUSPENDED + : MODE_SUSPENDED; if (excessDomChildren) { for (let i = 0; i < excessDomChildren.length; i++) { @@ -396,6 +394,7 @@ export function diff( if (child.nodeType == 8) { excessDomChildren[i] = NULL; if (child.data.startsWith('$s')) { + newVNode._flags |= MODE_HYDRATE; if (!commentMarkersToFind++) startMarker = child; } else if ( child.data.startsWith('/$s') && @@ -811,7 +810,7 @@ function doRender(props, state, context) { /** Collect candidates without claiming DOM from neighboring boundaries. */ function collectSuspenseBoundary(node, parent) { - let nodes = /** @type {PreactElement[] & { _flags?: boolean }} */ ([]), + let nodes = [], depth = 0; do { nodes.push(node); @@ -822,7 +821,5 @@ function collectSuspenseBoundary(node, parent) { } node = node.nextSibling; } while (node && depth); - // A further suspension must preserve this range for another client retry. - nodes._flags = true; return nodes; } From 885a8967aa2f0394c66747670c10b15e86ca90c0 Mon Sep 17 00:00:00 2001 From: Jovi De Croock Date: Mon, 14 Sep 2026 19:16:04 +0200 Subject: [PATCH 6/7] Use DOM availability for recoverable browser values --- compat/src/render.js | 20 ++++++++-------- compat/test/browser/recoverable.test.jsx | 30 ++++++------------------ src/diff/index.js | 1 - test/node/recoverable.test.js | 29 +++++++++++++++++++++++ 4 files changed, 46 insertions(+), 34 deletions(-) create mode 100644 test/node/recoverable.test.js diff --git a/compat/src/render.js b/compat/src/render.js index 99ce049f72..8e4af3b195 100644 --- a/compat/src/render.js +++ b/compat/src/render.js @@ -31,12 +31,10 @@ export function browser(reason) { return { // oxlint-disable-next-line unicorn/no-thenable -- `use` consumes instrumented thenables. then(resolve, reject) { - if (options._skipEffects) reject(this.reason); - else resolve(); - }, - get status() { - return options._skipEffects ? 'rejected' : 'fulfilled'; + if (IS_DOM) resolve(); + else reject(this.reason); }, + status: IS_DOM, reason: { $$typeof: Symbol.for('react.recoverable'), _reason: reason } }; } @@ -354,23 +352,25 @@ function initRenderTracking(value) { * Read the value of a Promise (suspending while pending) or a Context. * Unlike other hooks, `use` may be called conditionally. * @template T - * @param {(Promise & { status?: string, value?: T, reason?: any }) | import('../../src/internal').PreactContext} resource + * @param {(Promise & { status?: string | boolean, value?: T, reason?: any }) | import('../../src/internal').PreactContext} resource * @returns {T} */ export const use = /* @__PURE__ */ initRenderTracking(function use(resource) { // A Context is a function without a `then`, a thenable has one. if (resource.then) { - if (resource.status == 'fulfilled') return resource.value; - if (resource.status == 'rejected') throw resource.reason; + if (resource.status === true || resource.status == 'fulfilled') + return resource.value; + if (resource.status === false || resource.status == 'rejected') + throw resource.reason; if (!resource.status) { resource.status = 'pending'; resource.then( value => { - resource.status = 'fulfilled'; + resource.status = true; resource.value = value; }, reason => { - resource.status = 'rejected'; + resource.status = false; resource.reason = reason; } ); diff --git a/compat/test/browser/recoverable.test.jsx b/compat/test/browser/recoverable.test.jsx index d9e53ebbae..7770f9f01c 100644 --- a/compat/test/browser/recoverable.test.jsx +++ b/compat/test/browser/recoverable.test.jsx @@ -35,35 +35,19 @@ describe('recoverable rendering', () => { it('settles synchronously without retaining callbacks', async () => { const value = browser(); + const resolve = vi.fn(); + value.then(resolve); + expect(resolve).toHaveBeenCalledOnce(); expect(await value).to.equal(undefined); - let error; - const previous = options._skipEffects; - try { - options._skipEffects = true; - value.then(null, caught => { - error = caught; - }); - } finally { - options._skipEffects = previous; - } - expect(error).to.equal(value.reason); }); - it('throws its recoverable reason to the server renderer', () => { - const reason = vi.fn(); - const value = Object.freeze(browser(reason)); + it('remains fulfilled when rendering to a string in the browser', () => { + const value = Object.freeze(browser()); function App() { use(value); + return content; } - let error; - try { - renderToString(); - } catch (caught) { - error = caught; - } - expect(error).to.equal(value.reason); - expect(reason).not.toHaveBeenCalled(); - expect(options._skipEffects).not.to.equal(true); + expect(renderToString()).to.equal('content'); }); it('replaces a marked fallback without claiming adjacent siblings', () => { diff --git a/src/diff/index.js b/src/diff/index.js index 6667a1d772..cf4ba898de 100644 --- a/src/diff/index.js +++ b/src/diff/index.js @@ -332,7 +332,6 @@ export function diff( oldDom, excessDomChildren ); - oldDom = recovery[0]; isHydrating = false; } diff --git a/test/node/recoverable.test.js b/test/node/recoverable.test.js new file mode 100644 index 0000000000..a5ddfde9e5 --- /dev/null +++ b/test/node/recoverable.test.js @@ -0,0 +1,29 @@ +import { browser, createElement, use } from 'preact/compat'; +import { renderToString } from 'preact-render-to-string'; +import { vi } from 'vitest'; + +describe('recoverable rendering without a DOM', () => { + it('throws its recoverable reason without evaluating it', () => { + const reason = vi.fn(); + const value = Object.freeze(browser(reason)); + function App() { + use(value); + } + let error; + try { + renderToString(createElement(App)); + } catch (caught) { + error = caught; + } + expect(error).to.equal(value.reason); + expect(reason).not.toHaveBeenCalled(); + }); + + it('rejects synchronously and when awaited', async () => { + const value = browser(); + const reject = vi.fn(); + value.then(null, reject); + expect(reject).toHaveBeenCalledWith(value.reason); + await expect(Promise.resolve(value)).rejects.to.equal(value.reason); + }); +}); From 0c8bd7d0c14f226d973d7afbc03ae79846e40123 Mon Sep 17 00:00:00 2001 From: Jovi De Croock Date: Mon, 14 Sep 2026 19:18:16 +0200 Subject: [PATCH 7/7] Keep standard thenable status strings --- compat/src/render.js | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/compat/src/render.js b/compat/src/render.js index 8e4af3b195..4b94505cd8 100644 --- a/compat/src/render.js +++ b/compat/src/render.js @@ -34,7 +34,7 @@ export function browser(reason) { if (IS_DOM) resolve(); else reject(this.reason); }, - status: IS_DOM, + status: IS_DOM ? 'fulfilled' : 'rejected', reason: { $$typeof: Symbol.for('react.recoverable'), _reason: reason } }; } @@ -352,25 +352,23 @@ function initRenderTracking(value) { * Read the value of a Promise (suspending while pending) or a Context. * Unlike other hooks, `use` may be called conditionally. * @template T - * @param {(Promise & { status?: string | boolean, value?: T, reason?: any }) | import('../../src/internal').PreactContext} resource + * @param {(Promise & { status?: string, value?: T, reason?: any }) | import('../../src/internal').PreactContext} resource * @returns {T} */ export const use = /* @__PURE__ */ initRenderTracking(function use(resource) { // A Context is a function without a `then`, a thenable has one. if (resource.then) { - if (resource.status === true || resource.status == 'fulfilled') - return resource.value; - if (resource.status === false || resource.status == 'rejected') - throw resource.reason; + if (resource.status == 'fulfilled') return resource.value; + if (resource.status == 'rejected') throw resource.reason; if (!resource.status) { resource.status = 'pending'; resource.then( value => { - resource.status = true; + resource.status = 'fulfilled'; resource.value = value; }, reason => { - resource.status = false; + resource.status = 'rejected'; resource.reason = reason; } );