Skip to content
Merged
27 changes: 20 additions & 7 deletions docs/core/rendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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

Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/guides/platform-recipes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@askrjs/askr",
"version": "0.0.77",
"version": "0.0.78",
"description": "Actor-backed deterministic UI framework",
"keywords": [
"askr",
Expand Down
27 changes: 27 additions & 0 deletions src/common/control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<P extends Props = Props> = ((
props: P
Expand Down Expand Up @@ -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]
)
);
}
39 changes: 39 additions & 0 deletions src/common/portal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,42 @@
* @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 `<!--${SSR_PORTAL_HOST_PREFIX}${id}-->`;
}

export function createSSRPortalAnchorToken(id: number): string {
return `<!--${SSR_PORTAL_ANCHOR_PREFIX}${id}-->`;
}

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))
);
}
4 changes: 3 additions & 1 deletion src/renderer/boundaries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ export interface BoundaryDOMHost {
props: Record<string, unknown>,
parentNamespace?: string,
forceChildrenUpdate?: boolean,
retainedHostInstances?: Iterable<ComponentInstance>
retainedHostInstances?: Iterable<ComponentInstance>,
hydrationRangeEnd?: Node | null,
preserveHydrationCursorOnEmpty?: boolean
): Node | null;
updateElementFromVnode(
el: Element,
Expand Down
13 changes: 12 additions & 1 deletion src/renderer/boundary-range-adoption.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
restoreDomCommitScope,
type ChildScope,
type ComponentFunction,
type ComponentInstance,
} from '../runtime';
import {
createDetachedRange,
Expand All @@ -29,7 +30,11 @@ export type BoundaryRangeDOMHost = {
node: DOMElement,
type: ComponentFunction,
props: Record<string, unknown>,
parentNamespace?: string
parentNamespace?: string,
forceChildrenUpdate?: boolean,
retainedHostInstances?: Iterable<ComponentInstance>,
hydrationRangeEnd?: Node | null,
preserveHydrationCursorOnEmpty?: boolean
): Node | null;
updateElementFromVnode(
el: Element,
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion src/renderer/boundary-range-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { insertRangeBefore, rangeContains, removeRange } from './dom-range';
import {
adoptHydratedRange,
assignScopeRange,
canAdoptHydratedElement,
checkVNodeShapeChanged,
getBoundaryParentNamespace,
getBoundaryRangeHost,
Expand Down Expand Up @@ -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,
Expand Down
15 changes: 15 additions & 0 deletions src/renderer/child-shape.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 [];
Expand Down
Loading
Loading