From d487962c2705f4b45c6c567e6b11ec09a5f2d153 Mon Sep 17 00:00:00 2001 From: Jeff Repanich Date: Mon, 27 Jul 2026 13:13:11 -0400 Subject: [PATCH] fix(renderer): keep component fragments transparent --- docs/core/rendering.md | 21 ++ docs/guides/component-generation.md | 3 + docs/guides/platform-recipes.md | 2 +- package-lock.json | 4 +- package.json | 2 +- src/common/jsx.ts | 7 + src/renderer/boundary-range-adoption.ts | 4 +- src/renderer/child-shape.ts | 10 +- src/renderer/component-fragment-range.ts | 156 +++++++++++ src/renderer/component-host-results.ts | 4 +- src/renderer/component-host.ts | 13 + src/renderer/component-range-commit.ts | 5 + src/renderer/dom-internal.ts | 7 +- src/renderer/evaluate-reconcile.ts | 9 +- src/renderer/static-reuse.ts | 11 +- src/runtime/fastlane.ts | 7 +- src/ssr/hydration-verify.ts | 8 +- src/ssr/render-sync.ts | 9 +- .../dom/component-fragment-structure.test.tsx | 265 +++++++++++++++++- tests/jsdom/ssr/hydration.test.tsx | 54 ++++ 20 files changed, 549 insertions(+), 52 deletions(-) create mode 100644 src/renderer/component-fragment-range.ts diff --git a/docs/core/rendering.md b/docs/core/rendering.md index aaadeb47..0160b3d9 100644 --- a/docs/core/rendering.md +++ b/docs/core/rendering.md @@ -15,6 +15,27 @@ child owners do not become live. Cleanup belonging to a successful commit runs only after the coherent DOM update. Cleanup failures are reported together and do not roll back an already successful render. +### Component Fragment results + +A component may return a Fragment when it needs multiple sibling nodes without +an application-visible wrapper: + +```tsx +function PageHeader() { + return ( + <> +

Dashboard

+ + + ); +} +``` + +The Fragment remains structurally transparent in SPA rendering, SSR, SSG, and +hydration. Its nodes are direct siblings at the component call site. Askr uses +internal comment-anchored ranges to retain update and cleanup ownership; it +does not insert a `div` or another visible host element. + ### Imperative widget hosts Use `imperativeChildren` when a third-party widget owns all descendants of an diff --git a/docs/guides/component-generation.md b/docs/guides/component-generation.md index ea663503..58c5a2ee 100644 --- a/docs/guides/component-generation.md +++ b/docs/guides/component-generation.md @@ -56,12 +56,15 @@ Do: - compose `askr-ui` primitives when behavior is non-trivial - model variants with semantic props (`tone`, `size`, `intent`) and CSS selectors - keep rendering deterministic and side-effect free +- return a Fragment when a component must contribute multiple direct siblings + without changing the parent element's child topology Do not: - hardcode `--ak-*` token strings in runtime TS/JS - call APIs directly in leaf UI components - add component-specific business rules in generic primitives +- add a wrapper element solely to satisfy a component return shape ## Checklist for AI-generated Components diff --git a/docs/guides/platform-recipes.md b/docs/guides/platform-recipes.md index 0b6a7a33..4f8e4591 100644 --- a/docs/guides/platform-recipes.md +++ b/docs/guides/platform-recipes.md @@ -6,7 +6,7 @@ tables, and other styled compositions remain package-owned recipes. ## Prerequisites and version contract -The examples are verified against `@askrjs/askr@0.0.76`. Use one locked +The examples are verified against `@askrjs/askr@0.0.77`. Use one locked `@askrjs/askr` version for the root entry point and every subpath; do not resolve subpaths independently. diff --git a/package-lock.json b/package-lock.json index 5fea26b3..070df6f5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@askrjs/askr", - "version": "0.0.76", + "version": "0.0.77", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@askrjs/askr", - "version": "0.0.76", + "version": "0.0.77", "license": "Apache-2.0", "dependencies": { "@askrjs/auth": ">=0.0.1 <0.1.0", diff --git a/package.json b/package.json index eb009ba3..43a94276 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@askrjs/askr", - "version": "0.0.76", + "version": "0.0.77", "description": "Actor-backed deterministic UI framework", "keywords": [ "askr", diff --git a/src/common/jsx.ts b/src/common/jsx.ts index 62bee6ca..6b6a9e03 100644 --- a/src/common/jsx.ts +++ b/src/common/jsx.ts @@ -8,6 +8,13 @@ export const ELEMENT_TYPE = Symbol.for('askr.element'); export const Fragment = Symbol.for('askr.fragment'); export const STATIC_CHILDREN = Symbol.for('askr.static-children'); +export function isFragmentType(type: unknown): type is symbol { + if (typeof type !== 'symbol') return false; + if (type === Fragment) return true; + const label = String(type); + return label === 'Symbol(askr.fragment)' || label === 'Symbol(Fragment)'; +} + export type JSXComponent = { bivarianceHack(props: TProps): unknown; }['bivarianceHack']; diff --git a/src/renderer/boundary-range-adoption.ts b/src/renderer/boundary-range-adoption.ts index fda45417..37fdc03c 100644 --- a/src/renderer/boundary-range-adoption.ts +++ b/src/renderer/boundary-range-adoption.ts @@ -1,4 +1,4 @@ -import { Fragment } from '../jsx'; +import { isFragmentType } from '../common/jsx'; import type { DOMRange } from '../common/dom-range'; import { enterDomCommitScope, @@ -75,7 +75,7 @@ export function adoptHydratedRange( const range: DOMRange = { start: before, end, single: false }; const contentNodes = getRangeNodes(range); const expectedChildren = - _isDOMElement(vnode) && vnode.type === Fragment + _isDOMElement(vnode) && isFragmentType(vnode.type) ? ((vnode.props?.children as VNode[] | undefined) ?? []) : [vnode]; if (contentNodes.length !== expectedChildren.length) return null; diff --git a/src/renderer/child-shape.ts b/src/renderer/child-shape.ts index 6cff46ac..d5023ab5 100644 --- a/src/renderer/child-shape.ts +++ b/src/renderer/child-shape.ts @@ -1,7 +1,6 @@ -import { STATIC_CHILDREN } from '../common/jsx'; +import { isFragmentType, STATIC_CHILDREN } from '../common/jsx'; import { __CONTROL_BOUNDARY__ } from '../common/vnode'; import { logger } from '../common/logger'; -import { Fragment } from '../jsx/jsx-runtime'; import { getCurrentInstance } from '../runtime'; import { getRuntimeEnv } from './env'; import { _isDOMElement, type DOMElement } from './types'; @@ -12,12 +11,7 @@ export type StaticCreateChildShape = { }; export function isFragmentVNode(node: unknown): node is DOMElement { - return ( - _isDOMElement(node) && - typeof (node as DOMElement).type === 'symbol' && - ((node as DOMElement).type === Fragment || - String((node as DOMElement).type) === 'Symbol(askr.fragment)') - ); + return _isDOMElement(node) && isFragmentType((node as DOMElement).type); } export function normalizeComponentChildren(result: unknown): unknown[] { diff --git a/src/renderer/component-fragment-range.ts b/src/renderer/component-fragment-range.ts new file mode 100644 index 00000000..e54c30b2 --- /dev/null +++ b/src/renderer/component-fragment-range.ts @@ -0,0 +1,156 @@ +import { + registerLifecycleTransaction, + type ComponentInstance, +} from '../runtime'; +import { isFragmentVNode, normalizeComponentChildren } from './child-shape'; +import { getOwnedRange, rangeContains, type DOMRange } from './dom-range'; +import { getRendererDOMHost, type InstanceHostNode } from './dom-host'; +import type { VNode } from './types'; + +export function captureRangeFocus( + range: DOMRange, + parent: Element +): () => void { + const active = parent.ownerDocument.activeElement; + if ( + typeof HTMLElement === 'undefined' || + !(active instanceof HTMLElement) || + !parent.contains(active) + ) { + return () => {}; + } + + let rangeChild: Node = active; + while (rangeChild.parentNode && rangeChild.parentNode !== parent) { + rangeChild = rangeChild.parentNode; + } + if (rangeChild.parentNode !== parent || !rangeContains(range, rangeChild)) { + return () => {}; + } + + const selection = + active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement + ? { + start: active.selectionStart, + end: active.selectionEnd, + direction: active.selectionDirection, + } + : null; + + return () => { + if (!active.isConnected || active.ownerDocument.activeElement === active) { + return; + } + + try { + active.focus({ preventScroll: true }); + } catch { + active.focus(); + } + if ( + selection && + (active instanceof HTMLInputElement || + active instanceof HTMLTextAreaElement) && + selection.start !== null && + selection.end !== null + ) { + active.setSelectionRange( + selection.start, + selection.end, + selection.direction ?? undefined + ); + } + }; +} + +function unwrapStagingHost( + staging: Element, + parent: Element, + restoreFocus: () => void +): void { + if (staging.parentNode !== parent) { + return; + } + + while (staging.firstChild) { + parent.insertBefore(staging.firstChild, staging); + } + staging.remove(); + restoreFocus(); +} + +function createAttributeFreeStagingHost(parent: Element): Element { + return parent.ownerDocument.createElementNS( + parent.namespaceURI, + parent.localName + ); +} + +export function syncComponentFragmentRange( + host: InstanceHostNode, + instance: ComponentInstance, + result: unknown, + forceUpdate: boolean +): boolean { + if (!isFragmentVNode(result)) { + return false; + } + + const range = getOwnedRange(instance); + const parent = range?.start.parentNode; + if ( + !range || + range.single || + range.start !== host || + !(parent instanceof Element) + ) { + return false; + } + + const restoreFocus = captureRangeFocus(range, parent); + const staging = createAttributeFreeStagingHost(parent); + try { + parent.insertBefore(staging, range.end); + } catch { + staging.remove(); + return false; + } + if (staging.parentNode !== parent || staging.nextSibling !== range.end) { + staging.remove(); + restoreFocus(); + return false; + } + while (range.start.nextSibling && range.start.nextSibling !== staging) { + staging.appendChild(range.start.nextSibling); + } + + const rollbackStaging = registerLifecycleTransaction( + {}, + () => {}, + () => unwrapStagingHost(staging, parent, restoreFocus) + ); + + try { + getRendererDOMHost().updateElementChildren( + staging, + normalizeComponentChildren(result) as VNode[], + forceUpdate + ); + } catch (error) { + if (!rollbackStaging) { + unwrapStagingHost(staging, parent, restoreFocus); + } + throw error; + } + + if ( + !registerLifecycleTransaction( + {}, + () => unwrapStagingHost(staging, parent, restoreFocus), + () => {} + ) + ) { + unwrapStagingHost(staging, parent, restoreFocus); + } + return true; +} diff --git a/src/renderer/component-host-results.ts b/src/renderer/component-host-results.ts index 2b6fabd9..a60684fa 100644 --- a/src/renderer/component-host-results.ts +++ b/src/renderer/component-host-results.ts @@ -22,6 +22,7 @@ import { cleanupProvisionalComponentInstances, registerVNodeComponentInstanceRollback, } from './component-host-replacement'; +import { isFragmentVNode } from './child-shape'; import { findHostInstanceByType, getVNodeComponentInstance, @@ -69,7 +70,8 @@ export function materializeComponentResultNode( } if ( !_isDOMElement(result) || - (result as DOMElement).type !== __CONTROL_BOUNDARY__ + ((result as DOMElement).type !== __CONTROL_BOUNDARY__ && + !isFragmentVNode(result)) ) { const host = document.createElement('div') as InstanceHostElement; host.appendChild(dom); diff --git a/src/renderer/component-host.ts b/src/renderer/component-host.ts index 379d92b4..0e0372bf 100644 --- a/src/renderer/component-host.ts +++ b/src/renderer/component-host.ts @@ -17,6 +17,7 @@ import { } from '../runtime'; import { materializeFreshKey, materializeKey } from './attributes'; import { normalizeComponentChildren } from './child-shape'; +import { syncComponentFragmentRange } from './component-fragment-range'; import { pruneComponentHostInstances } from './component-host-cleanup'; import { getRendererDOMHost, @@ -301,6 +302,18 @@ export function syncComponentElement( return existingHost; } + if ( + existingHost instanceof Comment && + syncComponentFragmentRange( + existingHost, + existingInstance, + scopedResult, + forceChildrenUpdate || existingInstance.mounted === false + ) + ) { + return existingHost; + } + if ( existingHost instanceof Element && scopedResult && diff --git a/src/renderer/component-range-commit.ts b/src/renderer/component-range-commit.ts index 8db8d9eb..73f43885 100644 --- a/src/renderer/component-range-commit.ts +++ b/src/renderer/component-range-commit.ts @@ -1,5 +1,6 @@ import type { ComponentInstance } from '../runtime'; import { cleanupDetachedComponentHost } from './component-host-cleanup'; +import { syncComponentFragmentRange } from './component-fragment-range'; import { materializeComponentResultNode } from './component-host-results'; import { createDetachedRange, @@ -26,6 +27,10 @@ export function replaceComponentRange( return null; } + if (syncComponentFragmentRange(placeholder, instance, result, false)) { + return placeholder; + } + const materialized = materializeComponentResultNode( instance, result, diff --git a/src/renderer/dom-internal.ts b/src/renderer/dom-internal.ts index 37ababce..77505439 100644 --- a/src/renderer/dom-internal.ts +++ b/src/renderer/dom-internal.ts @@ -1,5 +1,5 @@ import { logger } from '../common/logger'; -import { Fragment } from '../jsx/jsx-runtime'; +import { isFragmentType } from '../common/jsx'; import { beginLifecycleCommitBatch, discardLifecycleCommitBatch, @@ -219,10 +219,7 @@ export function createDOMNode( ); } - if ( - typeof type === 'symbol' && - (type === Fragment || String(type) === 'Symbol(Fragment)') - ) { + if (isFragmentType(type)) { return createFragmentElement(node as DOMElement, props, parentNamespace); } } diff --git a/src/renderer/evaluate-reconcile.ts b/src/renderer/evaluate-reconcile.ts index 40da2556..192bfdae 100644 --- a/src/renderer/evaluate-reconcile.ts +++ b/src/renderer/evaluate-reconcile.ts @@ -24,7 +24,7 @@ import { updateUnkeyedChildren, } from './element-children'; import { setDevValue, incDevCounter } from '../runtime'; -import { Fragment } from '../common/jsx'; +import { isFragmentType } from '../common/jsx'; import { createWrappedHandler, extractKey, @@ -494,12 +494,7 @@ export function tryFirstRenderKeyedChildren( } export function isFragment(vnode: unknown): vnode is DOMElement { - return ( - _isDOMElement(vnode) && - typeof (vnode as DOMElement).type === 'symbol' && - ((vnode as DOMElement).type === Fragment || - String((vnode as DOMElement).type) === 'Symbol(askr.fragment)') - ); + return _isDOMElement(vnode) && isFragmentType((vnode as DOMElement).type); } export function getFragmentChildren(vnode: DOMElement): unknown[] { diff --git a/src/renderer/static-reuse.ts b/src/renderer/static-reuse.ts index e6b570a6..dc4eb2d8 100644 --- a/src/renderer/static-reuse.ts +++ b/src/renderer/static-reuse.ts @@ -2,20 +2,11 @@ * Static subtree reuse checks for intrinsic DOM children. */ -import { Fragment } from '../jsx/jsx-runtime'; import { hasMatchingStaticProps } from './attributes'; +import { isFragmentVNode } from './child-shape'; import { _isDOMElement, type DOMElement } from './types'; import { tagNamesEqualIgnoreCase } from './utils'; -function isFragmentVNode(node: unknown): node is DOMElement { - return ( - _isDOMElement(node) && - typeof (node as DOMElement).type === 'symbol' && - ((node as DOMElement).type === Fragment || - String((node as DOMElement).type) === 'Symbol(askr.fragment)') - ); -} - function upperCommonTagName(tag: string): string | null { switch (tag) { case 'div': diff --git a/src/runtime/fastlane.ts b/src/runtime/fastlane.ts index 8d643af5..319f3d47 100644 --- a/src/runtime/fastlane.ts +++ b/src/runtime/fastlane.ts @@ -7,7 +7,7 @@ import { } from './access'; import type { ComponentInstance } from './component'; import { finalizeReadableSubscriptions } from './readable'; -import { Fragment } from '../common/jsx'; +import { isFragmentType } from '../common/jsx'; import { setDevValue, getDevValue, getDevNamespace } from './dev-namespace'; declare const __ASKR_DEVELOPMENT_BUILD__: boolean; @@ -73,10 +73,7 @@ function unwrapFragmentForFastPath(vnode: unknown): unknown { props?: { children?: unknown }; }; // Check if it's a Fragment - if ( - typeof v.type === 'symbol' && - (v.type === Fragment || String(v.type) === 'Symbol(askr.fragment)') - ) { + if (isFragmentType(v.type)) { const children = v.props?.children ?? v.children; if (Array.isArray(children) && children.length > 0) { // Return the first child that's an intrinsic element diff --git a/src/ssr/hydration-verify.ts b/src/ssr/hydration-verify.ts index 076444cb..e7df6be8 100644 --- a/src/ssr/hydration-verify.ts +++ b/src/ssr/hydration-verify.ts @@ -1,9 +1,8 @@ import { getPublicAttributeName } from '../common/attr-names'; import { __CONTROL_BOUNDARY__ } from '../common/control'; -import type { JSXElement } from '../common/jsx'; +import { isFragmentType, type JSXElement } from '../common/jsx'; import type { Props } from '../common/props'; import { __ERROR_BOUNDARY__ } from '../common/vnode'; -import { Fragment } from '../jsx'; import { getVNodeContextFrame } from '../runtime'; import { createErrorBoundaryReset, @@ -35,7 +34,8 @@ function isMultiRangeChild(child: unknown): boolean { } const vnode = child as VNode; return ( - vnode.type === Fragment && (getRenderableChildren(vnode)?.length ?? 0) !== 1 + isFragmentType(vnode.type) && + (getRenderableChildren(vnode)?.length ?? 0) !== 1 ); } @@ -301,7 +301,7 @@ function verifyExpectedNode( ); return verifyRenderableNode(fallbackNode, state, ctx); } - if (type !== Fragment) { + if (!isFragmentType(type)) { throw new Error( `verifyHydrationSyncForUrl: unsupported VNode symbol type: ${String(type)}` ); diff --git a/src/ssr/render-sync.ts b/src/ssr/render-sync.ts index 7062389a..52cc6fa1 100644 --- a/src/ssr/render-sync.ts +++ b/src/ssr/render-sync.ts @@ -1,8 +1,7 @@ -import type { JSXElement } from '../common/jsx'; +import { isFragmentType, type JSXElement } from '../common/jsx'; import { __CONTROL_BOUNDARY__ } from '../common/control'; import type { DOMElement } from '../common/vnode'; import { __ERROR_BOUNDARY__ } from '../common/vnode'; -import { Fragment } from '../jsx'; import { logger } from '../common/logger'; import { getVNodeContextFrame } from '../runtime'; import { SSR_PORTAL_HOST } from '../common/portal'; @@ -62,7 +61,7 @@ function isMultiRangeChild(child: unknown): boolean { return false; } const vnode = child as VNode; - if (vnode.type !== Fragment) { + if (!isFragmentType(vnode.type)) { return false; } return (getRenderableChildren(vnode)?.length ?? 0) !== 1; @@ -362,7 +361,7 @@ function renderNodeSync(node: VNode | JSXElement, ctx: RenderContext): string { if (type === SSR_PORTAL_HOST) { return String(props?.token ?? ''); } - if (type === Fragment) { + if (isFragmentType(type)) { const childrenArr = getRenderableChildren(node); /* istanbul ignore if - dev-only debug */ if (__SSR_DEBUG) { @@ -474,7 +473,7 @@ function renderNodeSyncToSink( } return; } - if (type === Fragment) { + if (isFragmentType(type)) { const childrenArr = getRenderableChildren(node); renderChildrenSyncToSink(childrenArr, sink, ctx); return; diff --git a/tests/jsdom/dom/component-fragment-structure.test.tsx b/tests/jsdom/dom/component-fragment-structure.test.tsx index 9b15eb0f..bea51b20 100644 --- a/tests/jsdom/dom/component-fragment-structure.test.tsx +++ b/tests/jsdom/dom/component-fragment-structure.test.tsx @@ -1,12 +1,14 @@ -import { afterEach, describe, expect, it } from 'vite-plus/test'; +import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; import { For, state } from '../../../src'; import { cleanupApp, createIsland } from '../../../src/boot'; +import { captureRangeFocus } from '../../../src/renderer/component-fragment-range'; import { flushScheduler } from '../../../test-utils/render/test-renderer'; describe('component fragment structure', () => { let root: HTMLElement | undefined; afterEach(() => { + vi.unstubAllGlobals(); if (root) { cleanupApp(root); root.remove(); @@ -14,6 +16,208 @@ describe('component fragment structure', () => { } }); + it('should keep plain component Fragment siblings structurally transparent across updates', () => { + let update!: () => void; + let remove!: () => void; + + function Navigation() { + const revision = state('before'); + update = () => revision.set('after'); + return ( + <> +
{revision()}
+
Links
+ + ); + } + + function App() { + const showNavigation = state(true); + remove = () => showNavigation.set(false); + return ( +
+ {showNavigation() ? : null} +
Content
+
+ ); + } + + root = document.createElement('div'); + document.body.appendChild(root); + createIsland({ + root, + component: App, + }); + + const frame = root.querySelector('[data-frame]')!; + const navigation = frame.querySelector('[data-navigation]')!; + const links = frame.querySelector('[data-links]')!; + const observer = new MutationObserver(() => {}); + observer.observe(root, { childList: true, subtree: true }); + expect(Array.from(frame.children, (child) => child.tagName)).toEqual([ + 'SECTION', + 'DIV', + 'MAIN', + ]); + + update(); + flushScheduler(); + + expect(Array.from(frame.children, (child) => child.tagName)).toEqual([ + 'SECTION', + 'DIV', + 'MAIN', + ]); + expect(frame.querySelector(':scope > [data-navigation]')?.textContent).toBe( + 'after' + ); + expect(root.querySelector('[data-frame]')).toBe(frame); + expect( + observer + .takeRecords() + .flatMap((record) => Array.from(record.addedNodes)) + .filter( + (node): node is Element => + node instanceof Element && node.id === 'component-fragment-frame' + ) + ).toEqual([]); + observer.disconnect(); + + remove(); + flushScheduler(); + + expect(Array.from(frame.children, (child) => child.tagName)).toEqual([ + 'MAIN', + ]); + expect( + Array.from(frame.childNodes, (child) => child.textContent).filter( + (text) => text === 'askr-range-start' || text === 'askr-range-end' + ) + ).toEqual([]); + expect(navigation.isConnected).toBe(false); + expect(links.isConnected).toBe(false); + }); + + it('should not scan a component Fragment range when focus is outside its parent', () => { + root = document.createElement('div'); + document.body.appendChild(root); + const start = document.createComment('askr-range-start'); + const end = document.createComment('askr-range-end'); + root.appendChild(start); + for (let index = 0; index < 128; index++) { + root.appendChild(document.createElement('span')); + } + root.appendChild(end); + + const nextSibling = Object.getOwnPropertyDescriptor( + Node.prototype, + 'nextSibling' + )!.get!; + let nextSiblingReads = 0; + const nextSiblingSpy = vi + .spyOn(Node.prototype, 'nextSibling', 'get') + .mockImplementation(function (this: Node) { + nextSiblingReads++; + return nextSibling.call(this); + }); + + const restoreFocus = captureRangeFocus({ start, end, single: false }, root); + nextSiblingSpy.mockRestore(); + + expect(nextSiblingReads).toBe(0); + restoreFocus(); + }); + + it('should ignore focus capture when HTMLElement is unavailable', () => { + root = document.createElement('div'); + document.body.appendChild(root); + const start = document.createComment('start'); + const end = document.createComment('end'); + root.append(start, document.createElement('input'), end); + + vi.stubGlobal('HTMLElement', undefined); + + expect(() => + captureRangeFocus({ start, end, single: false }, root!) + ).not.toThrow(); + }); + + it('should retry focus without options when preventScroll is unsupported', () => { + root = document.createElement('div'); + document.body.appendChild(root); + const start = document.createComment('start'); + const input = document.createElement('input'); + const end = document.createComment('end'); + const outside = document.createElement('button'); + root.append(start, input, end, outside); + input.focus(); + + const restoreFocus = captureRangeFocus({ start, end, single: false }, root); + outside.focus(); + const nativeFocus = input.focus.bind(input); + const focusSpy = vi + .spyOn(input, 'focus') + .mockImplementation((options?: FocusOptions) => { + if (options) { + throw new TypeError('focus options are unsupported'); + } + nativeFocus(); + }); + + expect(restoreFocus).not.toThrow(); + expect(focusSpy).toHaveBeenNthCalledWith(1, { preventScroll: true }); + expect(focusSpy).toHaveBeenNthCalledWith(2); + expect(document.activeElement).toBe(input); + }); + + it('should keep legacy Fragment symbols transparent across component updates', () => { + let update!: () => void; + const LegacyFragment = Symbol('Fragment') as unknown as (props: { + children?: unknown; + }) => unknown; + + function LegacySiblings() { + const revision = state('before'); + update = () => revision.set('after'); + return ( + +
+ {revision()} +
+ +
+ ); + } + + root = document.createElement('div'); + document.body.appendChild(root); + createIsland({ + root, + component: () => ( +
+ +
+ ), + }); + + const frame = root.querySelector('[data-legacy-frame]')!; + expect(Array.from(frame.children, (child) => child.tagName)).toEqual([ + 'SECTION', + 'ASIDE', + ]); + + update(); + flushScheduler(); + + expect(Array.from(frame.children, (child) => child.tagName)).toEqual([ + 'SECTION', + 'ASIDE', + ]); + expect(frame.querySelector('[data-legacy-revision]')?.textContent).toBe( + 'after' + ); + }); + it('should keep component-returned keyed rows structurally transparent', async () => { let reorder!: () => void; let updateAndRemove!: () => void; @@ -70,6 +274,65 @@ describe('component fragment structure', () => { expect(tbody.querySelector(':scope > tr')?.textContent).toBe('Bob'); }); + it('should fall back when a staging host is relocated outside its component range', () => { + let update!: () => void; + + function Rows() { + const revision = state('before'); + update = () => revision.set('after'); + return ( + <> + + {revision()} + + + stable + + + ); + } + + root = document.createElement('div'); + document.body.appendChild(root); + createIsland({ + root, + component: () => ( + + + + +
+ ), + }); + + const tbody = root.querySelector('tbody')!; + const insertBefore = tbody.insertBefore.bind(tbody); + const insertSpy = vi + .spyOn(tbody, 'insertBefore') + .mockImplementation((node, reference) => { + if (node instanceof Element && node.localName === 'tbody') { + document.body.appendChild(node); + return node; + } + return insertBefore(node, reference); + }); + + update(); + flushScheduler(); + insertSpy.mockRestore(); + + expect( + Array.from(tbody.querySelectorAll(':scope > tr'), (row) => [ + row.getAttribute('data-row'), + row.textContent, + ]) + ).toEqual([ + ['first', 'after'], + ['second', 'stable'], + ]); + expect(document.body.querySelector(':scope > tbody')).toBeNull(); + }); + it('should keep component-returned options structurally transparent', () => { function Options() { return ( diff --git a/tests/jsdom/ssr/hydration.test.tsx b/tests/jsdom/ssr/hydration.test.tsx index abdc48a9..a6427c2a 100644 --- a/tests/jsdom/ssr/hydration.test.tsx +++ b/tests/jsdom/ssr/hydration.test.tsx @@ -196,6 +196,60 @@ describe('hydration (SSR)', () => { expect(container.querySelector('#btn')).not.toBeNull(); }); + it('should hydrate plain component Fragment siblings without changing topology', async () => { + let update!: () => void; + + function Navigation() { + const revision = state('before'); + update = () => revision.set('after'); + return ( + <> +
{revision()}
+
Links
+ + ); + } + + function App() { + return ( +
+ +
Content
+
+ ); + } + + const html = renderToStringSync(() => ); + container.innerHTML = html; + const frame = container.querySelector('[data-frame]')!; + const initialChildren = Array.from(frame.children); + + await hydrateSPA({ + root: container, + registry: routeRegistryFromTable([{ path: '/', handler: App }]), + }); + + expect(container.querySelector('[data-frame]')).toBe(frame); + expect(Array.from(frame.children)).toEqual(initialChildren); + expect(Array.from(frame.children, (child) => child.tagName)).toEqual([ + 'SECTION', + 'DIV', + 'MAIN', + ]); + + update(); + flushScheduler(); + + expect(Array.from(frame.children, (child) => child.tagName)).toEqual([ + 'SECTION', + 'DIV', + 'MAIN', + ]); + expect( + frame.querySelector(':scope > [data-navigation]')?.textContent + ).toBe('after'); + }); + it('should throw when hydrate encounters a mismatch', async () => { const Component = () =>
server
; container.innerHTML = '
client
';