Skip to content

Commit 53dcd03

Browse files
committed
feat(composition): add scoped default children
1 parent 0b8fe7b commit 53dcd03

13 files changed

Lines changed: 340 additions & 19 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,21 @@
11
# Changelog
22

3+
## 0.11.24 - 2026-06-21
4+
5+
- Added scoped default children fragments for `this.render(Component, props,
6+
children)`, including lazy children factories, single-consumption checks, and
7+
duplicate source validation.
8+
- Added component tests proving static children, lazy nested component children,
9+
escaped string children, duplicate children errors, and cleanup for handlers
10+
and signals created while rendering children.
11+
- Updated framework declarations and type fixtures so components can type
12+
optional `Children` props while callers use the third render argument instead
13+
of authoring `props.children`.
14+
- Documented the released default-children contract in the component README and
15+
component-system spec while keeping slots as the post-mount replacement
16+
primitive.
17+
- Bundle size from bundled TypeScript source: `browser.ts` raw 230,250 B (230.3 KB / 0.230 MB), gzip 43,813 B (43.8 KB / 0.044 MB), br 36,215 B (36.2 KB / 0.036 MB) -> `browser.min.js` raw 98,493 B (98.5 KB / 0.098 MB), gzip 29,286 B (29.3 KB / 0.029 MB), br 25,793 B (25.8 KB / 0.026 MB); delta raw -131,757 B (-131.8 KB / -0.132 MB), gzip -14,527 B (-14.5 KB / -0.015 MB), br -10,422 B (-10.4 KB / -0.010 MB).
18+
319
## 0.11.23 - 2026-06-21
420

521
- Added first-class JSX type profiles for runtime/no-build and

‎README.md‎

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1147,7 +1147,7 @@ Component helpers:
11471147
| `this.effect(fn)` | Scoped effect with cleanup |
11481148
| `this.handler(name, fn)` | Scoped named handler registry entry |
11491149
| `this.handler(fn)` | Generated scoped handler registry entry |
1150-
| `this.render(Component, props)` | Child fragment rendering |
1150+
| `this.render(Component, props, children?)` | Child fragment rendering with optional default children |
11511151
| `this.slot(Component, propsOrFn)` | Child component outlet using an `on:attach` target |
11521152
| `this.suspense(signalRef, views)` | Async boundary template helper |
11531153
| `this.on(event, fn)` | Fragment lifecycle fallback for `attach`, `visible`, and `destroy` |
@@ -1196,6 +1196,45 @@ this.suspense(product, (product) => html`
11961196
`this.suspense(...)` is not React Suspense. It does not throw promises,
11971197
hydrate, diff, rerender a component tree, or emit a wrapper element.
11981198

1199+
Default children are a scoped fragment owned by the framework. Pass them as the
1200+
third `this.render(...)` argument, then interpolate `children` in the child
1201+
component:
1202+
1203+
```js
1204+
const Card = component(function Card({ title, children }) {
1205+
return html`
1206+
<article>
1207+
<h2>${title}</h2>
1208+
${children}
1209+
</article>
1210+
`;
1211+
});
1212+
1213+
const Page = component(function Page() {
1214+
return html`
1215+
${this.render(Card, { title: "Status" }, html`
1216+
<p>Ready</p>
1217+
`)}
1218+
`;
1219+
});
1220+
```
1221+
1222+
Children can also be lazy when the caller supplies a factory. The factory runs
1223+
only if the child component interpolates `children`, and any nested components
1224+
or handlers created while rendering the fragment are cleaned up with the
1225+
consuming component fragment:
1226+
1227+
```js
1228+
this.render(Card, { title: "Status" }, function children() {
1229+
return html`<p>${this.render(Badge, { label: "Live" })}</p>`;
1230+
});
1231+
```
1232+
1233+
Do not pass `children` in the props object when also using the third argument.
1234+
Default children are consumed once by interpolation; use `this.slot(...)` for
1235+
post-mount replacement and use ordinary props when the child needs data from the
1236+
caller.
1237+
11991238
Component-scoped signals and handlers are unregistered when the mounted
12001239
fragment is destroyed. `loader.swap(...)` cleans up old DOM bindings and mounted
12011240
component fragments under the swapped boundary before inserting the new HTML.

‎examples/size/server-call-button/scenario.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,6 @@
77
],
88
"budget": {
99
"mode": "baseline",
10-
"maxGzipBytes": 29500
10+
"maxGzipBytes": 29600
1111
}
1212
}

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@async/framework",
3-
"version": "0.11.23",
3+
"version": "0.11.24",
44
"description": "No-build Loader app runtime with browser and server entrypoints, signals, command events, route partials, cache split, SSR activation, and streaming boundaries.",
55
"type": "module",
66
"private": true,

‎scripts/build-framework-bundle.js‎

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,8 @@ const browserDtsOutput = [
261261
"}",
262262
"",
263263
"export type TemplatePrimitive = string | number | boolean | null | undefined;",
264-
"export type TemplateLike = TemplateResult | TemplatePrimitive | Node | TemplateLike[];",
264+
"export type TemplateLike = TemplateResult | Children | TemplatePrimitive | Node | TemplateLike[];",
265+
"export type ChildrenInput = TemplateLike | ((this: ComponentContext) => TemplateLike);",
265266
"export interface LazyDescriptor {",
266267
" url: string;",
267268
" [key: string]: unknown;",
@@ -282,6 +283,10 @@ const browserDtsOutput = [
282283
" readonly values: readonly unknown[];",
283284
"}",
284285
"",
286+
"export interface Children {",
287+
" readonly __asyncChildrenBrand: true;",
288+
"}",
289+
"",
285290
"export type SchedulerStrategy = \"microtask\" | \"manual\";",
286291
"export type SchedulerPhase = \"binding\" | \"lifecycle\" | \"effect\" | \"async\" | \"post\" | string;",
287292
"export interface SchedulerJob {",
@@ -682,7 +687,7 @@ const browserDtsOutput = [
682687
" effect(fn: (this: ComponentContext) => unknown): Cleanup;",
683688
" handler(fn: HandlerFunction): string;",
684689
" handler(name: string, fn: HandlerFunction): string;",
685-
" render<TProps extends Record<string, unknown> = Record<string, unknown>>(Child: ComponentFunction<TProps>, props?: TProps): TemplateLike;",
690+
" render<TProps extends object = Record<string, unknown>>(Child: ComponentFunction<TProps>, props?: Omit<TProps, \"children\">, children?: ChildrenInput): TemplateLike;",
686691
" suspense(signalRef: Pick<SignalRef, \"id\">, views: SuspenseViews | SuspenseReadyView): TemplateLike;",
687692
" on(eventName: \"intersect\", fn: IntersectionCallback): void;",
688693
" on(eventName: \"intersect\", options: IntersectionOptions | undefined | null, fn: IntersectionCallback): void;",
@@ -693,7 +698,7 @@ const browserDtsOutput = [
693698
" intersect(target: Element, options: IntersectionOptions | undefined | null, fn: IntersectionCallback): Cleanup;",
694699
"}",
695700
"",
696-
"export type ComponentFunction<TProps extends Record<string, unknown> = Record<string, unknown>> = (this: ComponentContext, props: TProps) => TemplateLike;",
701+
"export type ComponentFunction<TProps extends object = Record<string, unknown>> = (this: ComponentContext, props: TProps) => TemplateLike;",
697702
"export type SuspenseReadyView = (this: ComponentContext, signalRef: Pick<SignalRef, \"id\">) => TemplateLike;",
698703
"export interface SuspenseViews {",
699704
" loading?: SuspenseReadyView;",
@@ -731,7 +736,7 @@ const browserDtsOutput = [
731736
" start(): this;",
732737
" scan(rootOrFragment?: Document | Element | DocumentFragment): this;",
733738
" swap(boundaryId: string, fragmentOrTemplate: TemplateLike): Element;",
734-
" mount<TProps extends Record<string, unknown> = Record<string, unknown>>(target: Element, Component: ComponentFunction<TProps>, props?: TProps): unknown;",
739+
" mount<TProps extends object = Record<string, unknown>>(target: Element, Component: ComponentFunction<TProps>, props?: TProps): unknown;",
735740
" destroy(): void;",
736741
"}",
737742
"",
@@ -748,7 +753,7 @@ const browserDtsOutput = [
748753
" ready(): Promise<LoaderInstance>;",
749754
" scan(rootOrFragment?: Document | Element | DocumentFragment): Promise<LoaderInstance>;",
750755
" swap(boundaryId: string, fragmentOrTemplate: TemplateLike): Promise<Element>;",
751-
" mount<TProps extends Record<string, unknown> = Record<string, unknown>>(target: Element, Component: ComponentFunction<TProps>, props?: TProps): Promise<unknown>;",
756+
" mount<TProps extends object = Record<string, unknown>>(target: Element, Component: ComponentFunction<TProps>, props?: TProps): Promise<unknown>;",
752757
" inspect(): AsyncLoaderFacadeInspection;",
753758
"}",
754759
"",
@@ -1033,16 +1038,18 @@ const browserDtsOutput = [
10331038
"export declare function createBoundaryReceiver(options: BoundaryReceiverOptions): BoundaryReceiver;",
10341039
"export declare function createCacheRegistry(initialMap?: Record<string, CacheDefinition | CacheDefinitionOptions>, options?: { now?: () => number; registry?: RegistryStore; type?: \"cache.browser\" | \"cache.server\" }): CacheRegistry;",
10351040
"export declare function defineCache(options?: CacheDefinitionOptions): CacheDefinition;",
1036-
"export declare function component<TProps extends Record<string, unknown> = Record<string, unknown>>(fn: ComponentFunction<TProps>): ComponentFunction<TProps>;",
1041+
"export declare function component<TProps extends object = Record<string, unknown>>(fn: ComponentFunction<TProps>): ComponentFunction<TProps>;",
1042+
"export declare function childrenFragment(source: ChildrenInput): Children;",
10371043
"export declare function createComponentRegistry(initialMap?: Record<string, ComponentFunction>, options?: { registry?: RegistryStore; type?: \"component\" }): ComponentRegistry;",
1038-
"export declare function defineComponent<TProps extends Record<string, unknown> = Record<string, unknown>>(fn: ComponentFunction<TProps>): ComponentFunction<TProps>;",
1044+
"export declare function defineComponent<TProps extends object = Record<string, unknown>>(fn: ComponentFunction<TProps>): ComponentFunction<TProps>;",
10391045
"export declare function defineAsyncContainerElement(options?: { tagName?: string; app?: AppHub; Async?: AppHub; customElements?: CustomElementRegistry; HTMLElement?: typeof HTMLElement; window?: Window }): CustomElementConstructor;",
10401046
"export declare function defineAsyncSuspenseElement(options?: { tagName?: string; customElements?: CustomElementRegistry; HTMLElement?: typeof HTMLElement; window?: Window }): CustomElementConstructor;",
10411047
"export declare function defineRegistrySnapshot<T extends RegistryRuntimeSnapshot>(snapshot?: T): T;",
10421048
"export declare function createLazyRegistry(options?: { registryAssets?: RegistryAssetsConfig; assets?: RegistryAssetsConfig; importModule?: (url: string) => MaybePromise<Record<string, unknown>> }): LazyRegistry;",
10431049
"export declare function delay(ms: number, signal?: AbortSignal): Promise<void>;",
10441050
"export declare function createHandlerRegistry(initialMap?: Record<string, HandlerFunction>, options?: { registry?: RegistryStore; type?: \"handler\" }): HandlerRegistry;",
10451051
"export declare function html(strings: TemplateStringsArray, ...values: unknown[]): TemplateResult;",
1052+
"export declare function isChildrenFragment(value: unknown): value is Children;",
10461053
"export declare function Loader(options?: LoaderOptions): LoaderInstance;",
10471054
"export declare const AsyncLoader: typeof Loader;",
10481055
"export declare function createPartialRegistry(initialMap?: Record<string, PartialFunction>, options?: { registry?: RegistryStore; type?: \"partial\" }): PartialRegistry;",
@@ -1250,10 +1257,10 @@ const jsxDtsOutput = [
12501257
"export declare const ASYNC_JSX_SUSPENSE: unique symbol;",
12511258
"export declare const ASYNC_JSX_REVEAL: unique symbol;",
12521259
"export type JsxSignal<T = unknown> = { readonly kind: \"async-jsx-signal\"; readonly type: typeof ASYNC_JSX_SIGNAL; readonly source: T; readonly options: Record<string, unknown> };",
1253-
"export type JsxComponent<TProps extends Record<string, unknown> = Record<string, unknown>> = { readonly kind: \"async-jsx-component\"; readonly type: typeof ASYNC_JSX_COMPONENT; readonly render: (props: TProps) => unknown; readonly options: Record<string, unknown> };",
1260+
"export type JsxComponent<TProps extends object = Record<string, unknown>> = { readonly kind: \"async-jsx-component\"; readonly type: typeof ASYNC_JSX_COMPONENT; readonly render: (props: TProps) => unknown; readonly options: Record<string, unknown> };",
12541261
"export type JsxBoundary = { readonly kind: \"async-jsx-suspense\" | \"async-jsx-reveal\"; readonly type: typeof ASYNC_JSX_SUSPENSE | typeof ASYNC_JSX_REVEAL; readonly props: Record<string, unknown> };",
12551262
"export declare function signal<T = unknown>(source: T, options?: Record<string, unknown>): JsxSignal<T>;",
1256-
"export declare function component<TProps extends Record<string, unknown> = Record<string, unknown>>(render: (props: TProps) => unknown, options?: Record<string, unknown>): JsxComponent<TProps>;",
1263+
"export declare function component<TProps extends object = Record<string, unknown>>(render: (props: TProps) => unknown, options?: Record<string, unknown>): JsxComponent<TProps>;",
12571264
"export declare function Suspense(props?: Record<string, unknown>): JsxBoundary;",
12581265
"export declare function Reveal(props?: Record<string, unknown>): JsxBoundary;",
12591266
""

‎specs/framework/05-component-system.md‎

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ Component helpers include:
3030
- `this.asyncSignal(name, fn)`.
3131
- `this.effect(fn)`.
3232
- `this.handler(name, fn)` and `this.handler(fn)`.
33-
- `this.render(Component, props)`.
33+
- `this.render(Component, props, children?)`.
3434
- `this.slot(Component, propsOrFn)`.
3535
- `this.suspense(signalRef, views)`.
3636
- `this.on(event, fn)` with `attach`, `visible`, `intersect`, and `destroy`
@@ -41,6 +41,17 @@ Component helpers include:
4141
Components return HTML-compatible values. Promise-returning components are not
4242
part of the synchronous component contract.
4343

44+
Default children are framework-owned scoped fragments. The canonical no-build
45+
form is `this.render(Component, props, children)`, and the child component
46+
receives the normalized fragment as `props.children`. The child consumes the
47+
fragment by interpolating `children` in an `html` template; it does not call a
48+
children callback directly.
49+
50+
Children fragments are lazy when supplied as factories, caller-lexical through
51+
closed-over values, single-consumption by default, escaped as text for strings,
52+
and cleaned up with the consuming component fragment. Supplying both
53+
`props.children` and the third `this.render(...)` argument is invalid.
54+
4455
## Subsystem Boundaries
4556

4657
- Components create scoped declarations; registries own their storage and
@@ -59,6 +70,9 @@ Components emit the same protocol as hand-authored HTML:
5970
- Local signals become scoped signal IDs referenced by `signal:*` or `class:*`.
6071
- Suspense helpers emit boundary templates and do not own wrapper elements.
6172
- Child rendering returns fragment output that remains scannable by the loader.
73+
- Default children interpolate through the same template renderer so escaping,
74+
nested component rendering, handler registration, signal bindings, and cleanup
75+
stay in one scoped path.
6276
- Slots mount a child component into an attached DOM target and may recompute
6377
props from signals without exposing loader mounting to application code.
6478

@@ -77,6 +91,10 @@ Components must not be required to rerun to activate server-rendered DOM:
7791

7892
- Components are scoped fragments, not virtual nodes.
7993
- Component output does not cause a component rerender loop.
94+
- Default children do not create a parent rerender path or runtime JSX node
95+
array.
96+
- Default children and slots are separate primitives: children are mount-time
97+
projection; slots are explicit attached child replacement.
8098
- Slot updates are explicit child component replacement, not parent rerendering.
8199
- Component-local state and handlers are unregistered on fragment cleanup.
82100
- `on:visible` is a one-shot visibility lifecycle hook.
@@ -90,6 +108,10 @@ Components must not be required to rerun to activate server-rendered DOM:
90108
- Lazy component descriptors that resolve asynchronously are not valid for
91109
synchronous render paths unless a future async component contract defines it.
92110
- Invalid suspense inputs fail before emitting ambiguous boundary markup.
111+
- Supplying children through both `props.children` and the third render
112+
argument fails before the child component renders.
113+
- Consuming the same children fragment twice fails instead of duplicating
114+
handler IDs or cleanup records.
93115
- Observer-less environments report unsupported intersection behavior through
94116
the defined fallback path.
95117

@@ -105,6 +127,9 @@ Components must not be required to rerun to activate server-rendered DOM:
105127
signal without creating a wrapper element.
106128
- Multiple child components with identical hook bodies do not dedupe each
107129
other incorrectly.
130+
- Static and lazy default children render through `this.render(...)`, preserve
131+
escaping, support nested component output, and clean up scoped child resources
132+
when the consuming fragment is destroyed.
108133

109134
## Open Or Deferred Decisions
110135

‎src/browser.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export { component, createComponentRegistry, defineComponent } from "./component
77
export { delay } from "./delay.js";
88
export { defineAsyncContainerElement, defineAsyncSuspenseElement } from "./elements.js";
99
export { createHandlerRegistry } from "./handlers.js";
10-
export { html } from "./html.js";
10+
export { childrenFragment, html, isChildrenFragment } from "./html.js";
1111
export { createLazyRegistry, defineRegistrySnapshot } from "./lazy-registry.js";
1212
export { Loader, AsyncLoader } from "./loader.js";
1313
export { createPartialRegistry } from "./partials.js";

‎src/component.js‎

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { attributeName } from "./attributes.js";
2-
import { escapeHtml, rawHtml, renderTemplate } from "./html.js";
2+
import { childrenFragment, escapeHtml, rawHtml, renderTemplate } from "./html.js";
33
import { attachRegistryInspection, createRegistryStore } from "./registry-store.js";
44
import { createLazyRegistry, isLazyDescriptor } from "./lazy-registry.js";
55

@@ -136,6 +136,7 @@ export function renderComponent(Component, props = {}, runtime, parentScope = "c
136136
destroyHooks,
137137
renderScopedTemplate
138138
});
139+
templateOptions.fragmentContext = context;
139140

140141
const output = Component.call(context, props);
141142
if (output && typeof output.then === "function") {
@@ -307,8 +308,8 @@ function createComponentContext({ runtime, scope, cleanups, attachHooks, visible
307308
return registerScopedHandler(name, fn);
308309
},
309310

310-
render(Child, childProps = {}) {
311-
const child = renderComponent(Child, childProps, runtime, scope);
311+
render(Child, childProps = {}, childrenInput) {
312+
const child = renderComponent(Child, normalizeRenderProps(childProps, childrenInput), runtime, scope);
312313
cleanups.push(child.cleanup);
313314
attachHooks.push((target) => child.attach(target));
314315
visibleHooks.push((target) => child.visible(target, loader._observeVisible));
@@ -432,6 +433,25 @@ function createComponentContext({ runtime, scope, cleanups, attachHooks, visible
432433
}
433434
}
434435

436+
function normalizeRenderProps(props, childrenInput) {
437+
const normalizedProps = props == null ? {} : props;
438+
if (typeof normalizedProps !== "object" || Array.isArray(normalizedProps)) {
439+
throw new TypeError("this.render(Component, props, children) props must be an object.");
440+
}
441+
const hasPropsChildren = Object.hasOwn(normalizedProps, "children") && normalizedProps.children !== undefined;
442+
const hasThirdArgumentChildren = childrenInput !== undefined;
443+
if (hasPropsChildren && hasThirdArgumentChildren) {
444+
throw new TypeError("this.render(Component, props, children) cannot receive both props.children and a children argument.");
445+
}
446+
if (!hasPropsChildren && !hasThirdArgumentChildren) {
447+
return normalizedProps;
448+
}
449+
return {
450+
...normalizedProps,
451+
children: childrenFragment(hasThirdArgumentChildren ? childrenInput : normalizedProps.children)
452+
};
453+
}
454+
435455
function normalizeOptionsCallback(label, optionsOrFn, maybeFn) {
436456
if (typeof optionsOrFn === "function" && maybeFn === undefined) {
437457
return { options: {}, fn: optionsOrFn };

0 commit comments

Comments
 (0)