diff --git a/.changeset/pending-boundary-deletion.md b/.changeset/pending-boundary-deletion.md new file mode 100644 index 0000000..ac0fc60 --- /dev/null +++ b/.changeset/pending-boundary-deletion.md @@ -0,0 +1,7 @@ +--- +"@tanstack/redact": patch +--- + +Remove opaque pending server ranges when their Suspense boundary is deleted and ignore obsolete reveal callbacks. + +Release pending reveal subscriptions with their boundary range and remove late transport segments whose boundary was deleted. diff --git a/packages/redact/src/dom/features/suspense/full.ts b/packages/redact/src/dom/features/suspense/full.ts index 41e28a8..cf29496 100644 --- a/packages/redact/src/dom/features/suspense/full.ts +++ b/packages/redact/src/dom/features/suspense/full.ts @@ -1,7 +1,7 @@ import { readOwnerStack, runWithOwnerStack } from '../../../core/devtools'; import { a as transitionRender } from '../suspense-runtime'; import { cleanupEffect } from '../../dispatcher'; -import { checkpointCommit, rewindCommit, queueMutation, onCommitFailure, commitSynchronously } from '../../commit'; +import { checkpointCommit, rewindCommit, queueMutation, onCommitFailure, onCommitRollback, commitSynchronously } from '../../commit'; import { REACT_SUSPENSE_TYPE, ReactSharedInternals, startTransition } from '../../../react'; import { FiberTag, createFiber, type Fiber } from '../../../core'; import { disconnectClass, inSuspensePrimary, setRetainedFlag, walkRetained } from '../../retained-effects'; @@ -62,6 +62,7 @@ interface SuspenseState { e?: Comment; r?: any; w?: boolean; + x?: () => void; u?: boolean; // Re-suspend preservation: d?: Array<[ @@ -82,6 +83,8 @@ function reportServerFailure(fiber: Fiber): void { } function recoverBoundaryHydration(fiber: Fiber, children: any, parent: Node, startMark: Comment | null, endMark: Comment | null, retryBoundary?: boolean): void { const clear = () => { + const dispose = fiber.b?.x; + if (dispose) queueMutation(dispose); if (startMark && endMark) clearBoundaryRange(startMark, endMark); else @@ -215,8 +218,14 @@ function hydrateSuspenseBoundary(fiber: Fiber, props: any, boundary: [ finally { clearHydrationCursor(fiber); } + // The server range remains owned even when its fallback has child fibers. + fiber.b.w = true; advanceCursorPast(fiber.p!, endMark); - (globalThis as any).$RH?.(id, () => rehydrateBoundary(fiber)); + const dispose = (globalThis as any).$RH?.(id, () => { + if (!fiber.u && !fiber.pd) rehydrateBoundary(fiber); + }); + fiber.b.x = dispose; + if (dispose) onCommitRollback(dispose); // If the inline runtime isn't present, nothing external will mark us dy. fiber.m = props; } @@ -313,7 +322,7 @@ function renderSuspense(fiber: Fiber): void { } } if (state.w && state.b && state.e) { - if (state.b.data === '$?' || state.b.data === '$~') { + if (state.b.data.startsWith('$?') || state.b.data === '$~') { if (!root?.h && props.children !== fiber.m?.children) { ; (state.b as any)._reactRetry = undefined; diff --git a/packages/redact/src/dom/reconcile.ts b/packages/redact/src/dom/reconcile.ts index a4f3c8c..5f29060 100644 --- a/packages/redact/src/dom/reconcile.ts +++ b/packages/redact/src/dom/reconcile.ts @@ -1219,6 +1219,7 @@ export function removeFiber(fiber: Fiber, remove = true): void { fiber.u = true; if (fiber.t === FiberTag.Suspense && fiber.b?.b) { const state = fiber.b; + state.x?.(); state.b._reactRetry = undefined; if (remove && state.w) { let node: Node | null = state.b; diff --git a/packages/redact/src/server/bootstrap-script.ts b/packages/redact/src/server/bootstrap-script.ts index 6acb6aa..8898ee3 100644 --- a/packages/redact/src/server/bootstrap-script.ts +++ b/packages/redact/src/server/bootstrap-script.ts @@ -33,11 +33,11 @@ export const BOUNDARY_REVEAL_RUNTIME = `function load(n,e){return new Promise(function(y,x){n.onload=function(){e.p=null;y()};n.onerror=function(){e.p=null;x()}})}` + `window.$RH=function(i,f){` + `if(!document.getElementById("B:"+i))f();` + - `else c[i]=f;` + + `else{c[i]=f;return function(){if(c[i]===f)delete c[i]}}` + `};` + `window.$RC=function(i){` + `var s=document.getElementById("S:"+i),b=document.getElementById("B:"+i);` + - `if(!s||!b)return;` + + `if(!b){delete c[i];if(s)s.remove();return}if(!s)return;` + `var a=document.querySelectorAll('body link[rel="stylesheet"][data-precedence],body style[data-precedence]');` + `for(var j=0;j void> = [] +afterEach(() => { + for (const dispose of cleanup.splice(0)) dispose() + ;(window as any).$RE_stop?.() + for (const name of ['$RH', '$RC', '$RL', '$RR', '$RB', '$RE_q', '$RE_stop', '__pendingReveals']) delete (window as any)[name] + vi.unstubAllGlobals() +}) + +it.each(['keep', 'replace', 'retarget', 'unmount'])('owns a pending stream range through %s and its late reveal', async action => { + let ready = false, resolve!: () => void + const pending = new Promise(yes => { resolve = () => { ready = true; yes() } }) + const effect = vi.fn() + function Child() { + if (!ready) throw pending + const [count, setCount] = useState(0) + useLayoutEffect(effect, []) + return + } + const tree =
LOADING}>STAYS
+ const stream = await renderToReadableStream(tree) + const reader = stream.getReader() + const shell = new TextDecoder().decode((await reader.read()).value) + const container = document.createElement('div') + container.innerHTML = shell + document.body.append(container) + cleanup.push(() => container.remove()) + // Expose only the existing private registry; keep registration/reveal control flow unchanged. + for (const script of container.querySelectorAll('script')) new Function(script.textContent!.replace('var c={},r=new Map;', 'var c=window.__pendingReveals={},r=new Map;'))() + const registrations = (window as any).__pendingReveals as Record void> + const root = hydrateRoot(container, tree) + const sibling = container.querySelector('em') + expect(Object.keys(registrations)).toEqual(['0']) + expect(container.querySelector('i')?.textContent).toBe('LOADING') + if (action === 'replace') { + flushSync(() => root.render(
REPLACEMENTSTAYS
)) + cleanup.push(() => root.unmount()) + expect(container.querySelector('em')).toBe(sibling) + } else if (action === 'retarget') { + flushSync(() => root.render(
LOADING}>REPLACEMENTSTAYS
)) + cleanup.push(() => root.unmount()) + expect(container.querySelector('em')).toBe(sibling) + } else if (action === 'unmount') flushSync(() => root.unmount()) + else cleanup.push(() => root.unmount()) + if (action !== 'keep') { + expect(container.querySelector('i, template')).toBeNull() + expect(Object.keys(registrations)).toEqual([]) + } + resolve() + let tail = '' + for (;;) { const chunk = await reader.read(); if (chunk.done) break; tail += new TextDecoder().decode(chunk.value) } + const transport = document.createElement('div') + transport.innerHTML = tail + document.body.append(transport) + cleanup.push(() => transport.remove()) + const serverButton = transport.querySelector('button') + for (const script of transport.querySelectorAll('script')) new Function(script.textContent!)() + await Promise.resolve() + expect(Object.keys(registrations)).toEqual([]) + expect(transport.querySelector('[id^="S:"]')).toBeNull() + if (action === 'keep') { + await vi.waitFor(() => expect(effect).toHaveBeenCalledTimes(1)) + expect(container.querySelector('button')).toBe(serverButton) + flushSync(() => serverButton!.click()) + expect(serverButton!.textContent).toBe('READY1') + } else { + expect(effect).not.toHaveBeenCalled() + expect(container.querySelector('button,i,template')).toBeNull() + expect(container.querySelector('main')?.textContent ?? '').toBe(action === 'unmount' ? '' : 'REPLACEMENTSTAYS') + } +}) + +it('does not retry a deleted pending boundary when its registered reveal callback fires', () => { + let reveal!: () => void + vi.stubGlobal('$RH', (_id: string, callback: () => void) => { reveal = callback }) + const container = document.createElement('div') + container.innerHTML = '
LOADING
' + document.body.append(container) + cleanup.push(() => container.remove()) + const effect = vi.fn() + function Child() { effect(); return READY } + const root = hydrateRoot(container, LOADING}>) + flushSync(() => root.render(REPLACEMENT)) + reveal() + expect(effect).not.toHaveBeenCalled() + expect(container.innerHTML).toBe('REPLACEMENT') + root.unmount() +}) + +it('does not let a retired subscription dispose a newer registration for the same boundary', () => { + const container = document.createElement('div') + container.innerHTML = '
ready
' + document.body.append(container) + cleanup.push(() => container.remove()) + const first = vi.fn(), second = vi.fn() + new Function(BOUNDARY_REVEAL_RUNTIME)() + const dispose = (window as any).$RH(0, first) + ;(window as any).$RH(0, second) + expect(dispose).toBeTypeOf('function') + dispose() + ;(window as any).$RC(0) + expect(first).not.toHaveBeenCalled() + expect(second).toHaveBeenCalledTimes(1) +})