diff --git a/docs/core/rendering.md b/docs/core/rendering.md index 0160b3d9..af6ba1dd 100644 --- a/docs/core/rendering.md +++ b/docs/core/rendering.md @@ -15,10 +15,10 @@ 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 +### Transparent component ranges -A component may return a Fragment when it needs multiple sibling nodes without -an application-visible wrapper: +A component may return a Fragment or an array when it needs multiple sibling +nodes without an application-visible wrapper: ```tsx function PageHeader() { @@ -31,10 +31,17 @@ function PageHeader() { } ``` -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. +Fragments and arrays remain structurally transparent in SPA rendering, SSR, +SSG, and hydration. Their nodes are direct siblings at the component call +site. This includes context scopes that return their marked children together +with an automatic portal host. Askr uses internal comment-anchored ranges to +retain update and cleanup ownership; it does not insert a `div` or another +visible host element. + +During hydration, a keyed `For` adopts only its own server-rendered rows even +when a static or component child precedes it in the same parent. The unrelated +sibling and every adopted row keep their DOM identity through later reorder +and removal commits. ### Imperative widget hosts @@ -167,6 +174,12 @@ Portal values are scoped to one server render root. A portal created with between routes or requests. Hydration adopts the server-rendered portal content and attaches its normal bindings. +SSR and SSG retain internal comment anchors at default-portal writer positions +and at a written automatic host whose current value is empty. Hydration adopts +those anchors so adjacent application nodes keep their identity without a +visible wrapper element. Unused or explicitly suppressed automatic hosts are +omitted. + ## Static Site Generation (SSG) SSG pre-renders Askr routes into `.html` files at build time. diff --git a/docs/guides/platform-recipes.md b/docs/guides/platform-recipes.md index 4f8e4591..abf81d90 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.77`. Use one locked +The examples are verified against `@askrjs/askr@0.0.78`. 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 070df6f5..6aa3efc0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@askrjs/askr", - "version": "0.0.77", + "version": "0.0.78", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@askrjs/askr", - "version": "0.0.77", + "version": "0.0.78", "license": "Apache-2.0", "dependencies": { "@askrjs/auth": ">=0.0.1 <0.1.0", diff --git a/package.json b/package.json index 43a94276..fc1761a5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@askrjs/askr", - "version": "0.0.77", + "version": "0.0.78", "description": "Actor-backed deterministic UI framework", "keywords": [ "askr", diff --git a/src/common/control.ts b/src/common/control.ts index 3251f57e..2a27ecf8 100644 --- a/src/common/control.ts +++ b/src/common/control.ts @@ -3,6 +3,9 @@ import type { Props } from './props'; export const __CONTROL_BOUNDARY__ = Symbol('__CONTROL_BOUNDARY__'); const EAGER_CONTROL_PRIMITIVE = Symbol('__ASKR_EAGER_CONTROL_PRIMITIVE__'); +const TRANSPARENT_COMPONENT_RESULT = Symbol( + '__ASKR_TRANSPARENT_COMPONENT_RESULT__' +); export type EagerControlPrimitive

= (( props: P @@ -35,3 +38,27 @@ export function isEagerControlPrimitive( ) ); } + +export function markTransparentComponentResult< + T extends (...args: never[]) => unknown, +>(component: T): T { + ( + component as T & { + [TRANSPARENT_COMPONENT_RESULT]?: true; + } + )[TRANSPARENT_COMPONENT_RESULT] = true; + return component; +} + +export function hasTransparentComponentResult(value: unknown): boolean { + return ( + typeof value === 'function' && + Boolean( + ( + value as { + [TRANSPARENT_COMPONENT_RESULT]?: true; + } + )[TRANSPARENT_COMPONENT_RESULT] + ) + ); +} diff --git a/src/common/portal.ts b/src/common/portal.ts index ee0a0aa0..d27f9a61 100644 --- a/src/common/portal.ts +++ b/src/common/portal.ts @@ -5,3 +5,41 @@ * @internal */ export const SSR_PORTAL_HOST = Symbol.for('askr.ssr-portal-host'); +export const SSR_PORTAL_ANCHOR = Symbol.for('askr.ssr-portal-anchor'); + +const SSR_PORTAL_HOST_PREFIX = 'askr-portal:'; +const SSR_PORTAL_ANCHOR_PREFIX = 'askr-portal-anchor:'; + +export function createSSRPortalHostToken(id: number): string { + return ``; +} + +export function createSSRPortalAnchorToken(id: number): string { + return ``; +} + +function isSSRPortalMarkerData(data: string, prefix: string): boolean { + if (!data.startsWith(prefix)) { + return false; + } + const id = data.slice(prefix.length); + return ( + id.length > 0 && Array.from(id).every((char) => char >= '0' && char <= '9') + ); +} + +export function isSSRPortalHydrationAnchor(node: unknown): node is Comment { + if ( + typeof node !== 'object' || + node === null || + (node as { nodeType?: number }).nodeType !== 8 + ) { + return false; + } + const data = (node as { data?: unknown }).data; + return ( + typeof data === 'string' && + (isSSRPortalMarkerData(data, SSR_PORTAL_HOST_PREFIX) || + isSSRPortalMarkerData(data, SSR_PORTAL_ANCHOR_PREFIX)) + ); +} diff --git a/src/renderer/boundaries.ts b/src/renderer/boundaries.ts index 97420426..6522a241 100644 --- a/src/renderer/boundaries.ts +++ b/src/renderer/boundaries.ts @@ -59,7 +59,9 @@ export interface BoundaryDOMHost { props: Record, parentNamespace?: string, forceChildrenUpdate?: boolean, - retainedHostInstances?: Iterable + retainedHostInstances?: Iterable, + hydrationRangeEnd?: Node | null, + preserveHydrationCursorOnEmpty?: boolean ): Node | null; updateElementFromVnode( el: Element, diff --git a/src/renderer/boundary-range-adoption.ts b/src/renderer/boundary-range-adoption.ts index 37fdc03c..f62cbec5 100644 --- a/src/renderer/boundary-range-adoption.ts +++ b/src/renderer/boundary-range-adoption.ts @@ -5,6 +5,7 @@ import { restoreDomCommitScope, type ChildScope, type ComponentFunction, + type ComponentInstance, } from '../runtime'; import { createDetachedRange, @@ -29,7 +30,11 @@ export type BoundaryRangeDOMHost = { node: DOMElement, type: ComponentFunction, props: Record, - parentNamespace?: string + parentNamespace?: string, + forceChildrenUpdate?: boolean, + retainedHostInstances?: Iterable, + hydrationRangeEnd?: Node | null, + preserveHydrationCursorOnEmpty?: boolean ): Node | null; updateElementFromVnode( el: Element, @@ -63,6 +68,12 @@ export function checkVNodeShapeChanged(dom: Node, vnode: VNode): boolean { return dom.tagName.toLowerCase() !== vnode.type.toLowerCase(); } +export function canAdoptHydratedElement(dom: Element, vnode: VNode): boolean { + if (!_isDOMElement(vnode)) return false; + if (typeof vnode.type === 'function') return true; + return !checkVNodeShapeChanged(dom, vnode); +} + export function adoptHydratedRange( parent: Element, scope: ChildScope, diff --git a/src/renderer/boundary-range-sync.ts b/src/renderer/boundary-range-sync.ts index 8b0ee3c6..2e0400b4 100644 --- a/src/renderer/boundary-range-sync.ts +++ b/src/renderer/boundary-range-sync.ts @@ -15,6 +15,7 @@ import { insertRangeBefore, rangeContains, removeRange } from './dom-range'; import { adoptHydratedRange, assignScopeRange, + canAdoptHydratedElement, checkVNodeShapeChanged, getBoundaryParentNamespace, getBoundaryRangeHost, @@ -296,7 +297,7 @@ export function syncControlBoundaryInMixedParent( !item.scope.range && syncBefore instanceof Element && getMaterializedKey(syncBefore) === controlState.orderedKeys[index] && - !checkVNodeShapeChanged(syncBefore, vnode) + canAdoptHydratedElement(syncBefore, vnode) ) { assignScopeRange(item.scope, { start: syncBefore, diff --git a/src/renderer/child-shape.ts b/src/renderer/child-shape.ts index d5023ab5..5ef59137 100644 --- a/src/renderer/child-shape.ts +++ b/src/renderer/child-shape.ts @@ -1,5 +1,6 @@ import { isFragmentType, STATIC_CHILDREN } from '../common/jsx'; import { __CONTROL_BOUNDARY__ } from '../common/vnode'; +import { hasTransparentComponentResult } from '../common/control'; import { logger } from '../common/logger'; import { getCurrentInstance } from '../runtime'; import { getRuntimeEnv } from './env'; @@ -14,6 +15,20 @@ export function isFragmentVNode(node: unknown): node is DOMElement { return _isDOMElement(node) && isFragmentType((node as DOMElement).type); } +export function isTransparentComponentResult(result: unknown): boolean { + return ( + Array.isArray(result) || + (_isDOMElement(result) && + ((result as DOMElement).type === __CONTROL_BOUNDARY__ || + hasTransparentComponentResult((result as DOMElement).type) || + isFragmentVNode(result))) + ); +} + +export function isTransparentComponentRangeResult(result: unknown): boolean { + return Array.isArray(result) || isFragmentVNode(result); +} + export function normalizeComponentChildren(result: unknown): unknown[] { if (result === null || result === undefined || result === false) { return []; diff --git a/src/renderer/component-fragment-range.ts b/src/renderer/component-fragment-range.ts index e54c30b2..5492bad8 100644 --- a/src/renderer/component-fragment-range.ts +++ b/src/renderer/component-fragment-range.ts @@ -1,9 +1,20 @@ import { + mountInstanceInline, registerLifecycleTransaction, type ComponentInstance, } from '../runtime'; -import { isFragmentVNode, normalizeComponentChildren } from './child-shape'; -import { getOwnedRange, rangeContains, type DOMRange } from './dom-range'; +import { + isTransparentComponentRangeResult, + normalizeComponentChildren, +} from './child-shape'; +import { + getOwnedRange, + RANGE_END_MARKER, + RANGE_START_MARKER, + rangeContains, + registerRange, + type DOMRange, +} from './dom-range'; import { getRendererDOMHost, type InstanceHostNode } from './dom-host'; import type { VNode } from './types'; @@ -86,13 +97,99 @@ function createAttributeFreeStagingHost(parent: Element): Element { ); } +function isAutomaticPortalHost(node: Node): boolean { + const host = node as InstanceHostNode; + const instances = new Set(host.__ASKR_INSTANCES ?? []); + if (host.__ASKR_INSTANCE) { + instances.add(host.__ASKR_INSTANCE); + } + return ( + instances.size > 0 && + Array.from(instances).some( + (candidate) => + ( + candidate.props as { + __askrAutoDefaultPortal?: boolean; + } + ).__askrAutoDefaultPortal === true + ) + ); +} + +export function adoptHydratedComponentRange( + existingHost: Element | Comment, + instance: ComponentInstance, + result: unknown, + endExclusive: Node | null, + forceChildrenUpdate: boolean, + retainedInstances: Iterable +): Comment | null { + const parent = existingHost.parentNode; + if ( + !(parent instanceof Element) || + (endExclusive !== null && endExclusive.parentNode !== parent) + ) { + return null; + } + + if (endExclusive !== null) { + let current: Node | null = existingHost; + while (current && current !== endExclusive) { + current = current.nextSibling; + } + if (current !== endExclusive) { + return null; + } + } + + const start = existingHost.ownerDocument.createComment(RANGE_START_MARKER); + const end = existingHost.ownerDocument.createComment(RANGE_END_MARKER); + parent.insertBefore(start, existingHost); + parent.insertBefore(end, endExclusive); + registerRange({ start, end, single: false }, instance); + + const host = start as InstanceHostNode; + const owners = new Set(retainedInstances); + owners.add(instance); + host.__ASKR_INSTANCE = instance; + host.__ASKR_INSTANCES = Array.from(owners); + instance.target = null; + instance._placeholder = start; + mountInstanceInline(instance, null); + + const registered = registerLifecycleTransaction( + {}, + () => {}, + () => { + start.parentNode?.removeChild(start); + end.parentNode?.removeChild(end); + } + ); + + try { + if ( + !syncComponentFragmentRange(host, instance, result, forceChildrenUpdate) + ) { + throw new Error('[askr] Failed to adopt hydrated component range.'); + } + } catch (error) { + if (!registered) { + start.remove(); + end.remove(); + } + throw error; + } + + return start; +} + export function syncComponentFragmentRange( host: InstanceHostNode, instance: ComponentInstance, result: unknown, forceUpdate: boolean ): boolean { - if (!isFragmentVNode(result)) { + if (!isTransparentComponentRangeResult(result)) { return false; } @@ -108,6 +205,8 @@ export function syncComponentFragmentRange( } const restoreFocus = captureRangeFocus(range, parent); + const normalizedChildren = normalizeComponentChildren(result) as VNode[]; + const preserveForeignHosts = Array.isArray(result); const staging = createAttributeFreeStagingHost(parent); try { parent.insertBefore(staging, range.end); @@ -120,8 +219,13 @@ export function syncComponentFragmentRange( restoreFocus(); return false; } - while (range.start.nextSibling && range.start.nextSibling !== staging) { - staging.appendChild(range.start.nextSibling); + let current = range.start.nextSibling; + while (current && current !== staging) { + const next = current.nextSibling; + if (!preserveForeignHosts || !isAutomaticPortalHost(current)) { + staging.appendChild(current); + } + current = next; } const rollbackStaging = registerLifecycleTransaction( @@ -133,7 +237,7 @@ export function syncComponentFragmentRange( try { getRendererDOMHost().updateElementChildren( staging, - normalizeComponentChildren(result) as VNode[], + normalizedChildren, forceUpdate ); } catch (error) { diff --git a/src/renderer/component-host-instances.ts b/src/renderer/component-host-instances.ts index dfd1e012..2e090f19 100644 --- a/src/renderer/component-host-instances.ts +++ b/src/renderer/component-host-instances.ts @@ -3,6 +3,7 @@ import type { Props } from '../common/props'; import { isProductionEnvironment } from '../common/env'; import { getCurrentInstance, type ComponentInstance } from '../runtime'; import { getDevValue, incDevCounter } from '../runtime'; +import { getOwnedRange, RANGE_START_MARKER } from './dom-range'; import type { InstanceHostNode } from './dom-host'; import type { DOMElement } from './types'; import { extractKey } from './utils'; @@ -151,6 +152,27 @@ export function inheritComponentKey( return target; } +function extractComponentIdentityKey( + node: unknown +): string | number | undefined { + const publicKey = extractKey(node); + if (publicKey !== undefined) { + return publicKey; + } + if (typeof node !== 'object' || node === null) { + return undefined; + } + const internalKey = ( + (node as DOMElement).props as Record | undefined + )?.['__askrIdentityKey']; + if (internalKey === undefined || internalKey === null) { + return undefined; + } + return typeof internalKey === 'symbol' + ? String(internalKey) + : (internalKey as string | number); +} + export function setComponentOwnershipIdentity( instance: ComponentInstance, node: unknown, @@ -162,7 +184,7 @@ export function setComponentOwnershipIdentity( typeof node === 'object' && node !== null ? node : undefined; instance._vnodeParent = parent; instance._vnodeParentGeneration = parent?._ownershipGeneration; - const key = extractKey(node as DOMElement); + const key = extractComponentIdentityKey(node as DOMElement); if (key === undefined) { delete instance._vnodeKey; } else { @@ -190,7 +212,7 @@ export function findHostInstanceByType( return vnodeOwner; } - const key = extractKey(node as DOMElement); + const key = extractComponentIdentityKey(node as DOMElement); const identityMatches = instances.filter((instance) => { if (instance.fn !== type) return false; if (parent !== undefined && instance._vnodeParent !== parent) { @@ -223,7 +245,7 @@ export function findHostInstanceByType( if (host.__ASKR_INSTANCE?.fn === type) { const instance = host.__ASKR_INSTANCE; - const key = extractKey(node as DOMElement); + const key = extractComponentIdentityKey(node as DOMElement); if ( (parent === undefined || instance._vnodeParent === parent) && (key === undefined || instance._vnodeKey === key) && @@ -245,8 +267,9 @@ export function findHostInstanceByType( /** * Resolve an instance on a comment host only when the vnode carries durable - * identity. Fresh unkeyed null components intentionally supersede the old - * generation instead of falling back to type-only reuse. + * identity. An unkeyed multi-node range can also move through transparent + * wrapper owners while its recorded owner generation remains live. Fresh + * unkeyed null components still supersede the old generation. */ export function findStableHostInstanceByType( host: InstanceHostNode, @@ -270,8 +293,26 @@ export function findStableHostInstanceByType( return vnodeOwner; } - const key = extractKey(node as DOMElement); - if (key === undefined) return null; + const key = extractComponentIdentityKey(node as DOMElement); + if (key === undefined) { + const rangeMatches = Array.from(instances).filter((instance) => { + const range = getOwnedRange(instance); + return ( + instance.fn === type && + instance._vnodeParent !== parent && + instance._vnodeParent != null && + instance._vnodeParentGeneration === + instance._vnodeParent._ownershipGeneration && + instance._vnodeKey === undefined && + instance._wrapperDepth === wrapperDepth && + ((range?.single === false && range.start === host) || + (host instanceof Comment && + host.data === RANGE_START_MARKER && + instance._placeholder === host)) + ); + }); + return rangeMatches.length === 1 ? rangeMatches[0]! : null; + } const matches = Array.from(instances).filter( (instance) => @@ -281,5 +322,25 @@ export function findStableHostInstanceByType( instance._vnodeKey === key && instance._wrapperDepth === wrapperDepth ); - return matches.length > 0 ? matches[matches.length - 1]! : null; + if (matches.length > 0) { + return matches[matches.length - 1]!; + } + + const rangeMatches = Array.from(instances).filter((instance) => { + const range = getOwnedRange(instance); + return ( + instance.fn === type && + instance._vnodeParent !== parent && + instance._vnodeParent != null && + instance._vnodeParentGeneration === + instance._vnodeParent._ownershipGeneration && + instance._vnodeKey === key && + instance._wrapperDepth === wrapperDepth && + ((range?.single === false && range.start === host) || + (host instanceof Comment && + host.data === RANGE_START_MARKER && + instance._placeholder === host)) + ); + }); + return rangeMatches.length === 1 ? rangeMatches[0]! : null; } diff --git a/src/renderer/component-host-results.ts b/src/renderer/component-host-results.ts index a60684fa..1918933b 100644 --- a/src/renderer/component-host-results.ts +++ b/src/renderer/component-host-results.ts @@ -1,6 +1,6 @@ import { isPromiseLike } from '../common/promise'; +import { isSSRPortalHydrationAnchor } from '../common/portal'; import type { Props } from '../common/props'; -import { __CONTROL_BOUNDARY__ } from '../common/vnode'; import { captureInlineRenderSnapshot, cleanupComponent, @@ -22,7 +22,7 @@ import { cleanupProvisionalComponentInstances, registerVNodeComponentInstanceRollback, } from './component-host-replacement'; -import { isFragmentVNode } from './child-shape'; +import { isTransparentComponentResult } from './child-shape'; import { findHostInstanceByType, getVNodeComponentInstance, @@ -41,6 +41,85 @@ import { import { createDetachedRange } from './dom-range'; import { _isDOMElement, type DOMElement, type VNode } from './types'; +export function retainReplacementOwnerChain( + host: Node, + owner: ComponentInstance, + retainedInstances: Iterable +): void { + const componentHost = host as InstanceHostNode; + const next = [...(componentHost.__ASKR_INSTANCES ?? [])]; + for (const instance of retainedInstances) { + if (!next.includes(instance)) { + next.push(instance); + } + } + componentHost.__ASKR_INSTANCES = next; + componentHost.__ASKR_INSTANCE = owner; +} + +export function adoptEmptySSRPortalHydrationHost( + host: InstanceHostNode, + instance: ComponentInstance, + retainedInstances: Iterable, + result: unknown +): boolean { + if ( + !isSSRPortalHydrationAnchor(host) || + (result !== null && result !== undefined && result !== false) + ) { + return false; + } + const instanceHost = host as InstanceHostNode; + instanceHost.__ASKR_INSTANCE = instance; + instanceHost.__ASKR_INSTANCES = Array.from( + new Set([instance, ...retainedInstances]) + ); + instance._placeholder = instanceHost as Comment; + mountInstanceInline(instance, null); + retainReplacementOwnerChain(instanceHost, instance, retainedInstances); + return true; +} + +export function materializeEmptyHydrationPlaceholder( + existingHost: InstanceHostNode, + instance: ComponentInstance, + retainedInstances: Iterable, + result: unknown, + preserveHydrationCursor: boolean +): Comment | null { + if ( + (!preserveHydrationCursor && !isSSRPortalHydrationAnchor(existingHost)) || + (result !== null && result !== undefined && result !== false) + ) { + return null; + } + const placeholder = isSSRPortalHydrationAnchor(existingHost) + ? existingHost + : (existingHost.ownerDocument ?? document).createComment(''); + if (placeholder !== existingHost) { + existingHost.parentNode?.insertBefore(placeholder, existingHost); + } + const host = placeholder as InstanceHostNode; + host.__ASKR_INSTANCE = instance; + host.__ASKR_INSTANCES = [instance]; + instance._placeholder = placeholder; + mountInstanceInline(instance, null); + retainReplacementOwnerChain(placeholder, instance, retainedInstances); + return placeholder; +} + +export function itemInstanceHydrationComplete(host: InstanceHostElement): void { + const instance = host.__ASKR_INSTANCE; + const scope = ( + instance as unknown as + | { scope?: { hydrationPending?: boolean } } + | undefined + )?.scope; + if (scope) { + scope.hydrationPending = false; + } +} + export function materializeComponentResultNode( childInstance: ComponentInstance, result: unknown, @@ -68,11 +147,19 @@ export function materializeComponentResultNode( mountInstanceInline(childInstance, null); return placeholder; } - if ( - !_isDOMElement(result) || - ((result as DOMElement).type !== __CONTROL_BOUNDARY__ && - !isFragmentVNode(result)) - ) { + if (dom instanceof Comment) { + const host = dom as InstanceHostNode; + const instances = host.__ASKR_INSTANCES ?? []; + if (!instances.includes(childInstance)) { + instances.push(childInstance); + } + host.__ASKR_INSTANCES = instances; + host.__ASKR_INSTANCE = instances[0] ?? childInstance; + childInstance._placeholder = dom; + mountInstanceInline(childInstance, null); + return dom; + } + if (!isTransparentComponentResult(result)) { const host = document.createElement('div') as InstanceHostElement; host.appendChild(dom); host.__ASKR_WRAPPER_HOST = true; diff --git a/src/renderer/component-host.ts b/src/renderer/component-host.ts index 0e0372bf..c95e90b2 100644 --- a/src/renderer/component-host.ts +++ b/src/renderer/component-host.ts @@ -1,5 +1,6 @@ import { isPromiseLike } from '../common/promise'; import type { Props } from '../common/props'; +import { isSSRPortalHydrationAnchor } from '../common/portal'; import { captureInlineRenderSnapshot, createComponentInstance, @@ -16,8 +17,14 @@ import { withContext, } from '../runtime'; import { materializeFreshKey, materializeKey } from './attributes'; -import { normalizeComponentChildren } from './child-shape'; -import { syncComponentFragmentRange } from './component-fragment-range'; +import { + isTransparentComponentRangeResult, + normalizeComponentChildren, +} from './child-shape'; +import { + adoptHydratedComponentRange, + syncComponentFragmentRange, +} from './component-fragment-range'; import { pruneComponentHostInstances } from './component-host-cleanup'; import { getRendererDOMHost, @@ -51,7 +58,11 @@ import { registerVNodeComponentInstanceRollback, } from './component-host-replacement'; import { + adoptEmptySSRPortalHydrationHost, + itemInstanceHydrationComplete, materializeComponentResultNode, + materializeEmptyHydrationPlaceholder, + retainReplacementOwnerChain, resolveHostNestedComponentResult, resolveWrapperHostResult, } from './component-host-results'; @@ -65,7 +76,6 @@ export { } from './component-host-instances'; // Provisional component cleanup is owned by component-host-replacement.ts. - export function syncComponentElement( currentDom: Node | null, node: ElementWithContext, @@ -73,7 +83,9 @@ export function syncComponentElement( props: Record, parentNamespace?: string, forceChildrenUpdate = false, - retainedHostInstances?: Iterable + retainedHostInstances?: Iterable, + hydrationRangeEnd?: Node | null, + preserveHydrationCursorOnEmpty = false ): Node | null { const existingHost = currentDom instanceof Element || currentDom instanceof Comment @@ -103,14 +115,19 @@ export function syncComponentElement( const domHost = getRendererDOMHost(); if (!existingInstance || existingInstance.fn !== type) { - if (!(existingHost instanceof Element)) return null; + if ( + !(existingHost instanceof Element) && + !isSSRPortalHydrationAnchor(existingHost) + ) { + return null; + } const snapshot = getVNodeContextFrame(node) || getCurrentContextFrame() || null; const hydrationInstance = createComponentInstance( nextComponentInstanceId(), type, props || {}, - existingHost + existingHost instanceof Element ? existingHost : null ); setComponentOwnershipIdentity( hydrationInstance, @@ -163,7 +180,36 @@ export function syncComponentElement( snapshot ?? null ); + const emptyPlaceholder = materializeEmptyHydrationPlaceholder( + existingHost, + hydrationInstance, + liveRetainedInstances, + scopedResult, + preserveHydrationCursorOnEmpty + ); + if (emptyPlaceholder) { + return emptyPlaceholder; + } + + if ( + hydrationRangeEnd !== undefined && + isTransparentComponentRangeResult(scopedResult) + ) { + const adoptedHost = adoptHydratedComponentRange( + existingHost, + hydrationInstance, + scopedResult, + hydrationRangeEnd, + forceChildrenUpdate || hydrationInstance.mounted === false, + liveRetainedInstances + ); + if (adoptedHost) { + return adoptedHost; + } + } + if ( + existingHost instanceof Element && scopedResult && typeof scopedResult === 'object' && 'type' in (scopedResult as DOMElement) && @@ -195,6 +241,33 @@ export function syncComponentElement( liveRetainedInstances ); if ( + adoptEmptySSRPortalHydrationHost( + existingHost, + hydrationInstance, + liveRetainedInstances, + resolvedResult + ) + ) { + return existingHost; + } + if ( + hydrationRangeEnd !== undefined && + isTransparentComponentRangeResult(resolvedResult) + ) { + const adoptedHost = adoptHydratedComponentRange( + existingHost, + hydrationInstance, + resolvedResult, + hydrationRangeEnd, + forceChildrenUpdate || hydrationInstance.mounted === false, + liveRetainedInstances + ); + if (adoptedHost) { + return adoptedHost; + } + } + if ( + existingHost instanceof Element && _isDOMElement(resolvedResult) && typeof resolvedResult.type === 'string' && tagNamesEqualIgnoreCase(existingHost.tagName, resolvedResult.type) @@ -344,6 +417,17 @@ export function syncComponentElement( snapshot ?? null, liveRetainedInstances ); + if ( + existingHost instanceof Comment && + syncComponentFragmentRange( + existingHost, + existingInstance, + resolvedResult, + forceChildrenUpdate || existingInstance.mounted === false + ) + ) { + return existingHost; + } if ( existingHost instanceof Comment && (resolvedResult === null || @@ -403,35 +487,6 @@ export function syncComponentElement( return nextDom; } -function retainReplacementOwnerChain( - host: Node, - owner: ComponentInstance, - retainedInstances: Iterable -): void { - const componentHost = host as InstanceHostNode; - const current = componentHost.__ASKR_INSTANCES ?? []; - const next = [...current]; - for (const instance of retainedInstances) { - if (!next.includes(instance)) { - next.push(instance); - } - } - componentHost.__ASKR_INSTANCES = next; - componentHost.__ASKR_INSTANCE = owner; -} - -function itemInstanceHydrationComplete(host: InstanceHostElement): void { - const instance = host.__ASKR_INSTANCE; - if (instance) { - const scope = ( - instance as unknown as { scope?: { hydrationPending?: boolean } } - ).scope; - if (scope) { - scope.hydrationPending = false; - } - } -} - export function createComponentElement( node: ElementWithContext, type: JSXComponent, diff --git a/src/renderer/component-range-commit.ts b/src/renderer/component-range-commit.ts index 73f43885..1d48d936 100644 --- a/src/renderer/component-range-commit.ts +++ b/src/renderer/component-range-commit.ts @@ -1,7 +1,12 @@ -import type { ComponentInstance } from '../runtime'; +import { getVNodeContextFrame, type ComponentInstance } from '../runtime'; +import { hasTransparentComponentResult } from '../common/control'; import { cleanupDetachedComponentHost } from './component-host-cleanup'; import { syncComponentFragmentRange } from './component-fragment-range'; -import { materializeComponentResultNode } from './component-host-results'; +import { + materializeComponentResultNode, + resolveHostNestedComponentResult, +} from './component-host-results'; +import { createRetainedHostInstanceSet } from './component-host-replacement'; import { createDetachedRange, getOwnedRange, @@ -10,6 +15,7 @@ import { } from './dom-range'; import type { InstanceHostNode } from './dom-host'; import { getParentNamespace } from './namespaces'; +import { _isDOMElement } from './types'; export function replaceComponentRange( instance: ComponentInstance, @@ -31,6 +37,22 @@ export function replaceComponentRange( return placeholder; } + if (_isDOMElement(result) && hasTransparentComponentResult(result.type)) { + const retainedInstances = createRetainedHostInstanceSet(instance); + const resolvedResult = resolveHostNestedComponentResult( + placeholder, + instance, + result, + getVNodeContextFrame(result) ?? instance.ownerFrame ?? null, + retainedInstances + ); + if ( + syncComponentFragmentRange(placeholder, instance, resolvedResult, false) + ) { + return placeholder; + } + } + const materialized = materializeComponentResultNode( instance, result, diff --git a/src/renderer/dom-host.ts b/src/renderer/dom-host.ts index 02b009bb..4b46a0ec 100644 --- a/src/renderer/dom-host.ts +++ b/src/renderer/dom-host.ts @@ -30,7 +30,9 @@ export interface RendererDOMHost { props: Record, parentNamespace?: string, forceChildrenUpdate?: boolean, - retainedHostInstances?: Iterable + retainedHostInstances?: Iterable, + hydrationRangeEnd?: Node | null, + preserveHydrationCursorOnEmpty?: boolean ): Node | null; updateElementFromVnode( el: Element, diff --git a/src/renderer/element-children.ts b/src/renderer/element-children.ts index 5fea5aeb..7376508b 100644 --- a/src/renderer/element-children.ts +++ b/src/renderer/element-children.ts @@ -258,11 +258,21 @@ export function updateMixedControlChildren( } else { cursor = last.end.nextSibling; } - } else if (cursor?.parentNode !== parent) { - cursor = - cursorAfterBoundary?.parentNode === parent - ? cursorAfterBoundary + } else { + const inactiveBoundaryCursor = + cursor?.parentNode === parent + ? cursor + : cursorAfterBoundary?.parentNode === parent + ? cursorAfterBoundary + : null; + const inactiveBoundaryEnd = + inactiveBoundaryCursor && isRangeStart(inactiveBoundaryCursor) + ? findRangeEnd(inactiveBoundaryCursor) : null; + cursor = + inactiveBoundaryEnd?.previousSibling === inactiveBoundaryCursor + ? inactiveBoundaryEnd.nextSibling + : inactiveBoundaryCursor; } continue; } @@ -299,7 +309,10 @@ export function updateMixedControlChildren( child.type as ComponentFunction, ((child.props ?? {}) as Record) || {}, parentNamespace, - forceUpdate + forceUpdate, + undefined, + undefined, + true ); if (synced) { cursor = synced.nextSibling; @@ -364,7 +377,8 @@ export function updateUnkeyedChildren( const trySyncComponentChild = ( currentDom: Node, - next: DOMElement + next: DOMElement, + hydrationRangeEnd?: Node | null ): Node | null => { if (typeof next.type !== 'function') { return null; @@ -376,7 +390,10 @@ export function updateUnkeyedChildren( next.type as ComponentFunction, (((next as DOMElement).props ?? {}) as Record) || {}, parentNamespace, - forceUpdate + forceUpdate, + undefined, + hydrationRangeEnd, + true ); }; @@ -450,17 +467,15 @@ export function updateUnkeyedChildren( hasEmptyChildren || hasNonElementDomChildren ) { - const allNodes = Array.from(parent.childNodes); - const max = Math.max(allNodes.length, newChildren.length); - - for (let i = 0; i < max; i++) { + const allNodes: Node[] = Array.from(parent.childNodes); + for (let i = 0; i < Math.max(allNodes.length, newChildren.length); i += 1) { const currentNode = allNodes[i]; const next = newChildren[i]; const nextIsEmpty = isEmptyChild(next); if (nextIsEmpty && currentNode) { teardownNodeSubtree(currentNode); - currentNode.remove(); + currentNode.parentNode?.removeChild(currentNode); continue; } @@ -507,7 +522,33 @@ export function updateUnkeyedChildren( continue; } - const synced = trySyncComponentChild(currentEl, next); + const remainingExpected = newChildren.length - i - 1; + const hydrationRangeEndIndex = allNodes.length - remainingExpected; + const hydrationRangeEnd = + hydrationRangeEndIndex > i + ? (allNodes[hydrationRangeEndIndex] ?? null) + : undefined; + const synced = trySyncComponentChild( + currentEl, + next, + hydrationRangeEnd + ); + if (synced && isRangeStart(synced)) { + allNodes.splice( + i, + Math.max(1, hydrationRangeEndIndex - i), + synced + ); + continue; + } + if ( + synced && + synced !== currentNode && + synced.nextSibling === currentNode + ) { + allNodes.splice(i, 0, synced); + continue; + } if (!synced) { const dom = domHost.createDOMNode(next, parentNamespace); if (dom) { @@ -517,11 +558,37 @@ export function updateUnkeyedChildren( } } } else { - if ( - typeof next.type === 'function' && - trySyncComponentChild(currentNode, next) - ) { - continue; + if (typeof next.type === 'function') { + const remainingExpected = newChildren.length - i - 1; + const hydrationRangeEndIndex = allNodes.length - remainingExpected; + const hydrationRangeEnd = + hydrationRangeEndIndex > i + ? (allNodes[hydrationRangeEndIndex] ?? null) + : undefined; + const synced = trySyncComponentChild( + currentNode, + next, + hydrationRangeEnd + ); + if (synced && isRangeStart(synced)) { + allNodes.splice( + i, + Math.max(1, hydrationRangeEndIndex - i), + synced + ); + continue; + } + if ( + synced && + synced !== currentNode && + synced.nextSibling === currentNode + ) { + allNodes.splice(i, 0, synced); + continue; + } + if (synced) { + continue; + } } const dom = domHost.createDOMNode(next, parentNamespace); if (dom) { diff --git a/src/runtime/component-commit.ts b/src/runtime/component-commit.ts index 64aea5d9..bebc0a0b 100644 --- a/src/runtime/component-commit.ts +++ b/src/runtime/component-commit.ts @@ -124,7 +124,9 @@ function commitPlaceholderReplacement( const onlyChild = hostElement.childNodes.length === 1 ? hostElement.firstChild : null; const replacement = - onlyChild instanceof Element ? onlyChild : hostElement; + onlyChild instanceof Element || onlyChild instanceof Comment + ? onlyChild + : hostElement; if (replacement === hostElement) { ( hostElement as Element & { __ASKR_WRAPPER_HOST?: boolean } @@ -132,8 +134,10 @@ function commitPlaceholderReplacement( } parent.replaceChild(replacement, placeholder); - instance.target = replacement; - const instanceHost = replacement as Element & { + instance.target = replacement instanceof Element ? replacement : null; + instance._placeholder = + replacement instanceof Comment ? replacement : undefined; + const instanceHost = replacement as Node & { __ASKR_INSTANCE?: ComponentInstance; __ASKR_INSTANCES?: ComponentInstance[]; }; @@ -148,8 +152,6 @@ function commitPlaceholderReplacement( throw err; } - instance._placeholder = undefined; - host.finalizeReadSubscriptions(instance); host.commitRenderedComponent(instance); flushLifecycleCommitBatch(lifecycleBatch); diff --git a/src/runtime/context.ts b/src/runtime/context.ts index a5e0291c..1ed53d85 100644 --- a/src/runtime/context.ts +++ b/src/runtime/context.ts @@ -26,10 +26,18 @@ import type { Props } from '../common/props'; import type { RenderableChild } from '../common/vnode'; import { getCurrentComponentInstance } from './component'; import type { ComponentInstance } from './component'; -import { markEagerControlPrimitive } from '../common/control'; +import { + markEagerControlPrimitive, + markTransparentComponentResult, +} from '../common/control'; export type ContextKey = symbol; +const TransparentContextScopeComponent = markTransparentComponentResult( + ContextScopeComponent +); +let nextScopeIdentityKey = 0; + type Renderable = RenderableChild; type ContextScopeChildren = Renderable | (() => Renderable); @@ -448,12 +456,18 @@ export function withAsyncResourceContext( export function defineScope(defaultValue: T): Scope { const key = Symbol('AskrContext'); + const identityKey = nextScopeIdentityKey++; const scope = markEagerControlPrimitive( (props: { value: T; children?: ContextScopeChildren }): JSXElement => ({ $$typeof: ELEMENT_TYPE, - type: ContextScopeComponent, - props: { key, value: props.value, children: props.children }, + type: TransparentContextScopeComponent, + props: { + __askrIdentityKey: identityKey, + __scopeKey: key, + value: props.value, + children: props.children, + }, key: null, }) as JSXElement ); @@ -517,7 +531,7 @@ export function readScope(context: Scope): T { */ function ContextScopeComponent(props: Props): Renderable { // Extract expected properties (we accept a loose shape so this can be used as a component type) - const key = props['key'] as ContextKey; + const key = props['__scopeKey'] as ContextKey; const value = props['value']; const children = props['children'] as ContextScopeChildren; diff --git a/src/runtime/portal.ts b/src/runtime/portal.ts index 2fd20198..5d6343f5 100644 --- a/src/runtime/portal.ts +++ b/src/runtime/portal.ts @@ -1,7 +1,12 @@ import type { RenderableChild } from '../common/vnode'; import type { JSXElement } from '../common/jsx'; import { getActiveRenderContext } from '../common/render-context'; -import { SSR_PORTAL_HOST } from '../common/portal'; +import { + createSSRPortalAnchorToken, + createSSRPortalHostToken, + SSR_PORTAL_ANCHOR, + SSR_PORTAL_HOST, +} from '../common/portal'; import { ELEMENT_TYPE } from '../jsx'; import { markReactivePropsDirtySource, @@ -67,7 +72,9 @@ function createSSRPortalHost( return null; } - const token = ``; + const token = createSSRPortalHostToken( + current.context.ssrPortals.nextHostId++ + ); current.slot.hosts.push({ token, automatic }); return { $$typeof: ELEMENT_TYPE, @@ -89,6 +96,19 @@ function writeSSRPortal( return true; } +function createSSRPortalAnchor(): JSXElement | null { + const context = getActiveRenderContext(); + if (context?.mode !== 'ssr') { + return null; + } + const token = createSSRPortalAnchorToken(context.ssrPortals.nextHostId++); + return { + $$typeof: ELEMENT_TYPE, + type: SSR_PORTAL_ANCHOR, + props: { token }, + } as unknown as JSXElement; +} + function createPortalSlot(): { read(): T | undefined; write(value: T | undefined): void; @@ -714,9 +734,9 @@ export const DefaultPortal: Portal = (() => { return Host as Portal; })(); -export function Portal(props: PortalProps): null { +export function Portal(props: PortalProps): JSXElement | null { if (writeSSRPortal(DEFAULT_SSR_PORTAL_KEY, props.children)) { - return null; + return createSSRPortalAnchor(); } const owner = getCurrentComponentInstance(); diff --git a/src/ssr/render-sync.ts b/src/ssr/render-sync.ts index 52cc6fa1..dbaa032a 100644 --- a/src/ssr/render-sync.ts +++ b/src/ssr/render-sync.ts @@ -4,7 +4,7 @@ import type { DOMElement } from '../common/vnode'; import { __ERROR_BOUNDARY__ } from '../common/vnode'; import { logger } from '../common/logger'; import { getVNodeContextFrame } from '../runtime'; -import { SSR_PORTAL_HOST } from '../common/portal'; +import { SSR_PORTAL_ANCHOR, SSR_PORTAL_HOST } from '../common/portal'; import { createRenderContext, withRenderContext, @@ -164,7 +164,14 @@ function resolveSSRPortals(html: string, ctx: RenderContext): string { activeHosts.has(host.token) && slot.hasValue ? renderRenderableSync(slot.value, ctx) : ''; - resolved = resolved.replace(host.token, () => content); + resolved = resolved.replace(host.token, () => + host.automatic && + content === '' && + slot.hasValue && + explicitHosts.length === 0 + ? host.token + : content + ); renderedHosts.add(host.token); } } @@ -361,6 +368,9 @@ function renderNodeSync(node: VNode | JSXElement, ctx: RenderContext): string { if (type === SSR_PORTAL_HOST) { return String(props?.token ?? ''); } + if (type === SSR_PORTAL_ANCHOR) { + return String(props?.token ?? ''); + } if (isFragmentType(type)) { const childrenArr = getRenderableChildren(node); /* istanbul ignore if - dev-only debug */ @@ -473,6 +483,10 @@ function renderNodeSyncToSink( } return; } + if (type === SSR_PORTAL_ANCHOR) { + sink.write(String(props?.token ?? '')); + return; + } if (isFragmentType(type)) { const childrenArr = getRenderableChildren(node); renderChildrenSyncToSink(childrenArr, sink, ctx); diff --git a/tests/jsdom/dom/component-array-structure.test.tsx b/tests/jsdom/dom/component-array-structure.test.tsx new file mode 100644 index 00000000..be77b235 --- /dev/null +++ b/tests/jsdom/dom/component-array-structure.test.tsx @@ -0,0 +1,147 @@ +import { afterEach, describe, expect, it } from 'vite-plus/test'; +import { state } from '../../../src'; +import { cleanupApp, createIsland } from '../../../src/boot'; +import { flushScheduler } from '../../../test-utils/render/test-renderer'; + +describe('component array structure', () => { + let root: HTMLElement | undefined; + + afterEach(() => { + if (root) { + cleanupApp(root); + root.remove(); + root = undefined; + } + }); + + it('should keep component-returned array siblings transparent across updates and cleanup', () => { + let update!: () => void; + let remove!: () => void; + + function ArrayContent() { + const revision = state('before'); + update = () => revision.set('after'); + return [ + , + , + ]; + } + + function App() { + const visible = state(true); + remove = () => visible.set(false); + return ( +

+ {visible() ? : null} + tail +
+ ); + } + + root = document.createElement('div'); + document.body.appendChild(root); + createIsland({ root, component: App }); + + const frame = root.querySelector('[data-frame]')!; + const editor = frame.querySelector('[data-editor]') as HTMLInputElement; + const action = frame.querySelector('[data-action]') as HTMLButtonElement; + expect(Array.from(frame.children, (child) => child.tagName)).toEqual([ + 'INPUT', + 'BUTTON', + 'STRONG', + ]); + + editor.focus(); + editor.setSelectionRange(2, 4, 'forward'); + update(); + flushScheduler(); + + expect(frame.querySelector('[data-editor]')).toBe(editor); + expect(frame.querySelector('[data-action]')).toBe(action); + expect(Array.from(frame.children, (child) => child.tagName)).toEqual([ + 'INPUT', + 'BUTTON', + 'STRONG', + ]); + expect(editor.title).toBe('after'); + expect(action.textContent).toBe('after'); + expect(document.activeElement).toBe(editor); + expect([ + editor.selectionStart, + editor.selectionEnd, + editor.selectionDirection, + ]).toEqual([2, 4, 'forward']); + + remove(); + flushScheduler(); + + expect(Array.from(frame.children, (child) => child.tagName)).toEqual([ + 'STRONG', + ]); + expect(editor.isConnected).toBe(false); + expect(action.isConnected).toBe(false); + }); + + it('should roll back a partially reconciled component array without replacing siblings', () => { + let fail!: () => void; + + function ArrayContent(props: { failing: boolean }) { + const secondProps: Record = { + id: 'array-second', + children: 'stable', + }; + if (props.failing) { + Object.defineProperty(secondProps, 'title', { + enumerable: true, + get() { + throw new Error('component array rollback'); + }, + }); + } + + return [ + + {props.failing ? 'changed' : 'before'} + , + , + ]; + } + + function App() { + const failing = state(false); + fail = () => failing.set(true); + return ( +
+ +
+ ); + } + + root = document.createElement('div'); + document.body.appendChild(root); + createIsland({ root, component: App }); + + const frame = root.querySelector('[data-frame]')!; + const first = frame.querySelector('#array-first'); + const second = frame.querySelector('#array-second'); + + fail(); + expect(() => flushScheduler()).toThrow('component array rollback'); + + expect(frame.querySelector('#array-first')).toBe(first); + expect(frame.querySelector('#array-second')).toBe(second); + expect(first?.textContent).toBe('before'); + expect(second?.textContent).toBe('stable'); + expect(Array.from(frame.children, (child) => child.tagName)).toEqual([ + 'SPAN', + 'SPAN', + ]); + }); +}); diff --git a/tests/jsdom/ssg/static-gen.test.tsx b/tests/jsdom/ssg/static-gen.test.tsx index 2dd13aa3..dac5d32c 100644 --- a/tests/jsdom/ssg/static-gen.test.tsx +++ b/tests/jsdom/ssg/static-gen.test.tsx @@ -407,10 +407,14 @@ describe('Static Site Generation', () => { expect(result.failed).toBe(0); expect( fs.readFileSync(path.join(tempDir, 'explicit', 'index.html'), 'utf8') - ).toBe('
explicit portal
'); + ).toBe( + '
explicit portal
' + ); expect( fs.readFileSync(path.join(tempDir, 'automatic', 'index.html'), 'utf8') - ).toBe('
automatic portal'); + ).toBe( + '
automatic portal' + ); }); it('should generate HTML files in correct directory structure', async () => { diff --git a/tests/jsdom/ssr/component-array-hydration.test.tsx b/tests/jsdom/ssr/component-array-hydration.test.tsx new file mode 100644 index 00000000..6b8dad7e --- /dev/null +++ b/tests/jsdom/ssr/component-array-hydration.test.tsx @@ -0,0 +1,315 @@ +import { afterEach, describe, expect, it } from 'vite-plus/test'; +import { For } from '../../../src/control'; +import { defineScope, readScope, state } from '../../../src'; +import { hydrateSPA } from '../../../src/boot'; +import { definePortal } from '../../../src/foundations/structures/portal'; +import { renderToStringSync } from '../../../src/ssr'; +import { + createTestContainer, + flushScheduler, +} from '../../../test-utils/render/test-renderer'; +import { routeRegistryFromTable } from '../../router-test-utils'; + +describe('component array hydration', () => { + const cleanups: Array<() => void> = []; + + afterEach(() => { + while (cleanups.length > 0) { + cleanups.pop()?.(); + } + }); + + it('should hydrate defineScope children and a portal host without a visible context wrapper', async () => { + const DialogContext = defineScope('closed'); + const DialogPortal = definePortal(); + let update!: () => void; + let close!: () => void; + let reopen!: () => void; + + function DialogLabel() { + return {readScope(DialogContext)}; + } + + function DialogPortalWriter(props: { open: boolean }) { + const value = readScope(DialogContext); + DialogPortal.render({ + children: props.open ? ( + + ) : null, + }); + return null; + } + + function DialogRoot() { + const value = state('ready'); + const open = state(true); + update = () => value.set('updated'); + close = () => open.set(false); + reopen = () => open.set(true); + return ( + + + + + + + ); + } + + function App() { + return ( + + ); + } + + const { container, cleanup } = createTestContainer(); + cleanups.push(cleanup); + container.innerHTML = renderToStringSync(() => ); + const mobileBar = container.querySelector('[data-mobile-bar]')!; + + expect(Array.from(mobileBar.children, (child) => child.tagName)).toEqual([ + 'BUTTON', + 'SPAN', + 'ASIDE', + 'STRONG', + ]); + + await hydrateSPA({ + root: container, + registry: routeRegistryFromTable([{ path: '/', handler: App }]), + }); + + expect(container.querySelector('[data-mobile-bar]')).toBe(mobileBar); + expect(Array.from(mobileBar.children, (child) => child.tagName)).toEqual([ + 'BUTTON', + 'SPAN', + 'ASIDE', + 'STRONG', + ]); + expect( + mobileBar.querySelector(':scope > div[data-key="Symbol(AskrContext)"]') + ).toBeNull(); + + const trigger = mobileBar.querySelector( + '[data-dialog-trigger]' + ) as HTMLButtonElement; + const label = mobileBar.querySelector('[data-dialog-label]'); + const portal = mobileBar.querySelector('[data-dialog-portal]'); + trigger.focus(); + update(); + flushScheduler(); + + expect(mobileBar.querySelector('[data-dialog-trigger]')).toBe(trigger); + expect(mobileBar.querySelector('[data-dialog-label]')).toBe(label); + expect(mobileBar.querySelector('[data-dialog-portal]')).toBe(portal); + expect(document.activeElement).toBe(trigger); + expect(mobileBar.querySelector('[data-dialog-label]')?.textContent).toBe( + 'updated' + ); + expect(mobileBar.querySelector('[data-dialog-portal]')?.textContent).toBe( + 'updated' + ); + expect(Array.from(mobileBar.children, (child) => child.tagName)).toEqual([ + 'BUTTON', + 'SPAN', + 'ASIDE', + 'STRONG', + ]); + + close(); + flushScheduler(); + + expect(mobileBar.querySelector('[data-dialog-trigger]')).toBe(trigger); + expect(mobileBar.querySelector('[data-dialog-label]')).toBe(label); + expect(mobileBar.querySelector('[data-dialog-portal]')).toBeNull(); + expect(portal?.isConnected).toBe(false); + + reopen(); + flushScheduler(); + + expect(mobileBar.querySelector('[data-dialog-trigger]')).toBe(trigger); + expect(mobileBar.querySelector('[data-dialog-label]')).toBe(label); + expect(mobileBar.querySelector('[data-dialog-portal]')?.textContent).toBe( + 'updated' + ); + expect(Array.from(mobileBar.children, (child) => child.tagName)).toEqual([ + 'BUTTON', + 'SPAN', + 'ASIDE', + 'STRONG', + ]); + }); + + it('should preserve nested scope, keyed row, and portal identities during hydration', async () => { + const Scope = defineScope('initial'); + const Portal = definePortal(); + const initialRows = ['one', 'two', 'three']; + let setLabel!: (value: string) => void; + let setRows!: (value: string[]) => void; + + function StaticLink() { + return {readScope(Scope)}; + } + + function Row(props: { name: string }) { + return {`${props.name}:${readScope(Scope)}`}; + } + + function PortalWriter() { + Portal.render({ + children: , + }); + return null; + } + + function App() { + const label = state('initial'); + const rows = state(initialRows); + setLabel = label.set; + setRows = rows.set; + return ( + + ); + } + + const { container, cleanup } = createTestContainer(); + cleanups.push(cleanup); + container.innerHTML = renderToStringSync(App); + const nav = container.querySelector('nav')!; + const staticLink = nav.querySelector('[data-static]'); + const rowNodes = new Map( + Array.from(nav.querySelectorAll('[data-row]'), (row) => [ + row.getAttribute('data-row'), + row, + ]) + ); + const portal = nav.querySelector('[data-portal]'); + + await hydrateSPA({ + root: container, + registry: routeRegistryFromTable([{ path: '/', handler: App }]), + }); + + expect.soft(nav.querySelector('[data-static]')).toBe(staticLink); + for (const [name, row] of rowNodes) { + expect.soft(nav.querySelector(`[data-row="${name}"]`)).toBe(row); + } + expect.soft(nav.querySelector('[data-portal]')).toBe(portal); + expect(nav.querySelector('div[data-key="Symbol(AskrContext)"]')).toBeNull(); + + setLabel('updated'); + setRows(['three', 'one']); + flushScheduler(); + + expect(nav.querySelector('[data-static]')).toBe(staticLink); + expect(nav.querySelector('[data-static]')?.textContent).toBe('updated'); + expect( + Array.from(nav.querySelectorAll('[data-row]'), (row) => + row.getAttribute('data-row') + ) + ).toEqual(['three', 'one']); + expect(nav.querySelector('[data-row="three"]')).toBe(rowNodes.get('three')); + expect(nav.querySelector('[data-row="one"]')).toBe(rowNodes.get('one')); + expect(nav.querySelector('[data-row="three"]')?.textContent).toBe( + 'three:updated' + ); + expect(nav.querySelector('[data-portal]')).toBe(portal); + expect(nav.querySelector('[data-portal]')?.textContent).toBe('updated'); + expect(Array.from(nav.children, (child) => child.tagName)).toEqual([ + 'A', + 'A', + 'A', + 'ASIDE', + 'STRONG', + ]); + }); + + it('should keep sibling scope providers distinct during hydration and updates', async () => { + const LeftScope = defineScope('left-default'); + const RightScope = defineScope('right-default'); + const leftIdentity = ( + LeftScope({ value: 'left' }).props as Record + )['__askrIdentityKey']; + const rightIdentity = ( + RightScope({ value: 'right' }).props as Record + )['__askrIdentityKey']; + let updateLeft!: () => void; + let updateRight!: () => void; + + expect(typeof leftIdentity).toBe('number'); + expect(typeof rightIdentity).toBe('number'); + expect(leftIdentity).not.toBe(rightIdentity); + + function LeftLabel() { + return {readScope(LeftScope)}; + } + + function RightLabel() { + return {readScope(RightScope)}; + } + + function App() { + const left = state('left'); + const right = state('right'); + updateLeft = () => left.set('left-updated'); + updateRight = () => right.set('right-updated'); + return ( + + ); + } + + const { container, cleanup } = createTestContainer(); + cleanups.push(cleanup); + container.innerHTML = renderToStringSync(App); + const nav = container.querySelector('nav')!; + const leftLabel = nav.querySelector('[data-left]'); + const rightLabel = nav.querySelector('[data-right]'); + + await hydrateSPA({ + root: container, + registry: routeRegistryFromTable([{ path: '/', handler: App }]), + }); + + expect.soft(nav.querySelector('[data-left]')).toBe(leftLabel); + expect.soft(nav.querySelector('[data-right]')).toBe(rightLabel); + + updateLeft(); + flushScheduler(); + + expect(nav.querySelector('[data-left]')).toBe(leftLabel); + expect(nav.querySelector('[data-left]')?.textContent).toBe('left-updated'); + expect(nav.querySelector('[data-right]')).toBe(rightLabel); + expect(nav.querySelector('[data-right]')?.textContent).toBe('right'); + + updateRight(); + flushScheduler(); + + expect(nav.querySelector('[data-left]')).toBe(leftLabel); + expect(nav.querySelector('[data-left]')?.textContent).toBe('left-updated'); + expect(nav.querySelector('[data-right]')).toBe(rightLabel); + expect(nav.querySelector('[data-right]')?.textContent).toBe( + 'right-updated' + ); + }); +}); diff --git a/tests/jsdom/ssr/default-portal-hydration-parity.test.tsx b/tests/jsdom/ssr/default-portal-hydration-parity.test.tsx new file mode 100644 index 00000000..666b714b --- /dev/null +++ b/tests/jsdom/ssr/default-portal-hydration-parity.test.tsx @@ -0,0 +1,137 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vite-plus/test'; +import { defineScope, readScope, state } from '../../../src'; +import { hydrateSPA } from '../../../src/boot'; +import { + Portal, + _resetDefaultPortal, +} from '../../../src/foundations/structures/portal'; +import { renderToStringSync } from '../../../src/ssr'; +import { + createTestContainer, + flushScheduler, +} from '../../../test-utils/render/test-renderer'; +import { routeRegistryFromTable } from '../../router-test-utils'; +import { isSSRPortalHydrationAnchor } from '../../../src/common/portal'; + +describe('default portal hydration parity', () => { + beforeEach(() => { + _resetDefaultPortal(); + }); + + afterEach(() => { + _resetDefaultPortal(); + }); + + it('should keep a closed nested-scope portal out of application topology', async () => { + const SheetScope = defineScope('sheet'); + const LayerScope = defineScope('layer'); + let setOpen!: (open: boolean) => void; + + function SheetSurface(props: { open: boolean }) { + if (!props.open) { + return null; + } + return ( +
+ {`${readScope(SheetScope)}:${readScope(LayerScope)}`} +
+ ); + } + + function SheetPortal(props: { open: boolean }) { + return ( + + + + ); + } + + function App() { + const open = state(false); + setOpen = open.set; + return ( +
+ + + + {'Docs'} + + +
+ ); + } + + const { container, cleanup } = createTestContainer(); + const registry = routeRegistryFromTable([{ path: '/', handler: App }]); + + try { + container.innerHTML = renderToStringSync(App); + const page = container.querySelector('[data-page]'); + const label = container.querySelector('[data-label]'); + const serverElements = Array.from(container.children); + const portalAnchor = page?.firstChild; + const defaultPortalAnchor = container.lastChild; + + expect(serverElements).toEqual([page]); + expect(isSSRPortalHydrationAnchor(portalAnchor)).toBe(true); + expect(isSSRPortalHydrationAnchor(defaultPortalAnchor)).toBe(true); + expect( + isSSRPortalHydrationAnchor(document.createComment('user-owned')) + ).toBe(false); + expect( + isSSRPortalHydrationAnchor(document.createComment('askr-portal:user')) + ).toBe(false); + expect( + isSSRPortalHydrationAnchor( + document.createComment('askr-portal-anchor:0-extra') + ) + ).toBe(false); + + await hydrateSPA({ root: container, registry }); + flushScheduler(); + + expect(container.querySelector('[data-page]')).toBe(page); + expect(container.querySelector('[data-label]')).toBe(label); + expect(page?.contains(portalAnchor ?? null)).toBe(true); + expect(portalAnchor?.nextSibling).toBe(label); + expect(container.querySelector('[data-sheet]')).toBeNull(); + expect(Array.from(container.children)).toEqual(serverElements); + expect(container.childNodes).toHaveLength(2); + expect(container.lastChild).toBeInstanceOf(Comment); + expect(container.querySelectorAll(':scope > div')).toHaveLength(0); + expect(page?.querySelectorAll(':scope > div')).toHaveLength(0); + + setOpen(true); + flushScheduler(); + + expect(container.querySelector('[data-page]')).toBe(page); + expect(container.querySelector('[data-label]')).toBe(label); + expect(page?.contains(portalAnchor ?? null)).toBe(true); + expect(portalAnchor?.nextSibling).toBe(label); + expect(container.querySelector('[data-sheet]')?.textContent).toBe( + 'docs-sheet:docs-layer' + ); + + setOpen(false); + flushScheduler(); + + expect(container.querySelector('[data-sheet]')).toBeNull(); + expect(Array.from(container.children)).toEqual(serverElements); + expect(container.querySelector('[data-label]')).toBe(label); + expect(page?.contains(portalAnchor ?? null)).toBe(true); + expect(portalAnchor?.nextSibling).toBe(label); + + setOpen(true); + flushScheduler(); + + expect(container.querySelector('[data-sheet]')?.textContent).toBe( + 'docs-sheet:docs-layer' + ); + expect(container.querySelector('[data-label]')).toBe(label); + expect(page?.contains(portalAnchor ?? null)).toBe(true); + expect(portalAnchor?.nextSibling).toBe(label); + } finally { + cleanup(); + } + }); +}); diff --git a/tests/jsdom/ssr/empty-show-tail-hydration.test.tsx b/tests/jsdom/ssr/empty-show-tail-hydration.test.tsx new file mode 100644 index 00000000..f48c16f4 --- /dev/null +++ b/tests/jsdom/ssr/empty-show-tail-hydration.test.tsx @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { hydrateSPA } from '../../../src/boot'; +import { Show } from '../../../src/control'; +import { state } from '../../../src'; +import { renderToStringSync } from '../../../src/ssr'; +import { + createTestContainer, + flushScheduler, +} from '../../../test-utils/render/test-renderer'; +import { routeRegistryFromTable } from '../../router-test-utils'; + +describe('empty Show hydration cursor', () => { + it('should adopt a component sibling after one empty Show boundary', async () => { + function Tail() { + return ; + } + + function App() { + return ( +
+ +
first
+
+ +
+ ); + } + + const { container, cleanup } = createTestContainer(); + try { + container.innerHTML = renderToStringSync(App); + 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); + } finally { + cleanup(); + } + }); + + it('should adopt a component sibling after consecutive empty Show boundaries', async () => { + let setFirst!: (value: boolean) => void; + let setSecond!: (value: boolean) => void; + + function Tail() { + return ; + } + + function App() { + const first = state(false); + const second = state(false); + setFirst = first.set; + setSecond = second.set; + return ( +
+ +
first
+
+ +
second
+
+ +
+ ); + } + + const { container, cleanup } = createTestContainer(); + try { + container.innerHTML = renderToStringSync(App); + const serverTail = container.querySelector('[data-tail]'); + + expect(serverTail).not.toBeNull(); + expect(container.querySelectorAll('[data-tail]')).toHaveLength(1); + + await hydrateSPA({ + root: container, + registry: routeRegistryFromTable([{ path: '/', handler: App }]), + }); + + expect(container.querySelectorAll('[data-tail]')).toHaveLength(1); + expect(container.querySelector('[data-tail]')).toBe(serverTail); + + setFirst(true); + flushScheduler(); + + expect(container.querySelector('[data-first]')).not.toBeNull(); + expect(container.querySelector('[data-second]')).toBeNull(); + expect(container.querySelectorAll('[data-tail]')).toHaveLength(1); + expect(container.querySelector('[data-tail]')).toBe(serverTail); + + setSecond(true); + flushScheduler(); + + expect(container.querySelector('[data-first]')).not.toBeNull(); + expect(container.querySelector('[data-second]')).not.toBeNull(); + expect(container.querySelectorAll('[data-tail]')).toHaveLength(1); + expect(container.querySelector('[data-tail]')).toBe(serverTail); + + setFirst(false); + setSecond(false); + flushScheduler(); + + expect(container.querySelector('[data-first]')).toBeNull(); + expect(container.querySelector('[data-second]')).toBeNull(); + expect(container.querySelectorAll('[data-tail]')).toHaveLength(1); + expect(container.querySelector('[data-tail]')).toBe(serverTail); + + setFirst(true); + setSecond(true); + flushScheduler(); + + expect(container.querySelector('[data-first]')).not.toBeNull(); + expect(container.querySelector('[data-second]')).not.toBeNull(); + expect(container.querySelectorAll('[data-tail]')).toHaveLength(1); + expect(container.querySelector('[data-tail]')).toBe(serverTail); + } finally { + cleanup(); + } + }); +}); diff --git a/tests/jsdom/ssr/for-static-sibling-hydration.test.tsx b/tests/jsdom/ssr/for-static-sibling-hydration.test.tsx new file mode 100644 index 00000000..2cb3f1f2 --- /dev/null +++ b/tests/jsdom/ssr/for-static-sibling-hydration.test.tsx @@ -0,0 +1,137 @@ +import { afterEach, describe, expect, it } from 'vite-plus/test'; +import { state } from '../../../src'; +import { cleanupApp, hydrateSPA } from '../../../src/boot'; +import { Link } from '../../../src/components/link'; +import { For } from '../../../src/control'; +import { renderToStringSync } from '../../../src/ssr'; +import { flushScheduler } from '../../../test-utils/render/test-renderer'; +import { + resetRouteState, + routeRegistryFromTable, +} from '../../router-test-utils'; + +describe('For hydration after a static sibling', () => { + let root: HTMLElement | undefined; + + afterEach(() => { + if (root) { + cleanupApp(root); + root.remove(); + root = undefined; + } + resetRouteState(); + }); + + it('should adopt only the keyed For range and preserve later updates', async () => { + const initialPages = Array.from({ length: 7 }, (_, index) => ({ + path: `/page-${index + 1}`, + label: `Page ${index + 1}`, + })); + let replacePages = (_pages: typeof initialPages) => {}; + + const FooterLinks = (props: { children?: unknown }) => ( + + ); + + const App = () => { + const pages = state(initialPages); + replacePages = (next) => pages.set(next); + + return ( +
+ + + Overview + + page.path}> + {(page) => ( + + {page.label} + + )} + + + +
+ ); + }; + + root = document.createElement('div'); + document.body.appendChild(root); + root.innerHTML = renderToStringSync(App); + + const footer = root.querySelector('footer')!; + const linksWithStaticSibling = footer.querySelector( + 'nav:not([data-control])' + )!; + const control = footer.querySelector('[data-control]')!; + const serverOverview = + linksWithStaticSibling.querySelector('[data-overview]'); + const serverPages = Array.from( + linksWithStaticSibling.querySelectorAll('[data-page]') + ); + const serverControlPages = Array.from( + control.querySelectorAll('[data-control-page]') + ); + + await hydrateSPA({ + root, + registry: routeRegistryFromTable([{ path: '/', handler: App }]), + }); + + expect(linksWithStaticSibling.querySelectorAll(':scope > a')).toHaveLength( + 8 + ); + expect(control.querySelectorAll(':scope > a')).toHaveLength(7); + expect(linksWithStaticSibling.querySelector('[data-overview]')).toBe( + serverOverview + ); + expect( + Array.from(linksWithStaticSibling.querySelectorAll('[data-page]')) + ).toEqual(serverPages); + expect(Array.from(control.querySelectorAll('[data-control-page]'))).toEqual( + serverControlPages + ); + + replacePages([ + initialPages[6]!, + initialPages[2]!, + initialPages[0]!, + initialPages[4]!, + ]); + flushScheduler(); + + expect( + Array.from( + linksWithStaticSibling.querySelectorAll('[data-page]'), + (link) => link.getAttribute('data-page') + ) + ).toEqual(['/page-7', '/page-3', '/page-1', '/page-5']); + expect( + Array.from(control.querySelectorAll('[data-control-page]'), (link) => + link.getAttribute('data-control-page') + ) + ).toEqual(['/page-7', '/page-3', '/page-1', '/page-5']); + expect(linksWithStaticSibling.querySelector('[data-overview]')).toBe( + serverOverview + ); + expect(linksWithStaticSibling.querySelector('[data-page="/page-7"]')).toBe( + serverPages[6] + ); + expect(control.querySelector('[data-control-page="/page-7"]')).toBe( + serverControlPages[6] + ); + }); +}); diff --git a/tests/jsdom/ssr/portal-rendering.test.tsx b/tests/jsdom/ssr/portal-rendering.test.tsx index 60e3fcc2..d2e35ee8 100644 --- a/tests/jsdom/ssr/portal-rendering.test.tsx +++ b/tests/jsdom/ssr/portal-rendering.test.tsx @@ -32,7 +32,9 @@ describe('SSR portal rendering', () => { )); - expect(html).toBe('
Portalled
'); + expect(html).toBe( + '
Portalled
' + ); }); it('should render default portal content at an explicit host before its writer', () => { @@ -47,7 +49,7 @@ describe('SSR portal rendering', () => { )); expect(html).toBe( - '
Portalled
middle
' + '
Portalled
middle
' ); }); @@ -60,7 +62,9 @@ describe('SSR portal rendering', () => { )); - expect(html).toBe('
Portalled
'); + expect(html).toBe( + '
Portalled
' + ); }); it('should insert portal HTML containing replacement tokens verbatim', () => { @@ -73,7 +77,9 @@ describe('SSR portal rendering', () => { )); - expect(html).toBe("
cash $& $1 $$ $` $'
"); + expect(html).toBe( + "
cash $& $1 $$ $` $'
" + ); }); it('should render a defined portal independent of host evaluation order', () => {