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 [
+ ,
+
+ {revision()}
+ ,
+ ];
+ }
+
+ 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 (
+
+ Open
+
+
+
+
+ );
+ }
+
+ function App() {
+ return (
+
+
+ tail
+
+ );
+ }
+
+ 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 (
+
+
+
+ name}>
+ {(name) =>
}
+
+
+
+
+ tail
+
+ );
+ }
+
+ 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 (
+
+
+
+
+
+
+
+ tail
+
+ );
+ }
+
+ 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 tail ;
+ }
+
+ function App() {
+ return (
+
+
+
+
+
+
+ );
+ }
+
+ 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 tail ;
+ }
+
+ function App() {
+ const first = state(false);
+ const second = state(false);
+ setFirst = first.set;
+ setSecond = second.set;
+ return (
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ 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 }) => (
+ {props.children}
+ );
+
+ const App = () => {
+ const pages = state(initialPages);
+ replacePages = (next) => pages.set(next);
+
+ return (
+
+
+
+ Overview
+
+ page.path}>
+ {(page) => (
+
+ {page.label}
+
+ )}
+
+
+
+ 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', () => {