From 0c39a34373d51940b5c3831f66b945a5eb162a41 Mon Sep 17 00:00:00 2001 From: Jeff Repanich Date: Mon, 27 Jul 2026 16:49:51 -0400 Subject: [PATCH] fix(renderer): adopt populated Show hydration ranges --- src/renderer/boundary-range-sync.ts | 15 ++ .../ssr/empty-show-tail-hydration.test.tsx | 151 ++++++++++++++++++ 2 files changed, 166 insertions(+) diff --git a/src/renderer/boundary-range-sync.ts b/src/renderer/boundary-range-sync.ts index 2e0400b4..c0199e22 100644 --- a/src/renderer/boundary-range-sync.ts +++ b/src/renderer/boundary-range-sync.ts @@ -357,6 +357,21 @@ export function syncControlBoundaryInMixedParent( } const activeScope = controlState.activeScope; const activeVNode = childrenVNodes[0]; + if ( + activeScope?.hydrationPending && + activeVNode !== undefined && + !activeScope.range && + controlState.lastRemovedRanges.length === 0 && + before instanceof Element && + canAdoptHydratedElement(before, activeVNode) + ) { + assignScopeRange(activeScope, { + start: before, + end: before, + single: true, + }); + activeScope.hydrationPending = false; + } const nextRange = activeScope && activeVNode !== undefined ? syncControlBoundaryScopeDom( diff --git a/tests/jsdom/ssr/empty-show-tail-hydration.test.tsx b/tests/jsdom/ssr/empty-show-tail-hydration.test.tsx index f48c16f4..1c7c72fb 100644 --- a/tests/jsdom/ssr/empty-show-tail-hydration.test.tsx +++ b/tests/jsdom/ssr/empty-show-tail-hydration.test.tsx @@ -122,4 +122,155 @@ describe('empty Show hydration cursor', () => { cleanup(); } }); + + it('should adopt populated and component siblings after an empty Show boundary', async () => { + let setFirst!: (value: boolean) => void; + let setSecond!: (value: boolean) => void; + + function Tail() { + return ; + } + + function App() { + const first = state(false); + const second = state(true); + setFirst = first.set; + setSecond = second.set; + return ( +
+ +
first
+
+ +
visible
+
+ +
+ ); + } + + const { container, cleanup } = createTestContainer(); + try { + container.innerHTML = renderToStringSync(App); + const serverVisible = container.querySelector('[data-visible]'); + const serverTail = container.querySelector('[data-tail]'); + + await hydrateSPA({ + root: container, + registry: routeRegistryFromTable([{ path: '/', handler: App }]), + }); + + expect(container.querySelectorAll('[data-tail]')).toHaveLength(1); + expect(container.querySelector('[data-tail]')).toBe(serverTail); + expect(container.querySelector('[data-visible]')).toBe(serverVisible); + + setFirst(true); + flushScheduler(); + expect(container.querySelector('[data-first]')).not.toBeNull(); + expect(container.querySelector('[data-visible]')).toBe(serverVisible); + expect(container.querySelector('[data-tail]')).toBe(serverTail); + + setSecond(false); + flushScheduler(); + expect(container.querySelector('[data-visible]')).toBeNull(); + expect(container.querySelectorAll('[data-tail]')).toHaveLength(1); + expect(container.querySelector('[data-tail]')).toBe(serverTail); + + setSecond(true); + flushScheduler(); + expect(container.querySelector('[data-visible]')).not.toBeNull(); + expect(container.querySelectorAll('[data-tail]')).toHaveLength(1); + expect(container.querySelector('[data-tail]')).toBe(serverTail); + + setFirst(false); + flushScheduler(); + expect(container.querySelector('[data-first]')).toBeNull(); + expect(container.querySelector('[data-visible]')).not.toBeNull(); + expect(container.querySelectorAll('[data-tail]')).toHaveLength(1); + expect(container.querySelector('[data-tail]')).toBe(serverTail); + } finally { + cleanup(); + } + }); + + it('should retain adopted sibling ownership when opening the empty Show fails', async () => { + let setFirst!: (value: boolean) => void; + let incrementVisible!: () => void; + let shouldFail = true; + + function FailingFirst() { + if (shouldFail) { + throw new Error('first Show failed'); + } + return
first
; + } + + function Visible() { + const count = state(0); + incrementVisible = () => count.set((value) => value + 1); + return ( + + ); + } + + function Tail() { + return ; + } + + function App() { + const first = state(false); + setFirst = first.set; + return ( +
+ + + + + + + +
+ ); + } + + const { container, cleanup } = createTestContainer(); + try { + container.innerHTML = renderToStringSync(App); + const serverVisible = container.querySelector('[data-visible]'); + const serverTail = container.querySelector('[data-tail]'); + + await hydrateSPA({ + root: container, + registry: routeRegistryFromTable([{ path: '/', handler: App }]), + }); + + expect(container.querySelector('[data-visible]')).toBe(serverVisible); + expect(container.querySelector('[data-tail]')).toBe(serverTail); + + setFirst(true); + expect(() => flushScheduler()).toThrow('first Show failed'); + + expect(container.querySelector('[data-first]')).toBeNull(); + expect(container.querySelector('[data-visible]')).toBe(serverVisible); + expect(container.querySelector('[data-tail]')).toBe(serverTail); + + incrementVisible(); + flushScheduler(); + expect(serverVisible?.textContent).toBe('1'); + + setFirst(false); + flushScheduler(); + shouldFail = false; + setFirst(true); + flushScheduler(); + + expect(container.querySelector('[data-first]')).not.toBeNull(); + expect(container.querySelector('[data-visible]')).toBe(serverVisible); + expect(container.querySelector('[data-tail]')).toBe(serverTail); + } finally { + cleanup(); + } + }); });