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