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..58993b692a 100644 --- a/compat/src/index.d.ts +++ b/compat/src/index.d.ts @@ -133,6 +133,14 @@ declare namespace React { ): T; export function useEffectEvent(cb: T): T; // React 19 hooks + 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; // 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..4b94505cd8 100644 --- a/compat/src/render.js +++ b/compat/src/render.js @@ -23,6 +23,22 @@ import { assign, IS_NON_DIMENSIONAL } from './util'; export const REACT_ELEMENT_TYPE = Symbol.for('react.element'); +/** + * Create a thenable that resolves in the browser and defers server rendering. + * @param {string | (() => any)} [reason] + */ +export function browser(reason) { + return { + // oxlint-disable-next-line unicorn/no-thenable -- `use` consumes instrumented thenables. + then(resolve, reject) { + if (IS_DOM) resolve(); + else reject(this.reason); + }, + status: IS_DOM ? 'fulfilled' : 'rejected', + reason: { $$typeof: Symbol.for('react.recoverable'), _reason: reason } + }; +} + const MODE_HYDRATE = 1 << 5; let currentComponent, hydrationRoot, renderTrackingInitialized; diff --git a/compat/test/browser/recoverable.test.jsx b/compat/test/browser/recoverable.test.jsx new file mode 100644 index 0000000000..7770f9f01c --- /dev/null +++ b/compat/test/browser/recoverable.test.jsx @@ -0,0 +1,237 @@ +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('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); + return
content
; + } + render(, scratch); + expect(scratch.innerHTML).to.equal('
content
'); + expect(reason).not.toHaveBeenCalled(); + }); + + 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); + }); + + it('remains fulfilled when rendering to a string in the browser', () => { + const value = Object.freeze(browser()); + function App() { + use(value); + return content; + } + expect(renderToString()).to.equal('content'); + }); + + 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; + }); + function App() { + use(promise); + return
content
; + } + scratch.innerHTML = + 'after'; + const serverFallback = scratch.querySelector('button'); + hydrate( + + client fallback}> + + + after + , + scratch + ); + rerender(); + 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.lastElementChild.textContent).to.equal('after'); + }); + + it('replaces nested and late marked fallback ranges', async () => { + let resolve; + const promise = new Promise(r => { + resolve = r; + }); + function App() { + use(promise); + return ready; + } + scratch.innerHTML = + 'nestedafter'; + hydrate( + + + + + after + , + scratch + ); + scratch.firstChild.data = '$s!:1'; + resolve(); + await promise; + rerender(); + rerender(); + expect(scratch.innerHTML).to.equal('readyafter'); + }); + + it('removes a marked last-child fallback for empty primary content', () => { + scratch.innerHTML = 'fallback'; + hydrate({null}, scratch); + expect(scratch.innerHTML).to.equal(''); + }); + + 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(); + 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; + } + }); + + 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 = + 'loadingafter'; + const fallback = scratch.querySelector('.fallback'), + after = scratch.lastChild; + hydrate( + + + + + after + , + scratch + ); + resolveFirst(); + await first; + rerender(); + 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 new file mode 100644 index 0000000000..2ef87fa4cf --- /dev/null +++ b/compat/test/ts/recoverable.tsx @@ -0,0 +1,11 @@ +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); + +export async function consumeRecoverable() { + const value: undefined = await browser(); + return value; +} 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..cf4ba898de 100644 --- a/src/diff/index.js +++ b/src/diff/index.js @@ -68,6 +68,7 @@ export function diff( ) { /** @type {any} */ let tmp, + recovery, newType = newVNode.type; // When passing through createElement it assigns the object @@ -83,25 +84,9 @@ export function diff( (tmp = oldVNode._component._excess) ) { newVNode._flags |= MODE_HYDRATE; - excessDomChildren = []; - 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); - } + if (tmp.nodeType == 8 && !tmp.data.startsWith('$s!')) { + excessDomChildren = collectSuspenseBoundary(tmp).slice(1, -1); + } else excessDomChildren = [tmp]; oldDom = excessDomChildren[0]; oldVNode._component._excess = NULL; } @@ -335,6 +320,21 @@ export function diff( oldDom = oldVNode._children ? getDomSibling(oldVNode, 0) : NULL; } + // Reconcile the server fallback within this boundary. + if ( + isHydrating && + (c._childDidSuspend || oldVNode._flags & MODE_SUSPENDED) && + oldDom && + oldDom.nodeType == 8 && + oldDom.data.startsWith('$s!') + ) { + excessDomChildren = recovery = collectSuspenseBoundary( + oldDom, + excessDomChildren + ); + isHydrating = false; + } + oldDom = diffChildren( parentDom, isArray(renderResult) ? renderResult : [renderResult], @@ -348,6 +348,7 @@ export function diff( isHydrating, refQueue ); + if (recovery) recovery.some(removeNode); // When we exit a portal we // change up the oldDom @@ -392,6 +393,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') && @@ -804,3 +806,19 @@ 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 = [], + 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); + return nodes; +} 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; 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); + }); +});