Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions docs/core/rendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,27 @@ child owners do not become live. Cleanup belonging to a successful commit runs
only after the coherent DOM update. Cleanup failures are reported together and
do not roll back an already successful render.

### Component Fragment results

A component may return a Fragment when it needs multiple sibling nodes without
an application-visible wrapper:

```tsx
function PageHeader() {
return (
<>
<h1>Dashboard</h1>
<nav>...</nav>
</>
);
}
```

The Fragment remains structurally transparent in SPA rendering, SSR, SSG, and
hydration. Its nodes are direct siblings at the component call site. Askr uses
internal comment-anchored ranges to retain update and cleanup ownership; it
does not insert a `div` or another visible host element.

### Imperative widget hosts

Use `imperativeChildren` when a third-party widget owns all descendants of an
Expand Down
3 changes: 3 additions & 0 deletions docs/guides/component-generation.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,15 @@ Do:
- compose `askr-ui` primitives when behavior is non-trivial
- model variants with semantic props (`tone`, `size`, `intent`) and CSS selectors
- keep rendering deterministic and side-effect free
- return a Fragment when a component must contribute multiple direct siblings
without changing the parent element's child topology

Do not:

- hardcode `--ak-*` token strings in runtime TS/JS
- call APIs directly in leaf UI components
- add component-specific business rules in generic primitives
- add a wrapper element solely to satisfy a component return shape

## Checklist for AI-generated Components

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.76`. Use one locked
The examples are verified against `@askrjs/askr@0.0.77`. Use one locked
`@askrjs/askr` version for the root entry point and every subpath; do not resolve
subpaths independently.

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.76",
"version": "0.0.77",
"description": "Actor-backed deterministic UI framework",
"keywords": [
"askr",
Expand Down
7 changes: 7 additions & 0 deletions src/common/jsx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ export const ELEMENT_TYPE = Symbol.for('askr.element');
export const Fragment = Symbol.for('askr.fragment');
export const STATIC_CHILDREN = Symbol.for('askr.static-children');

export function isFragmentType(type: unknown): type is symbol {
if (typeof type !== 'symbol') return false;
if (type === Fragment) return true;
const label = String(type);
return label === 'Symbol(askr.fragment)' || label === 'Symbol(Fragment)';
}

export type JSXComponent<TProps extends object = Props> = {
bivarianceHack(props: TProps): unknown;
}['bivarianceHack'];
Expand Down
4 changes: 2 additions & 2 deletions src/renderer/boundary-range-adoption.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Fragment } from '../jsx';
import { isFragmentType } from '../common/jsx';
import type { DOMRange } from '../common/dom-range';
import {
enterDomCommitScope,
Expand Down Expand Up @@ -75,7 +75,7 @@ export function adoptHydratedRange(
const range: DOMRange = { start: before, end, single: false };
const contentNodes = getRangeNodes(range);
const expectedChildren =
_isDOMElement(vnode) && vnode.type === Fragment
_isDOMElement(vnode) && isFragmentType(vnode.type)
? ((vnode.props?.children as VNode[] | undefined) ?? [])
: [vnode];
if (contentNodes.length !== expectedChildren.length) return null;
Expand Down
10 changes: 2 additions & 8 deletions src/renderer/child-shape.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { STATIC_CHILDREN } from '../common/jsx';
import { isFragmentType, STATIC_CHILDREN } from '../common/jsx';
import { __CONTROL_BOUNDARY__ } from '../common/vnode';
import { logger } from '../common/logger';
import { Fragment } from '../jsx/jsx-runtime';
import { getCurrentInstance } from '../runtime';
import { getRuntimeEnv } from './env';
import { _isDOMElement, type DOMElement } from './types';
Expand All @@ -12,12 +11,7 @@ export type StaticCreateChildShape = {
};

export function isFragmentVNode(node: unknown): node is DOMElement {
return (
_isDOMElement(node) &&
typeof (node as DOMElement).type === 'symbol' &&
((node as DOMElement).type === Fragment ||
String((node as DOMElement).type) === 'Symbol(askr.fragment)')
);
return _isDOMElement(node) && isFragmentType((node as DOMElement).type);
}

export function normalizeComponentChildren(result: unknown): unknown[] {
Expand Down
156 changes: 156 additions & 0 deletions src/renderer/component-fragment-range.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import {
registerLifecycleTransaction,
type ComponentInstance,
} from '../runtime';
import { isFragmentVNode, normalizeComponentChildren } from './child-shape';
import { getOwnedRange, rangeContains, type DOMRange } from './dom-range';
import { getRendererDOMHost, type InstanceHostNode } from './dom-host';
import type { VNode } from './types';

export function captureRangeFocus(
range: DOMRange,
parent: Element
): () => void {
const active = parent.ownerDocument.activeElement;
if (
typeof HTMLElement === 'undefined' ||
!(active instanceof HTMLElement) ||
!parent.contains(active)
) {
return () => {};
}
Comment thread
smiggleworth marked this conversation as resolved.

let rangeChild: Node = active;
while (rangeChild.parentNode && rangeChild.parentNode !== parent) {
rangeChild = rangeChild.parentNode;
}
if (rangeChild.parentNode !== parent || !rangeContains(range, rangeChild)) {
return () => {};
}

const selection =
active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement
? {
start: active.selectionStart,
end: active.selectionEnd,
direction: active.selectionDirection,
}
: null;

return () => {
if (!active.isConnected || active.ownerDocument.activeElement === active) {
return;
}

try {
active.focus({ preventScroll: true });
} catch {
active.focus();
}
if (
selection &&
(active instanceof HTMLInputElement ||
active instanceof HTMLTextAreaElement) &&
selection.start !== null &&
selection.end !== null
) {
active.setSelectionRange(
selection.start,
selection.end,
selection.direction ?? undefined
);
}
};
}

function unwrapStagingHost(
staging: Element,
parent: Element,
restoreFocus: () => void
): void {
if (staging.parentNode !== parent) {
return;
}

while (staging.firstChild) {
parent.insertBefore(staging.firstChild, staging);
}
staging.remove();
restoreFocus();
}

function createAttributeFreeStagingHost(parent: Element): Element {
return parent.ownerDocument.createElementNS(
parent.namespaceURI,
parent.localName
);
}

export function syncComponentFragmentRange(
host: InstanceHostNode,
instance: ComponentInstance,
result: unknown,
forceUpdate: boolean
): boolean {
if (!isFragmentVNode(result)) {
return false;
}

const range = getOwnedRange(instance);
const parent = range?.start.parentNode;
if (
!range ||
range.single ||
range.start !== host ||
!(parent instanceof Element)
) {
return false;
}

const restoreFocus = captureRangeFocus(range, parent);
const staging = createAttributeFreeStagingHost(parent);
try {
parent.insertBefore(staging, range.end);
} catch {
staging.remove();
return false;
}
if (staging.parentNode !== parent || staging.nextSibling !== range.end) {
staging.remove();
restoreFocus();
return false;
}
while (range.start.nextSibling && range.start.nextSibling !== staging) {
staging.appendChild(range.start.nextSibling);
}

const rollbackStaging = registerLifecycleTransaction(
{},
() => {},
() => unwrapStagingHost(staging, parent, restoreFocus)
);

try {
getRendererDOMHost().updateElementChildren(
staging,
normalizeComponentChildren(result) as VNode[],
forceUpdate
);
} catch (error) {
if (!rollbackStaging) {
unwrapStagingHost(staging, parent, restoreFocus);
}
throw error;
}

if (
!registerLifecycleTransaction(
{},
() => unwrapStagingHost(staging, parent, restoreFocus),
() => {}
)
) {
unwrapStagingHost(staging, parent, restoreFocus);
}
return true;
}
4 changes: 3 additions & 1 deletion src/renderer/component-host-results.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
cleanupProvisionalComponentInstances,
registerVNodeComponentInstanceRollback,
} from './component-host-replacement';
import { isFragmentVNode } from './child-shape';
import {
findHostInstanceByType,
getVNodeComponentInstance,
Expand Down Expand Up @@ -69,7 +70,8 @@ export function materializeComponentResultNode(
}
if (
!_isDOMElement(result) ||
(result as DOMElement).type !== __CONTROL_BOUNDARY__
((result as DOMElement).type !== __CONTROL_BOUNDARY__ &&
!isFragmentVNode(result))
) {
Comment thread
smiggleworth marked this conversation as resolved.
const host = document.createElement('div') as InstanceHostElement;
host.appendChild(dom);
Expand Down
13 changes: 13 additions & 0 deletions src/renderer/component-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
} from '../runtime';
import { materializeFreshKey, materializeKey } from './attributes';
import { normalizeComponentChildren } from './child-shape';
import { syncComponentFragmentRange } from './component-fragment-range';
import { pruneComponentHostInstances } from './component-host-cleanup';
import {
getRendererDOMHost,
Expand Down Expand Up @@ -301,6 +302,18 @@ export function syncComponentElement(
return existingHost;
}

if (
existingHost instanceof Comment &&
syncComponentFragmentRange(
existingHost,
existingInstance,
scopedResult,
forceChildrenUpdate || existingInstance.mounted === false
)
) {
return existingHost;
}

if (
existingHost instanceof Element &&
scopedResult &&
Expand Down
5 changes: 5 additions & 0 deletions src/renderer/component-range-commit.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ComponentInstance } from '../runtime';
import { cleanupDetachedComponentHost } from './component-host-cleanup';
import { syncComponentFragmentRange } from './component-fragment-range';
import { materializeComponentResultNode } from './component-host-results';
import {
createDetachedRange,
Expand All @@ -26,6 +27,10 @@ export function replaceComponentRange(
return null;
}

if (syncComponentFragmentRange(placeholder, instance, result, false)) {
return placeholder;
}

const materialized = materializeComponentResultNode(
instance,
result,
Expand Down
7 changes: 2 additions & 5 deletions src/renderer/dom-internal.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { logger } from '../common/logger';
import { Fragment } from '../jsx/jsx-runtime';
import { isFragmentType } from '../common/jsx';
import {
beginLifecycleCommitBatch,
discardLifecycleCommitBatch,
Expand Down Expand Up @@ -219,10 +219,7 @@ export function createDOMNode(
);
}

if (
typeof type === 'symbol' &&
(type === Fragment || String(type) === 'Symbol(Fragment)')
) {
if (isFragmentType(type)) {
return createFragmentElement(node as DOMElement, props, parentNamespace);
}
}
Expand Down
9 changes: 2 additions & 7 deletions src/renderer/evaluate-reconcile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
updateUnkeyedChildren,
} from './element-children';
import { setDevValue, incDevCounter } from '../runtime';
import { Fragment } from '../common/jsx';
import { isFragmentType } from '../common/jsx';
import {
createWrappedHandler,
extractKey,
Expand Down Expand Up @@ -494,12 +494,7 @@ export function tryFirstRenderKeyedChildren(
}

export function isFragment(vnode: unknown): vnode is DOMElement {
return (
_isDOMElement(vnode) &&
typeof (vnode as DOMElement).type === 'symbol' &&
((vnode as DOMElement).type === Fragment ||
String((vnode as DOMElement).type) === 'Symbol(askr.fragment)')
);
return _isDOMElement(vnode) && isFragmentType((vnode as DOMElement).type);
}

export function getFragmentChildren(vnode: DOMElement): unknown[] {
Expand Down
Loading
Loading