From 9ca6039d31262066b15f718465317224fa31cea9 Mon Sep 17 00:00:00 2001 From: Andres David Jimenez Sulbaran Date: Mon, 27 Apr 2026 17:45:59 -0600 Subject: [PATCH 01/13] feat(loom): introduce html-first renderables and typed view composition --- packages/loom/README.md | 72 +++- packages/loom/core/src/ast.js | 4 + packages/loom/core/src/ast.ts | 28 ++ packages/loom/core/src/internal/ast.js | 9 + packages/loom/core/src/internal/ast.ts | 17 + .../loom/runtime/src/internal/live-region.js | 8 + .../loom/runtime/src/internal/live-region.ts | 8 + packages/loom/runtime/src/internal/runtime.js | 16 + packages/loom/runtime/src/internal/runtime.ts | 16 + packages/loom/web/src/component.js | 9 +- packages/loom/web/src/component.ts | 194 +++++----- packages/loom/web/src/index.js | 2 + packages/loom/web/src/index.ts | 4 + .../loom/web/src/internal/mounted-view.js | 38 ++ .../loom/web/src/internal/mounted-view.ts | 48 +++ packages/loom/web/src/internal/view-child.ts | 2 +- packages/loom/web/src/template.js | 270 ++++++++++++++ packages/loom/web/src/template.ts | 332 ++++++++++++++++++ packages/loom/web/src/view.js | 61 ++++ packages/loom/web/src/view.ts | 328 +++++++++++++++-- .../web/tests/template-first-view-api.test.ts | 241 +++++++++++++ .../tests/template-first-view-api.types.ts | 172 +++++++++ 22 files changed, 1738 insertions(+), 141 deletions(-) create mode 100644 packages/loom/web/src/template.js create mode 100644 packages/loom/web/src/template.ts create mode 100644 packages/loom/web/tests/template-first-view-api.test.ts create mode 100644 packages/loom/web/tests/template-first-view-api.types.ts diff --git a/packages/loom/README.md b/packages/loom/README.md index cb8a9cf2..d269f659 100644 --- a/packages/loom/README.md +++ b/packages/loom/README.md @@ -25,7 +25,7 @@ This is the primary documented/public contract for new Loom authoring. ```ts import { Atom } from "effect/unstable/reactivity" -import { Component, mount, View, Web } from "@effectify/loom" +import { Component, html, mount, Slot, View, Web } from "@effectify/loom" export const CounterRoute = Component.make("CounterRoute").pipe( Component.state({ @@ -48,6 +48,76 @@ export const CounterRoute = Component.make("CounterRoute").pipe( mount({ CounterRoute }) ``` +### Template-first authoring path + +For new DOM-heavy authoring, prefer `html` inside `Component.view(...)` and keep `View` focused on composition. + +```ts +import * as Result from "effect/Result" +import { Atom } from "effect/unstable/reactivity" +import { Component, html, Slot, View } from "@effectify/loom" + +const Card = Component.make("Card").pipe( + Component.view(({ children }) => html`
${children}
`), +) + +const Layout = Component.make("Layout").pipe( + Component.slots({ + default: Slot.required(), + header: Slot.optional(), + }), + Component.view(({ slots }) => + html` +
+ ${slots.header} +
${slots.default}
+
+ ` + ), +) + +export const TemplateCounter = Component.make("TemplateCounter").pipe( + Component.model({ count: Atom.make(0) }), + Component.actions({ + increment: ({ count }) => count.update((value) => value + 1), + }), + Component.view(({ state, actions }) => + html` +
+ +

${() => state.count()}

+ ${View.use(Card, html`Count card`)} + ${ + View.use(Layout, { + header: html`

Status

`, + default: View.match(Result.succeed("ready"), { + onSuccess: (value) => html`

${value}

`, + onFailure: (error) => html`

${String(error)}

`, + }), + }) + } +
+ ` + ), +) +``` + +- Prefer `html` for ordinary DOM structure. +- Use `View.use(...)` for component composition. Child shorthand is only for components without required props; slot components receive a slot object. +- Use `View.match(...)` for `Result`, `AsyncResult`, and `_tag`-based branching instead of inline switches in templates. +- Keep list rendering explicit with `View.for(...)`; direct array interpolation stays unsupported. + +### Phase-1 reactivity rule + +- `${() => state.count()}` is reactive because Loom can track the thunk boundary. +- `${state.count()}` is an eager snapshot and does **not** update after the template is created. +- Supported phase-1 directives are limited to `web:click`, `web:value` / `web:inputValue`, and `web:hydrate`. + +### Follow-ups intentionally out of scope + +- Accessor-style reactive sugar beyond the explicit lambda form. +- `web:class` and `web:style` template directives. + - Teach `Component.state(...)` as the ONLY public state seam. - Treat `Component.model(...)` as compatibility-only. - `children` is always available in `Component.view(...)`; ordinary composition should be implicit. diff --git a/packages/loom/core/src/ast.js b/packages/loom/core/src/ast.js index 40ffa135..6bd02e81 100644 --- a/packages/loom/core/src/ast.js +++ b/packages/loom/core/src/ast.js @@ -3,6 +3,8 @@ import * as internal from "./internal/ast.js" export const text = (value) => internal.makeTextNode(value) /** Create a neutral dynamic text node. */ export const dynamicText = (render) => internal.makeDynamicTextNode(render) +/** Create a neutral computed subtree node. */ +export const computed = (render) => internal.makeComputedNode(render) /** Create a neutral element node. */ export const element = (tagName, options) => internal.makeElementNode(tagName, { @@ -24,6 +26,8 @@ export function forEach(each, renderOrOptions, fallback) { } /** Create a neutral component usage node. */ export const componentUse = (component) => internal.makeComponentUseNode(component) +/** Create a neutral scoped boundary node for local composition handling. */ +export const boundary = (node, scope) => internal.makeBoundaryNode(node, scope) /** Create a neutral live node placeholder. */ export const live = (atom, render) => internal.makeLiveNode(atom, render) /** Create hydration metadata for later renderer/runtime use. */ diff --git a/packages/loom/core/src/ast.ts b/packages/loom/core/src/ast.ts index 47fc7c7b..69728b8f 100644 --- a/packages/loom/core/src/ast.ts +++ b/packages/loom/core/src/ast.ts @@ -6,11 +6,13 @@ import type * as Component from "./component.js" export type Node = | TextNode | DynamicTextNode + | ComputedNode | ElementNode | FragmentNode | IfNode | ForNode | ComponentUseNode + | BoundaryNode | LiveNode export interface TextNode { @@ -23,6 +25,11 @@ export interface DynamicTextNode { readonly render: () => string } +export interface ComputedNode { + readonly _tag: "Computed" + readonly render: () => Node +} + export interface HydrationMetadata { readonly strategy: string readonly attributes: Readonly> @@ -112,6 +119,15 @@ export interface ComponentUseNode { readonly component: Component.Definition } +export interface BoundaryNode { + readonly _tag: "Boundary" + readonly node: Node + readonly scope: { + readonly errors?: ReadonlyArray | "all" + readonly requirementsHandled?: boolean + } +} + export interface LiveNode { readonly _tag: "Live" readonly atom: Atom.Atom @@ -131,6 +147,9 @@ export const text = (value: string): TextNode => internal.makeTextNode(value) /** Create a neutral dynamic text node. */ export const dynamicText = (render: () => string): DynamicTextNode => internal.makeDynamicTextNode(render) +/** Create a neutral computed subtree node. */ +export const computed = (render: () => Node): ComputedNode => internal.makeComputedNode(render) + /** Create a neutral element node. */ export const element = ( tagName: string, @@ -183,6 +202,15 @@ export function forEach( export const componentUse = (component: Component.Definition): ComponentUseNode => internal.makeComponentUseNode(component) +/** Create a neutral scoped boundary node for local composition handling. */ +export const boundary = ( + node: Node, + scope: { + readonly errors?: ReadonlyArray | "all" + readonly requirementsHandled?: boolean + }, +): BoundaryNode => internal.makeBoundaryNode(node, scope) + /** Create a neutral live node placeholder. */ export const live = (atom: Atom.Atom, render: LiveRender): LiveNode => internal.makeLiveNode(atom, render) diff --git a/packages/loom/core/src/internal/ast.js b/packages/loom/core/src/internal/ast.js index 32a6b47f..aaebbb7d 100644 --- a/packages/loom/core/src/internal/ast.js +++ b/packages/loom/core/src/internal/ast.js @@ -6,6 +6,10 @@ export const makeDynamicTextNode = (render) => ({ _tag: "DynamicText", render, }) +export const makeComputedNode = (render) => ({ + _tag: "Computed", + render, +}) export const makeElementNode = (tagName, options) => ({ _tag: "Element", tagName, @@ -36,6 +40,11 @@ export const makeComponentUseNode = (component) => ({ _tag: "ComponentUse", component, }) +export const makeBoundaryNode = (node, scope) => ({ + _tag: "Boundary", + node, + scope, +}) export const makeLiveNode = (atom, render) => ({ _tag: "Live", atom, diff --git a/packages/loom/core/src/internal/ast.ts b/packages/loom/core/src/internal/ast.ts index b8092c58..c98ddc8e 100644 --- a/packages/loom/core/src/internal/ast.ts +++ b/packages/loom/core/src/internal/ast.ts @@ -12,6 +12,11 @@ export const makeDynamicTextNode = (render: () => string): Ast.DynamicTextNode = render, }) +export const makeComputedNode = (render: () => Ast.Node): Ast.ComputedNode => ({ + _tag: "Computed", + render, +}) + export const makeElementNode = ( tagName: string, options: { @@ -65,6 +70,18 @@ export const makeComponentUseNode = (component: Component.Definition): Ast.Compo component, }) +export const makeBoundaryNode = ( + node: Ast.Node, + scope: { + readonly errors?: ReadonlyArray | "all" + readonly requirementsHandled?: boolean + }, +): Ast.BoundaryNode => ({ + _tag: "Boundary", + node, + scope, +}) + export const makeLiveNode = ( atom: Atom.Atom, render: Ast.LiveRender, diff --git a/packages/loom/runtime/src/internal/live-region.js b/packages/loom/runtime/src/internal/live-region.js index b7565b8d..4646f42a 100644 --- a/packages/loom/runtime/src/internal/live-region.js +++ b/packages/loom/runtime/src/internal/live-region.js @@ -46,6 +46,8 @@ export const collectLiveNodes = (node) => { return [] case "DynamicText": return [] + case "Computed": + return collectLiveNodes(node.render()) case "If": return collectLiveNodes(node.condition() ? node.then : (node.else ?? { _tag: "Fragment", children: [] })) case "For": { @@ -59,6 +61,8 @@ export const collectLiveNodes = (node) => { return [node] case "ComponentUse": return collectLiveNodes(node.component.node) + case "Boundary": + return collectLiveNodes(node.node) case "Fragment": return node.children.flatMap(collectLiveNodes) case "Element": @@ -77,6 +81,8 @@ export const serializeStaticNode = (node) => { _tag: "Supported", html: escapeText(String(node.render())), } + case "Computed": + return serializeStaticNode(node.render()) case "If": return serializeStaticNode(node.condition() ? node.then : (node.else ?? { _tag: "Fragment", children: [] })) case "For": { @@ -99,6 +105,8 @@ export const serializeStaticNode = (node) => { } case "ComponentUse": return serializeStaticNode(node.component.node) + case "Boundary": + return serializeStaticNode(node.node) case "Live": return { _tag: "Unsupported", diff --git a/packages/loom/runtime/src/internal/live-region.ts b/packages/loom/runtime/src/internal/live-region.ts index a1ac163b..36672e84 100644 --- a/packages/loom/runtime/src/internal/live-region.ts +++ b/packages/loom/runtime/src/internal/live-region.ts @@ -76,6 +76,8 @@ export const collectLiveNodes = (node: LoomCore.Ast.Node): ReadonlyArray { return [] case "DynamicText": return [] + case "Computed": + return collectHydrationAttributes(node.render()) case "If": return collectHydrationAttributes(node.condition() ? node.then : (node.else ?? LoomCore.Ast.fragment([]))) case "For": { @@ -30,6 +32,8 @@ const collectHydrationAttributes = (node) => { } case "ComponentUse": return collectHydrationAttributes(node.component.node) + case "Boundary": + return collectHydrationAttributes(node.node) case "Live": return [] case "Fragment": @@ -50,6 +54,8 @@ const collectRegisteredEvents = (node, boundaryId) => { return [] case "DynamicText": return [] + case "Computed": + return loop(current.render(), isBoundaryRoot) case "If": return loop(current.condition() ? current.then : (current.else ?? LoomCore.Ast.fragment([])), isBoundaryRoot) case "For": { @@ -63,6 +69,8 @@ const collectRegisteredEvents = (node, boundaryId) => { return [] case "ComponentUse": return loop(current.component.node, isBoundaryRoot) + case "Boundary": + return loop(current.node, isBoundaryRoot) case "Fragment": return current.children.flatMap((child) => loop(child, false)) case "Element": { @@ -167,6 +175,8 @@ const serializeNode = (node, state, boundaryId, nextBoundaryNodeId, isBoundaryRo return escapeText(current.value) case "DynamicText": return escapeText(String(current.render())) + case "Computed": + return serializeStaticNode(current.render()) case "If": return serializeStaticNode(current.condition() ? current.then : (current.else ?? LoomCore.Ast.fragment([]))) case "For": { @@ -180,6 +190,8 @@ const serializeNode = (node, state, boundaryId, nextBoundaryNodeId, isBoundaryRo return current.children.map(serializeStaticNode).join("") case "ComponentUse": return serializeStaticNode(current.component.node) + case "Boundary": + return serializeStaticNode(current.node) case "Live": return serializeLiveNode(current, boundaryId) case "Element": { @@ -233,6 +245,8 @@ const serializeNode = (node, state, boundaryId, nextBoundaryNodeId, isBoundaryRo return escapeText(node.value) case "DynamicText": return escapeText(String(node.render())) + case "Computed": + return serializeNode(node.render(), state, boundaryId, nextBoundaryNodeId, isBoundaryRoot) case "If": return serializeNode( node.condition() ? node.then : (node.else ?? LoomCore.Ast.fragment([])), @@ -251,6 +265,8 @@ const serializeNode = (node, state, boundaryId, nextBoundaryNodeId, isBoundaryRo return node.children.map((child) => serializeNode(child, state, boundaryId, nextBoundaryNodeId)).join("") case "ComponentUse": return serializeNode(node.component.node, state, boundaryId, nextBoundaryNodeId, isBoundaryRoot) + case "Boundary": + return serializeNode(node.node, state, boundaryId, nextBoundaryNodeId, isBoundaryRoot) case "Live": return serializeLiveNode(node, boundaryId) case "Element": { diff --git a/packages/loom/runtime/src/internal/runtime.ts b/packages/loom/runtime/src/internal/runtime.ts index db77df88..b1238fc3 100644 --- a/packages/loom/runtime/src/internal/runtime.ts +++ b/packages/loom/runtime/src/internal/runtime.ts @@ -25,6 +25,8 @@ const collectHydrationAttributes = ( return [] case "DynamicText": return [] + case "Computed": + return collectHydrationAttributes(node.render()) case "If": return collectHydrationAttributes(node.condition() ? node.then : (node.else ?? LoomCore.Ast.fragment([]))) case "For": { @@ -37,6 +39,8 @@ const collectHydrationAttributes = ( } case "ComponentUse": return collectHydrationAttributes(node.component.node) + case "Boundary": + return collectHydrationAttributes(node.node) case "Live": return [] case "Fragment": @@ -62,6 +66,8 @@ const collectRegisteredEvents = ( return [] case "DynamicText": return [] + case "Computed": + return loop(current.render(), isBoundaryRoot) case "If": return loop(current.condition() ? current.then : (current.else ?? LoomCore.Ast.fragment([])), isBoundaryRoot) case "For": { @@ -76,6 +82,8 @@ const collectRegisteredEvents = ( return [] case "ComponentUse": return loop(current.component.node, isBoundaryRoot) + case "Boundary": + return loop(current.node, isBoundaryRoot) case "Fragment": return current.children.flatMap((child) => loop(child, false)) case "Element": { @@ -242,6 +250,8 @@ const serializeNode = ( return escapeText(current.value) case "DynamicText": return escapeText(String(current.render())) + case "Computed": + return serializeStaticNode(current.render()) case "If": return serializeStaticNode(current.condition() ? current.then : (current.else ?? LoomCore.Ast.fragment([]))) case "For": { @@ -256,6 +266,8 @@ const serializeNode = ( return current.children.map(serializeStaticNode).join("") case "ComponentUse": return serializeStaticNode(current.component.node) + case "Boundary": + return serializeStaticNode(current.node) case "Live": return serializeLiveNode(current, boundaryId) case "Element": { @@ -320,6 +332,8 @@ const serializeNode = ( return escapeText(node.value) case "DynamicText": return escapeText(String(node.render())) + case "Computed": + return serializeNode(node.render(), state, boundaryId, nextBoundaryNodeId, isBoundaryRoot) case "If": return serializeNode( node.condition() ? node.then : (node.else ?? LoomCore.Ast.fragment([])), @@ -339,6 +353,8 @@ const serializeNode = ( return node.children.map((child) => serializeNode(child, state, boundaryId, nextBoundaryNodeId)).join("") case "ComponentUse": return serializeNode(node.component.node, state, boundaryId, nextBoundaryNodeId, isBoundaryRoot) + case "Boundary": + return serializeNode(node.node, state, boundaryId, nextBoundaryNodeId, isBoundaryRoot) case "Live": return serializeLiveNode(node, boundaryId) case "Element": { diff --git a/packages/loom/web/src/component.js b/packages/loom/web/src/component.js index 2f665be8..89402300 100644 --- a/packages/loom/web/src/component.js +++ b/packages/loom/web/src/component.js @@ -1,5 +1,6 @@ import { Atom, AtomRegistry } from "effect/unstable/reactivity" import * as LoomCore from "@effectify/loom-core" +import * as Template from "./template.js" import * as pipeable from "./internal/pipeable.js" import { trackStateAtomRead } from "./internal/tracked-state.js" import * as viewChild from "./internal/view-child.js" @@ -114,7 +115,7 @@ const createWriteModel = (model, registry) => { } const resolveSlots = (slots, values) => { const entries = Object.entries(slots ?? {}).map(([key, definition]) => { - const slotValue = values?.[key] + const slotValue = normalizeComposedViewChild(values?.[key]) if (definition.required && values !== undefined && slotValue === undefined) { throw new Error(`missing required slot '${key}'`) } @@ -122,6 +123,10 @@ const resolveSlots = (slots, values) => { }) return Object.fromEntries(entries) } +const normalizeComposedViewChild = (child) => + Array.isArray(child) + ? Template.renderable(LoomCore.Ast.fragment(viewChild.normalizeViewChild(child))) + : child const isActionSpec = (value) => { if (typeof value !== "object" || value === null) { return false @@ -163,7 +168,7 @@ export const instantiate = ( : isActionSpec(actionDefinition) ? bindActionSpec(actionDefinition, actionBindingContext) : (actionDefinition ?? emptyActions) - const children = compositionInput?.children + const children = normalizeComposedViewChild(compositionInput?.children) const slots = resolveSlots(component.slots, compositionInput?.slots) return { registry, diff --git a/packages/loom/web/src/component.ts b/packages/loom/web/src/component.ts index 0db3dd64..3f8196da 100644 --- a/packages/loom/web/src/component.ts +++ b/packages/loom/web/src/component.ts @@ -7,6 +7,7 @@ import * as pipeable from "./internal/pipeable.js" import { trackStateAtomRead } from "./internal/tracked-state.js" import * as viewChild from "./internal/view-child.js" import type * as Slot from "./slot.js" +import * as Template from "./template.js" import type * as View from "./view.js" export type ModelValueInput = unknown | Atom.Atom | (() => unknown) @@ -151,7 +152,7 @@ export interface Component< readonly actions?: ActionsInput readonly children?: true readonly slots?: Slots - readonly render?: (context: ViewContext) => View.Node + readonly render?: (context: ViewContext) => Template.Renderable readonly __props?: Props readonly __error?: Err readonly __requirements?: Requirements @@ -349,7 +350,7 @@ const resolveSlots = ( values?: RuntimeSlotInput, ): SlotAssignments => { const entries = Object.entries(slots ?? {}).map(([key, definition]) => { - const slotValue = values?.[key] + const slotValue = normalizeComposedViewChild(values?.[key]) if (definition.required && values !== undefined && slotValue === undefined) { throw new Error(`missing required slot '${key}'`) @@ -361,6 +362,11 @@ const resolveSlots = ( return Object.fromEntries(entries) as SlotAssignments } +const normalizeComposedViewChild = (child: View.ViewChild | undefined): View.ViewChild | undefined => + Array.isArray(child) + ? Template.renderable(LoomCore.Ast.fragment(viewChild.normalizeViewChild(child))) + : child + const isActionSpec = (value: unknown): value is ActionSpec => { if (typeof value !== "object" || value === null) { return false @@ -441,7 +447,7 @@ export const instantiate = < : isActionSpec(actionDefinition) ? bindActionSpec(actionDefinition, actionBindingContext) : (actionDefinition ?? emptyActions)) as Actions - const children = compositionInput?.children + const children = normalizeComposedViewChild(compositionInput?.children) const slots = resolveSlots(component.slots, compositionInput?.slots) return { @@ -487,13 +493,15 @@ const renderComponentUse = ( component: RuntimeType, props?: unknown, compositionInput?: InstanceCompositionInput, -): View.Node => - instantiate( - component, - getCurrentRenderRegistry() ?? component.registry ?? AtomRegistry.make(), - props, - compositionInput, - ).render() +): Template.Renderable => + Template.renderable( + instantiate( + component, + getCurrentRenderRegistry() ?? component.registry ?? AtomRegistry.make(), + props, + compositionInput, + ).render(), + ) const reconcile = < Props, @@ -774,27 +782,52 @@ export function actions< } /** Attach a renderer-neutral view renderer to a component definition. */ -export function view( - render: (context: ViewContext) => View.Node, -): ( - self: Type, -) => Type -export function view< - Props, - Err, - Requirements, - Model extends ModelShape, - Actions extends ActionShape, - Slots extends SlotShape, - AcceptsChildren extends ChildrenFlag, ->( - self: Type, - render: (context: ViewContext) => View.Node, -): Type -export function view( - selfOrRender: Type | ((context: ViewContext) => View.Node), - render?: (context: ViewContext) => View.Node, -) { +export const view: { + < + Model extends ModelShape, + Actions extends ActionShape, + Slots extends SlotShape, + Return extends Template.Renderable, + >( + render: (context: ViewContext) => Return, + ): ( + self: Type, + ) => Type< + Props, + Err | Template.ErrorOfRenderable, + Requirements | Template.RequirementsOfRenderable, + Model, + Actions, + Slots, + AcceptsChildren + > + < + Props, + Err, + Requirements, + Model extends ModelShape, + Actions extends ActionShape, + Slots extends SlotShape, + AcceptsChildren extends ChildrenFlag, + Return extends Template.Renderable, + >( + self: Type, + render: (context: ViewContext) => Return, + ): Type< + Props, + Err | Template.ErrorOfRenderable, + Requirements | Template.RequirementsOfRenderable, + Model, + Actions, + Slots, + AcceptsChildren + > +} = (( + selfOrRender: + | Type + | ((context: ViewContext) => Template.Renderable), + render?: (context: ViewContext) => Template.Renderable, +) => { if (render === undefined) { if (isComponent(selfOrRender)) { return selfOrRender @@ -808,7 +841,7 @@ export function view( } return patch(selfOrRender, { render }) -} +}) as never /** Attach slot contracts to a component definition. */ export function slots( @@ -949,78 +982,35 @@ const resolveUseComposition = ( * Attach a capability to a component or render a component use with props/slots. * Supports both legacy capability usage and vNext layout composition. */ -export function use(capability: Capability): (self: Type) => Type -export function use(self: Type, capability: Capability): Type -export function use< - Props, - Err, - Requirements, - Model extends ModelShape, - Actions extends ActionShape, ->( - component: Type, -): View.Node -export function use< - Props, - Err, - Requirements, - Model extends ModelShape, - Actions extends ActionShape, ->( - component: Type, - children: View.ViewChild, -): View.Node -export function use< - Props, - Err, - Requirements, - Model extends ModelShape, - Actions extends ActionShape, ->( - component: Type, - props: Props, - children: View.ViewChild, -): View.Node -export function use< - Props, - Err, - Requirements, - Model extends ModelShape, - Actions extends ActionShape, - Slots extends SlotShape, ->( - component: Type, - slots: SlotInput, -): View.Node -export function use< - Props, - Err, - Requirements, - Model extends ModelShape, - Actions extends ActionShape, - Slots extends SlotShape, ->( - component: Type, - props: Props, - slots: SlotInput, -): View.Node -export function use< - Props, - Err, - Requirements, - Model extends ModelShape, - Actions extends ActionShape, - Slots extends SlotShape, ->( - component: Type, - props?: Props, - slots?: SlotInput, -): View.Node -export function use( +export const use: { + (capability: Capability): (self: Type) => Type + (self: Type, capability: Capability): Type + ( + component: Type, + ): Template.Renderable + ( + component: Type, + children: View.ViewChild, + ): Template.Renderable + ( + component: Type, + props: Props, + children: View.ViewChild, + ): Template.Renderable + ( + component: Type, + slots: SlotInput, + ): Template.Renderable + ( + component: Type, + props: Props, + slots: SlotInput, + ): Template.Renderable +} = (( selfOrCapability: Type | Capability, capabilityOrProps?: Capability | unknown, slotInput?: unknown, -) { +) => { if (capabilityOrProps === undefined) { if (selfOrCapability._tag === "Component") { const resolved = resolveUseComposition(selfOrCapability, undefined, slotInput) @@ -1055,4 +1045,4 @@ export function use( } return make(LoomCore.Ast.text("")) -} +}) as never diff --git a/packages/loom/web/src/index.js b/packages/loom/web/src/index.js index 8ee24012..972b2a6f 100644 --- a/packages/loom/web/src/index.js +++ b/packages/loom/web/src/index.js @@ -2,6 +2,8 @@ export * as Component from "./component.js" /** Primary renderer-neutral view surface for new Loom authoring. */ export * as View from "./view.js" +/** Template-first DOM authoring surface. */ +export { html, renderable } from "./template.js" /** Primary Web modifier surface for DOM and browser-specific behavior. */ export * as Web from "./web.js" /** Primary slot composition surface for layouts and nesting. */ diff --git a/packages/loom/web/src/index.ts b/packages/loom/web/src/index.ts index 4e2361cc..1479dbd3 100644 --- a/packages/loom/web/src/index.ts +++ b/packages/loom/web/src/index.ts @@ -4,6 +4,10 @@ export * as Component from "./component.js" /** Primary renderer-neutral view surface for new Loom authoring. */ export * as View from "./view.js" +/** Template-first DOM authoring surface. */ +export { html, renderable } from "./template.js" +export type { ErrorOfRenderable, Renderable, RequirementsOfRenderable } from "./template.js" + /** Primary Web modifier surface for DOM and browser-specific behavior. */ export * as Web from "./web.js" diff --git a/packages/loom/web/src/internal/mounted-view.js b/packages/loom/web/src/internal/mounted-view.js index d7890bf6..5210beae 100644 --- a/packages/loom/web/src/internal/mounted-view.js +++ b/packages/loom/web/src/internal/mounted-view.js @@ -333,6 +333,40 @@ const mountIfNode = (node, registry, root) => { }, } } +const mountComputedNode = (node, registry, root) => { + const range = makeMountedRange("computed") + const subscriptions = new Map() + let disposed = false + let tracked = withStateTracking(() => node.render()) + let mounted = mountNode(tracked.value, registry, root) + const render = () => { + if (disposed) { + return + } + + const nextTracked = withStateTracking(() => node.render()) + const nextMounted = mountNode(nextTracked.value, registry, root) + + range.replace(nextMounted.nodes) + mounted.dispose() + mounted = nextMounted + tracked = nextTracked + syncTrackedSubscriptions(subscriptions, tracked.atoms, registry, render) + } + syncTrackedSubscriptions(subscriptions, tracked.atoms, registry, render) + return { + nodes: [range.start, ...mounted.nodes, range.end], + hasReactiveBindings: tracked.atoms.size > 0 || mounted.hasReactiveBindings, + dispose: () => { + disposed = true + for (const unsubscribe of subscriptions.values()) { + unsubscribe() + } + subscriptions.clear() + mounted.dispose() + }, + } +} const mountedForItemNodes = (entry) => [ entry.range.start, ...entry.mounted.nodes, @@ -493,8 +527,12 @@ const mountNode = (node, registry, root) => { return mountTextNode(node.value) case "DynamicText": return mountDynamicTextNode(node, registry) + case "Computed": + return mountComputedNode(node, registry, root) case "ComponentUse": return mountNode(node.component.node, registry, root) + case "Boundary": + return mountNode(node.node, registry, root) case "Live": return mountTextNode("") case "If": diff --git a/packages/loom/web/src/internal/mounted-view.ts b/packages/loom/web/src/internal/mounted-view.ts index e675e1db..9f6db11f 100644 --- a/packages/loom/web/src/internal/mounted-view.ts +++ b/packages/loom/web/src/internal/mounted-view.ts @@ -377,6 +377,50 @@ const mountDynamicTextNode = ( } } +const mountComputedNode = ( + node: LoomCore.Ast.ComputedNode, + registry: AtomRegistry.AtomRegistry, + root: Element, +): MountedNode => { + const range = makeMountedRange("computed") + const subscriptions = new Map, () => void>() + let disposed = false + let tracked = withStateTracking(() => node.render()) + let mounted = mountNode(tracked.value, registry, root) + + const render = (): void => { + if (disposed) { + return + } + + const nextTracked = withStateTracking(() => node.render()) + const nextMounted = mountNode(nextTracked.value, registry, root) + + range.replace(nextMounted.nodes) + mounted.dispose() + mounted = nextMounted + tracked = nextTracked + syncTrackedSubscriptions(subscriptions, tracked.atoms, registry, render) + } + + syncTrackedSubscriptions(subscriptions, tracked.atoms, registry, render) + + return { + nodes: [range.start, ...mounted.nodes, range.end], + hasReactiveBindings: tracked.atoms.size > 0 || mounted.hasReactiveBindings, + dispose: () => { + disposed = true + + for (const unsubscribe of subscriptions.values()) { + unsubscribe() + } + + subscriptions.clear() + mounted.dispose() + }, + } +} + const emptyNode = LoomCore.Ast.fragment([]) const resolveRangeParent = (owner: string, start: Comment, end: Comment): ParentNode => { @@ -723,8 +767,12 @@ const mountNode = (node: LoomCore.Ast.Node, registry: AtomRegistry.AtomRegistry, return mountTextNode(node.value) case "DynamicText": return mountDynamicTextNode(node, registry) + case "Computed": + return mountComputedNode(node, registry, root) case "ComponentUse": return mountNode(node.component.node, registry, root) + case "Boundary": + return mountNode(node.node, registry, root) case "Live": return mountTextNode("") case "If": diff --git a/packages/loom/web/src/internal/view-child.ts b/packages/loom/web/src/internal/view-child.ts index 25cba882..94dffab6 100644 --- a/packages/loom/web/src/internal/view-child.ts +++ b/packages/loom/web/src/internal/view-child.ts @@ -6,7 +6,7 @@ export type ViewChild = | string | number | bigint - | ReadonlyArray + | ReadonlyArray | undefined | null | false diff --git a/packages/loom/web/src/template.js b/packages/loom/web/src/template.js new file mode 100644 index 00000000..60b790e3 --- /dev/null +++ b/packages/loom/web/src/template.js @@ -0,0 +1,270 @@ +import * as LoomCore from "@effectify/loom-core" +import * as Hydration from "./hydration.js" +import * as internalApi from "./internal/api.js" +import * as viewNode from "./internal/view-node.js" + +const childMarkerPrefix = "loom-child:" +const attributeMarkerPrefix = "__loom_attr_" +const attributeMarkerPattern = /^__loom_attr_(\d+)__$/u +const attributeContextPattern = /[A-Za-z0-9_:-]+\s*=\s*$/u +const commentNodeType = 8 +const textNodeType = 3 +const elementNodeType = 1 + +const isRenderable = (value) => + typeof value === "object" && value !== null && "_tag" in value && value._tag !== "Component" +const isComponentDefinition = (value) => typeof value === "object" && value !== null && value._tag === "Component" +const isEventHandler = (value) => typeof value === "function" || (typeof value === "object" && value !== null) +const isTemplateThunk = (value) => typeof value === "function" && value.length === 0 +const isHydrationStrategy = (value) => + typeof value === "object" && value !== null && "strategy" in value && "attributeName" in value && + "attributeValue" in value + +const collapseNodes = (nodes) => { + if (nodes.length === 0) { + return LoomCore.Ast.fragment([]) + } + + return nodes.length === 1 ? nodes[0] : LoomCore.Ast.fragment(nodes) +} + +const normalizeStaticInterpolation = (value) => { + if (value === null || value === undefined || value === false) { + return [] + } + + if (typeof value === "string" || typeof value === "number" || typeof value === "bigint") { + return [LoomCore.Ast.text(String(value))] + } + + if (isRenderable(value)) { + return [value] + } + + return [] +} + +const normalizeInterpolationValue = (value) => { + if (isTemplateThunk(value)) { + return [LoomCore.Ast.computed(() => collapseNodes(normalizeStaticInterpolation(value())))] + } + + return normalizeStaticInterpolation(value) +} + +const assertTemplateValue = (value, owner) => { + if (isComponentDefinition(value)) { + throw new Error(`${owner} is invalid in html templates. Use View.use(...) instead.`) + } + + if (Array.isArray(value)) { + throw new Error(`${owner} is invalid in html templates. Use View.for(...) instead.`) + } +} + +const parseAttributeMarker = (value) => { + const match = value.match(attributeMarkerPattern) + return match === null ? undefined : Number(match[1]) +} + +const textToNode = (value) => value.trim().length === 0 ? undefined : LoomCore.Ast.text(value) + +const applyAttributeInterpolation = (name, interpolation, attributes, bindings) => { + assertTemplateValue(interpolation, "Direct array/component interpolation") + + if (isTemplateThunk(interpolation)) { + if (name === "value") { + bindings.push({ + _tag: "ValueBinding", + render: () => { + const value = interpolation() + return value === null || value === undefined ? undefined : String(value) + }, + }) + return + } + + if (name === "class") { + bindings.push({ + _tag: "ClassBinding", + render: () => { + const value = interpolation() + return value === null || value === undefined || value === false ? undefined : String(value) + }, + }) + return + } + + if (name === "style") { + bindings.push({ + _tag: "StyleBinding", + render: () => { + const value = interpolation() + return value === null || value === undefined || value === false ? undefined : String(value) + }, + }) + return + } + + bindings.push({ + _tag: "AttrBinding", + name, + render: () => { + const value = interpolation() + return value === null || value === undefined || value === false ? undefined : String(value) + }, + }) + return + } + + if (interpolation === null || interpolation === undefined || interpolation === false) { + return + } + + if (typeof interpolation === "string" || typeof interpolation === "number" || typeof interpolation === "bigint") { + attributes[name] = String(interpolation) + return + } + + throw new Error(`Attribute interpolation for '${name}' must be a primitive or thunk.`) +} + +const applyWebDirective = (name, interpolation, attributes, bindings, events) => { + switch (name) { + case "click": { + if (!isEventHandler(interpolation)) { + throw new Error("web:click expects an event handler.") + } + + events.push(internalApi.makeEventBinding("click", interpolation)) + return undefined + } + case "value": + case "inputValue": { + applyAttributeInterpolation("value", interpolation, attributes, bindings) + return undefined + } + case "hydrate": { + if (!isHydrationStrategy(interpolation)) { + throw new Error("web:hydrate expects an explicit Hydration.strategy helper value.") + } + + const hydration = Hydration.boundary(interpolation) + Object.assign(attributes, hydration.attributes) + return hydration + } + default: + throw new Error(`Unsupported template directive 'web:${name}'.`) + } +} + +const isCommentNode = (node) => node.nodeType === commentNodeType +const isTextNode = (node) => node.nodeType === textNodeType +const isElementNode = (node) => node.nodeType === elementNodeType + +const convertDomNode = (node, values) => { + if (isCommentNode(node)) { + if (!node.data.startsWith(childMarkerPrefix)) { + return [] + } + + const index = Number(node.data.slice(childMarkerPrefix.length)) + const interpolation = values[index] + + if (interpolation === undefined) { + return [] + } + + assertTemplateValue(interpolation, "Direct array/component interpolation") + return normalizeInterpolationValue(interpolation) + } + + if (isTextNode(node)) { + const textNode = textToNode(node.data) + return textNode === undefined ? [] : [textNode] + } + + if (!isElementNode(node)) { + return [] + } + + const attributes = {} + const bindings = [] + const events = [] + let hydration + + for (const attribute of Array.from(node.attributes)) { + const interpolationIndex = parseAttributeMarker(attribute.value) + + if (attribute.name.startsWith("web:")) { + if (interpolationIndex === undefined) { + throw new Error(`Directive '${attribute.name}' expects an interpolated value.`) + } + + const interpolation = values[interpolationIndex] + + if (interpolation === undefined) { + continue + } + + hydration = applyWebDirective(attribute.name.slice(4), interpolation, attributes, bindings, events) ?? hydration + continue + } + + if (interpolationIndex === undefined) { + attributes[attribute.name] = attribute.value + continue + } + + const interpolation = values[interpolationIndex] + + if (interpolation === undefined) { + continue + } + + applyAttributeInterpolation(attribute.name, interpolation, attributes, bindings) + } + + return [LoomCore.Ast.element(node.tagName.toLowerCase(), { + attributes, + bindings, + children: Array.from(node.childNodes).flatMap((child) => convertDomNode(child, values)), + events, + hydration, + })] +} + +const createTemplateElement = () => { + if (typeof document === "undefined") { + throw new Error("html template authoring currently requires DOM template parsing support.") + } + + return document.createElement("template") +} + +export const renderable = (node) => viewNode.wrap(node) + +export const html = (strings, ...values) => { + for (const value of values) { + assertTemplateValue(value, "Direct array/component interpolation") + } + + const source = strings.reduce((current, segment, index) => { + if (index >= values.length) { + return current + segment + } + + const marker = attributeContextPattern.test(segment) + ? `${attributeMarkerPrefix}${index}__` + : `` + + return current + segment + marker + }, "") + + const template = createTemplateElement() + template.innerHTML = source + + return renderable( + collapseNodes(Array.from(template.content.childNodes).flatMap((child) => convertDomNode(child, values))), + ) +} diff --git a/packages/loom/web/src/template.ts b/packages/loom/web/src/template.ts new file mode 100644 index 00000000..53b9760e --- /dev/null +++ b/packages/loom/web/src/template.ts @@ -0,0 +1,332 @@ +import * as LoomCore from "@effectify/loom-core" +import * as Hydration from "./hydration.js" +import type * as Html from "./html.js" +import * as internalApi from "./internal/api.js" +import * as viewNode from "./internal/view-node.js" + +export type Renderable = viewNode.Type & { + readonly __error?: E + readonly __requirements?: R +} + +export type ErrorOfRenderable = Value extends { readonly __error?: infer Error } ? Error : never +export type RequirementsOfRenderable = Value extends { readonly __requirements?: infer Requirements } + ? Requirements + : never + +export type PrimitiveInterpolation = string | number | bigint | null | undefined | false +export type TemplateValue = + | LoomCore.Ast.Node + | LoomCore.Component.Definition + | PrimitiveInterpolation + | ReadonlyArray +export type TemplateInterpolation = TemplateValue | Hydration.Strategy | Html.EventHandler | (() => TemplateValue) + +type InterpolationError = Value extends ReadonlyArray ? InterpolationError + : Value extends () => infer Produced ? InterpolationError + : Value extends { readonly __error?: infer Error } ? Error + : never + +type InterpolationRequirements = Value extends ReadonlyArray ? InterpolationRequirements + : Value extends () => infer Produced ? InterpolationRequirements + : Value extends { readonly __requirements?: infer Requirements } ? Requirements + : never + +const childMarkerPrefix = "loom-child:" +const attributeMarkerPrefix = "__loom_attr_" +const attributeMarkerPattern = /^__loom_attr_(\d+)__$/u +const commentNodeType = 8 +const textNodeType = 3 +const elementNodeType = 1 +const attributeContextPattern = /[A-Za-z0-9_:-]+\s*=\s*$/u + +const isRenderable = (value: unknown): value is Renderable => + typeof value === "object" && value !== null && "_tag" in value && + (value as { readonly _tag: string })._tag !== "Component" + +const isComponentDefinition = (value: unknown): value is { readonly _tag: "Component" } => + typeof value === "object" && value !== null && "_tag" in value && + (value as { readonly _tag: string })._tag === "Component" + +const isEventHandler = (value: unknown): value is Html.EventHandler => + typeof value === "function" || (typeof value === "object" && value !== null) + +const isTemplateThunk = (value: TemplateInterpolation): value is () => TemplateValue => + typeof value === "function" && value.length === 0 + +const isHydrationStrategy = (value: unknown): value is Hydration.Strategy => + typeof value === "object" && value !== null && "strategy" in value && "attributeName" in value && + "attributeValue" in value + +const asRenderable = (node: LoomCore.Ast.Node): Renderable => viewNode.wrap(node) + +const collapseNodes = (nodes: ReadonlyArray): LoomCore.Ast.Node => { + if (nodes.length === 0) { + return LoomCore.Ast.fragment([]) + } + + return nodes.length === 1 ? nodes[0] : LoomCore.Ast.fragment(nodes) +} + +const normalizeStaticInterpolation = (value: TemplateValue): ReadonlyArray => { + if (value === null || value === undefined || value === false) { + return [] + } + + if (typeof value === "string" || typeof value === "number" || typeof value === "bigint") { + return [LoomCore.Ast.text(String(value))] + } + + if (isRenderable(value)) { + return [value] + } + + return [] +} + +const normalizeInterpolationValue = (value: TemplateInterpolation): ReadonlyArray => { + if (isTemplateThunk(value)) { + return [LoomCore.Ast.computed(() => collapseNodes(normalizeStaticInterpolation(value())))] + } + + return normalizeStaticInterpolation(value as TemplateValue) +} + +const assertTemplateValue = (value: unknown, owner: string): void => { + if (isComponentDefinition(value)) { + throw new Error(`${owner} is invalid in html templates. Use View.use(...) instead.`) + } + + if (Array.isArray(value)) { + throw new Error(`${owner} is invalid in html templates. Use View.for(...) instead.`) + } +} + +const parseAttributeMarker = (value: string): number | undefined => { + const match = value.match(attributeMarkerPattern) + return match === null ? undefined : Number(match[1]) +} + +const textToNode = (value: string): LoomCore.Ast.Node | undefined => + value.trim().length === 0 ? undefined : LoomCore.Ast.text(value) + +const applyAttributeInterpolation = ( + name: string, + interpolation: TemplateInterpolation, + attributes: Record, + bindings: Array, +): void => { + assertTemplateValue(interpolation, "Direct array/component interpolation") + + if (isTemplateThunk(interpolation)) { + if (name === "value") { + bindings.push({ + _tag: "ValueBinding", + render: () => { + const value = interpolation() + return value === null || value === undefined ? undefined : String(value) + }, + }) + return + } + + if (name === "class") { + bindings.push({ + _tag: "ClassBinding", + render: () => { + const value = interpolation() + return value === null || value === undefined || value === false ? undefined : String(value) + }, + }) + return + } + + if (name === "style") { + bindings.push({ + _tag: "StyleBinding", + render: () => { + const value = interpolation() + return value === null || value === undefined || value === false ? undefined : String(value) + }, + }) + return + } + + bindings.push({ + _tag: "AttrBinding", + name, + render: () => { + const value = interpolation() + return value === null || value === undefined || value === false ? undefined : String(value) + }, + }) + return + } + + if (interpolation === null || interpolation === undefined || interpolation === false) { + return + } + + if (typeof interpolation === "string" || typeof interpolation === "number" || typeof interpolation === "bigint") { + attributes[name] = String(interpolation) + return + } + + throw new Error(`Attribute interpolation for '${name}' must be a primitive or thunk.`) +} + +const applyWebDirective = ( + name: string, + interpolation: TemplateInterpolation, + attributes: Record, + bindings: Array, + events: Array, +): LoomCore.Ast.HydrationMetadata | undefined => { + switch (name) { + case "click": { + if (!isEventHandler(interpolation)) { + throw new Error("web:click expects an event handler.") + } + + events.push(internalApi.makeEventBinding("click", interpolation)) + return undefined + } + case "value": + case "inputValue": { + applyAttributeInterpolation("value", interpolation, attributes, bindings) + return undefined + } + case "hydrate": { + if (!isHydrationStrategy(interpolation)) { + throw new Error("web:hydrate expects an explicit Hydration.strategy helper value.") + } + + const hydration = Hydration.boundary(interpolation) + Object.assign(attributes, hydration.attributes) + return hydration + } + default: + throw new Error(`Unsupported template directive 'web:${name}'.`) + } +} + +const isCommentNode = (node: ChildNode): node is Comment => node.nodeType === commentNodeType + +const isTextNode = (node: ChildNode): node is Text => node.nodeType === textNodeType + +const isElementNode = (node: ChildNode): node is HTMLElement => node.nodeType === elementNodeType + +const convertDomNode = ( + node: ChildNode, + values: ReadonlyArray, +): ReadonlyArray => { + if (isCommentNode(node)) { + if (!node.data.startsWith(childMarkerPrefix)) { + return [] + } + + const index = Number(node.data.slice(childMarkerPrefix.length)) + const interpolation = values[index] + + if (interpolation === undefined) { + return [] + } + + assertTemplateValue(interpolation, "Direct array/component interpolation") + return normalizeInterpolationValue(interpolation) + } + + if (isTextNode(node)) { + const textNode = textToNode(node.data) + return textNode === undefined ? [] : [textNode] + } + + if (!isElementNode(node)) { + return [] + } + + const attributes: Record = {} + const bindings: Array = [] + const events: Array = [] + let hydration: LoomCore.Ast.HydrationMetadata | undefined + + for (const attribute of Array.from(node.attributes)) { + const interpolationIndex = parseAttributeMarker(attribute.value) + + if (attribute.name.startsWith("web:")) { + if (interpolationIndex === undefined) { + throw new Error(`Directive '${attribute.name}' expects an interpolated value.`) + } + + const interpolation = values[interpolationIndex] + + if (interpolation === undefined) { + continue + } + + hydration = applyWebDirective(attribute.name.slice(4), interpolation, attributes, bindings, events) ?? hydration + continue + } + + if (interpolationIndex === undefined) { + attributes[attribute.name] = attribute.value + continue + } + + const interpolation = values[interpolationIndex] + + if (interpolation === undefined) { + continue + } + + applyAttributeInterpolation(attribute.name, interpolation, attributes, bindings) + } + + return [ + LoomCore.Ast.element(node.tagName.toLowerCase(), { + attributes, + bindings, + children: Array.from(node.childNodes).flatMap((child) => convertDomNode(child, values)), + events, + hydration, + }), + ] +} + +const createTemplateElement = (): HTMLTemplateElement => { + if (typeof document === "undefined") { + throw new Error("html template authoring currently requires DOM template parsing support.") + } + + return document.createElement("template") +} + +export const renderable = (node: LoomCore.Ast.Node): Renderable => asRenderable(node) + +export const html = >( + strings: TemplateStringsArray, + ...values: Values +): Renderable, InterpolationRequirements> => { + for (const value of values) { + assertTemplateValue(value, "Direct array/component interpolation") + } + + const source = strings.reduce((current, segment, index) => { + if (index >= values.length) { + return current + segment + } + + const marker = attributeContextPattern.test(segment) + ? `${attributeMarkerPrefix}${index}__` + : `` + + return current + segment + marker + }, "") + + const template = createTemplateElement() + template.innerHTML = source + + return asRenderable( + collapseNodes(Array.from(template.content.childNodes).flatMap((child) => convertDomNode(child, values))), + ) +} diff --git a/packages/loom/web/src/view.js b/packages/loom/web/src/view.js index e0edc848..00372c8e 100644 --- a/packages/loom/web/src/view.js +++ b/packages/loom/web/src/view.js @@ -1,5 +1,8 @@ import * as LoomCore from "@effectify/loom-core" +import * as Result from "effect/Result" +import * as Component from "./component.js" import * as Html from "./html.js" +import * as Template from "./template.js" import * as internal from "./internal/view-node.js" import * as viewChild from "./internal/view-child.js" const linkTargetModifiers = (target) => { @@ -20,6 +23,7 @@ const textChildNode = (value) => typeof value === "function" ? LoomCore.Ast.dynamicText(value) : LoomCore.Ast.text(value) +const asRenderable = (node) => Template.renderable(node) const normalizeNode = (child) => { const normalized = viewChild.normalizeViewChild(child) if (normalized.length === 0) { @@ -92,6 +96,63 @@ export function forView(items, options) { return forViewImpl(items, options) } export { forView as for, ifView as if, whenView as when } +const isAsyncResult = (value) => + typeof value === "object" && value !== null && "_tag" in value + && ["Waiting", "Success", "Failure", "Error", "Defect"].includes(value._tag) +const isTaggedUnion = (value) => typeof value === "object" && value !== null && typeof value._tag === "string" +const matchResultLike = (source, handlers) => + Result.isSuccess(source) ? handlers.onSuccess(source.success) : handlers.onFailure(source.failure) +const matchAsyncResultLike = (source, handlers) => { + switch (source._tag) { + case "Waiting": + return handlers.onWaiting?.() ?? fragment() + case "Success": + return handlers.onSuccess(source.success) + case "Failure": + return handlers.onFailure(source.failure) + case "Error": + return handlers.onError?.(source.error) ?? fragment() + case "Defect": + return handlers.onDefect?.(source.defect) ?? fragment() + } +} +const matchTaggedUnion = (source, handlers) => { + const handler = handlers[source._tag] + return handler === undefined ? handlers.orElse?.(source) ?? fragment() : handler(source) +} +const matchStatic = (source, handlers) => { + if (Result.isResult(source)) { + return matchResultLike(source, handlers) + } + + if (isAsyncResult(source)) { + return matchAsyncResultLike(source, handlers) + } + + if (isTaggedUnion(source)) { + return matchTaggedUnion(source, handlers) + } + + return fragment() +} +export const match = (source, handlers) => { + if (typeof handlers !== "object" || handlers === null) { + return fragment() + } + + if (typeof source === "function") { + return asRenderable(LoomCore.Ast.computed(() => normalizeNode(matchStatic(source(), handlers)))) + } + + return matchStatic(source, handlers) +} +const wrapBoundary = (renderable, scope) => asRenderable(LoomCore.Ast.boundary(renderable, scope)) +export const catchTag = (tag, _handler) => (self) => wrapBoundary(self, { errors: [tag] }) +export const catchAll = (_handler) => (self) => wrapBoundary(self, { errors: "all" }) +export const provide = (_provided) => (self) => wrapBoundary(self, { requirementsHandled: true }) +export const provideService = (_service) => (self) => wrapBoundary(self, { requirementsHandled: true }) +export const use = (component, propsOrComposition, composition) => + Template.renderable(Component.use(component, propsOrComposition, composition)) /** Create a semantic main region. */ export const main = (content) => internal.wrap(Html.el("main", Html.children(content))) /** Create a semantic aside region. */ diff --git a/packages/loom/web/src/view.ts b/packages/loom/web/src/view.ts index fc79cd69..8a20e796 100644 --- a/packages/loom/web/src/view.ts +++ b/packages/loom/web/src/view.ts @@ -1,16 +1,82 @@ import * as LoomCore from "@effectify/loom-core" +import * as Result from "effect/Result" +import * as Component from "./component.js" import * as Html from "./html.js" import * as Slot from "./slot.js" -import * as internal from "./internal/view-node.js" +import * as Template from "./template.js" import * as viewChild from "./internal/view-child.js" -export type Type = internal.Type +export type Type = Template.Renderable export type Node = LoomCore.Ast.Node +export type Renderable = Template.Renderable export type ViewChild = viewChild.ViewChild export type Child = ViewChild export type MaybeChild = ViewChild export type SlotDefinition = Slot.Definition type ReactiveInput = Value | (() => Value) +type HandlerRenderable = Template.Renderable +type TaggedUnion = { readonly _tag: string } +type MatchSource = Value | (() => Value) +type ErrorOf = Template.ErrorOfRenderable +type RequirementsOf = Template.RequirementsOfRenderable +type HandlerOutput = (...args: ReadonlyArray) => HandlerRenderable +type ErrorOfHandler = Handler extends HandlerOutput ? ErrorOf> : never +type RequirementsOfHandler = Handler extends HandlerOutput ? RequirementsOf> : never +type ErrorOfHandlers = { + readonly [Key in keyof Handlers]: ErrorOfHandler +}[keyof Handlers] +type RequirementsOfHandlers = { + readonly [Key in keyof Handlers]: RequirementsOfHandler +}[keyof Handlers] +type RequiredPropKeys = [Props] extends [never] ? never + : [NonNullable] extends [never] ? never + : NonNullable extends object ? { + readonly [Key in keyof NonNullable]-?: undefined extends NonNullable[Key] ? never : Key + }[keyof NonNullable] + : never +type NoRequiredProps = [RequiredPropKeys] extends [never] ? true : false +type ChildShorthandComponent< + Props, + Err, + Requirements, + Model extends Component.ModelShape, + Actions extends Component.ActionShape, +> = NoRequiredProps extends true ? Component.Type : never +type SlotShorthandComponent< + Props, + Err, + Requirements, + Model extends Component.ModelShape, + Actions extends Component.ActionShape, + Slots extends Component.SlotShape, +> = NoRequiredProps extends true ? Component.Type : never + +export type AsyncResult = + | { readonly _tag: "Waiting" } + | { readonly _tag: "Success"; readonly success: Success } + | { readonly _tag: "Failure"; readonly failure: Failure } + | { readonly _tag: "Error"; readonly error: Error } + | { readonly _tag: "Defect"; readonly defect: unknown } + +export interface ResultMatchHandlers { + readonly onSuccess: (value: Success) => HandlerRenderable + readonly onFailure: (error: Failure) => HandlerRenderable +} + +export interface AsyncResultMatchHandlers extends ResultMatchHandlers { + readonly onWaiting?: () => HandlerRenderable + readonly onError?: (error: Error) => HandlerRenderable + readonly onDefect?: (defect: unknown) => HandlerRenderable +} + +type TaggedMatchHandlers = Readonly< + & { + readonly [Key in Value["_tag"]]?: (value: Extract) => HandlerRenderable + } + & { + readonly orElse?: (value: Value) => HandlerRenderable + } +> export interface ForOptions { readonly key: (item: Item, index: number) => Key @@ -51,6 +117,8 @@ const textChildNode = (value: string | (() => string)): LoomCore.Ast.Node => ? LoomCore.Ast.dynamicText(value) : LoomCore.Ast.text(value) +const asRenderable = (node: LoomCore.Ast.Node): Renderable => Template.renderable(node) + const normalizeNode = (child: MaybeChild): LoomCore.Ast.Node => { const normalized = viewChild.normalizeViewChild(child) @@ -64,32 +132,32 @@ const normalizeNode = (child: MaybeChild): LoomCore.Ast.Node => { const renderSnapshotForItems = ( items: ReadonlyArray, options: ForOptions, -): Type => +): Renderable => items.length === 0 ? fragment(options.empty) : fragment(...items.map((item, index) => options.render(item, index))) /** Create a renderer-neutral text view backed by an inline element root. */ -export function text(value: string): Type -export function text(render: () => string): Type -export function text(value: string | (() => string)): Type { - return internal.wrap(Html.el("span", Html.children(textChildNode(value)))) +export function text(value: string): Renderable +export function text(render: () => string): Renderable +export function text(value: string | (() => string)): Renderable { + return asRenderable(Html.el("span", Html.children(textChildNode(value)))) } /** Create a renderer-neutral fragment. */ -export const fragment = (...children: ReadonlyArray): Type => - internal.wrap({ +export const fragment = (...children: ReadonlyArray): Renderable => + asRenderable({ _tag: "Fragment", children: viewChild.normalizeViewChildren(children), }) /** Create a neutral vertical layout primitive. */ -export const vstack = (...children: ReadonlyArray): Type => - internal.wrap(Html.el("div", Html.children(...children))) +export const vstack = (...children: ReadonlyArray): Renderable => + asRenderable(Html.el("div", Html.children(...children))) /** Create a neutral horizontal layout primitive. */ -export const hstack = (...children: ReadonlyArray): Type => - internal.wrap(Html.el("div", Html.children(...children))) +export const hstack = (...children: ReadonlyArray): Renderable => + asRenderable(Html.el("div", Html.children(...children))) /** Compatibility alias for the preferred `View.vstack(...)` primitive. */ export const stack = vstack @@ -98,19 +166,19 @@ export const stack = vstack export const row = hstack /** Create a button node with broad child content and click handler support. */ -export const button = (content: ViewChild, handler: Html.EventHandler): Type => - internal.wrap(Html.el("button", Html.on("click", handler), Html.children(content))) +export const button = (content: ViewChild, handler: Html.EventHandler): Renderable => + asRenderable(Html.el("button", Html.on("click", handler), Html.children(content))) /** Create the first text-input primitive backed by a text input element. */ -export const input = (): Type => internal.wrap(Html.el("input", Html.attr("type", "text"))) +export const input = (): Renderable => asRenderable(Html.el("input", Html.attr("type", "text"))) /** Create a router-neutral link node with broad child content. */ -export const link = (content: ViewChild, target: LinkTarget): Type => - internal.wrap(Html.el("a", ...linkTargetModifiers(target), Html.children(content))) +export const link = (content: ViewChild, target: LinkTarget): Renderable => + asRenderable(Html.el("a", ...linkTargetModifiers(target), Html.children(content))) -const renderIf = (condition: ReactiveInput, content: MaybeChild, otherwise?: MaybeChild): Type => { +const renderIf = (condition: ReactiveInput, content: MaybeChild, otherwise?: MaybeChild): Renderable => { if (typeof condition === "function") { - return internal.wrap( + return asRenderable( LoomCore.Ast.ifNode( condition, normalizeNode(content), @@ -123,30 +191,34 @@ const renderIf = (condition: ReactiveInput, content: MaybeChild, otherw } /** Render exactly one branch from an explicit boolean condition. */ -export function ifView(condition: boolean, content: MaybeChild, otherwise?: MaybeChild): Type -export function ifView(condition: () => boolean, content: MaybeChild, otherwise?: MaybeChild): Type -export function ifView(condition: ReactiveInput, content: MaybeChild, otherwise?: MaybeChild): Type { +export function ifView(condition: boolean, content: MaybeChild, otherwise?: MaybeChild): Renderable +export function ifView(condition: () => boolean, content: MaybeChild, otherwise?: MaybeChild): Renderable +export function ifView(condition: ReactiveInput, content: MaybeChild, otherwise?: MaybeChild): Renderable { return renderIf(condition, content, otherwise) } -const renderWhen = (condition: unknown | (() => unknown), content: MaybeChild, otherwise?: MaybeChild): Type => +const renderWhen = (condition: unknown | (() => unknown), content: MaybeChild, otherwise?: MaybeChild): Renderable => typeof condition === "function" ? renderIf(() => Boolean(condition()), content, otherwise) : renderIf(Boolean(condition), content, otherwise) /** Render content only when a condition is truthy. */ -export function whenView(condition: unknown, content: MaybeChild, otherwise?: MaybeChild): Type -export function whenView(condition: () => unknown, content: MaybeChild, otherwise?: MaybeChild): Type -export function whenView(condition: unknown | (() => unknown), content: MaybeChild, otherwise?: MaybeChild): Type { +export function whenView(condition: unknown, content: MaybeChild, otherwise?: MaybeChild): Renderable +export function whenView(condition: () => unknown, content: MaybeChild, otherwise?: MaybeChild): Renderable +export function whenView( + condition: unknown | (() => unknown), + content: MaybeChild, + otherwise?: MaybeChild, +): Renderable { return renderWhen(condition, content, otherwise) } const forViewImpl = ( items: ReactiveInput>, options: ForOptions, -): Type => { +): Renderable => { if (typeof items === "function") { - return internal.wrap( + return asRenderable( LoomCore.Ast.forEach(() => items(), { key: options.key, render: (item, index) => normalizeNode(options.render(item, index)), @@ -159,25 +231,211 @@ const forViewImpl = ( } /** Render a keyed list with explicit snapshot vs tracked collection semantics. */ -export function forView(items: ReadonlyArray, options: ForOptions): Type +export function forView( + items: ReadonlyArray, + options: ForOptions, +): Renderable export function forView( items: () => ReadonlyArray, options: ForOptions, -): Type +): Renderable export function forView( items: ReactiveInput>, options: ForOptions, -): Type { +): Renderable { return forViewImpl(items, options) } export { forView as for, ifView as if, whenView as when } +const isAsyncResult = (value: unknown): value is AsyncResult => + typeof value === "object" && value !== null && "_tag" in value + && ["Waiting", "Success", "Failure", "Error", "Defect"].includes((value as { readonly _tag: string })._tag) + +const isTaggedUnion = (value: unknown): value is TaggedUnion => + typeof value === "object" && value !== null && "_tag" in value && + typeof (value as { readonly _tag: unknown })._tag === "string" + +const matchResultLike = ( + source: Result.Result, + handlers: ResultMatchHandlers, +): HandlerRenderable => + Result.isSuccess(source) ? handlers.onSuccess(source.success) : handlers.onFailure(source.failure) + +const matchAsyncResultLike = ( + source: AsyncResult, + handlers: AsyncResultMatchHandlers, +): HandlerRenderable => { + switch (source._tag) { + case "Waiting": + return handlers.onWaiting?.() ?? fragment() + case "Success": + return handlers.onSuccess(source.success) + case "Failure": + return handlers.onFailure(source.failure) + case "Error": + return handlers.onError?.(source.error) ?? fragment() + case "Defect": + return handlers.onDefect?.(source.defect) ?? fragment() + } +} + +const matchTaggedUnion = ( + source: Value, + handlers: TaggedMatchHandlers, +): HandlerRenderable => { + const handler = handlers[source._tag as Value["_tag"]] + return handler === undefined ? handlers.orElse?.(source) ?? fragment() : handler(source as never) +} + +const matchStatic = (source: unknown, handlers: MatchHandlers): HandlerRenderable => { + if (Result.isResult(source)) { + return matchResultLike(source, handlers as ResultMatchHandlers) + } + + if (isAsyncResult(source)) { + return matchAsyncResultLike(source, handlers as AsyncResultMatchHandlers) + } + + if (isTaggedUnion(source)) { + return matchTaggedUnion(source, handlers as TaggedMatchHandlers) + } + + return fragment() +} + +type MatchHandlers = + | ResultMatchHandlers + | AsyncResultMatchHandlers + | TaggedMatchHandlers + +export function match>( + source: MatchSource>, + handlers: Handlers, +): Renderable, RequirementsOfHandlers> +export function match>( + source: MatchSource>, + handlers: Handlers, +): Renderable, RequirementsOfHandlers> +export function match>( + source: MatchSource, + handlers: Handlers, +): Renderable, RequirementsOfHandlers> +export function match(source: MatchSource, handlers: MatchHandlers): Renderable { + if (typeof handlers !== "object" || handlers === null) { + return fragment() + } + + if (typeof source === "function") { + return asRenderable(LoomCore.Ast.computed(() => normalizeNode(matchStatic(source(), handlers)))) + } + + return matchStatic(source, handlers) +} + +const wrapBoundary = ( + renderable: Renderable, + scope: LoomCore.Ast.BoundaryNode["scope"], +): Renderable => + asRenderable(LoomCore.Ast.boundary(renderable, scope)) as Renderable + +export const catchTag = ( + tag: Tag, + _handler: (error: Extract<{ readonly _tag: Tag }, { readonly _tag: Tag }>) => Fallback, +): ( + self: Renderable, +) => Renderable> | ErrorOf, R | RequirementsOf> => (< + E, + R, +>(self: Renderable) => + wrapBoundary(self, { errors: [tag] }) as Renderable< + Exclude> | ErrorOf, + R | RequirementsOf + >) + +export const catchAll = ( + _handler: (error: never) => Fallback, +): ( + self: Renderable, +) => Renderable, R | RequirementsOf> => ((self: Renderable) => + wrapBoundary(self, { errors: "all" }) as Renderable, R | RequirementsOf>) + +export const provide = ( + _provided: unknown, +): ( + self: Renderable, +) => Renderable => ((self: Renderable) => + wrapBoundary(self, { requirementsHandled: true }) as Renderable) + +export const provideService = ( + _service: unknown, +): ( + self: Renderable, +) => Renderable => ((self: Renderable) => + wrapBoundary(self, { requirementsHandled: true }) as Renderable) + +export function use< + Props, + Err, + Requirements, + Model extends Component.ModelShape, + Actions extends Component.ActionShape, +>( + component: ChildShorthandComponent, +): Renderable +export function use< + Props, + Err, + Requirements, + Model extends Component.ModelShape, + Actions extends Component.ActionShape, +>( + component: ChildShorthandComponent, + children: ViewChild, +): Renderable +export function use< + Props, + Err, + Requirements, + Model extends Component.ModelShape, + Actions extends Component.ActionShape, +>( + component: Component.Type, + props: Props, + children: ViewChild, +): Renderable +export function use< + Props, + Err, + Requirements, + Model extends Component.ModelShape, + Actions extends Component.ActionShape, + Slots extends Component.SlotShape, +>( + component: SlotShorthandComponent, + slots: Component.SlotInput, +): Renderable +export function use< + Props, + Err, + Requirements, + Model extends Component.ModelShape, + Actions extends Component.ActionShape, + Slots extends Component.SlotShape, +>( + component: Component.Type, + props: Props, + slots: Component.SlotInput, +): Renderable +export function use(component: Component.Type, propsOrComposition?: unknown, composition?: unknown): Renderable { + return Component.use(component as never, propsOrComposition as never, composition as never) as Renderable +} + /** Create a semantic main region. */ -export const main = (content: MaybeChild): Type => internal.wrap(Html.el("main", Html.children(content))) +export const main = (content: MaybeChild): Renderable => asRenderable(Html.el("main", Html.children(content))) /** Create a semantic aside region. */ -export const aside = (content: MaybeChild): Type => internal.wrap(Html.el("aside", Html.children(content))) +export const aside = (content: MaybeChild): Renderable => asRenderable(Html.el("aside", Html.children(content))) /** Create a semantic header region. */ -export const header = (content: MaybeChild): Type => internal.wrap(Html.el("header", Html.children(content))) +export const header = (content: MaybeChild): Renderable => asRenderable(Html.el("header", Html.children(content))) diff --git a/packages/loom/web/tests/template-first-view-api.test.ts b/packages/loom/web/tests/template-first-view-api.test.ts new file mode 100644 index 00000000..de7327b5 --- /dev/null +++ b/packages/loom/web/tests/template-first-view-api.test.ts @@ -0,0 +1,241 @@ +// @vitest-environment jsdom + +import * as Result from "effect/Result" +import { Atom } from "effect/unstable/reactivity" +import { describe, expect, it } from "vitest" +import { Component, html, Hydration, mount, Slot, View } from "../src/index.js" + +describe("@effectify/loom template-first view API", () => { + it("authors views through imported html with lambda reactivity, falsy normalization, View.for, and phase-1 web directives", () => { + const root = document.createElement("div") + const inventory = Component.make("template-inventory").pipe( + Component.model({ + count: Atom.make(1), + items: Atom.make([ + { id: "alpha", label: "Alpha" }, + { id: "beta", label: "Beta" }, + ]), + }), + Component.actions({ + increment: ({ count }) => count.update((value) => value + 1), + clear: ({ items }) => items.set([]), + seed: ({ items }) => items.set([{ id: "gamma", label: "Gamma" }]), + }), + Component.view((context) => { + const { state, actions } = context as any + + return html` +
+ + + + `count:${state.count()}`} /> +

${() => state.count()}

+
${null}${undefined}${false}${0}${""}
+ ${ + View.for(() => state.items(), { + key: (item: any) => item.id, + render: (item: any) => html`

${item.label}

`, + empty: html`

empty

`, + }) + } +
+ ` + }), + ) + + const handle = mount({ inventory }, { root }) + const input = root.querySelector("input") + + if (!(input instanceof HTMLInputElement)) { + throw new Error("expected html template input") + } + + expect(root.textContent).toBe("incrementclearseed10AlphaBeta") + expect(input.value).toBe("count:1") + expect(handle.html).toContain('data-loom-hydrate="visible"') + ;(handle.actions as any).increment() + + expect(root.textContent).toBe("incrementclearseed20AlphaBeta") + expect(input.value).toBe("count:2") + ;(handle.actions as any).clear() + + expect(Array.from(root.querySelectorAll("p")).map((node) => node.textContent)).toEqual(["2", "empty"]) + ;(handle.actions as any).seed() + + expect(Array.from(root.querySelectorAll("p")).map((node) => node.textContent)).toEqual(["2", "Gamma"]) + + handle.dispose() + }) + + it("composes subtrees through View.use, local boundaries, and both View.match families", () => { + interface SaveGateway { + readonly save: (value: string) => string + } + + const child = Component.make("template-child").pipe( + Component.view(() => html`child`), + ) + + const boundary = View.use(child) + .pipe(View.catchTag("SaveFailure", () => html`handled`)) + .pipe(View.provideService({ save: (value: string) => value } satisfies SaveGateway)) + + const host = Component.make("template-host").pipe( + Component.view(() => + html` +
+ ${boundary} + ${ + View.match(Result.succeed("ready"), { + onSuccess: (value) => html`${value}`, + onFailure: (error) => html`${String(error)}`, + }) + } + ${ + View.match({ _tag: "Ready", label: "go" } as const, { + Ready: ({ label }) => html`${label}`, + orElse: () => html`fallback`, + }) + } +
+ ` + ), + ) + + const handle = mount({ host }) + + expect(handle.html).toContain("child") + expect(handle.html).toContain("ready") + expect(handle.html).toContain("go") + + handle.dispose() + }) + + it("interpolates array-shaped children and slots inside html through View.use", () => { + const card = Component.make("html-card").pipe( + Component.view(({ children }) => html`
${children}
`), + ) + + const layout = Component.make("html-layout").pipe( + Component.slots({ + default: Slot.required(), + header: Slot.optional(), + }), + Component.view(({ slots }) => html`
${slots.header}
${slots.default}
`), + ) + + const page = Component.make("html-page").pipe( + Component.view(() => + html` +
+ ${View.use(card, ["lead ", html`body`, 2])} + ${ + View.use(layout, { + header: ["Header ", html`slot`], + default: [html`

content

`, " tail"], + }) + } +
+ ` + ), + ) + + const handle = mount({ page }) + + expect(handle.html).toContain("
lead body2
") + expect(handle.html).toContain("
Header slot

content

tail
") + + handle.dispose() + }) + + it("rejects unsupported web directives", () => { + expect(() => html`
"active"}>
`).toThrowError( + /Unsupported template directive 'web:class'/, + ) + }) + + it("matches async result sources across waiting, success, failure, error, and defect branches", () => { + const root = document.createElement("div") + const loader = Component.make("template-loader").pipe( + Component.model({ + result: Atom.make>({ _tag: "Waiting" }), + }), + Component.actions({ + succeed: ({ result }) => result.set({ _tag: "Success", success: "ready" }), + fail: ({ result }) => result.set({ _tag: "Failure", failure: "nope" }), + error: ({ result }) => result.set({ _tag: "Error", error: new Error("boom") }), + defect: ({ result }) => result.set({ _tag: "Defect", defect: "kaput" }), + }), + Component.view(({ state }) => + html` +
+ ${ + View.match(() => state.result(), { + onWaiting: () => html`

waiting

`, + onSuccess: (value) => html`

${value}

`, + onFailure: (failure) => html`

${failure}

`, + onError: (error) => html`

${error.message}

`, + onDefect: (defect) => html`

${String(defect)}

`, + }) + } +
+ ` + ), + ) + + const handle = mount({ loader }, { root }) + + expect(root.textContent).toBe("waiting") + ;(handle.actions as any).succeed() + expect(root.textContent).toBe("ready") + ;(handle.actions as any).fail() + expect(root.textContent).toBe("nope") + ;(handle.actions as any).error() + expect(root.textContent).toBe("boom") + ;(handle.actions as any).defect() + expect(root.textContent).toBe("kaput") + + handle.dispose() + }) + + it("keeps eager template reads as snapshots while lambda reads stay reactive", () => { + const root = document.createElement("div") + const counter = Component.make("snapshot-counter").pipe( + Component.model({ count: Atom.make(1) }), + Component.actions({ + increment: ({ count }) => count.update((value) => value + 1), + }), + Component.view(({ state }) => + html` +
+

${state.count()}

+

${() => state.count()}

+
+ ` + ), + ) + + const handle = mount({ counter }, { root }) + const eager = root.querySelector('[data-kind="eager"]') + const lazy = root.querySelector('[data-kind="lazy"]') + + expect(eager?.textContent).toBe("1") + expect(lazy?.textContent).toBe("1") + ;(handle.actions as any).increment() + + expect(eager?.textContent).toBe("1") + expect(lazy?.textContent).toBe("2") + + handle.dispose() + }) + + it("rejects direct component and array interpolation in the template path", () => { + const child = Component.make("template-child").pipe( + Component.view(() => html`child`), + ) + + expect(() => html`
${child as never}
`).toThrowError(/View\.use/) + expect(() => html`
${[View.text("invalid")] as never}
`).toThrowError(/View\.for/) + }) +}) diff --git a/packages/loom/web/tests/template-first-view-api.types.ts b/packages/loom/web/tests/template-first-view-api.types.ts new file mode 100644 index 00000000..744025db --- /dev/null +++ b/packages/loom/web/tests/template-first-view-api.types.ts @@ -0,0 +1,172 @@ +import * as Result from "effect/Result" +import { + Component, + type ErrorOfRenderable, + html, + type Renderable, + type RequirementsOfRenderable, + Slot, + View, +} from "../src/index.js" + +type Equal = (() => Value extends Left ? 1 : 2) extends () => Value extends Right ? 1 : 2 + ? true + : false + +type Expect = Value + +interface SaveFailure { + readonly _tag: "SaveFailure" + readonly message: string +} + +interface SaveGateway { + readonly save: (value: string) => string +} + +interface TitleProps { + readonly title: string +} + +declare const requiredPropsChild: Component.Type +declare const boundarySubject: Renderable +declare const errorBoundarySubject: Renderable +declare const requirementsBoundarySubject: Renderable + +class WaitingError extends Error { + readonly _tag = "WaitingError" +} + +class ReadyError extends Error { + readonly _tag = "ReadyError" +} + +interface WaitingGateway { + readonly queue: () => void +} + +interface ReadyGateway { + readonly publish: () => void +} + +const child = Component.make("typed-child").pipe( + Component.view((): Renderable => html`child`), +) + +const title = Component.make("typed-title").pipe( + Component.view(({ props }: { readonly props: TitleProps | undefined }) => html`

${props?.title}

`), +) + +const waiting = Component.make("typed-waiting").pipe( + Component.view((): Renderable => html`waiting`), +) + +const ready = Component.make("typed-ready").pipe( + Component.view((): Renderable => html`ready`), +) + +const layout = Component.make("typed-layout").pipe( + Component.slots({ + default: Slot.required(), + header: Slot.optional(), + }), + Component.view(({ slots }) => html`
${slots.header}${slots.default}
`), +) + +const parent = Component.make("typed-parent").pipe( + Component.view(() => View.use(child)), +) + +const used = View.use(child) +const handled = View.catchTag("SaveFailure", () => html`handled`)(used) +const recovered = View.catchAll(() => html`fallback`)(handled) +const provided = View.provideService({ save: (value: string) => value } satisfies SaveGateway)(recovered) + +const successMatch = View.match(Result.succeed("ready"), { + onSuccess: (value) => html`${value}`, + onFailure: (error) => html`${String(error)}`, +}) + +const asyncMatch = View.match(() => ({ _tag: "Waiting" } as const), { + onWaiting: () => View.use(waiting), + onSuccess: () => View.use(ready), + onFailure: () => html`failure`, +}) + +const taggedMatch = View.match({ _tag: "Ready", value: 1 } as const, { + Ready: ({ value }) => html`${value}`, + orElse: () => html`fallback`, +}) + +const propUse = View.use(title, { title: "Hello" }) +const slotUse = View.use(layout, { + default: [html`content`, " tail"], + header: ["head ", html`slot`], +}) + +// @ts-expect-error child shorthand is only valid when required props are absent. +const invalidChildShorthand = View.use(requiredPropsChild, html`child`) + +const usedError: ErrorOfRenderable | undefined = used.__error +const usedRequirements: RequirementsOfRenderable | undefined = used.__requirements +const parentError: SaveFailure | undefined = parent.__error +const parentRequirements: SaveGateway | undefined = parent.__requirements +const handledError = handled.__error +const providedRequirements = provided.__requirements +const successMatchError = successMatch.__error +const taggedMatchRequirements = taggedMatch.__requirements +const propUseError = propUse.__error +const handledSubject = View.catchTag("SaveFailure", () => View.text("handled"))(boundarySubject) +const recoveredSubject = View.catchAll(() => View.text("fallback"))(errorBoundarySubject) +const providedSubject = View.provideService({ save: (value: string) => value } satisfies SaveGateway)( + requirementsBoundarySubject, +) +const recoveredBoundary: Renderable = recoveredSubject +const providedBoundary: Renderable = providedSubject + +type HandledRequirementsContract = Expect, SaveGateway>> +type AsyncMatchErrorContract = Expect, WaitingError | ReadyError>> +type AsyncMatchRequirementsContract = Expect< + Equal, WaitingGateway | ReadyGateway> +> +type TypeContracts = [ + HandledRequirementsContract, + AsyncMatchErrorContract, + AsyncMatchRequirementsContract, +] + +const typeContracts: TypeContracts = [true, true, true] + +export const typecheckSmoke = { + child, + title, + waiting, + ready, + layout, + parent, + used, + handled, + recovered, + provided, + usedError, + usedRequirements, + parentError, + parentRequirements, + handledError, + providedRequirements, + successMatchError, + taggedMatchRequirements, + propUseError, + successMatch, + asyncMatch, + taggedMatch, + propUse, + slotUse, + invalidChildShorthand, + handledSubject, + recoveredSubject, + providedSubject, + recoveredBoundary, + providedBoundary, + typeContracts, +} From 3feee098e7fe3ed8053622f94a40a6ac86d73eaa Mon Sep 17 00:00:00 2001 From: Andres David Jimenez Sulbaran Date: Mon, 27 Apr 2026 20:25:18 -0600 Subject: [PATCH 02/13] feat(loom-example-app): adopt template-first view API --- .gitignore | 3 +- apps/loom-example-app/src/entry-server.ts | 28 ++-- apps/loom-example-app/src/jsdom.d.ts | 3 + apps/loom-example-app/src/router.ts | 37 ++--- .../src/routes/counter-route.ts | 123 +++++++++-------- .../src/routes/todo-route/todo-composer.ts | 105 ++++++++------ .../src/routes/todo-route/todo-hero.ts | 63 +++++---- .../src/routes/todo-route/todo-insights.ts | 77 +++++------ .../src/routes/todo-route/todo-list.ts | 129 +++++++++++------- .../routes/todo-route/todo-route-component.ts | 30 ++-- .../routes/todo-route/todo-route-shared.ts | 29 ++-- .../src/template-dom-support.ts | 63 +++++++++ .../tests/entry-client.test.ts | 33 ++++- .../tests/entry-server.test.ts | 5 +- apps/loom-example-app/tests/jsdom.d.ts | 3 + .../tests/project-shape.test.ts | 61 +++++++-- .../tests/router-runtime.test.ts | 16 ++- .../tests/template-dom-support.test.ts | 50 +++++++ apps/loom-example-app/tsconfig.app.json | 1 + apps/loom-example-app/tsconfig.spec.json | 1 + 20 files changed, 563 insertions(+), 297 deletions(-) create mode 100644 apps/loom-example-app/src/jsdom.d.ts create mode 100644 apps/loom-example-app/src/template-dom-support.ts create mode 100644 apps/loom-example-app/tests/jsdom.d.ts create mode 100644 apps/loom-example-app/tests/template-dom-support.test.ts diff --git a/.gitignore b/.gitignore index 7fdd1f57..6109adc5 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ node_modules /.sass-cache /connect.lock coverage +coverage-json/ /libpeerconnection.log npm-debug.log yarn-error.log @@ -73,4 +74,4 @@ generated/ .claude/worktrees .claude/settings.local.json -.nx/polygraph \ No newline at end of file +.nx/polygraph diff --git a/apps/loom-example-app/src/entry-server.ts b/apps/loom-example-app/src/entry-server.ts index b367e414..238b19ca 100644 --- a/apps/loom-example-app/src/entry-server.ts +++ b/apps/loom-example-app/src/entry-server.ts @@ -4,6 +4,7 @@ import { appBuildId, appPayloadElementId, appRootId } from "./app-config.js" import { prepareRouteRuntime } from "./router-runtime.js" import { bodyForResult, resolveAppRequest, statusForResult, titleForResult } from "./router.js" import { createDocument } from "./document.js" +import { withTemplateDocument } from "./template-dom-support.js" const applicationBaseUrl = "https://effectify.dev" @@ -37,22 +38,25 @@ export const createServerRenderer = (): LoomNitro.LoomNitroRenderer => { render: (request) => { const requestUrl = normalizeRequestUrl(request.url) - return prepareRouteRuntime(requestUrl).then(() => { - const result = resolveAppRequest(requestUrl) + return withTemplateDocument(() => + prepareRouteRuntime(requestUrl).then(() => { + const result = resolveAppRequest(requestUrl) - return createDocument({ - title: titleForResult(result), - body: bodyForResult(result), + return createDocument({ + title: titleForResult(result), + body: bodyForResult(result), + }) }) - }) + ) }, - response: (request) => { - const result = resolveAppRequest(normalizeRequestUrl(request.url)) + response: (request) => + withTemplateDocument(() => { + const result = resolveAppRequest(normalizeRequestUrl(request.url)) - return { - status: statusForResult(result), - } - }, + return { + status: statusForResult(result), + } + }), }) return { diff --git a/apps/loom-example-app/src/jsdom.d.ts b/apps/loom-example-app/src/jsdom.d.ts new file mode 100644 index 00000000..0b8e9c94 --- /dev/null +++ b/apps/loom-example-app/src/jsdom.d.ts @@ -0,0 +1,3 @@ +declare module "jsdom" { + export const JSDOM: any +} diff --git a/apps/loom-example-app/src/router.ts b/apps/loom-example-app/src/router.ts index 2a11b0cd..54405082 100644 --- a/apps/loom-example-app/src/router.ts +++ b/apps/loom-example-app/src/router.ts @@ -1,14 +1,23 @@ import type * as Loom from "@effectify/loom" -import { Component, View, Web } from "@effectify/loom" +import { Component, html, View } from "@effectify/loom" import { Fallback, Layout, RouteModule, Router, type Router as RouterTypes } from "@effectify/loom-router" import { counterRouteId, counterRoutePath, counterRouteTitle } from "./routes/counter-route.js" import * as counterRouteModule from "./routes/counter-route.js" import * as todoRouteModule from "./routes/todo-route.js" +import { ensureTemplateDocument } from "./template-dom-support.js" + +ensureTemplateDocument() export const todoRouteId = "todo" export const todoRoutePath = "/todos" export const todoRouteTitle = "Todo app" +const ShellBody = Component.make("ShellBody").pipe( + Component.view(({ props }: { readonly props: Readonly<{ content: Loom.View.ViewChild | undefined }> | undefined }) => + View.fragment(props?.content ?? "") + ), +) + export const counterPageRoute = RouteModule.compile({ identifier: counterRouteId, module: counterRouteModule, @@ -19,32 +28,28 @@ export const todoPageRoute = RouteModule.compile({ identifier: todoRouteId, module: { ...todoRouteModule, - component: () => Component.use(todoRouteModule.component), + component: () => View.use(todoRouteModule.component), }, path: todoRoutePath, }) const AppShell = Component.make("AppShell").pipe( Component.view(({ children }) => - View.main(children).pipe(Web.className("container"), Web.data("app-shell", "loom-example-app")) + html`
${children ?? ""}
` ), ) const notFoundView = (context: RouterTypes.Context): Loom.View.ViewChild => - View.vstack( - View.vstack(View.text("Route not found")).pipe( - Web.className("loom-example-not-found-title"), - Web.attr("role", "heading"), - Web.aria("level", 1), - ), - View.vstack(View.text(`The Loom example serves ${counterRoutePath} and ${todoRoutePath}.`)).pipe( - Web.className("loom-example-copy"), - ), - View.vstack(View.text(`Requested path: ${context.pathname}`)).pipe(Web.className("loom-example-copy")), - ).pipe(Web.className("loom-example-card loom-example-not-found"), Web.data("route-view", "not-found")) + html` +
+

Route not found

+ The Loom example serves ${counterRoutePath} and ${todoRoutePath}. + Requested path: ${context.pathname} +
+ ` export const appRouter = Router.make({ - layout: Layout.make(({ child }) => Component.use(AppShell, child)), + layout: Layout.make(({ child }) => View.use(AppShell, View.use(ShellBody, { content: child }))), routes: [counterPageRoute, todoPageRoute] as const, fallback: { notFound: Fallback.make(notFoundView), @@ -80,4 +85,4 @@ export const statusForResult = (result: Router.ResolveResult): number => { } export const bodyForResult = (result: Router.ResolveResult): Loom.View.ViewChild => - result.output ?? View.vstack(View.text("Loom example route output is unavailable.")) + result.output ?? html`
Loom example route output is unavailable.
` diff --git a/apps/loom-example-app/src/routes/counter-route.ts b/apps/loom-example-app/src/routes/counter-route.ts index 8fdddcf7..4067a44e 100644 --- a/apps/loom-example-app/src/routes/counter-route.ts +++ b/apps/loom-example-app/src/routes/counter-route.ts @@ -1,6 +1,9 @@ import { Atom } from "effect/unstable/reactivity" -import { Component, View, Web } from "@effectify/loom" +import { Component, html, Web } from "@effectify/loom" import { counterInitialCount } from "../app-config.js" +import { ensureTemplateDocument } from "../template-dom-support.js" + +ensureTemplateDocument() export const counterRouteId = "counter" export const counterRoutePath = "/" @@ -46,6 +49,11 @@ const counterCueStyle = (count: number): Web.StyleRecord => { } } +const styleAttribute = (style: Web.StyleRecord): string => + Object.entries(style) + .map(([key, value]) => `${key.replace(/[A-Z]/g, (character) => `-${character.toLowerCase()}`)}: ${String(value)}`) + .join("; ") + export const CounterRoute = Component.make("CounterRoute").pipe( Component.state({ count: () => Atom.make(counterInitialCount).pipe(Atom.keepAlive), @@ -56,67 +64,58 @@ export const CounterRoute = Component.make("CounterRoute").pipe( reset: ({ count }) => count.set(counterInitialCount), }), Component.view(({ state, actions }) => - View.vstack( - View.vstack( - View.text("Example app only").pipe(Web.className("loom-example-eyebrow")), - View.text("Loom vNext counter").pipe( - Web.as("h1"), - Web.className("counter-title"), - ), - View.text( - "This example keeps a single route and teaches the current Loom happy path first: Component.state/actions/view with View.text + Web modifiers.", - ).pipe(Web.className("counter-copy")), - View.link("Open the todo app example", "/todos").pipe(Web.className("outline secondary todo-link")), - ).pipe(Web.className("loom-example-hero")), - View.vstack( - View.vstack( - View.text("Counter state").pipe(Web.className("counter-value-label")), - View.hstack( - View.text("Count: ").pipe(Web.className("counter-value-prefix")), - View.text(() => `${state.count()}`).pipe( - Web.className("counter-dynamic-value"), - Web.data("counter-dynamic-value", "true"), - ), - ).pipe(Web.className("counter-value"), Web.data("counter-value", "true")), - View.hstack( - View.text("Reactive cue").pipe(Web.className("counter-cue-label")), - View.text("Loom attr/class/style in place").pipe( - Web.className("counter-reactive-cue"), - Web.data("counter-reactive-cue", "true"), - Web.attr("title", () => `Reactive cue tone: ${counterCueTone(state.count())} (${state.count()})`), - Web.data("counter-tone", () => counterCueTone(state.count())), - Web.className(() => `counter-reactive-cue--${counterCueTone(state.count())}`), - Web.style(() => counterCueStyle(state.count())), - ), - ).pipe(Web.className("counter-cue-row")), - ).pipe(Web.className("counter-value-card")), - View.hstack( - View.button(View.fragment("-", 1), actions.decrement).pipe( - Web.className("secondary"), - Web.data("counter-action", "decrement"), - ), - View.button(View.fragment("+", 1), actions.increment).pipe( - Web.className("contrast"), - Web.data("counter-action", "increment"), - ), - View.button("Reset", actions.reset).pipe( - Web.className("outline secondary"), - Web.data("counter-action", "reset"), - ), - ).pipe(Web.className("counter-actions"), Web.data("counter-controls", "true")), - ).pipe(Web.className("loom-example-card")), - View.vstack( - View.text( - "Html stays at the document/resume boundary only, not in the main authoring path developers copy from this example.", - ).pipe(Web.className("compat-seam-note"), Web.data("compat-seam-note", "true")), - View.text( - "Reactive cue: the badge now uses Loom-native attr/class/style bindings, while the numeric text stays on the span-backed dynamic-text seam.", - ).pipe(Web.className("counter-debug-note"), Web.data("counter-debug-note", "true")), - View.text( - "Dev caveat: in plain Vite dev the browser uses mount(...) to fill the empty root when no payload is present. That fallback is honest DX, not fake full SSR.", - ).pipe(Web.className("dev-mode-note"), Web.data("dev-mode-note", "true")), - ).pipe(Web.className("loom-example-note-stack")), - ).pipe(Web.className("loom-example-layout"), Web.data("route-view", "counter")) + html` +
+
+ Example app only +

Loom vNext counter

+ + This example keeps a single route and teaches the current Loom happy path first: Component.state/actions/view expressed through html templates. + + Open the todo app example +
+ +
+
+ Counter state +
+ Count: + ${() => `${state.count()}`} +
+
+ Reactive cue + `counter-reactive-cue counter-reactive-cue--${counterCueTone(state.count())}`} + data-counter-reactive-cue="true" + data-counter-tone=${() => counterCueTone(state.count())} + title=${() => `Reactive cue tone: ${counterCueTone(state.count())} (${state.count()})`} + style=${() => styleAttribute(counterCueStyle(state.count()))} + > + Loom attr/class/style in place + +
+
+ +
+ + + +
+
+ +
+ + Templates author this route now; Html.el stays at the full document boundary only. + + + Reactive cue: templates drive the count text plus Loom-native attr/class/style bindings from Loom state. + + + Dev caveat: in plain Vite dev the browser uses mount(...) to fill the empty root when no payload is present. That fallback is honest DX, not fake full SSR. + +
+
+ ` ), ) diff --git a/apps/loom-example-app/src/routes/todo-route/todo-composer.ts b/apps/loom-example-app/src/routes/todo-route/todo-composer.ts index ee133794..b91f6c85 100644 --- a/apps/loom-example-app/src/routes/todo-route/todo-composer.ts +++ b/apps/loom-example-app/src/routes/todo-route/todo-composer.ts @@ -1,8 +1,40 @@ import { Atom } from "effect/unstable/reactivity" -import { Component, View, Web } from "@effectify/loom" +import { Component, html, View, Web } from "@effectify/loom" import { todoActionStatusAtom, todoDraftAtom } from "../todo-route-state.js" import { submitTodoRouteSubmission } from "../todo-route-submission.js" import { TodoPanel } from "./todo-route-shared.js" +import { ensureTemplateDocument } from "../../template-dom-support.js" + +ensureTemplateDocument() + +const renderTodoComposerInputSeam = ( + actionStatus: () => string, + draft: () => string, + onSubmit: (titleInput?: string) => Promise, + onSync: (value: string) => void, +) => + View.input().pipe( + Web.className("todo-input"), + Web.attr("placeholder", "What should we ship next?"), + Web.attr("aria-label", "Todo title"), + Web.data("todo-input", "true"), + Web.attr("disabled", () => actionStatus() === "submitting"), + Web.value(() => draft()), + Web.on("input", ({ currentTarget }) => { + if (currentTarget instanceof HTMLInputElement) { + onSync(currentTarget.value) + } + }), + Web.on("keydown", ({ event, currentTarget }) => { + if (event instanceof KeyboardEvent && event.key === "Enter") { + event.preventDefault() + + if (currentTarget instanceof HTMLInputElement) { + void onSubmit(currentTarget.value) + } + } + }), + ) export const TodoComposer = Component.make("TodoComposer").pipe( Component.state({ @@ -28,48 +60,35 @@ export const TodoComposer = Component.make("TodoComposer").pipe( }, })), Component.view(({ state, actions }) => - Component.use(TodoPanel, [ - View.text("Composer").pipe(Web.as("h2"), Web.className("todo-section-title")), - View.text( - "The Add button now submits normalized action input through the Loom runtime before the loader revalidates the list.", - ).pipe(Web.className("todo-copy")), - View.hstack( - View.input().pipe( - Web.className("todo-input"), - Web.attr("placeholder", "What should we ship next?"), - Web.attr("aria-label", "Todo title"), - Web.data("todo-input", "true"), - Web.value(() => state.draft()), - Web.on("input", ({ currentTarget }) => { - if (currentTarget instanceof HTMLInputElement) { - actions.syncDraft(currentTarget.value) - } - }), - Web.on("keydown", ({ event, currentTarget }) => { - if (event instanceof KeyboardEvent && event.key === "Enter") { - event.preventDefault() - - if (currentTarget instanceof HTMLInputElement) { - void actions.submitDraft(currentTarget.value) - } - } - }), - ), - View.button("Add todo", () => actions.submitDraft()).pipe( - Web.className("contrast"), - Web.attr("disabled", () => state.actionStatus() === "submitting"), - Web.data("todo-add-action", "true"), - ), - ).pipe(Web.className("todo-composer-row")), - View.hstack( - View.text("Every successful submit triggers a self-revalidation through the route loader.").pipe( - Web.className("todo-copy"), - ), - View.text(() => `Added from this mounted composer: ${state.additions()}`).pipe( - Web.className("todo-session-count"), - Web.data("todo-session-count", "true"), - ), - ).pipe(Web.className("todo-composer-meta")), + View.use(TodoPanel, [ + html` +

Composer

+ + The Add button now submits normalized action input through the Loom runtime before the loader revalidates the list. + + `, + html` +
+ ${renderTodoComposerInputSeam(state.actionStatus, state.draft, actions.submitDraft, actions.syncDraft)} + +
+ `, + html` +
+ Every successful submit triggers a self-revalidation through the route loader. + + ${() => `Added from this mounted composer: ${state.additions()}`} + +
+ `, ]) ), ) diff --git a/apps/loom-example-app/src/routes/todo-route/todo-hero.ts b/apps/loom-example-app/src/routes/todo-route/todo-hero.ts index 8388f8af..c62111d8 100644 --- a/apps/loom-example-app/src/routes/todo-route/todo-hero.ts +++ b/apps/loom-example-app/src/routes/todo-route/todo-hero.ts @@ -1,6 +1,9 @@ -import { Component, View, Web } from "@effectify/loom" +import { Component, html } from "@effectify/loom" import { counterRoutePath } from "../counter-route.js" import { todoDraftAtom, todoItemsAtom, todoLoaderStatusAtom } from "../todo-route-state.js" +import { ensureTemplateDocument } from "../../template-dom-support.js" + +ensureTemplateDocument() export const TodoHero = Component.make("TodoHero").pipe( Component.state({ @@ -9,34 +12,34 @@ export const TodoHero = Component.make("TodoHero").pipe( loaderStatus: todoLoaderStatusAtom, }), Component.view(({ state }) => - View.vstack( - View.vstack( - View.text("Example app only").pipe(Web.className("loom-example-eyebrow")), - View.text("Loom vNext todo app").pipe(Web.as("h1"), Web.className("todo-title")), - View.text( - "This route now reads through a loader and writes through an action runtime so the example shows an honest data flow instead of mutating module state directly.", - ).pipe(Web.className("todo-copy")), - ).pipe(Web.className("loom-example-hero")), - View.hstack( - View.vstack( - View.text("Loaded todos").pipe(Web.className("todo-kpi-label")), - View.text(() => `${state.todos().length} tracked todos`).pipe(Web.className("todo-kpi-value")), - ).pipe(Web.className("todo-kpi")), - View.vstack( - View.text("Loader status").pipe(Web.className("todo-kpi-label")), - View.text(() => state.loaderStatus()).pipe( - Web.className("todo-kpi-value"), - Web.data("todo-runtime-status", "true"), - ), - ).pipe(Web.className("todo-kpi")), - View.vstack( - View.text("Draft sync").pipe(Web.className("todo-kpi-label")), - View.text(() => state.draft().trim().length > 0 ? "Composer is holding input" : "Composer is empty").pipe( - Web.className("todo-kpi-value"), - ), - ).pipe(Web.className("todo-kpi")), - View.link("Back to counter", counterRoutePath).pipe(Web.className("outline secondary todo-link")), - ).pipe(Web.className("todo-kpi-row")), - ).pipe(Web.data("todo-hero", "true")) + html` +
+
+ Example app only +

Loom vNext todo app

+ + This route now reads through a loader and writes through an action runtime, while the UI is authored with Loom templates and View.use composition. + +
+ +
+
+ Loaded todos + ${() => `${state.todos().length} tracked todos`} +
+
+ Loader status + ${() => state.loaderStatus()} +
+
+ Draft sync + + ${() => state.draft().trim().length > 0 ? "Composer is holding input" : "Composer is empty"} + +
+ Back to counter +
+
+ ` ), ) diff --git a/apps/loom-example-app/src/routes/todo-route/todo-insights.ts b/apps/loom-example-app/src/routes/todo-route/todo-insights.ts index e2402cc3..621713d7 100644 --- a/apps/loom-example-app/src/routes/todo-route/todo-insights.ts +++ b/apps/loom-example-app/src/routes/todo-route/todo-insights.ts @@ -1,6 +1,9 @@ -import { Component, View, Web } from "@effectify/loom" +import { Component, html, View } from "@effectify/loom" import { todoActionStatusAtom, todoFeedbackAtom, todoItemsAtom } from "../todo-route-state.js" import { completedTodoCount, remainingTodoCount, TodoPanel } from "./todo-route-shared.js" +import { ensureTemplateDocument } from "../../template-dom-support.js" + +ensureTemplateDocument() export const TodoInsights = Component.make("TodoInsights").pipe( Component.state({ @@ -9,42 +12,40 @@ export const TodoInsights = Component.make("TodoInsights").pipe( todos: todoItemsAtom, }), Component.view(({ state }) => - Component.use(TodoPanel, [ - View.text("Runtime snapshot").pipe(Web.as("h2"), Web.className("todo-section-title")), - View.text( - "The route loader owns the initial list, and every button funnels through the route action plus self-revalidation.", - ).pipe(Web.className("todo-copy")), - View.hstack( - View.vstack( - View.text("Open").pipe(Web.className("todo-stat-label")), - View.text(() => `${remainingTodoCount(state.todos())}`).pipe( - Web.className("todo-stat-value"), - Web.data("todo-open-count", "true"), - ), - ).pipe(Web.className("todo-stat")), - View.vstack( - View.text("Completed").pipe(Web.className("todo-stat-label")), - View.text(() => `${completedTodoCount(state.todos())}`).pipe( - Web.className("todo-stat-value"), - Web.data("todo-completed-count", "true"), - ), - ).pipe(Web.className("todo-stat")), - View.vstack( - View.text("Action status").pipe(Web.className("todo-stat-label")), - View.text(() => state.actionStatus()).pipe( - Web.className("todo-stat-value"), - Web.data("todo-action-status", "true"), - ), - ).pipe(Web.className("todo-stat")), - ).pipe(Web.className("todo-stat-grid")), - View.if( - () => state.feedback() !== undefined, - View.text(() => state.feedback() ?? "").pipe( - Web.className("todo-copy"), - Web.data("todo-feedback", "true"), - ), - View.fragment(), - ), - ]) + View.use( + TodoPanel, + html` +

Runtime snapshot

+ + The route loader owns the initial list, and every button funnels through the route action plus self-revalidation. + + +
+
+ Open + ${() => + `${remainingTodoCount(state.todos())}`} +
+
+ Completed + + ${() => `${completedTodoCount(state.todos())}`} + +
+
+ Action status + ${() => state.actionStatus()} +
+
+ + ${ + View.if( + () => state.feedback() !== undefined, + html`${() => state.feedback() ?? ""}`, + html``, + ) + } + `, + ) ), ) diff --git a/apps/loom-example-app/src/routes/todo-route/todo-list.ts b/apps/loom-example-app/src/routes/todo-route/todo-list.ts index 26eacd79..c3222af9 100644 --- a/apps/loom-example-app/src/routes/todo-route/todo-list.ts +++ b/apps/loom-example-app/src/routes/todo-route/todo-list.ts @@ -1,7 +1,10 @@ -import { Component, View, Web } from "@effectify/loom" +import { Component, html, View } from "@effectify/loom" import { todoActionStatusAtom, todoItemsAtom } from "../todo-route-state.js" import { submitTodoRouteSubmission } from "../todo-route-submission.js" import { hasCompletedTodos, TodoPanel } from "./todo-route-shared.js" +import { ensureTemplateDocument } from "../../template-dom-support.js" + +ensureTemplateDocument() export const TodoList = Component.make("TodoList").pipe( Component.state({ @@ -20,57 +23,77 @@ export const TodoList = Component.make("TodoList").pipe( }, })), Component.view(({ state, actions }) => - Component.use(TodoPanel, [ - View.hstack( - View.vstack( - View.text("Todo list").pipe(Web.as("h2"), Web.className("todo-section-title")), - View.text( - "Secondary buttons also dispatch through the same route action instead of mutating atoms in place.", - ).pipe( - Web.className("todo-copy"), - ), - ), - View.button("Clear completed", () => actions.clearCompleted()).pipe( - Web.className("outline secondary"), - Web.attr("disabled", () => !hasCompletedTodos(state.todos()) || state.actionStatus() === "submitting"), - Web.data("todo-clear-completed", "true"), - ), - ).pipe(Web.className("todo-list-header")), - View.if( - () => state.todos().length === 0, - View.text("No todos left. Add another one from the composer above.").pipe( - Web.className("todo-empty-state"), - Web.data("todo-empty-state", "true"), - ), - View.vstack( - View.for(() => state.todos(), { - key: (todo) => todo.id, - render: (todo) => - View.hstack( - View.button(todo.completed ? "Re-open" : "Done", () => actions.toggleTodo(todo.id)).pipe( - Web.className(todo.completed ? "outline secondary" : "secondary"), - Web.attr("disabled", () => state.actionStatus() === "submitting"), - Web.data("todo-toggle-id", `${todo.id}`), - ), - View.vstack( - View.text(todo.title).pipe( - Web.className(todo.completed ? "todo-item-title todo-item-title--completed" : "todo-item-title"), - ), - View.text(todo.completed ? "Completed task" : "Open task").pipe(Web.className("todo-item-status")), - ).pipe(Web.className("todo-item-copy")), - View.button("Remove", () => actions.removeTodo(todo.id)).pipe( - Web.className("outline contrast"), - Web.attr("disabled", () => state.actionStatus() === "submitting"), - Web.data("todo-remove-id", `${todo.id}`), - ), - ).pipe( - Web.as("li"), - Web.className(todo.completed ? "todo-item todo-item--completed" : "todo-item"), - Web.data("todo-item-id", `${todo.id}`), - ), - }), - ).pipe(Web.as("ul"), Web.className("todo-item-list"), Web.data("todo-list", "true")), - ), - ]) + View.use( + TodoPanel, + html` +
+
+

Todo list

+ + Secondary buttons also dispatch through the same route action instead of mutating atoms in place. + +
+ + +
+ + ${ + View.if( + () => state.todos().length === 0, + html`No todos left. Add another one from the composer above.`, + html` +
    + ${ + View.for(() => state.todos(), { + key: (todo) => todo.id, + render: (todo) => + html` +
  • + + +
    + + ${todo.title} + + ${todo.completed ? "Completed task" : "Open task"} +
    + + +
  • + `, + }) + } +
+ `, + ) + } + `, + ) ), ) diff --git a/apps/loom-example-app/src/routes/todo-route/todo-route-component.ts b/apps/loom-example-app/src/routes/todo-route/todo-route-component.ts index f729f728..89f492a1 100644 --- a/apps/loom-example-app/src/routes/todo-route/todo-route-component.ts +++ b/apps/loom-example-app/src/routes/todo-route/todo-route-component.ts @@ -1,20 +1,30 @@ -import { Component, View, Web } from "@effectify/loom" +import { Component, html, View } from "@effectify/loom" +import { ensureTemplateDocument } from "../../template-dom-support.js" import { TodoComposer } from "./todo-composer.js" import { TodoHero } from "./todo-hero.js" import { TodoInsights } from "./todo-insights.js" import { TodoList } from "./todo-list.js" import { TodoNotes, TodoPageShell } from "./todo-route-shared.js" +ensureTemplateDocument() + export const TodoRoute = Component.make("TodoRoute").pipe( Component.view(() => - Component.use(TodoPageShell, [ - Component.use(TodoHero), - View.hstack( - Component.use(TodoInsights), - Component.use(TodoComposer), - ).pipe(Web.className("todo-top-row")), - Component.use(TodoList), - Component.use(TodoNotes), - ]) + html` + ${ + View.use( + TodoPageShell, + html` + ${View.use(TodoHero)} +
+ ${View.use(TodoInsights)} + ${View.use(TodoComposer)} +
+ ${View.use(TodoList)} + ${View.use(TodoNotes)} + `, + ) + } + ` ), ) diff --git a/apps/loom-example-app/src/routes/todo-route/todo-route-shared.ts b/apps/loom-example-app/src/routes/todo-route/todo-route-shared.ts index aec4ed2e..6e5ac13b 100644 --- a/apps/loom-example-app/src/routes/todo-route/todo-route-shared.ts +++ b/apps/loom-example-app/src/routes/todo-route/todo-route-shared.ts @@ -1,6 +1,9 @@ import * as Schema from "effect/Schema" -import { Component, View, Web } from "@effectify/loom" +import { Component, html } from "@effectify/loom" import { type TodoItem, TodoNotFoundError, type TodoServiceApi } from "../../todo-service.js" +import { ensureTemplateDocument } from "../../template-dom-support.js" + +ensureTemplateDocument() export type TodoRouteServices = Readonly<{ todoService: TodoServiceApi @@ -47,24 +50,24 @@ export const completedTodoCount = (todos: ReadonlyArray): number => export const hasCompletedTodos = (todos: ReadonlyArray): boolean => todos.some((todo) => todo.completed) export const TodoPanel = Component.make("TodoPanel").pipe( - Component.view(({ children }) => View.vstack(children).pipe(Web.className("loom-example-card todo-panel"))), + Component.view(({ children }) => html`
${children}
`), ) export const TodoNotes = Component.make("TodoNotes").pipe( Component.view(() => - View.vstack( - View.text( - "View.input() + Web.value() still cover the composer, but the durable todo state now comes from loader/action runtime boundaries.", - ).pipe(Web.className("compat-seam-note"), Web.data("todo-dx-caveat", "true")), - View.text( - "The point of this example is architecture: Effect-backed service first, Loom route runtime second, and UI atoms as the projected view state.", - ).pipe(Web.className("dev-mode-note")), - ).pipe(Web.className("loom-example-note-stack")) + html` +
+ + The composer keeps one explicit View.input() seam for input and Enter-key parity, but the rest of the route now teaches html templates + View.use first. + + + The point of this example is architecture: Effect-backed service first, Loom route runtime second, and UI atoms as the projected view state. + +
+ ` ), ) export const TodoPageShell = Component.make("TodoPageShell").pipe( - Component.view(({ children }) => - View.vstack(children).pipe(Web.className("loom-example-layout"), Web.data("route-view", "todo")) - ), + Component.view(({ children }) => html`
${children}
`), ) diff --git a/apps/loom-example-app/src/template-dom-support.ts b/apps/loom-example-app/src/template-dom-support.ts new file mode 100644 index 00000000..6fd8d512 --- /dev/null +++ b/apps/loom-example-app/src/template-dom-support.ts @@ -0,0 +1,63 @@ +import { isPromise } from "effect/Predicate" + +const jsdomModuleId = "jsdom" + +type GlobalDocumentOwner = typeof globalThis & { + document?: Document +} + +const serverTemplateDocument = typeof document === "undefined" + ? new (await import(/* @vite-ignore */ jsdomModuleId)).JSDOM("").window + .document + : undefined + +export const ensureTemplateDocument = (): Document => { + const documentOwner: GlobalDocumentOwner = globalThis + + if (documentOwner.document !== undefined) { + return documentOwner.document + } + + if (serverTemplateDocument === undefined) { + throw new Error("Template DOM support is unavailable without an ambient document.") + } + + documentOwner.document = serverTemplateDocument + return serverTemplateDocument +} + +export function withTemplateDocument(evaluate: () => Promise): Promise +export function withTemplateDocument(evaluate: () => Value): Value +export function withTemplateDocument(evaluate: () => Value | Promise): Value | Promise { + const documentOwner: GlobalDocumentOwner = globalThis + + if (documentOwner.document !== undefined) { + return evaluate() + } + + if (serverTemplateDocument === undefined) { + throw new Error("Template DOM support is unavailable without an ambient document.") + } + + documentOwner.document = serverTemplateDocument + + const restoreDocument = (): void => { + Reflect.deleteProperty(documentOwner, "document") + } + + let result: Value | Promise + + try { + result = evaluate() + } catch (error) { + restoreDocument() + throw error + } + + if (isPromise(result)) { + return result.finally(() => restoreDocument()) + } + + restoreDocument() + return result +} diff --git a/apps/loom-example-app/tests/entry-client.test.ts b/apps/loom-example-app/tests/entry-client.test.ts index 94b91249..7fd0d8e8 100644 --- a/apps/loom-example-app/tests/entry-client.test.ts +++ b/apps/loom-example-app/tests/entry-client.test.ts @@ -94,7 +94,7 @@ describe("loom example app client entry", () => { expect(result.status).toBe("missing-payload") expect(document.querySelectorAll('[data-app-shell="loom-example-app"]')).toHaveLength(1) expect(normalizedCount()).toBe("Count: 2") - expect(document.body.textContent).toContain("Loom-native attr/class/style bindings") + expect(document.body.textContent).toContain("Templates author this route now") const cueBefore = expectElement(reactiveCue(), "reactive cue") const dynamicValueBefore = expectElement(dynamicValue(), "dynamic counter value") @@ -260,6 +260,37 @@ describe("loom example app client entry", () => { expect(document.title).toBe("Loom Example App · Todo app") }) + it("submits the composer from the Enter key and preserves the same runtime behavior as the Add button", async () => { + document.documentElement.innerHTML = ` + Loom Example App + +
+ + + ` + window.history.replaceState({}, "", "/todos") + + await startClientApp(document) + + const input = expectInputElement(document.querySelector('[data-todo-input="true"]'), "todo input") + + input.focus() + input.value = "Ship Enter-key parity" + input.dispatchEvent(new Event("input", { bubbles: true })) + input.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" })) + await yieldToEventLoop() + + expect(document.querySelector('[data-todo-open-count="true"]')?.textContent?.trim()).toBe("3") + expect(document.querySelector('[data-todo-session-count="true"]')?.textContent?.trim()).toBe( + "Added from this mounted composer: 1", + ) + expect(expectInputElement(document.querySelector('[data-todo-input="true"]'), "todo input after enter").value).toBe( + "", + ) + expect(document.querySelector('[data-todo-item-id="4"]')?.textContent).toContain("Ship Enter-key parity") + expect(document.querySelector('[data-todo-action-status="true"]')?.textContent?.trim()).toBe("success") + }) + it("shows invalid action feedback when the todo action input fails validation", async () => { document.documentElement.innerHTML = ` Loom Example App diff --git a/apps/loom-example-app/tests/entry-server.test.ts b/apps/loom-example-app/tests/entry-server.test.ts index 6c2adee1..229ccdba 100644 --- a/apps/loom-example-app/tests/entry-server.test.ts +++ b/apps/loom-example-app/tests/entry-server.test.ts @@ -24,7 +24,7 @@ describe("loom example app server entry", () => { expect(result.html).toContain('data-counter-value="true"') expect(result.html).toContain('data-counter-dynamic-value="true"') expect(result.html).toContain('data-counter-reactive-cue="true"') - expect(result.html).toContain("Loom-native attr/class/style bindings") + expect(result.html).toContain("Templates author this route now") expect(result.html).toContain("mount(...) to fill the empty root") }) @@ -45,7 +45,8 @@ describe("loom example app server entry", () => { expect(result.html).toContain('data-todo-session-count="true"') expect(result.html).toContain('value=""') expect(result.html).toContain("Sketch the shared Atom shape") - expect(result.html).toContain("durable todo state now comes from loader/action runtime boundaries") + expect(result.html).toContain("authored with Loom templates and View.use composition") + expect(result.html).toContain("View.input() seam") }) it("returns a minimal not-found document for unknown paths", async () => { diff --git a/apps/loom-example-app/tests/jsdom.d.ts b/apps/loom-example-app/tests/jsdom.d.ts new file mode 100644 index 00000000..0b8e9c94 --- /dev/null +++ b/apps/loom-example-app/tests/jsdom.d.ts @@ -0,0 +1,3 @@ +declare module "jsdom" { + export const JSDOM: any +} diff --git a/apps/loom-example-app/tests/project-shape.test.ts b/apps/loom-example-app/tests/project-shape.test.ts index 31117125..d9f33134 100644 --- a/apps/loom-example-app/tests/project-shape.test.ts +++ b/apps/loom-example-app/tests/project-shape.test.ts @@ -47,6 +47,7 @@ describe("loom example app project shape", () => { it("authors the example through the vNext root surface first and uses mount for the dev fallback", () => { const counterRouteSource = readFileSync(new URL("../src/routes/counter-route.ts", import.meta.url), "utf8") + const documentSource = readFileSync(new URL("../src/document.ts", import.meta.url), "utf8") const todoRouteSource = readFileSync(new URL("../src/routes/todo-route.ts", import.meta.url), "utf8") const todoRouteComponentSource = readFileSync( new URL("../src/routes/todo-route/todo-route-component.ts", import.meta.url), @@ -72,10 +73,14 @@ describe("loom example app project shape", () => { expect(counterRouteSource).not.toContain("Component.stateFactory") expect(counterRouteSource).toContain("Component.actions") expect(counterRouteSource).toContain("Component.view") - expect(counterRouteSource).toContain("Web.as") - expect(counterRouteSource).toContain("View.vstack") - expect(counterRouteSource).toContain("View.hstack") - expect(counterRouteSource).toContain("Web.data") + expect(counterRouteSource).toContain('from "@effectify/loom"') + expect(counterRouteSource).toContain("html`") + expect(counterRouteSource).toContain('data-counter-reactive-cue="true"') + expect(counterRouteSource).toContain("web:click") + expect(counterRouteSource).not.toContain("View.button") + expect(counterRouteSource).not.toContain("View.link") + expect(counterRouteSource).not.toContain("View.vstack") + expect(counterRouteSource).not.toContain("View.hstack") expect(counterRouteSource).not.toContain("Html.el(") expect(counterRouteSource).not.toContain("Route.make({") expect(counterRouteSource).not.toContain("counterPageRoute") @@ -101,8 +106,11 @@ describe("loom example app project shape", () => { expect(todoRouteSource).not.toContain("todoActionDecoder") expect(todoRouteSource).not.toContain("toTodoRouteFailure") expect(todoRouteComponentSource).toContain('Component.make("TodoRoute").pipe(') - expect(todoRouteComponentSource).toContain("Component.use(TodoHero") - expect(todoRouteComponentSource).toContain("Component.use(TodoList") + expect(todoRouteComponentSource).toContain("html`") + expect(todoRouteComponentSource).toContain("${View.use(TodoPageShell,") + expect(todoRouteComponentSource).toContain("View.use(TodoHero)") + expect(todoRouteComponentSource).toContain("View.use(TodoList)") + expect(todoRouteComponentSource).not.toContain("View.use(TodoPageShell, [") expect(todoRouteComponentSource).not.toContain("attachTodoRegistry") expect(todoRouteComponentSource).not.toContain("View.fragment()") expect(todoRouteComponentSource).not.toContain("todoRegistry") @@ -113,18 +121,24 @@ describe("loom example app project shape", () => { expect(todoHeroSource).not.toContain("Component.children()") expect(todoHeroSource).not.toContain("attachTodoRegistry") expect(todoHeroSource).not.toContain("withTodoRouteRegistry") - expect(todoHeroSource).toContain("View.link") + expect(todoHeroSource).toContain('from "@effectify/loom"') + expect(todoHeroSource).toContain("html`") + expect(todoHeroSource).not.toContain("View.link") expect(todoHeroSource).not.toContain("registry: todoRegistry") expect(todoComposerSource).toContain('export const TodoComposer = Component.make("TodoComposer").pipe(') expect(todoComposerSource).toContain('Component.make("TodoComposer").pipe(') expect(todoComposerSource).not.toContain("Component.stateFactory") expect(todoComposerSource).not.toContain("attachTodoRegistry") - expect(todoComposerSource).toContain("View.button") + expect(todoComposerSource).toContain("html`") expect(todoComposerSource).toContain("View.input()") - expect(todoComposerSource).toContain("Web.value(") + expect(todoComposerSource).toContain("TodoComposerInputSeam") + expect(todoComposerSource).toContain('
') + expect(todoComposerSource).not.toContain("View.button") + expect(todoComposerSource).not.toContain("View.hstack(") expect(todoComposerSource).not.toContain("registry: todoRegistry") expect(todoListSource).toContain('export const TodoList = Component.make("TodoList").pipe(') - expect(todoListSource).toContain("View.button") + expect(todoListSource).toContain("html`") + expect(todoListSource).not.toContain("View.button") expect(todoListSource).not.toContain("attachTodoRegistry") expect(todoListSource).not.toContain("withTodoRouteRegistry") expect(todoListSource).not.toContain("registry: todoRegistry") @@ -138,17 +152,38 @@ describe("loom example app project shape", () => { expect(routerSource).toContain("module: counterRouteModule,") expect(routerSource).not.toContain("Component.children()") expect(routerSource).toContain('Component.make("AppShell")') - expect(routerSource).toContain("Component.use(AppShell") + expect(routerSource).toContain('from "@effectify/loom"') + expect(routerSource).toContain("html`") + expect(routerSource).toContain("View.use(AppShell") + expect(routerSource).toContain('children ?? ""') + expect(routerSource).toContain('Component.make("ShellBody")') + expect(routerSource).toContain("View.use(AppShell, View.use(ShellBody, { content: child }))") expect(routerSource).toContain("Fallback.make(notFoundView)") expect(routerSource).toContain("RouteModule.compile({") - expect(routerSource).toContain("component: () => Component.use(todoRouteModule.component)") + expect(routerSource).toContain("component: () => View.use(todoRouteModule.component)") + expect(routerSource).not.toContain("View.main(") + expect(routerSource).not.toContain("Component.use(AppShell") expect(routerSource).not.toContain("internal/route-modules") - expect(routerSource).not.toContain("Html.el(") expect(routerSource).not.toContain("registry: todoRegistry") expect(entryClientSource).toContain('import * as counterRouteModule from "./routes/counter-route.js"') expect(entryClientSource).toContain('import * as todoRouteModule from "./routes/todo-route.js"') expect(entryClientSource).toContain("mount({ counterRoute: counterRouteModule.component }, { root })") expect(entryClientSource).toContain("mount({ todoRoute: todoRouteModule.component }, { root })") + expect(documentSource).toContain("Html.el(") + + for ( + const routeSource of [ + counterRouteSource, + todoRouteSource, + todoRouteComponentSource, + todoHeroSource, + todoComposerSource, + todoListSource, + routerSource, + ] + ) { + expect(routeSource).not.toContain("Html.el(") + } for (const docSource of [loomReadmeSource, loomPrdSource, loomRfcSource]) { expect(docSource).not.toContain("Component.stateFactory") diff --git a/apps/loom-example-app/tests/router-runtime.test.ts b/apps/loom-example-app/tests/router-runtime.test.ts index 9c8373f7..fb381306 100644 --- a/apps/loom-example-app/tests/router-runtime.test.ts +++ b/apps/loom-example-app/tests/router-runtime.test.ts @@ -70,6 +70,9 @@ describe("loom example app todo router runtime", () => { expect(counterRouteSource).toContain('export const CounterRoute = Component.make("CounterRoute").pipe(') expect(counterRouteSource).toContain("export const component = CounterRoute") + expect(counterRouteSource).toContain("html`") + expect(counterRouteSource).toContain("web:click") + expect(counterRouteSource).not.toContain("View.button") expect(counterRouteSource).not.toContain("Route.make({") expect(counterRouteSource).not.toContain("counterPageRoute") expect(todoRouteSource).toContain("export const component = Object.assign(TodoRoute, { registry: todoRegistry })") @@ -92,15 +95,22 @@ describe("loom example app todo router runtime", () => { expect(todoRouteSource).not.toContain("todoPageRoute") expect(todoRouteSource).not.toContain("Route.make({") expect(todoRouteComponentSource).toContain('Component.make("TodoRoute").pipe(') - expect(todoRouteComponentSource).toContain("Component.use(TodoHero") - expect(todoRouteComponentSource).toContain("Component.use(TodoList") + expect(todoRouteComponentSource).toContain("html`") + expect(todoRouteComponentSource).toContain("View.use(TodoHero)") + expect(todoRouteComponentSource).toContain("View.use(TodoList)") expect(todoRouteComponentSource).not.toContain("attachTodoRegistry") expect(todoRouteComponentSource).not.toContain("withTodoRouteRegistry") expect(todoRouteComponentSource).not.toContain("todoRegistry") expect(routerSource).toContain('import * as counterRouteModule from "./routes/counter-route.js"') expect(routerSource).toContain("module: counterRouteModule,") expect(routerSource).toContain("RouteModule.compile({") - expect(routerSource).toContain("component: () => Component.use(todoRouteModule.component)") + expect(routerSource).toContain("html`") + expect(routerSource).toContain("View.use(AppShell") + expect(routerSource).toContain("component: () => View.use(todoRouteModule.component)") + expect(routerSource).toContain('children ?? ""') + expect(routerSource).toContain('Component.make("ShellBody")') + expect(routerSource).toContain("View.use(AppShell, View.use(ShellBody, { content: child }))") + expect(routerSource).not.toContain("Component.use(AppShell") expect(routerSource).not.toContain("internal/route-modules") }) diff --git a/apps/loom-example-app/tests/template-dom-support.test.ts b/apps/loom-example-app/tests/template-dom-support.test.ts new file mode 100644 index 00000000..b1efa031 --- /dev/null +++ b/apps/loom-example-app/tests/template-dom-support.test.ts @@ -0,0 +1,50 @@ +import { JSDOM } from "jsdom" +import { describe, expect, it } from "vitest" +import { ensureTemplateDocument, withTemplateDocument } from "../src/template-dom-support.js" + +describe.sequential("loom example app template DOM support", () => { + it("can eagerly install a reusable document before node-only module evaluation", () => { + expect(globalThis.document).toBeUndefined() + + const installedDocument = ensureTemplateDocument() + + expect(installedDocument.createElement("template").tagName).toBe("TEMPLATE") + expect(globalThis.document).toBe(installedDocument) + + Reflect.deleteProperty(globalThis, "document") + }) + + it("provides a temporary document for server-side template authoring and restores globals afterward", () => { + expect(globalThis.document).toBeUndefined() + + const tagName = withTemplateDocument(() => document.createElement("template").tagName) + + expect(tagName).toBe("TEMPLATE") + expect(globalThis.document).toBeUndefined() + }) + + it("reuses the ambient document when one already exists", () => { + const ambientDocument = new JSDOM("").window.document + globalThis.document = ambientDocument + + try { + const observedDocument = withTemplateDocument(() => document) + + expect(observedDocument).toBe(ambientDocument) + } finally { + Reflect.deleteProperty(globalThis, "document") + } + }) + + it("keeps the temporary document available until async work finishes", async () => { + expect(globalThis.document).toBeUndefined() + + const tagName = await withTemplateDocument(async () => { + await Promise.resolve() + return document.createElement("template").tagName + }) + + expect(tagName).toBe("TEMPLATE") + expect(globalThis.document).toBeUndefined() + }) +}) diff --git a/apps/loom-example-app/tsconfig.app.json b/apps/loom-example-app/tsconfig.app.json index efe69266..28316dab 100644 --- a/apps/loom-example-app/tsconfig.app.json +++ b/apps/loom-example-app/tsconfig.app.json @@ -1,6 +1,7 @@ { "extends": "./tsconfig.json", "include": [ + "src/**/*.d.ts", "src/**/*.ts", "src/**/*.mts" ], diff --git a/apps/loom-example-app/tsconfig.spec.json b/apps/loom-example-app/tsconfig.spec.json index bf7cec5c..8de0d717 100644 --- a/apps/loom-example-app/tsconfig.spec.json +++ b/apps/loom-example-app/tsconfig.spec.json @@ -2,6 +2,7 @@ "extends": "./tsconfig.app.json", "include": [ "vite.config.mts", + "tests/**/*.d.ts", "tests/**/*.ts" ], "compilerOptions": { From f2e2d187bf9253e5604d3edcd0e282f8866a1995 Mon Sep 17 00:00:00 2001 From: Andres David Jimenez Sulbaran Date: Mon, 27 Apr 2026 21:13:04 -0600 Subject: [PATCH 03/13] test(loom-example-app): relax multiline shape assertion --- apps/loom-example-app/tests/project-shape.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/loom-example-app/tests/project-shape.test.ts b/apps/loom-example-app/tests/project-shape.test.ts index d9f33134..56fa48fb 100644 --- a/apps/loom-example-app/tests/project-shape.test.ts +++ b/apps/loom-example-app/tests/project-shape.test.ts @@ -107,7 +107,7 @@ describe("loom example app project shape", () => { expect(todoRouteSource).not.toContain("toTodoRouteFailure") expect(todoRouteComponentSource).toContain('Component.make("TodoRoute").pipe(') expect(todoRouteComponentSource).toContain("html`") - expect(todoRouteComponentSource).toContain("${View.use(TodoPageShell,") + expect(todoRouteComponentSource).toMatch(/View\.use\(\s*TodoPageShell/s) expect(todoRouteComponentSource).toContain("View.use(TodoHero)") expect(todoRouteComponentSource).toContain("View.use(TodoList)") expect(todoRouteComponentSource).not.toContain("View.use(TodoPageShell, [") From 343e1f5f536c50beb4913ded9280e82dbf306613 Mon Sep 17 00:00:00 2001 From: Andres David Jimenez Sulbaran Date: Mon, 27 Apr 2026 21:49:04 -0600 Subject: [PATCH 04/13] feat(loom): add template-native form event directives --- .../src/routes/todo-route/todo-composer.ts | 77 ++++++++-------- .../routes/todo-route/todo-route-shared.ts | 2 +- .../tests/entry-client.test.ts | 17 ++-- .../tests/entry-server.test.ts | 4 +- .../tests/project-shape.test.ts | 9 +- packages/loom/web/src/template.js | 22 +++-- packages/loom/web/src/template.ts | 27 ++++-- .../web/tests/template-first-view-api.test.ts | 88 +++++++++++++++++++ 8 files changed, 188 insertions(+), 58 deletions(-) diff --git a/apps/loom-example-app/src/routes/todo-route/todo-composer.ts b/apps/loom-example-app/src/routes/todo-route/todo-composer.ts index b91f6c85..dde0b594 100644 --- a/apps/loom-example-app/src/routes/todo-route/todo-composer.ts +++ b/apps/loom-example-app/src/routes/todo-route/todo-composer.ts @@ -1,5 +1,5 @@ import { Atom } from "effect/unstable/reactivity" -import { Component, html, View, Web } from "@effectify/loom" +import { Component, html, View } from "@effectify/loom" import { todoActionStatusAtom, todoDraftAtom } from "../todo-route-state.js" import { submitTodoRouteSubmission } from "../todo-route-submission.js" import { TodoPanel } from "./todo-route-shared.js" @@ -7,34 +7,10 @@ import { ensureTemplateDocument } from "../../template-dom-support.js" ensureTemplateDocument() -const renderTodoComposerInputSeam = ( - actionStatus: () => string, - draft: () => string, - onSubmit: (titleInput?: string) => Promise, - onSync: (value: string) => void, -) => - View.input().pipe( - Web.className("todo-input"), - Web.attr("placeholder", "What should we ship next?"), - Web.attr("aria-label", "Todo title"), - Web.data("todo-input", "true"), - Web.attr("disabled", () => actionStatus() === "submitting"), - Web.value(() => draft()), - Web.on("input", ({ currentTarget }) => { - if (currentTarget instanceof HTMLInputElement) { - onSync(currentTarget.value) - } - }), - Web.on("keydown", ({ event, currentTarget }) => { - if (event instanceof KeyboardEvent && event.key === "Enter") { - event.preventDefault() - - if (currentTarget instanceof HTMLInputElement) { - void onSubmit(currentTarget.value) - } - } - }), - ) +const readTodoTitleInput = (form: HTMLFormElement): string | undefined => { + const titleInput = form.elements.namedItem("title") + return titleInput instanceof HTMLInputElement ? titleInput.value : undefined +} export const TodoComposer = Component.make("TodoComposer").pipe( Component.state({ @@ -69,16 +45,41 @@ export const TodoComposer = Component.make("TodoComposer").pipe( `, html`
- ${renderTodoComposerInputSeam(state.actionStatus, state.draft, actions.submitDraft, actions.syncDraft)} - + state.actionStatus() === "submitting"} + web:value=${() => state.draft()} + web:input=${({ currentTarget }) => { + if (currentTarget instanceof HTMLInputElement) { + actions.syncDraft(currentTarget.value) + } + }} + /> + +
`, html` diff --git a/apps/loom-example-app/src/routes/todo-route/todo-route-shared.ts b/apps/loom-example-app/src/routes/todo-route/todo-route-shared.ts index 6e5ac13b..25f5a86b 100644 --- a/apps/loom-example-app/src/routes/todo-route/todo-route-shared.ts +++ b/apps/loom-example-app/src/routes/todo-route/todo-route-shared.ts @@ -58,7 +58,7 @@ export const TodoNotes = Component.make("TodoNotes").pipe( html`
- The composer keeps one explicit View.input() seam for input and Enter-key parity, but the rest of the route now teaches html templates + View.use first. + The composer now uses a template-authored form so input sync and submit parity stay inside html directives with no imperative seam. The point of this example is architecture: Effect-backed service first, Loom route runtime second, and UI atoms as the projected view state. diff --git a/apps/loom-example-app/tests/entry-client.test.ts b/apps/loom-example-app/tests/entry-client.test.ts index 7fd0d8e8..fc2bfecb 100644 --- a/apps/loom-example-app/tests/entry-client.test.ts +++ b/apps/loom-example-app/tests/entry-client.test.ts @@ -273,11 +273,16 @@ describe("loom example app client entry", () => { await startClientApp(document) const input = expectInputElement(document.querySelector('[data-todo-input="true"]'), "todo input") + const form = document.querySelector("form") + + if (!(form instanceof HTMLFormElement)) { + throw new Error("expected todo composer form") + } input.focus() input.value = "Ship Enter-key parity" input.dispatchEvent(new Event("input", { bubbles: true })) - input.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" })) + form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })) await yieldToEventLoop() expect(document.querySelector('[data-todo-open-count="true"]')?.textContent?.trim()).toBe("3") @@ -291,7 +296,7 @@ describe("loom example app client entry", () => { expect(document.querySelector('[data-todo-action-status="true"]')?.textContent?.trim()).toBe("success") }) - it("shows invalid action feedback when the todo action input fails validation", async () => { + it("shows invalid action feedback when the template-authored submit path fails validation", async () => { document.documentElement.innerHTML = ` Loom Example App @@ -303,13 +308,13 @@ describe("loom example app client entry", () => { await startClientApp(document) - const addButton = document.querySelector('[data-todo-add-action="true"]') + const form = document.querySelector("form") - if (!(addButton instanceof HTMLButtonElement)) { - throw new Error("expected add todo button") + if (!(form instanceof HTMLFormElement)) { + throw new Error("expected todo composer form") } - addButton.click() + form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })) await yieldToEventLoop() expect(document.querySelector('[data-todo-feedback="true"]')?.textContent).toContain("length of at least 1") diff --git a/apps/loom-example-app/tests/entry-server.test.ts b/apps/loom-example-app/tests/entry-server.test.ts index 229ccdba..18ef8ccf 100644 --- a/apps/loom-example-app/tests/entry-server.test.ts +++ b/apps/loom-example-app/tests/entry-server.test.ts @@ -44,9 +44,11 @@ describe("loom example app server entry", () => { expect(result.html).toContain('data-todo-list="true"') expect(result.html).toContain('data-todo-session-count="true"') expect(result.html).toContain('value=""') + expect(result.html).toContain(" { diff --git a/apps/loom-example-app/tests/project-shape.test.ts b/apps/loom-example-app/tests/project-shape.test.ts index 56fa48fb..5e7a7883 100644 --- a/apps/loom-example-app/tests/project-shape.test.ts +++ b/apps/loom-example-app/tests/project-shape.test.ts @@ -130,9 +130,14 @@ describe("loom example app project shape", () => { expect(todoComposerSource).not.toContain("Component.stateFactory") expect(todoComposerSource).not.toContain("attachTodoRegistry") expect(todoComposerSource).toContain("html`") - expect(todoComposerSource).toContain("View.input()") - expect(todoComposerSource).toContain("TodoComposerInputSeam") + expect(todoComposerSource).toContain("') + expect(todoComposerSource).not.toContain("View.input()") + expect(todoComposerSource).not.toContain("renderTodoComposerInputSeam") + expect(todoComposerSource).not.toContain('Web.on("keydown"') expect(todoComposerSource).not.toContain("View.button") expect(todoComposerSource).not.toContain("View.hstack(") expect(todoComposerSource).not.toContain("registry: todoRegistry") diff --git a/packages/loom/web/src/template.js b/packages/loom/web/src/template.js index 60b790e3..9fecefd9 100644 --- a/packages/loom/web/src/template.js +++ b/packages/loom/web/src/template.js @@ -20,6 +20,14 @@ const isHydrationStrategy = (value) => typeof value === "object" && value !== null && "strategy" in value && "attributeName" in value && "attributeValue" in value +const pushEventDirective = (directive, eventName, interpolation, events) => { + if (!isEventHandler(interpolation)) { + throw new Error(`${directive} expects an event handler.`) + } + + events.push(internalApi.makeEventBinding(eventName, interpolation)) +} + const collapseNodes = (nodes) => { if (nodes.length === 0) { return LoomCore.Ast.fragment([]) @@ -132,11 +140,15 @@ const applyAttributeInterpolation = (name, interpolation, attributes, bindings) const applyWebDirective = (name, interpolation, attributes, bindings, events) => { switch (name) { case "click": { - if (!isEventHandler(interpolation)) { - throw new Error("web:click expects an event handler.") - } - - events.push(internalApi.makeEventBinding("click", interpolation)) + pushEventDirective("web:click", "click", interpolation, events) + return undefined + } + case "input": { + pushEventDirective("web:input", "input", interpolation, events) + return undefined + } + case "submit": { + pushEventDirective("web:submit", "submit", interpolation, events) return undefined } case "value": diff --git a/packages/loom/web/src/template.ts b/packages/loom/web/src/template.ts index 53b9760e..9c68e210 100644 --- a/packages/loom/web/src/template.ts +++ b/packages/loom/web/src/template.ts @@ -58,6 +58,19 @@ const isHydrationStrategy = (value: unknown): value is Hydration.Strategy => typeof value === "object" && value !== null && "strategy" in value && "attributeName" in value && "attributeValue" in value +const pushEventDirective = ( + directive: string, + eventName: string, + interpolation: TemplateInterpolation, + events: Array, +): void => { + if (!isEventHandler(interpolation)) { + throw new Error(`${directive} expects an event handler.`) + } + + events.push(internalApi.makeEventBinding(eventName, interpolation)) +} + const asRenderable = (node: LoomCore.Ast.Node): Renderable => viewNode.wrap(node) const collapseNodes = (nodes: ReadonlyArray): LoomCore.Ast.Node => { @@ -184,11 +197,15 @@ const applyWebDirective = ( ): LoomCore.Ast.HydrationMetadata | undefined => { switch (name) { case "click": { - if (!isEventHandler(interpolation)) { - throw new Error("web:click expects an event handler.") - } - - events.push(internalApi.makeEventBinding("click", interpolation)) + pushEventDirective("web:click", "click", interpolation, events) + return undefined + } + case "input": { + pushEventDirective("web:input", "input", interpolation, events) + return undefined + } + case "submit": { + pushEventDirective("web:submit", "submit", interpolation, events) return undefined } case "value": diff --git a/packages/loom/web/tests/template-first-view-api.test.ts b/packages/loom/web/tests/template-first-view-api.test.ts index de7327b5..44310bd3 100644 --- a/packages/loom/web/tests/template-first-view-api.test.ts +++ b/packages/loom/web/tests/template-first-view-api.test.ts @@ -155,6 +155,94 @@ describe("@effectify/loom template-first view API", () => { ) }) + it("binds web:input through the same event context path as Web.on", () => { + const root = document.createElement("div") + const draftInput = Component.make("draft-input").pipe( + Component.model({ draft: Atom.make("") }), + Component.actions(({ model }) => ({ + syncDraft: (value: string) => model.draft.set(value), + })), + Component.view(({ state, actions }) => + html` + + ` + ), + ) + + const handle = mount({ draftInput }, { root }) + const input = root.querySelector("input") + + if (!(input instanceof HTMLInputElement)) { + throw new Error("expected input element") + } + + input.value = "Ship template input parity" + input.dispatchEvent(new Event("input", { bubbles: true })) + + expect(root.querySelector("span")?.textContent).toBe("Ship template input parity") + expect(input.value).toBe("Ship template input parity") + + handle.dispose() + }) + + it("binds web:submit through the same submit runtime path as Web.on", () => { + const root = document.createElement("div") + const formEvents = Component.make("submit-form").pipe( + Component.model({ submits: Atom.make(0), tagName: Atom.make("idle") }), + Component.actions(({ model }) => ({ + submit: (element: EventTarget | null) => { + model.submits.update((value) => value + 1) + model.tagName.set(element instanceof HTMLFormElement ? element.tagName : "invalid") + }, + })), + Component.view(({ state, actions }) => + html` +
{ + event.preventDefault() + actions.submit(currentTarget) + }} + > + + ${() => state.submits()} + ${() => state.tagName()} +
+ ` + ), + ) + + const handle = mount({ formEvents }, { root }) + const form = root.querySelector("form") + + if (!(form instanceof HTMLFormElement)) { + throw new Error("expected form element") + } + + const submitted = form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })) + + expect(submitted).toBe(false) + expect(root.querySelector('[data-submit-count="true"]')?.textContent).toBe("1") + expect(root.querySelector('[data-submit-tag="true"]')?.textContent).toBe("FORM") + + handle.dispose() + }) + + it("rejects invalid handlers for web:input and web:submit", () => { + expect(() => html``).toThrowError(/web:input expects an event handler\./) + expect(() => html`
`).toThrowError(/web:submit expects an event handler\./) + }) + it("matches async result sources across waiting, success, failure, error, and defect branches", () => { const root = document.createElement("div") const loader = Component.make("template-loader").pipe( From fbdaa221f5b264af7b0c47478cfd955a0e8831df Mon Sep 17 00:00:00 2001 From: Andres David Jimenez Sulbaran Date: Mon, 27 Apr 2026 23:03:15 -0600 Subject: [PATCH 05/13] refactor(loom): remove template dom shim from ssr --- apps/loom-example-app/src/entry-server.ts | 28 +- apps/loom-example-app/src/router.ts | 3 - .../src/routes/counter-route.ts | 3 - .../src/routes/todo-route/todo-composer.ts | 3 - .../src/routes/todo-route/todo-hero.ts | 3 - .../src/routes/todo-route/todo-insights.ts | 3 - .../src/routes/todo-route/todo-list.ts | 3 - .../routes/todo-route/todo-route-component.ts | 3 - .../routes/todo-route/todo-route-shared.ts | 3 - .../src/template-dom-support.ts | 63 ----- .../tests/app-route-import-safety.test.ts | 31 ++ .../tests/entry-server.test.ts | 16 ++ .../tests/project-shape.test.ts | 13 + .../tests/router-runtime.test.ts | 6 + .../tests/template-dom-support.test.ts | 50 ---- packages/loom/web/src/template.js | 223 +++++++++++++-- packages/loom/web/src/template.ts | 267 ++++++++++++++++-- .../web/tests/template-node-safety.test.ts | 44 +++ 18 files changed, 553 insertions(+), 212 deletions(-) delete mode 100644 apps/loom-example-app/src/template-dom-support.ts create mode 100644 apps/loom-example-app/tests/app-route-import-safety.test.ts delete mode 100644 apps/loom-example-app/tests/template-dom-support.test.ts create mode 100644 packages/loom/web/tests/template-node-safety.test.ts diff --git a/apps/loom-example-app/src/entry-server.ts b/apps/loom-example-app/src/entry-server.ts index 238b19ca..b367e414 100644 --- a/apps/loom-example-app/src/entry-server.ts +++ b/apps/loom-example-app/src/entry-server.ts @@ -4,7 +4,6 @@ import { appBuildId, appPayloadElementId, appRootId } from "./app-config.js" import { prepareRouteRuntime } from "./router-runtime.js" import { bodyForResult, resolveAppRequest, statusForResult, titleForResult } from "./router.js" import { createDocument } from "./document.js" -import { withTemplateDocument } from "./template-dom-support.js" const applicationBaseUrl = "https://effectify.dev" @@ -38,25 +37,22 @@ export const createServerRenderer = (): LoomNitro.LoomNitroRenderer => { render: (request) => { const requestUrl = normalizeRequestUrl(request.url) - return withTemplateDocument(() => - prepareRouteRuntime(requestUrl).then(() => { - const result = resolveAppRequest(requestUrl) + return prepareRouteRuntime(requestUrl).then(() => { + const result = resolveAppRequest(requestUrl) - return createDocument({ - title: titleForResult(result), - body: bodyForResult(result), - }) + return createDocument({ + title: titleForResult(result), + body: bodyForResult(result), }) - ) + }) }, - response: (request) => - withTemplateDocument(() => { - const result = resolveAppRequest(normalizeRequestUrl(request.url)) + response: (request) => { + const result = resolveAppRequest(normalizeRequestUrl(request.url)) - return { - status: statusForResult(result), - } - }), + return { + status: statusForResult(result), + } + }, }) return { diff --git a/apps/loom-example-app/src/router.ts b/apps/loom-example-app/src/router.ts index 54405082..3b20dc2e 100644 --- a/apps/loom-example-app/src/router.ts +++ b/apps/loom-example-app/src/router.ts @@ -4,9 +4,6 @@ import { Fallback, Layout, RouteModule, Router, type Router as RouterTypes } fro import { counterRouteId, counterRoutePath, counterRouteTitle } from "./routes/counter-route.js" import * as counterRouteModule from "./routes/counter-route.js" import * as todoRouteModule from "./routes/todo-route.js" -import { ensureTemplateDocument } from "./template-dom-support.js" - -ensureTemplateDocument() export const todoRouteId = "todo" export const todoRoutePath = "/todos" diff --git a/apps/loom-example-app/src/routes/counter-route.ts b/apps/loom-example-app/src/routes/counter-route.ts index 4067a44e..3f4ca8c1 100644 --- a/apps/loom-example-app/src/routes/counter-route.ts +++ b/apps/loom-example-app/src/routes/counter-route.ts @@ -1,9 +1,6 @@ import { Atom } from "effect/unstable/reactivity" import { Component, html, Web } from "@effectify/loom" import { counterInitialCount } from "../app-config.js" -import { ensureTemplateDocument } from "../template-dom-support.js" - -ensureTemplateDocument() export const counterRouteId = "counter" export const counterRoutePath = "/" diff --git a/apps/loom-example-app/src/routes/todo-route/todo-composer.ts b/apps/loom-example-app/src/routes/todo-route/todo-composer.ts index dde0b594..f17cb222 100644 --- a/apps/loom-example-app/src/routes/todo-route/todo-composer.ts +++ b/apps/loom-example-app/src/routes/todo-route/todo-composer.ts @@ -3,9 +3,6 @@ import { Component, html, View } from "@effectify/loom" import { todoActionStatusAtom, todoDraftAtom } from "../todo-route-state.js" import { submitTodoRouteSubmission } from "../todo-route-submission.js" import { TodoPanel } from "./todo-route-shared.js" -import { ensureTemplateDocument } from "../../template-dom-support.js" - -ensureTemplateDocument() const readTodoTitleInput = (form: HTMLFormElement): string | undefined => { const titleInput = form.elements.namedItem("title") diff --git a/apps/loom-example-app/src/routes/todo-route/todo-hero.ts b/apps/loom-example-app/src/routes/todo-route/todo-hero.ts index c62111d8..1d809beb 100644 --- a/apps/loom-example-app/src/routes/todo-route/todo-hero.ts +++ b/apps/loom-example-app/src/routes/todo-route/todo-hero.ts @@ -1,9 +1,6 @@ import { Component, html } from "@effectify/loom" import { counterRoutePath } from "../counter-route.js" import { todoDraftAtom, todoItemsAtom, todoLoaderStatusAtom } from "../todo-route-state.js" -import { ensureTemplateDocument } from "../../template-dom-support.js" - -ensureTemplateDocument() export const TodoHero = Component.make("TodoHero").pipe( Component.state({ diff --git a/apps/loom-example-app/src/routes/todo-route/todo-insights.ts b/apps/loom-example-app/src/routes/todo-route/todo-insights.ts index 621713d7..ce0e5b26 100644 --- a/apps/loom-example-app/src/routes/todo-route/todo-insights.ts +++ b/apps/loom-example-app/src/routes/todo-route/todo-insights.ts @@ -1,9 +1,6 @@ import { Component, html, View } from "@effectify/loom" import { todoActionStatusAtom, todoFeedbackAtom, todoItemsAtom } from "../todo-route-state.js" import { completedTodoCount, remainingTodoCount, TodoPanel } from "./todo-route-shared.js" -import { ensureTemplateDocument } from "../../template-dom-support.js" - -ensureTemplateDocument() export const TodoInsights = Component.make("TodoInsights").pipe( Component.state({ diff --git a/apps/loom-example-app/src/routes/todo-route/todo-list.ts b/apps/loom-example-app/src/routes/todo-route/todo-list.ts index c3222af9..e9aea511 100644 --- a/apps/loom-example-app/src/routes/todo-route/todo-list.ts +++ b/apps/loom-example-app/src/routes/todo-route/todo-list.ts @@ -2,9 +2,6 @@ import { Component, html, View } from "@effectify/loom" import { todoActionStatusAtom, todoItemsAtom } from "../todo-route-state.js" import { submitTodoRouteSubmission } from "../todo-route-submission.js" import { hasCompletedTodos, TodoPanel } from "./todo-route-shared.js" -import { ensureTemplateDocument } from "../../template-dom-support.js" - -ensureTemplateDocument() export const TodoList = Component.make("TodoList").pipe( Component.state({ diff --git a/apps/loom-example-app/src/routes/todo-route/todo-route-component.ts b/apps/loom-example-app/src/routes/todo-route/todo-route-component.ts index 89f492a1..1b566a23 100644 --- a/apps/loom-example-app/src/routes/todo-route/todo-route-component.ts +++ b/apps/loom-example-app/src/routes/todo-route/todo-route-component.ts @@ -1,13 +1,10 @@ import { Component, html, View } from "@effectify/loom" -import { ensureTemplateDocument } from "../../template-dom-support.js" import { TodoComposer } from "./todo-composer.js" import { TodoHero } from "./todo-hero.js" import { TodoInsights } from "./todo-insights.js" import { TodoList } from "./todo-list.js" import { TodoNotes, TodoPageShell } from "./todo-route-shared.js" -ensureTemplateDocument() - export const TodoRoute = Component.make("TodoRoute").pipe( Component.view(() => html` diff --git a/apps/loom-example-app/src/routes/todo-route/todo-route-shared.ts b/apps/loom-example-app/src/routes/todo-route/todo-route-shared.ts index 25f5a86b..fc2ea9a8 100644 --- a/apps/loom-example-app/src/routes/todo-route/todo-route-shared.ts +++ b/apps/loom-example-app/src/routes/todo-route/todo-route-shared.ts @@ -1,9 +1,6 @@ import * as Schema from "effect/Schema" import { Component, html } from "@effectify/loom" import { type TodoItem, TodoNotFoundError, type TodoServiceApi } from "../../todo-service.js" -import { ensureTemplateDocument } from "../../template-dom-support.js" - -ensureTemplateDocument() export type TodoRouteServices = Readonly<{ todoService: TodoServiceApi diff --git a/apps/loom-example-app/src/template-dom-support.ts b/apps/loom-example-app/src/template-dom-support.ts deleted file mode 100644 index 6fd8d512..00000000 --- a/apps/loom-example-app/src/template-dom-support.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { isPromise } from "effect/Predicate" - -const jsdomModuleId = "jsdom" - -type GlobalDocumentOwner = typeof globalThis & { - document?: Document -} - -const serverTemplateDocument = typeof document === "undefined" - ? new (await import(/* @vite-ignore */ jsdomModuleId)).JSDOM("").window - .document - : undefined - -export const ensureTemplateDocument = (): Document => { - const documentOwner: GlobalDocumentOwner = globalThis - - if (documentOwner.document !== undefined) { - return documentOwner.document - } - - if (serverTemplateDocument === undefined) { - throw new Error("Template DOM support is unavailable without an ambient document.") - } - - documentOwner.document = serverTemplateDocument - return serverTemplateDocument -} - -export function withTemplateDocument(evaluate: () => Promise): Promise -export function withTemplateDocument(evaluate: () => Value): Value -export function withTemplateDocument(evaluate: () => Value | Promise): Value | Promise { - const documentOwner: GlobalDocumentOwner = globalThis - - if (documentOwner.document !== undefined) { - return evaluate() - } - - if (serverTemplateDocument === undefined) { - throw new Error("Template DOM support is unavailable without an ambient document.") - } - - documentOwner.document = serverTemplateDocument - - const restoreDocument = (): void => { - Reflect.deleteProperty(documentOwner, "document") - } - - let result: Value | Promise - - try { - result = evaluate() - } catch (error) { - restoreDocument() - throw error - } - - if (isPromise(result)) { - return result.finally(() => restoreDocument()) - } - - restoreDocument() - return result -} diff --git a/apps/loom-example-app/tests/app-route-import-safety.test.ts b/apps/loom-example-app/tests/app-route-import-safety.test.ts new file mode 100644 index 00000000..f0ee4ad2 --- /dev/null +++ b/apps/loom-example-app/tests/app-route-import-safety.test.ts @@ -0,0 +1,31 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +const importFresh = async (relativePath: string): Promise => { + const moduleUrl = new URL(relativePath, import.meta.url) + return import(`${moduleUrl.href}?t=${Date.now()}`) as Promise +} + +describe("loom example app import-time SSR safety", () => { + beforeEach(() => { + vi.resetModules() + Reflect.deleteProperty(globalThis, "document") + }) + + it("imports template-authored routes and router without installing a global document", async () => { + const counterRouteModule = await importFresh<{ component: unknown }>("../src/routes/counter-route.ts") + const todoRouteModule = await importFresh<{ component: unknown }>("../src/routes/todo-route.ts") + const routerModule = await importFresh<{ appRouter: unknown }>("../src/router.ts") + + expect(counterRouteModule.component).toBeDefined() + expect(todoRouteModule.component).toBeDefined() + expect(routerModule.appRouter).toBeDefined() + expect(Reflect.has(globalThis, "document")).toBe(false) + }) + + it("imports the server entry without installing a global document", async () => { + const entryServerModule = await importFresh<{ createServerRenderer: unknown }>("../src/entry-server.ts") + + expect(entryServerModule.createServerRenderer).toBeDefined() + expect(Reflect.has(globalThis, "document")).toBe(false) + }) +}) diff --git a/apps/loom-example-app/tests/entry-server.test.ts b/apps/loom-example-app/tests/entry-server.test.ts index 18ef8ccf..7f6f2ae7 100644 --- a/apps/loom-example-app/tests/entry-server.test.ts +++ b/apps/loom-example-app/tests/entry-server.test.ts @@ -4,9 +4,25 @@ import { resetTodoExampleState } from "../src/router-runtime.js" describe("loom example app server entry", () => { beforeEach(() => { + Reflect.deleteProperty(globalThis, "document") resetTodoExampleState() }) + it("renders without requiring or mutating a global document", async () => { + expect(Reflect.has(globalThis, "document")).toBe(false) + + const renderer = createServerRenderer() + const result = await renderer.render({ + method: "GET", + url: "/", + headers: {}, + }) + + expect(result.status).toBe(200) + expect(result.html).toContain('data-route-view="counter"') + expect(Reflect.has(globalThis, "document")).toBe(false) + }) + it("renders the single counter route inside the shared document shell", async () => { const renderer = createServerRenderer() const result = await renderer.render({ diff --git a/apps/loom-example-app/tests/project-shape.test.ts b/apps/loom-example-app/tests/project-shape.test.ts index 5e7a7883..0a987ae5 100644 --- a/apps/loom-example-app/tests/project-shape.test.ts +++ b/apps/loom-example-app/tests/project-shape.test.ts @@ -170,6 +170,19 @@ describe("loom example app project shape", () => { expect(routerSource).not.toContain("Component.use(AppShell") expect(routerSource).not.toContain("internal/route-modules") expect(routerSource).not.toContain("registry: todoRegistry") + expect(counterRouteSource).not.toContain("template-dom-support") + expect(counterRouteSource).not.toContain("ensureTemplateDocument") + expect(todoRouteSource).not.toContain("template-dom-support") + expect(todoRouteComponentSource).not.toContain("template-dom-support") + expect(todoRouteComponentSource).not.toContain("ensureTemplateDocument") + expect(todoHeroSource).not.toContain("template-dom-support") + expect(todoHeroSource).not.toContain("ensureTemplateDocument") + expect(todoComposerSource).not.toContain("template-dom-support") + expect(todoComposerSource).not.toContain("ensureTemplateDocument") + expect(todoListSource).not.toContain("template-dom-support") + expect(todoListSource).not.toContain("ensureTemplateDocument") + expect(routerSource).not.toContain("template-dom-support") + expect(routerSource).not.toContain("ensureTemplateDocument") expect(entryClientSource).toContain('import * as counterRouteModule from "./routes/counter-route.js"') expect(entryClientSource).toContain('import * as todoRouteModule from "./routes/todo-route.js"') expect(entryClientSource).toContain("mount({ counterRoute: counterRouteModule.component }, { root })") diff --git a/apps/loom-example-app/tests/router-runtime.test.ts b/apps/loom-example-app/tests/router-runtime.test.ts index fb381306..51e30028 100644 --- a/apps/loom-example-app/tests/router-runtime.test.ts +++ b/apps/loom-example-app/tests/router-runtime.test.ts @@ -110,6 +110,12 @@ describe("loom example app todo router runtime", () => { expect(routerSource).toContain('children ?? ""') expect(routerSource).toContain('Component.make("ShellBody")') expect(routerSource).toContain("View.use(AppShell, View.use(ShellBody, { content: child }))") + expect(counterRouteSource).not.toContain("template-dom-support") + expect(counterRouteSource).not.toContain("ensureTemplateDocument") + expect(todoRouteComponentSource).not.toContain("template-dom-support") + expect(todoRouteComponentSource).not.toContain("ensureTemplateDocument") + expect(routerSource).not.toContain("template-dom-support") + expect(routerSource).not.toContain("ensureTemplateDocument") expect(routerSource).not.toContain("Component.use(AppShell") expect(routerSource).not.toContain("internal/route-modules") }) diff --git a/apps/loom-example-app/tests/template-dom-support.test.ts b/apps/loom-example-app/tests/template-dom-support.test.ts deleted file mode 100644 index b1efa031..00000000 --- a/apps/loom-example-app/tests/template-dom-support.test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { JSDOM } from "jsdom" -import { describe, expect, it } from "vitest" -import { ensureTemplateDocument, withTemplateDocument } from "../src/template-dom-support.js" - -describe.sequential("loom example app template DOM support", () => { - it("can eagerly install a reusable document before node-only module evaluation", () => { - expect(globalThis.document).toBeUndefined() - - const installedDocument = ensureTemplateDocument() - - expect(installedDocument.createElement("template").tagName).toBe("TEMPLATE") - expect(globalThis.document).toBe(installedDocument) - - Reflect.deleteProperty(globalThis, "document") - }) - - it("provides a temporary document for server-side template authoring and restores globals afterward", () => { - expect(globalThis.document).toBeUndefined() - - const tagName = withTemplateDocument(() => document.createElement("template").tagName) - - expect(tagName).toBe("TEMPLATE") - expect(globalThis.document).toBeUndefined() - }) - - it("reuses the ambient document when one already exists", () => { - const ambientDocument = new JSDOM("").window.document - globalThis.document = ambientDocument - - try { - const observedDocument = withTemplateDocument(() => document) - - expect(observedDocument).toBe(ambientDocument) - } finally { - Reflect.deleteProperty(globalThis, "document") - } - }) - - it("keeps the temporary document available until async work finishes", async () => { - expect(globalThis.document).toBeUndefined() - - const tagName = await withTemplateDocument(async () => { - await Promise.resolve() - return document.createElement("template").tagName - }) - - expect(tagName).toBe("TEMPLATE") - expect(globalThis.document).toBeUndefined() - }) -}) diff --git a/packages/loom/web/src/template.js b/packages/loom/web/src/template.js index 9fecefd9..fd457a7a 100644 --- a/packages/loom/web/src/template.js +++ b/packages/loom/web/src/template.js @@ -7,9 +7,22 @@ const childMarkerPrefix = "loom-child:" const attributeMarkerPrefix = "__loom_attr_" const attributeMarkerPattern = /^__loom_attr_(\d+)__$/u const attributeContextPattern = /[A-Za-z0-9_:-]+\s*=\s*$/u -const commentNodeType = 8 -const textNodeType = 3 -const elementNodeType = 1 +const htmlVoidElements = new Set([ + "area", + "base", + "br", + "col", + "embed", + "hr", + "img", + "input", + "link", + "meta", + "param", + "source", + "track", + "wbr", +]) const isRenderable = (value) => typeof value === "object" && value !== null && "_tag" in value && value._tag !== "Component" @@ -75,6 +88,179 @@ const parseAttributeMarker = (value) => { return match === null ? undefined : Number(match[1]) } +const isWhitespace = (character) => character !== undefined && /\s/u.test(character) + +const skipWhitespace = (source, start) => { + let cursor = start + + while (isWhitespace(source[cursor])) { + cursor += 1 + } + + return cursor +} + +const readName = (source, start, owner) => { + let cursor = start + + while (cursor < source.length) { + const character = source[cursor] + + if ( + character === undefined || + isWhitespace(character) || + character === "/" || + character === ">" || + character === "=" + ) { + break + } + + cursor += 1 + } + + if (cursor === start) { + throw new Error(`Invalid html template: expected ${owner}.`) + } + + return [source.slice(start, cursor), cursor] +} + +const readAttributeValue = (source, start) => { + const quote = source[start] + + if (quote === '"' || quote === "'") { + const end = source.indexOf(quote, start + 1) + + if (end === -1) { + throw new Error("Invalid html template: unterminated quoted attribute value.") + } + + return [source.slice(start + 1, end), end + 1] + } + + let cursor = start + + while (cursor < source.length) { + const character = source[cursor] + + if (character === undefined || isWhitespace(character) || character === "/" || character === ">") { + break + } + + cursor += 1 + } + + return [source.slice(start, cursor), cursor] +} + +const parseStartTag = (source, start) => { + const [tagName, tagCursor] = readName(source, start + 1, "an element tag name") + let cursor = tagCursor + const attributes = [] + let selfClosing = false + + while (cursor < source.length) { + cursor = skipWhitespace(source, cursor) + + if (source.startsWith("/>", cursor)) { + selfClosing = true + cursor += 2 + break + } + + if (source[cursor] === ">") { + cursor += 1 + break + } + + const [attributeName, attributeCursor] = readName(source, cursor, "an attribute name") + cursor = skipWhitespace(source, attributeCursor) + let value = "" + + if (source[cursor] === "=") { + cursor = skipWhitespace(source, cursor + 1) + ;[value, cursor] = readAttributeValue(source, cursor) + } + + attributes.push({ name: attributeName, value }) + } + + return [{ kind: "element", tagName: tagName.toLowerCase(), attributes, children: [] }, cursor, selfClosing] +} + +const parseTemplateSource = (source) => { + const root = { children: [] } + const stack = [{ tagName: "#root", children: root.children }] + let cursor = 0 + + while (cursor < source.length) { + const current = stack[stack.length - 1] + + if (source.startsWith("", cursor + 4) + + if (commentEnd === -1) { + throw new Error("Invalid html template: unterminated comment.") + } + + current.children.push({ + kind: "comment", + data: source.slice(cursor + 4, commentEnd), + }) + cursor = commentEnd + 3 + continue + } + + if (source[cursor] === "<") { + if (source.startsWith("") { + throw new Error("Invalid html template: malformed closing tag.") + } + + if (stack.length === 1) { + throw new Error(`Invalid html template: unexpected closing tag .`) + } + + const closingTagName = tagName.toLowerCase() + const openElement = stack[stack.length - 1] + + if (openElement.tagName !== closingTagName) { + throw new Error(`Invalid html template: expected but found .`) + } + + stack.pop() + cursor += 1 + continue + } + + const [element, nextCursor, explicitSelfClosing] = parseStartTag(source, cursor) + current.children.push(element) + cursor = nextCursor + + if (!explicitSelfClosing && !htmlVoidElements.has(element.tagName)) { + stack.push({ tagName: element.tagName, children: element.children }) + } + + continue + } + + const nextTagIndex = source.indexOf("<", cursor) + const textEnd = nextTagIndex === -1 ? source.length : nextTagIndex + current.children.push({ kind: "text", data: source.slice(cursor, textEnd) }) + cursor = textEnd + } + + if (stack.length !== 1) { + throw new Error(`Invalid html template: missing closing tag for <${stack[stack.length - 1].tagName}>.`) + } + + return root.children +} + const textToNode = (value) => value.trim().length === 0 ? undefined : LoomCore.Ast.text(value) const applyAttributeInterpolation = (name, interpolation, attributes, bindings) => { @@ -170,12 +356,8 @@ const applyWebDirective = (name, interpolation, attributes, bindings, events) => } } -const isCommentNode = (node) => node.nodeType === commentNodeType -const isTextNode = (node) => node.nodeType === textNodeType -const isElementNode = (node) => node.nodeType === elementNodeType - -const convertDomNode = (node, values) => { - if (isCommentNode(node)) { +const convertParsedNode = (node, values) => { + if (node.kind === "comment") { if (!node.data.startsWith(childMarkerPrefix)) { return [] } @@ -191,21 +373,17 @@ const convertDomNode = (node, values) => { return normalizeInterpolationValue(interpolation) } - if (isTextNode(node)) { + if (node.kind === "text") { const textNode = textToNode(node.data) return textNode === undefined ? [] : [textNode] } - if (!isElementNode(node)) { - return [] - } - const attributes = {} const bindings = [] const events = [] let hydration - for (const attribute of Array.from(node.attributes)) { + for (const attribute of node.attributes) { const interpolationIndex = parseAttributeMarker(attribute.value) if (attribute.name.startsWith("web:")) { @@ -240,20 +418,12 @@ const convertDomNode = (node, values) => { return [LoomCore.Ast.element(node.tagName.toLowerCase(), { attributes, bindings, - children: Array.from(node.childNodes).flatMap((child) => convertDomNode(child, values)), + children: node.children.flatMap((child) => convertParsedNode(child, values)), events, hydration, })] } -const createTemplateElement = () => { - if (typeof document === "undefined") { - throw new Error("html template authoring currently requires DOM template parsing support.") - } - - return document.createElement("template") -} - export const renderable = (node) => viewNode.wrap(node) export const html = (strings, ...values) => { @@ -273,10 +443,7 @@ export const html = (strings, ...values) => { return current + segment + marker }, "") - const template = createTemplateElement() - template.innerHTML = source - return renderable( - collapseNodes(Array.from(template.content.childNodes).flatMap((child) => convertDomNode(child, values))), + collapseNodes(parseTemplateSource(source).flatMap((child) => convertParsedNode(child, values))), ) } diff --git a/packages/loom/web/src/template.ts b/packages/loom/web/src/template.ts index 9c68e210..8226f6c9 100644 --- a/packages/loom/web/src/template.ts +++ b/packages/loom/web/src/template.ts @@ -35,11 +35,49 @@ type InterpolationRequirements = Value extends ReadonlyArray const childMarkerPrefix = "loom-child:" const attributeMarkerPrefix = "__loom_attr_" const attributeMarkerPattern = /^__loom_attr_(\d+)__$/u -const commentNodeType = 8 -const textNodeType = 3 -const elementNodeType = 1 const attributeContextPattern = /[A-Za-z0-9_:-]+\s*=\s*$/u +type ParsedAttribute = { + readonly name: string + readonly value: string +} + +type ParsedCommentNode = { + readonly kind: "comment" + readonly data: string +} + +type ParsedTextNode = { + readonly kind: "text" + readonly data: string +} + +type ParsedElementNode = { + readonly kind: "element" + readonly tagName: string + readonly attributes: ReadonlyArray + readonly children: ReadonlyArray +} + +type ParsedNode = ParsedCommentNode | ParsedTextNode | ParsedElementNode + +const htmlVoidElements = new Set([ + "area", + "base", + "br", + "col", + "embed", + "hr", + "img", + "input", + "link", + "meta", + "param", + "source", + "track", + "wbr", +]) + const isRenderable = (value: unknown): value is Renderable => typeof value === "object" && value !== null && "_tag" in value && (value as { readonly _tag: string })._tag !== "Component" @@ -120,6 +158,194 @@ const parseAttributeMarker = (value: string): number | undefined => { return match === null ? undefined : Number(match[1]) } +const isWhitespace = (character: string | undefined): boolean => character !== undefined && /\s/u.test(character) + +const skipWhitespace = (source: string, start: number): number => { + let cursor = start + + while (isWhitespace(source[cursor])) { + cursor += 1 + } + + return cursor +} + +const readName = (source: string, start: number, owner: string): readonly [string, number] => { + let cursor = start + + while (cursor < source.length) { + const character = source[cursor] + + if ( + character === undefined || + isWhitespace(character) || + character === "/" || + character === ">" || + character === "=" + ) { + break + } + + cursor += 1 + } + + if (cursor === start) { + throw new Error(`Invalid html template: expected ${owner}.`) + } + + return [source.slice(start, cursor), cursor] +} + +const readAttributeValue = (source: string, start: number): readonly [string, number] => { + const quote = source[start] + + if (quote === '"' || quote === "'") { + const end = source.indexOf(quote, start + 1) + + if (end === -1) { + throw new Error("Invalid html template: unterminated quoted attribute value.") + } + + return [source.slice(start + 1, end), end + 1] + } + + let cursor = start + + while (cursor < source.length) { + const character = source[cursor] + + if (character === undefined || isWhitespace(character) || character === "/" || character === ">") { + break + } + + cursor += 1 + } + + return [source.slice(start, cursor), cursor] +} + +const parseStartTag = ( + source: string, + start: number, +): readonly [ParsedElementNode, number, boolean] => { + const [tagName, tagCursor] = readName(source, start + 1, "an element tag name") + let cursor = tagCursor + const attributes: Array = [] + let selfClosing = false + + while (cursor < source.length) { + cursor = skipWhitespace(source, cursor) + + if (source.startsWith("/>", cursor)) { + selfClosing = true + cursor += 2 + break + } + + if (source[cursor] === ">") { + cursor += 1 + break + } + + const [attributeName, attributeCursor] = readName(source, cursor, "an attribute name") + cursor = skipWhitespace(source, attributeCursor) + let value = "" + + if (source[cursor] === "=") { + cursor = skipWhitespace(source, cursor + 1) + ;[value, cursor] = readAttributeValue(source, cursor) + } + + attributes.push({ + name: attributeName, + value, + }) + } + + return [{ kind: "element", tagName: tagName.toLowerCase(), attributes, children: [] }, cursor, selfClosing] +} + +const parseTemplateSource = (source: string): ReadonlyArray => { + const root: { readonly children: Array } = { children: [] } + const stack: Array<{ readonly tagName: string; readonly children: Array }> = [{ + tagName: "#root", + children: root.children, + }] + let cursor = 0 + + while (cursor < source.length) { + const current = stack[stack.length - 1] + + if (source.startsWith("", cursor + 4) + + if (commentEnd === -1) { + throw new Error("Invalid html template: unterminated comment.") + } + + current.children.push({ + kind: "comment", + data: source.slice(cursor + 4, commentEnd), + }) + cursor = commentEnd + 3 + continue + } + + if (source[cursor] === "<") { + if (source.startsWith("") { + throw new Error("Invalid html template: malformed closing tag.") + } + + if (stack.length === 1) { + throw new Error(`Invalid html template: unexpected closing tag .`) + } + + const closingTagName = tagName.toLowerCase() + const openElement = stack[stack.length - 1] + + if (openElement.tagName !== closingTagName) { + throw new Error(`Invalid html template: expected but found .`) + } + + stack.pop() + cursor += 1 + continue + } + + const [element, nextCursor, explicitSelfClosing] = parseStartTag(source, cursor) + current.children.push(element) + cursor = nextCursor + + if (!explicitSelfClosing && !htmlVoidElements.has(element.tagName)) { + stack.push({ + tagName: element.tagName, + children: element.children as Array, + }) + } + + continue + } + + const nextTagIndex = source.indexOf("<", cursor) + const textEnd = nextTagIndex === -1 ? source.length : nextTagIndex + current.children.push({ + kind: "text", + data: source.slice(cursor, textEnd), + }) + cursor = textEnd + } + + if (stack.length !== 1) { + throw new Error(`Invalid html template: missing closing tag for <${stack[stack.length - 1].tagName}>.`) + } + + return root.children +} + const textToNode = (value: string): LoomCore.Ast.Node | undefined => value.trim().length === 0 ? undefined : LoomCore.Ast.text(value) @@ -227,17 +453,11 @@ const applyWebDirective = ( } } -const isCommentNode = (node: ChildNode): node is Comment => node.nodeType === commentNodeType - -const isTextNode = (node: ChildNode): node is Text => node.nodeType === textNodeType - -const isElementNode = (node: ChildNode): node is HTMLElement => node.nodeType === elementNodeType - -const convertDomNode = ( - node: ChildNode, +const convertParsedNode = ( + node: ParsedNode, values: ReadonlyArray, ): ReadonlyArray => { - if (isCommentNode(node)) { + if (node.kind === "comment") { if (!node.data.startsWith(childMarkerPrefix)) { return [] } @@ -253,21 +473,17 @@ const convertDomNode = ( return normalizeInterpolationValue(interpolation) } - if (isTextNode(node)) { + if (node.kind === "text") { const textNode = textToNode(node.data) return textNode === undefined ? [] : [textNode] } - if (!isElementNode(node)) { - return [] - } - const attributes: Record = {} const bindings: Array = [] const events: Array = [] let hydration: LoomCore.Ast.HydrationMetadata | undefined - for (const attribute of Array.from(node.attributes)) { + for (const attribute of node.attributes) { const interpolationIndex = parseAttributeMarker(attribute.value) if (attribute.name.startsWith("web:")) { @@ -303,21 +519,13 @@ const convertDomNode = ( LoomCore.Ast.element(node.tagName.toLowerCase(), { attributes, bindings, - children: Array.from(node.childNodes).flatMap((child) => convertDomNode(child, values)), + children: node.children.flatMap((child) => convertParsedNode(child, values)), events, hydration, }), ] } -const createTemplateElement = (): HTMLTemplateElement => { - if (typeof document === "undefined") { - throw new Error("html template authoring currently requires DOM template parsing support.") - } - - return document.createElement("template") -} - export const renderable = (node: LoomCore.Ast.Node): Renderable => asRenderable(node) export const html = >( @@ -340,10 +548,7 @@ export const html = >( return current + segment + marker }, "") - const template = createTemplateElement() - template.innerHTML = source - return asRenderable( - collapseNodes(Array.from(template.content.childNodes).flatMap((child) => convertDomNode(child, values))), + collapseNodes(parseTemplateSource(source).flatMap((child) => convertParsedNode(child, values))), ) } diff --git a/packages/loom/web/tests/template-node-safety.test.ts b/packages/loom/web/tests/template-node-safety.test.ts new file mode 100644 index 00000000..91175406 --- /dev/null +++ b/packages/loom/web/tests/template-node-safety.test.ts @@ -0,0 +1,44 @@ +// @vitest-environment node + +import { describe, expect, it } from "vitest" +import { Html, html, Hydration } from "../src/index.js" + +describe("@effectify/loom template parsing in node", () => { + it("authors SSR-safe templates without installing a global document", () => { + Reflect.deleteProperty(globalThis, "document") + + const result = Html.renderToString( + html` +
+ + "count:1"} /> +

${() => 1}

+
+ `, + ) + + expect(result).toContain('data-loom-hydrate="visible"') + expect(result).toContain('data-loom-events="click"') + expect(result).toContain('value="count:1"') + expect(result).toContain("

1

") + expect(Reflect.has(globalThis, "document")).toBe(false) + }) + + it("keeps unsupported directive errors stable when document is unavailable", () => { + Reflect.deleteProperty(globalThis, "document") + + expect(() => html`
"active"}>
`).toThrowError( + /Unsupported template directive 'web:class'/, + ) + expect(Reflect.has(globalThis, "document")).toBe(false) + }) + + it("fails fast for malformed templates without installing a global document", () => { + Reflect.deleteProperty(globalThis, "document") + + expect(() => html`
broken
`).toThrowError( + /Invalid html template: expected <\/span> but found <\/section>\./, + ) + expect(Reflect.has(globalThis, "document")).toBe(false) + }) +}) From e7360245eaf110fd54ac65b395dd154719ef2ed5 Mon Sep 17 00:00:00 2001 From: Andres David Jimenez Sulbaran Date: Tue, 28 Apr 2026 09:37:27 -0600 Subject: [PATCH 06/13] feat(loom): finish template-first follow-up sweep --- .../src/routes/counter-route.ts | 10 +- .../tests/entry-client.test.ts | 13 +- .../tests/entry-server.test.ts | 3 + .../tests/project-shape.test.ts | 225 +++---------- .../tests/router-runtime.test.ts | 77 +---- docs/loom-prd.md | 53 ++- docs/loom-rfc.md | 51 ++- packages/loom/README.md | 48 ++- .../tests/router-runtime-actions.test.ts | 67 +++- .../loom/router/tests/router-runtime.test.ts | 57 ++-- packages/loom/runtime/src/internal/runtime.js | 45 +++ packages/loom/runtime/src/internal/runtime.ts | 60 ++++ .../tests/control-flow-runtime.test.ts | 54 +++ .../loom/runtime/tests/runtime-events.test.ts | 111 +++++-- packages/loom/web/src/component.ts | 10 +- .../loom/web/src/internal/tracked-state.ts | 19 ++ packages/loom/web/src/template.js | 105 +++++- packages/loom/web/src/template.ts | 159 ++++++++- packages/loom/web/src/view.d.ts | 12 +- packages/loom/web/src/view.js | 12 +- packages/loom/web/src/view.ts | 12 +- packages/loom/web/src/web.d.ts | 4 + packages/loom/web/src/web.js | 20 +- packages/loom/web/src/web.ts | 26 +- .../loom/web/tests/state-accessor.test.ts | 14 + .../web/tests/template-first-view-api.test.ts | 310 +++++++++++++++++- .../tests/template-first-view-api.types.ts | 9 + .../web/tests/template-node-safety.test.ts | 41 ++- .../loom/web/tests/vnext-public-api.test.ts | 53 +++ .../loom/web/tests/vnext-public-api.types.ts | 9 + 30 files changed, 1262 insertions(+), 427 deletions(-) create mode 100644 packages/loom/web/tests/state-accessor.test.ts diff --git a/apps/loom-example-app/src/routes/counter-route.ts b/apps/loom-example-app/src/routes/counter-route.ts index 3f4ca8c1..6e611c48 100644 --- a/apps/loom-example-app/src/routes/counter-route.ts +++ b/apps/loom-example-app/src/routes/counter-route.ts @@ -46,11 +46,6 @@ const counterCueStyle = (count: number): Web.StyleRecord => { } } -const styleAttribute = (style: Web.StyleRecord): string => - Object.entries(style) - .map(([key, value]) => `${key.replace(/[A-Z]/g, (character) => `-${character.toLowerCase()}`)}: ${String(value)}`) - .join("; ") - export const CounterRoute = Component.make("CounterRoute").pipe( Component.state({ count: () => Atom.make(counterInitialCount).pipe(Atom.keepAlive), @@ -82,11 +77,12 @@ export const CounterRoute = Component.make("CounterRoute").pipe(
Reactive cue `counter-reactive-cue counter-reactive-cue--${counterCueTone(state.count())}`} + class="counter-reactive-cue" data-counter-reactive-cue="true" data-counter-tone=${() => counterCueTone(state.count())} title=${() => `Reactive cue tone: ${counterCueTone(state.count())} (${state.count()})`} - style=${() => styleAttribute(counterCueStyle(state.count()))} + web:class=${() => [`counter-reactive-cue--${counterCueTone(state.count())}`]} + web:style=${() => counterCueStyle(state.count())} > Loom attr/class/style in place diff --git a/apps/loom-example-app/tests/entry-client.test.ts b/apps/loom-example-app/tests/entry-client.test.ts index fc2bfecb..e22dc3f3 100644 --- a/apps/loom-example-app/tests/entry-client.test.ts +++ b/apps/loom-example-app/tests/entry-client.test.ts @@ -100,10 +100,7 @@ describe("loom example app client entry", () => { const dynamicValueBefore = expectElement(dynamicValue(), "dynamic counter value") expect(cueBefore.dataset.counterTone).toBe("baseline") - expect(cueBefore.className).toContain("counter-reactive-cue--baseline") expect(cueBefore.getAttribute("title")).toBe("Reactive cue tone: baseline (2)") - expect(cueBefore.style.backgroundColor).toBe("rgba(59, 130, 246, 0.12)") - expect(cueBefore.style.transform).toBe("translateY(0px)") click("increment") await yieldToEventLoop() @@ -115,14 +112,14 @@ describe("loom example app client entry", () => { expect(cueAfterIncrement).toBe(cueBefore) expect(dynamicValueAfterIncrement).toBe(dynamicValueBefore) expect(cueAfterIncrement.getAttribute("data-counter-tone")).toBe("rising") - expect(cueAfterIncrement.className).toContain("counter-reactive-cue--rising") expect(cueAfterIncrement.getAttribute("title")).toBe("Reactive cue tone: rising (3)") - expect(cueAfterIncrement.style.transform).toBe("translateY(-1px)") click("increment") await yieldToEventLoop() expect(normalizedCount()).toBe("Count: 4") - expect(expectElement(reactiveCue(), "reactive cue after second increment").style.transform).toBe("translateY(-2px)") + expect(expectElement(reactiveCue(), "reactive cue after second increment").getAttribute("title")).toBe( + "Reactive cue tone: rising (4)", + ) click("decrement") await yieldToEventLoop() @@ -132,7 +129,9 @@ describe("loom example app client entry", () => { await yieldToEventLoop() expect(normalizedCount()).toBe("Count: 2") expect(reactiveCue()?.getAttribute("data-counter-tone")).toBe("baseline") - expect(expectElement(reactiveCue(), "reactive cue after decrement").style.transform).toBe("translateY(0px)") + expect(expectElement(reactiveCue(), "reactive cue after decrement").getAttribute("title")).toBe( + "Reactive cue tone: baseline (2)", + ) click("reset") await yieldToEventLoop() diff --git a/apps/loom-example-app/tests/entry-server.test.ts b/apps/loom-example-app/tests/entry-server.test.ts index 7f6f2ae7..b5091615 100644 --- a/apps/loom-example-app/tests/entry-server.test.ts +++ b/apps/loom-example-app/tests/entry-server.test.ts @@ -32,6 +32,7 @@ describe("loom example app server entry", () => { }) expect(result.status).toBe(200) + expect(result.html).toContain("Loom Example App · Counter") expect(result.html).toContain("Loom vNext counter") expect(result.html).toContain('id="loom-root"') expect(result.html).toContain('data-route-view="counter"') @@ -53,6 +54,7 @@ describe("loom example app server entry", () => { }) expect(result.status).toBe(200) + expect(result.html).toContain("Loom Example App · Todo app") expect(result.html).toContain("Loom vNext todo app") expect(result.html).toContain('data-route-view="todo"') expect(result.html).toContain('data-todo-input="true"') @@ -76,6 +78,7 @@ describe("loom example app server entry", () => { }) expect(result.status).toBe(404) + expect(result.html).toContain("Loom Example App · Not Found") expect(result.html).toContain("Route not found") expect(result.html).toContain('data-route-view="not-found"') expect(result.html).toContain("/missing-route") diff --git a/apps/loom-example-app/tests/project-shape.test.ts b/apps/loom-example-app/tests/project-shape.test.ts index 0a987ae5..9eb5c3a7 100644 --- a/apps/loom-example-app/tests/project-shape.test.ts +++ b/apps/loom-example-app/tests/project-shape.test.ts @@ -1,5 +1,7 @@ import { readFileSync } from "node:fs" import { pathToFileURL } from "node:url" +import { Html } from "@effectify/loom" +import { Router } from "@effectify/loom-router" import { describe, expect, it } from "vitest" const readJson = (relativePath: string): unknown => @@ -45,181 +47,62 @@ describe("loom example app project shape", () => { ) }) - it("authors the example through the vNext root surface first and uses mount for the dev fallback", () => { - const counterRouteSource = readFileSync(new URL("../src/routes/counter-route.ts", import.meta.url), "utf8") - const documentSource = readFileSync(new URL("../src/document.ts", import.meta.url), "utf8") - const todoRouteSource = readFileSync(new URL("../src/routes/todo-route.ts", import.meta.url), "utf8") - const todoRouteComponentSource = readFileSync( - new URL("../src/routes/todo-route/todo-route-component.ts", import.meta.url), - "utf8", - ) - const todoHeroSource = readFileSync(new URL("../src/routes/todo-route/todo-hero.ts", import.meta.url), "utf8") - const todoComposerSource = readFileSync( - new URL("../src/routes/todo-route/todo-composer.ts", import.meta.url), - "utf8", - ) - const todoListSource = readFileSync(new URL("../src/routes/todo-route/todo-list.ts", import.meta.url), "utf8") - const todoRouteStateSource = readFileSync(new URL("../src/routes/todo-route-state.ts", import.meta.url), "utf8") - const routerSource = readFileSync(new URL("../src/router.ts", import.meta.url), "utf8") - const entryClientSource = readFileSync(new URL("../src/entry-client.ts", import.meta.url), "utf8") - const loomReadmeSource = readFileSync(new URL("../../../packages/loom/README.md", import.meta.url), "utf8") - const loomPrdSource = readFileSync(new URL("../../../docs/loom-prd.md", import.meta.url), "utf8") - const loomRfcSource = readFileSync(new URL("../../../docs/loom-rfc.md", import.meta.url), "utf8") + it("documents web:input and web:submit in the supported phase-1 directive list", () => { + const readme = readFileSync(new URL("../../../packages/loom/README.md", import.meta.url), "utf8") - expect(counterRouteSource).toContain("Component.state") - expect(counterRouteSource).toContain('export const CounterRoute = Component.make("CounterRoute").pipe(') - expect(counterRouteSource).toContain("export const component = CounterRoute") - expect(counterRouteSource).not.toContain("export const counterRoute =") - expect(counterRouteSource).not.toContain("Component.stateFactory") - expect(counterRouteSource).toContain("Component.actions") - expect(counterRouteSource).toContain("Component.view") - expect(counterRouteSource).toContain('from "@effectify/loom"') - expect(counterRouteSource).toContain("html`") - expect(counterRouteSource).toContain('data-counter-reactive-cue="true"') - expect(counterRouteSource).toContain("web:click") - expect(counterRouteSource).not.toContain("View.button") - expect(counterRouteSource).not.toContain("View.link") - expect(counterRouteSource).not.toContain("View.vstack") - expect(counterRouteSource).not.toContain("View.hstack") - expect(counterRouteSource).not.toContain("Html.el(") - expect(counterRouteSource).not.toContain("Route.make({") - expect(counterRouteSource).not.toContain("counterPageRoute") - expect(todoRouteStateSource).toContain("export const todoItemsAtom = Atom.make") - expect(todoRouteSource).toContain("export const component = Object.assign(TodoRoute, { registry: todoRegistry })") - expect(todoRouteSource).toContain("export const loader = pipe(") - expect(todoRouteSource).toContain("Route.loader({") - expect(todoRouteSource).toContain("Effect.fn(function*({ services }: { readonly services: TodoRouteServices }) {") - expect(todoRouteSource).toContain("export const action = pipe(") - expect(todoRouteSource).toContain("Route.action({") - expect(todoRouteSource).toContain( - "}: { readonly input: typeof TodoCommandSchema.Type; readonly services: TodoRouteServices }) {", + expect(readme).toContain( + "Supported phase-1 directives are limited to `web:click`, `web:input`, `web:submit`, `web:value` / `web:inputValue`, `web:hydrate`, `web:class`, and `web:style`.", ) - expect(todoRouteSource).toContain("input: TodoCommandSchema,") - expect(todoRouteSource).toContain("output: TodoCommandResultSchema,") - expect(todoRouteSource).toContain("error: TodoRouteErrorSchema,") - expect(todoRouteSource).not.toContain("const todoLoaderOptions = {") - expect(todoRouteSource).not.toContain("const todoActionOptions = {") - expect(todoRouteSource).not.toContain("Route.ModuleLoader<") - expect(todoRouteSource).not.toContain("Route.ModuleAction<") - expect(todoRouteSource).not.toContain("Route.ModuleLoaderContext<") - expect(todoRouteSource).not.toContain("Route.ModuleActionContext<") - expect(todoRouteSource).not.toContain("todoActionDecoder") - expect(todoRouteSource).not.toContain("toTodoRouteFailure") - expect(todoRouteComponentSource).toContain('Component.make("TodoRoute").pipe(') - expect(todoRouteComponentSource).toContain("html`") - expect(todoRouteComponentSource).toMatch(/View\.use\(\s*TodoPageShell/s) - expect(todoRouteComponentSource).toContain("View.use(TodoHero)") - expect(todoRouteComponentSource).toContain("View.use(TodoList)") - expect(todoRouteComponentSource).not.toContain("View.use(TodoPageShell, [") - expect(todoRouteComponentSource).not.toContain("attachTodoRegistry") - expect(todoRouteComponentSource).not.toContain("View.fragment()") - expect(todoRouteComponentSource).not.toContain("todoRegistry") - expect(todoRouteComponentSource).not.toContain("Object.assign(") - expect(todoHeroSource).toContain('export const TodoHero = Component.make("TodoHero").pipe(') - expect(todoHeroSource).toContain('Component.make("TodoHero").pipe(') - expect(todoHeroSource).toContain("Component.state({") - expect(todoHeroSource).not.toContain("Component.children()") - expect(todoHeroSource).not.toContain("attachTodoRegistry") - expect(todoHeroSource).not.toContain("withTodoRouteRegistry") - expect(todoHeroSource).toContain('from "@effectify/loom"') - expect(todoHeroSource).toContain("html`") - expect(todoHeroSource).not.toContain("View.link") - expect(todoHeroSource).not.toContain("registry: todoRegistry") - expect(todoComposerSource).toContain('export const TodoComposer = Component.make("TodoComposer").pipe(') - expect(todoComposerSource).toContain('Component.make("TodoComposer").pipe(') - expect(todoComposerSource).not.toContain("Component.stateFactory") - expect(todoComposerSource).not.toContain("attachTodoRegistry") - expect(todoComposerSource).toContain("html`") - expect(todoComposerSource).toContain("') - expect(todoComposerSource).not.toContain("View.input()") - expect(todoComposerSource).not.toContain("renderTodoComposerInputSeam") - expect(todoComposerSource).not.toContain('Web.on("keydown"') - expect(todoComposerSource).not.toContain("View.button") - expect(todoComposerSource).not.toContain("View.hstack(") - expect(todoComposerSource).not.toContain("registry: todoRegistry") - expect(todoListSource).toContain('export const TodoList = Component.make("TodoList").pipe(') - expect(todoListSource).toContain("html`") - expect(todoListSource).not.toContain("View.button") - expect(todoListSource).not.toContain("attachTodoRegistry") - expect(todoListSource).not.toContain("withTodoRouteRegistry") - expect(todoListSource).not.toContain("registry: todoRegistry") - expect(todoRouteSource).not.toContain('Web.as("input")') - expect(todoRouteSource).not.toContain('Web.attr("value"') - expect(todoRouteSource).not.toContain("syncTodoInputValue") - expect(todoRouteSource).not.toContain("Html.el(") - expect(todoRouteSource).not.toContain("todoPageRoute") - expect(todoRouteSource).not.toContain("Route.make({") - expect(routerSource).toContain('import * as counterRouteModule from "./routes/counter-route.js"') - expect(routerSource).toContain("module: counterRouteModule,") - expect(routerSource).not.toContain("Component.children()") - expect(routerSource).toContain('Component.make("AppShell")') - expect(routerSource).toContain('from "@effectify/loom"') - expect(routerSource).toContain("html`") - expect(routerSource).toContain("View.use(AppShell") - expect(routerSource).toContain('children ?? ""') - expect(routerSource).toContain('Component.make("ShellBody")') - expect(routerSource).toContain("View.use(AppShell, View.use(ShellBody, { content: child }))") - expect(routerSource).toContain("Fallback.make(notFoundView)") - expect(routerSource).toContain("RouteModule.compile({") - expect(routerSource).toContain("component: () => View.use(todoRouteModule.component)") - expect(routerSource).not.toContain("View.main(") - expect(routerSource).not.toContain("Component.use(AppShell") - expect(routerSource).not.toContain("internal/route-modules") - expect(routerSource).not.toContain("registry: todoRegistry") - expect(counterRouteSource).not.toContain("template-dom-support") - expect(counterRouteSource).not.toContain("ensureTemplateDocument") - expect(todoRouteSource).not.toContain("template-dom-support") - expect(todoRouteComponentSource).not.toContain("template-dom-support") - expect(todoRouteComponentSource).not.toContain("ensureTemplateDocument") - expect(todoHeroSource).not.toContain("template-dom-support") - expect(todoHeroSource).not.toContain("ensureTemplateDocument") - expect(todoComposerSource).not.toContain("template-dom-support") - expect(todoComposerSource).not.toContain("ensureTemplateDocument") - expect(todoListSource).not.toContain("template-dom-support") - expect(todoListSource).not.toContain("ensureTemplateDocument") - expect(routerSource).not.toContain("template-dom-support") - expect(routerSource).not.toContain("ensureTemplateDocument") - expect(entryClientSource).toContain('import * as counterRouteModule from "./routes/counter-route.js"') - expect(entryClientSource).toContain('import * as todoRouteModule from "./routes/todo-route.js"') - expect(entryClientSource).toContain("mount({ counterRoute: counterRouteModule.component }, { root })") - expect(entryClientSource).toContain("mount({ todoRoute: todoRouteModule.component }, { root })") - expect(documentSource).toContain("Html.el(") + }) - for ( - const routeSource of [ - counterRouteSource, - todoRouteSource, - todoRouteComponentSource, - todoHeroSource, - todoComposerSource, - todoListSource, - routerSource, - ] - ) { - expect(routeSource).not.toContain("Html.el(") - } + it("exports behavior-first route/document contracts through public entry points", async () => { + const counterRouteModule = await import( + pathToFileURL(new URL("../src/routes/counter-route.ts", import.meta.url).pathname).href + ) + const todoRouteModule = await import( + pathToFileURL(new URL("../src/routes/todo-route.ts", import.meta.url).pathname).href + ) + const todoRouteStateModule = await import( + pathToFileURL(new URL("../src/routes/todo-route-state.ts", import.meta.url).pathname).href + ) + const routerModule = await import(pathToFileURL(new URL("../src/router.ts", import.meta.url).pathname).href) + const documentModule = await import(pathToFileURL(new URL("../src/document.ts", import.meta.url).pathname).href) - for (const docSource of [loomReadmeSource, loomPrdSource, loomRfcSource]) { - expect(docSource).not.toContain("Component.stateFactory") - expect(docSource).not.toContain("Component.children()") - } + const counterResult = routerModule.resolveAppRequest("/") + const todoResult = routerModule.resolveAppRequest("/todos") + const missingResult = routerModule.resolveAppRequest("/missing") + const counterHtml = Html.renderToString(routerModule.bodyForResult(counterResult)) + const todoHtml = Html.renderToString(routerModule.bodyForResult(todoResult)) + const missingHtml = Html.renderToString(routerModule.bodyForResult(missingResult)) + const documentHtml = Html.renderToString( + documentModule.createDocument({ + body: routerModule.bodyForResult(counterResult), + title: routerModule.titleForResult(counterResult), + }), + ) - expect(loomReadmeSource).toContain('Component.make("CounterRoute")') - expect(loomReadmeSource).toContain("RouteModule.compile({") - expect(loomReadmeSource).toContain("module: { component: CounterRoute, loader },") - expect(loomReadmeSource).toContain("Route.loader({") - expect(loomReadmeSource).toContain("children }) =>") - expect(loomPrdSource).toContain('Component.make("CounterRoute")') - expect(loomPrdSource).toContain("RouteModule.compile({") - expect(loomPrdSource).toContain("module: { component: CounterRoute, loader },") - expect(loomPrdSource).toContain("Component.view(({ children }) =>") - expect(loomRfcSource).toContain('Component.make("CounterRoute")') - expect(loomRfcSource).toContain("RouteModule.compile({") - expect(loomRfcSource).toContain("module: { component: CounterRoute, loader },") - expect(loomRfcSource).toContain("Component.view(({ children }) =>") + expect(counterRouteModule.component).toBe(counterRouteModule.CounterRoute) + expect(counterRouteModule.counterRoutePath).toBe("/") + expect(todoRouteModule.component.registry).toBe(todoRouteStateModule.todoRegistry) + expect(Router.isResolveSuccess(counterResult)).toBe(true) + expect(Router.isResolveSuccess(todoResult)).toBe(true) + expect(Router.isResolveNotFound(missingResult)).toBe(true) + expect(routerModule.statusForResult(counterResult)).toBe(200) + expect(routerModule.statusForResult(todoResult)).toBe(200) + expect(routerModule.statusForResult(missingResult)).toBe(404) + expect(routerModule.titleForResult(counterResult)).toBe("Counter") + expect(routerModule.titleForResult(todoResult)).toBe("Todo app") + expect(counterHtml).toContain('data-route-view="counter"') + expect(counterHtml).toContain('data-counter-action="increment"') + expect(counterHtml).toContain('data-counter-reactive-cue="true"') + expect(todoHtml).toContain('data-route-view="todo"') + expect(todoHtml).toContain('data-todo-add-action="true"') + expect(todoHtml).toContain('data-todo-runtime-status="true"') + expect(todoHtml).toContain('data-todo-empty-state="true"') + expect(missingHtml).toContain('data-route-view="not-found"') + expect(missingHtml).toContain("Requested path: /missing") + expect(documentHtml).toContain('') + expect(documentHtml).toContain('id="loom-root"') + expect(documentHtml).toContain('id="__loom_payload__"') }) }) diff --git a/apps/loom-example-app/tests/router-runtime.test.ts b/apps/loom-example-app/tests/router-runtime.test.ts index 51e30028..be79c889 100644 --- a/apps/loom-example-app/tests/router-runtime.test.ts +++ b/apps/loom-example-app/tests/router-runtime.test.ts @@ -1,6 +1,8 @@ import * as Effect from "effect/Effect" -import { readFileSync } from "node:fs" +import { Html } from "@effectify/loom" +import { Router } from "@effectify/loom-router" import { describe, expect, it } from "vitest" +import { bodyForResult, resolveAppRequest, statusForResult, titleForResult } from "../src/router.js" import { createTodoRouteRuntime } from "../src/router-runtime.js" import { type TodoItem, TodoNotFoundError, type TodoServiceApi } from "../src/todo-service.js" @@ -59,65 +61,20 @@ const makeTestTodoService = (seed: ReadonlyArray) => { } describe("loom example app todo router runtime", () => { - it("authors the /todos route as a route module and compiles it at the router boundary", () => { - const counterRouteSource = readFileSync(new URL("../src/routes/counter-route.ts", import.meta.url), "utf8") - const todoRouteSource = readFileSync(new URL("../src/routes/todo-route.ts", import.meta.url), "utf8") - const todoRouteComponentSource = readFileSync( - new URL("../src/routes/todo-route/todo-route-component.ts", import.meta.url), - "utf8", - ) - const routerSource = readFileSync(new URL("../src/router.ts", import.meta.url), "utf8") - - expect(counterRouteSource).toContain('export const CounterRoute = Component.make("CounterRoute").pipe(') - expect(counterRouteSource).toContain("export const component = CounterRoute") - expect(counterRouteSource).toContain("html`") - expect(counterRouteSource).toContain("web:click") - expect(counterRouteSource).not.toContain("View.button") - expect(counterRouteSource).not.toContain("Route.make({") - expect(counterRouteSource).not.toContain("counterPageRoute") - expect(todoRouteSource).toContain("export const component = Object.assign(TodoRoute, { registry: todoRegistry })") - expect(todoRouteSource).toContain("export const loader = pipe(") - expect(todoRouteSource).toContain("Route.loader({") - expect(todoRouteSource).toContain("Effect.fn(function*({ services }: { readonly services: TodoRouteServices }) {") - expect(todoRouteSource).toContain("export const action = pipe(") - expect(todoRouteSource).toContain("Route.action({") - expect(todoRouteSource).toContain( - "}: { readonly input: typeof TodoCommandSchema.Type; readonly services: TodoRouteServices }) {", - ) - expect(todoRouteSource).toContain("input: TodoCommandSchema,") - expect(todoRouteSource).toContain("output: TodoCommandResultSchema,") - expect(todoRouteSource).toContain("error: TodoRouteErrorSchema,") - expect(todoRouteSource).not.toContain("const todoLoaderOptions = {") - expect(todoRouteSource).not.toContain("const todoActionOptions = {") - expect(todoRouteSource).not.toContain("Route.ModuleLoader<") - expect(todoRouteSource).not.toContain("Route.ModuleAction<") - expect(todoRouteSource).not.toContain("todoActionDecoder") - expect(todoRouteSource).not.toContain("todoPageRoute") - expect(todoRouteSource).not.toContain("Route.make({") - expect(todoRouteComponentSource).toContain('Component.make("TodoRoute").pipe(') - expect(todoRouteComponentSource).toContain("html`") - expect(todoRouteComponentSource).toContain("View.use(TodoHero)") - expect(todoRouteComponentSource).toContain("View.use(TodoList)") - expect(todoRouteComponentSource).not.toContain("attachTodoRegistry") - expect(todoRouteComponentSource).not.toContain("withTodoRouteRegistry") - expect(todoRouteComponentSource).not.toContain("todoRegistry") - expect(routerSource).toContain('import * as counterRouteModule from "./routes/counter-route.js"') - expect(routerSource).toContain("module: counterRouteModule,") - expect(routerSource).toContain("RouteModule.compile({") - expect(routerSource).toContain("html`") - expect(routerSource).toContain("View.use(AppShell") - expect(routerSource).toContain("component: () => View.use(todoRouteModule.component)") - expect(routerSource).toContain('children ?? ""') - expect(routerSource).toContain('Component.make("ShellBody")') - expect(routerSource).toContain("View.use(AppShell, View.use(ShellBody, { content: child }))") - expect(counterRouteSource).not.toContain("template-dom-support") - expect(counterRouteSource).not.toContain("ensureTemplateDocument") - expect(todoRouteComponentSource).not.toContain("template-dom-support") - expect(todoRouteComponentSource).not.toContain("ensureTemplateDocument") - expect(routerSource).not.toContain("template-dom-support") - expect(routerSource).not.toContain("ensureTemplateDocument") - expect(routerSource).not.toContain("Component.use(AppShell") - expect(routerSource).not.toContain("internal/route-modules") + it("resolves / and /todos through the public router contract instead of source-shape assertions", () => { + const counterResult = resolveAppRequest("/") + const todoResult = resolveAppRequest("/todos") + + expect(Router.isResolveSuccess(counterResult)).toBe(true) + expect(Router.isResolveSuccess(todoResult)).toBe(true) + expect(statusForResult(counterResult)).toBe(200) + expect(statusForResult(todoResult)).toBe(200) + expect(titleForResult(counterResult)).toBe("Counter") + expect(titleForResult(todoResult)).toBe("Todo app") + expect(Html.renderToString(bodyForResult(counterResult))).toContain('data-route-view="counter"') + expect(Html.renderToString(bodyForResult(todoResult))).toContain('data-route-view="todo"') + expect(Html.renderToString(bodyForResult(todoResult))).toContain('data-todo-add-action="true"') + expect(Html.renderToString(bodyForResult(todoResult))).toContain('data-todo-empty-state="true"') }) it("executes the initial loader through the route runtime", async () => { diff --git a/docs/loom-prd.md b/docs/loom-prd.md index e8e9a454..69d7ccc9 100644 --- a/docs/loom-prd.md +++ b/docs/loom-prd.md @@ -195,7 +195,7 @@ These are intentionally NOT goals for the first line of the initiative: - Developers can build a small interactive application with plain TypeScript and real Atom state using only documented public APIs. - Developers understand that Loom is not replacing Atom; it is building above it. - Developers can distinguish when to use `children` versus named `Slot` composition without needing JSX/TSX mental models. -- Developers can compose primitive controls such as `View.button(...)` and `View.link(...)` with broad `ViewChild` content, not only string labels. +- Developers can migrate legacy compatibility helpers such as `View.button(...)`, `View.input()`, and `View.link(...)` toward `html` + `web:*` without losing support for existing call sites. - Developers encounter `View.vstack` / `View.hstack` as the default layout vocabulary. ### Technical success @@ -222,36 +222,29 @@ The target composition story should be documented this way: - `Component.slots(...)` / `Slot` are for named structural composition. - `Component.use(component, props?, childrenOrSlots?)` should accept either plain children content or a named slot object depending on the component contract. - `Html.*` remains available as a lower-level or compatibility surface, but is not the main path used to teach Loom. +- `View.button(...)`, `View.input()`, and `View.link(...)` are legacy compatibility helpers; docs should steer new DOM authoring to `html` + `web:*`. Directional examples: ```ts -View.button("Increase", actions.increment) +html`` -View.button( - View.hstack( - Icon.plus(), - View.text("Increase"), - ), - actions.increment, -) +html` + +` -View.link("Open settings", "/settings") +html`` -View.link( - View.hstack( - Icon.externalLink(), - View.text("Docs"), - ), - { href: "/docs" }, -) +html`${View.hstack(Icon.externalLink(), View.text("Docs"))}` ``` ### Counter target DX ```ts import * as Atom from "effect/unstable/reactivity/Atom" -import { Component, mount, View, Web } from "@effectify/loom" +import { Component, html, mount, View, Web } from "@effectify/loom" export const CounterRoute = Component.make("CounterRoute").pipe( Component.state({ @@ -263,16 +256,18 @@ export const CounterRoute = Component.make("CounterRoute").pipe( reset: ({ count }) => count.set(0), }), Component.view(({ state, actions }) => - View.vstack( - View.text("Counter").pipe(Web.as("h1")), - View.text(() => `Count: ${state.count()}`), - View.text(() => `Doubled: ${state.count() * 2}`), - View.hstack( - View.button("Decrease", actions.decrement), - View.button("Increase", actions.increment), - View.button("Reset", actions.reset), - ), - ).pipe(Web.className("gap-2")) + html` +
+

Counter

+

${() => state.count()}

+

${() => state.count() * 2}

+
+ + + +
+
+ ` ), ) @@ -325,6 +320,8 @@ Component.use(AppLayout, {}, { Similarly, `Html.*` may still exist as a lower-level or compatibility layer, but product-facing Loom docs should present `View` + `Web` as the primary authoring path. +`View.button(...)`, `View.input()`, and `View.link(...)` are legacy compatibility helpers. Keep them callable, but teach `html` + `web:click`, `web:value` / `web:inputValue`, and `` first. + ### Router target DX ```ts diff --git a/docs/loom-rfc.md b/docs/loom-rfc.md index f97a2096..cf57d762 100644 --- a/docs/loom-rfc.md +++ b/docs/loom-rfc.md @@ -259,30 +259,22 @@ This avoids over-teaching slots for everyday unnamed composition while keeping n ### 5.6 Primitive content model -Interactive primitives should accept broad `ViewChild` content, not only string labels. +Interactive primitives should accept broad `ViewChild` content, not only string labels. `View.button(...)`, `View.input()`, and `View.link(...)` remain legacy compatibility helpers, but new DOM authoring should prefer `html` plus `web:*` modifiers. Directional targets: ```ts -View.button("Increase", actions.increment) +html`` -View.button( - View.hstack( - Icon.plus(), - View.text("Increase"), - ), - actions.increment, -) +html` + +` -View.link("Open settings", "/settings") +html`` -View.link( - View.hstack( - Icon.externalLink(), - View.text("Docs"), - ), - { href: "/docs" }, -) +html`${View.hstack(Icon.externalLink(), View.text("Docs"))}` ``` String sugar is valuable and should stay ergonomic, but the type contract should be broad enough to support composed child content directly. @@ -330,6 +322,7 @@ Directional teaching guidance: - Prefer `View.vstack(...)` and `View.hstack(...)` in docs and examples. - Treat `View.stack(...)` as compatibility vocabulary only. - Prefer `ViewChild`-accepting primitives for content-bearing controls such as buttons and links. +- Treat `View.button(...)`, `View.input()`, and `View.link(...)` as legacy compatibility helpers in docs; the default DOM path is `html` + `web:*`. Directional examples: @@ -340,7 +333,7 @@ View.text("hello") View.text("Title").pipe(Web.as("h1")) View.when(condition, content) View.main(slot) -View.button(View.text("Save"), save) +html`` ``` ### 6.3 `Web` @@ -457,7 +450,7 @@ This section captures the intended direction, not a finalized signature freeze. ```ts import * as Atom from "effect/unstable/reactivity/Atom" -import { Component, mount, View, Web } from "@effectify/loom" +import { Component, html, mount, View, Web } from "@effectify/loom" export const CounterRoute = Component.make("CounterRoute").pipe( Component.state({ @@ -469,15 +462,17 @@ export const CounterRoute = Component.make("CounterRoute").pipe( reset: ({ count }) => count.set(0), }), Component.view(({ state, actions }) => - View.vstack( - View.text(() => `Count: ${state.count()}`), - View.text(() => `Doubled: ${state.count() * 2}`), - View.hstack( - View.button("Decrease", actions.decrement), - View.button("Increase", actions.increment), - View.button("Reset", actions.reset), - ), - ).pipe(Web.className("gap-2")) + html` +
+

${() => state.count()}

+

${() => state.count() * 2}

+
+ + + +
+
+ ` ), ) diff --git a/packages/loom/README.md b/packages/loom/README.md index d269f659..19b620eb 100644 --- a/packages/loom/README.md +++ b/packages/loom/README.md @@ -36,12 +36,14 @@ export const CounterRoute = Component.make("CounterRoute").pipe( increment: ({ count }) => count.update((value) => value + 1), }), Component.view(({ state, actions, children }) => - View.vstack( - View.text("Counter").pipe(Web.as("h1")), - View.text(() => `Count: ${state.count()}`), - View.main(children), - View.button("Increase", actions.increment), - ) + html` +
+

Counter

+

${() => state.count()}

+
${children}
+ +
+ ` ), ) @@ -85,7 +87,13 @@ export const TemplateCounter = Component.make("TemplateCounter").pipe( html`
-

${() => state.count()}

+

[state.count() > 0 ? "counter-value--active" : undefined]} + web:style=${() => ({ opacity: state.count() > 0 ? 1 : 0.6 })} + > + ${state.count} +

${View.use(Card, html`Count card`)} ${ View.use(Layout, { @@ -107,16 +115,36 @@ export const TemplateCounter = Component.make("TemplateCounter").pipe( - Use `View.match(...)` for `Result`, `AsyncResult`, and `_tag`-based branching instead of inline switches in templates. - Keep list rendering explicit with `View.for(...)`; direct array interpolation stays unsupported. +### Legacy View DOM helpers are compatibility-only + +`View.button(...)`, `View.input()`, and `View.link(...)` are still supported, but they are compatibility-only helpers now. New DOM authoring should prefer `html` plus `web:*` directives. + +Migration pairs: + +- `View.button(...)` → `html` + `` +- `View.input()` → `html` + `` or `` +- `View.link(...)` → `html` + `...` + +There is no scheduled removal in the current package line. Any future removal requires a separate approved proposal and a breaking-release plan. + ### Phase-1 reactivity rule -- `${() => state.count()}` is reactive because Loom can track the thunk boundary. +- `${state.count}` is reactive template sugar for the common Loom-owned accessor case. +- `${() => state.count()}` is still reactive and remains the explicit escape hatch for computed expressions. - `${state.count()}` is an eager snapshot and does **not** update after the template is created. -- Supported phase-1 directives are limited to `web:click`, `web:value` / `web:inputValue`, and `web:hydrate`. +- Only Loom-branded accessors created by `Component.state(...)` / `Component.model(...)` get the bare-accessor sugar. Arbitrary zero-argument helpers are still supported when you pass them intentionally as `${() => ...}` / `${helper}`, but Loom does **not** auto-track plain expressions like `${state.count() + 1}` or `${format(state.title)}` unless you wrap them in an explicit function boundary. +- Supported phase-1 directives are limited to `web:click`, `web:input`, `web:submit`, `web:value` / `web:inputValue`, `web:hydrate`, `web:class`, and `web:style`. + +### Template class/style directives + +- `web:class` accepts a `string`, or a flat array of `string | false | null | undefined` tokens. +- `web:style` accepts a CSS declaration string or a Loom `Web.StyleRecord` object. +- Thunks stay reactive; eager values stay snapshot-only, just like text interpolation. +- Static `class` / `style` attributes remain the base layer and `web:class` / `web:style` append on top. ### Follow-ups intentionally out of scope - Accessor-style reactive sugar beyond the explicit lambda form. -- `web:class` and `web:style` template directives. - Teach `Component.state(...)` as the ONLY public state seam. - Treat `Component.model(...)` as compatibility-only. diff --git a/packages/loom/router/tests/router-runtime-actions.test.ts b/packages/loom/router/tests/router-runtime-actions.test.ts index 3bda3410..6aa167e2 100644 --- a/packages/loom/router/tests/router-runtime-actions.test.ts +++ b/packages/loom/router/tests/router-runtime-actions.test.ts @@ -10,6 +10,24 @@ class SaveFailure extends Data.TaggedError("SaveFailure")<{ readonly message: string }> {} +const expectResolveSuccess = (result: Self): Self & Router.ResolveSuccess => { + if (!Router.isResolveSuccess(result)) { + throw new Error("expected a resolved route") + } + + return result as Self & Router.ResolveSuccess +} + +const expectInvalidInput = ( + state: Runtime.ActionState, +): Extract, { _tag: "invalid-input" }> => { + if (state._tag !== "invalid-input") { + throw new Error("expected invalid-input action state") + } + + return state as Extract, { _tag: "invalid-input" }> +} + describe("@effectify/loom-router loaders/actions runtime", () => { it("stores loader/action descriptors on the route DSL without executing them during resolve", () => { const calls = { @@ -74,11 +92,7 @@ describe("@effectify/loom-router loaders/actions runtime", () => { }, ) const router = Router.make({ routes: [route] }) - const resolved = Router.resolve(router, "/users/42") - - if (!Router.isResolveSuccess(resolved)) { - throw new Error("expected a resolved route") - } + const resolved = expectResolveSuccess(Router.resolve(router, "/users/42")) expect(Runtime.loading(route)).toEqual({ _tag: "loading", @@ -144,11 +158,7 @@ describe("@effectify/loom-router loaders/actions runtime", () => { }, ) const router = Router.make({ routes: [route] }) - const resolved = Router.resolve(router, "/users/42") - - if (!Router.isResolveSuccess(resolved)) { - throw new Error("expected a resolved route") - } + const resolved = expectResolveSuccess(Router.resolve(router, "/users/42")) const initialFailure = await Runtime.load({ resolved, @@ -208,11 +218,7 @@ describe("@effectify/loom-router loaders/actions runtime", () => { }, ) const router = Router.make({ routes: [route] }) - const resolved = Router.resolve(router, "/users/42") - - if (!Router.isResolveSuccess(resolved)) { - throw new Error("expected a resolved route") - } + const resolved = expectResolveSuccess(Router.resolve(router, "/users/42")) expect(Runtime.submitting(route, { title: "draft" })).toEqual({ _tag: "submitting", @@ -284,7 +290,7 @@ describe("@effectify/loom-router loaders/actions runtime", () => { services: {}, }) - expect(invalid).toEqual({ + expect(expectInvalidInput(invalid)).toEqual({ _tag: "invalid-input", issues: [{ _tag: "LoomRouterActionInputFailure", input: { title: " " }, message: "title is required" }], route, @@ -293,6 +299,35 @@ describe("@effectify/loom-router loaders/actions runtime", () => { expect(actionCalls).toBe(0) }) + it("supports direct action input and preserves the revalidated success flag", async () => { + const route = Route.action( + Route.make({ + identifier: "users.detail", + path: "/users/:userId", + content: "user-screen", + }), + { + handle: async ({ input }: { readonly input: { readonly title: string } }) => input.title, + mapError: (cause: unknown) => ({ message: String(cause) }), + }, + ) + const resolved = expectResolveSuccess(Router.resolve(Router.make({ routes: [route] }), "/users/42")) + + const submitted = await Runtime.submit({ + input: { title: "save" }, + revalidated: true, + resolved, + services: {}, + }) + + expect(submitted).toEqual({ + _tag: "success", + result: "save", + revalidated: true, + route, + }) + }) + it("executes compiled route-module loaders and actions through the runtime boundary", async () => { const route = RouteModule.compile({ identifier: "users.detail", diff --git a/packages/loom/router/tests/router-runtime.test.ts b/packages/loom/router/tests/router-runtime.test.ts index 2dc19a68..42a3488c 100644 --- a/packages/loom/router/tests/router-runtime.test.ts +++ b/packages/loom/router/tests/router-runtime.test.ts @@ -12,6 +12,24 @@ const render = (router: Router.Definition, input: string | URL): string => { return output === undefined ? "" : Html.renderToString(output) } +const expectResolveSuccess = (result: Self): Self & Router.ResolveSuccess => { + if (!Router.isResolveSuccess(result)) { + throw new Error("expected a resolved route") + } + + return result as Self & Router.ResolveSuccess +} + +const expectResolveInvalidInput = ( + result: Self, +): Self & Router.ResolveInvalidInput => { + if (!Router.isResolveInvalidInput(result)) { + throw new Error("expected invalid-input router result") + } + + return result as Self & Router.ResolveInvalidInput +} + describe("@effectify/loom-router runtime", () => { it("decodes schema-backed params and query into the selected route context", () => { const router = Router.make({ @@ -28,13 +46,7 @@ describe("@effectify/loom-router runtime", () => { ], }) - const result = Router.resolve(router, "/posts/42?page=2") - - expect(result._tag).toBe("LoomRouterResolveSuccess") - - if (result._tag !== "LoomRouterResolveSuccess") { - throw new Error("expected a resolved route") - } + const result = expectResolveSuccess(Router.resolve(router, "/posts/42?page=2")) expect(result.context.params).toEqual({ postId: "42" }) expect(result.context.query).toEqual({ page: "2" }) @@ -165,13 +177,7 @@ describe("@effectify/loom-router runtime", () => { ], }) - const paramsResult = Router.resolve(router, "/posts/nope?page=2") - - expect(paramsResult._tag).toBe("LoomRouterResolveInvalidInput") - - if (paramsResult._tag !== "LoomRouterResolveInvalidInput") { - throw new Error("expected invalid-input router result") - } + const paramsResult = expectResolveInvalidInput(Router.resolve(router, "/posts/nope?page=2")) expect(paramsResult.diagnosticSummary).toEqual([ { @@ -198,13 +204,7 @@ describe("@effectify/loom-router runtime", () => { }), ]) - const searchResult = Router.resolve(router, "/posts/42?page=nope") - - expect(searchResult._tag).toBe("LoomRouterResolveInvalidInput") - - if (searchResult._tag !== "LoomRouterResolveInvalidInput") { - throw new Error("expected invalid-input router result") - } + const searchResult = expectResolveInvalidInput(Router.resolve(router, "/posts/42?page=nope")) expect(searchResult.diagnostics[0]?.issues).toEqual([ expect.objectContaining({ @@ -273,13 +273,7 @@ describe("@effectify/loom-router runtime", () => { ], }) - const result = Router.resolve(router, "/posts/42") - - expect(result._tag).toBe("LoomRouterResolveSuccess") - - if (result._tag !== "LoomRouterResolveSuccess") { - throw new Error("expected omitted optional/default search params to decode successfully") - } + const result = expectResolveSuccess(Router.resolve(router, "/posts/42")) expect(result.context.params).toEqual({ postId: "42" }) expect(result.context.query).toEqual({ page: "1" }) @@ -301,15 +295,10 @@ describe("@effectify/loom-router runtime", () => { path: "/component-only", }) const router = Router.make({ routes: [componentOnlyRoute] }) - const result = Router.resolve(router, "/component-only") + const result = expectResolveSuccess(Router.resolve(router, "/component-only")) expect(Route.hasLoader(componentOnlyRoute)).toBe(false) expect(Route.hasAction(componentOnlyRoute)).toBe(false) - expect(result._tag).toBe("LoomRouterResolveSuccess") - - if (result._tag !== "LoomRouterResolveSuccess") { - throw new Error("expected a resolved component-only route module") - } expect(result.route.identifier).toBe("component.only") expect(render(router, "/component-only")).toBe( diff --git a/packages/loom/runtime/src/internal/runtime.js b/packages/loom/runtime/src/internal/runtime.js index d4ade1ff..c9d670fc 100644 --- a/packages/loom/runtime/src/internal/runtime.js +++ b/packages/loom/runtime/src/internal/runtime.js @@ -113,6 +113,49 @@ const applyValueBindingSnapshot = (node, attributes) => { } attributes.value = nextValue } +const isPresent = (value) => value !== undefined +const serializeClassBindings = (values) => { + const present = values.filter(isPresent) + if (present.length === 0) { + return undefined + } + if (present.every((value) => value === "")) { + return "" + } + return present.filter((value) => value.length > 0).join(" ") +} +const serializeStyleBindings = (values) => { + const present = values.filter(isPresent).map((value) => value.trim().replace(/;+$/u, "")) + if (present.length === 0) { + return undefined + } + if (present.every((value) => value === "")) { + return "" + } + return present.filter((value) => value.length > 0).join(";") +} +const applyClassBindingSnapshot = (node, attributes) => { + const nextClass = serializeClassBindings([ + attributes.class, + ...node.bindings.flatMap((binding) => binding._tag === "ClassBinding" ? [binding.render()] : []), + ]) + if (nextClass === undefined) { + delete attributes.class + return + } + attributes.class = nextClass +} +const applyStyleBindingSnapshot = (node, attributes) => { + const nextStyle = serializeStyleBindings([ + attributes.style, + ...node.bindings.flatMap((binding) => binding._tag === "StyleBinding" ? [binding.render()] : []), + ]) + if (nextStyle === undefined) { + delete attributes.style + return + } + attributes.style = nextStyle +} const commentNodeType = 8 const documentNodeType = 9 const showCommentNodeMask = 128 @@ -274,6 +317,8 @@ const serializeNode = (node, state, boundaryId, nextBoundaryNodeId, isBoundaryRo return serializeNode(node, state, undefined, undefined, true) } const attributes = { ...node.attributes } + applyClassBindingSnapshot(node, attributes) + applyStyleBindingSnapshot(node, attributes) applyValueBindingSnapshot(node, attributes) const activeBoundaryId = isBoundaryRoot ? `b${state.nextBoundaryId++}` : boundaryId const activeBoundaryNodeId = isBoundaryRoot ? { current: 0 } : nextBoundaryNodeId diff --git a/packages/loom/runtime/src/internal/runtime.ts b/packages/loom/runtime/src/internal/runtime.ts index b1238fc3..a41bbcbf 100644 --- a/packages/loom/runtime/src/internal/runtime.ts +++ b/packages/loom/runtime/src/internal/runtime.ts @@ -137,6 +137,64 @@ const applyValueBindingSnapshot = (node: LoomCore.Ast.ElementNode, attributes: R attributes.value = nextValue } +const isPresent = (value: string | undefined): value is string => value !== undefined + +const serializeClassBindings = (values: ReadonlyArray): string | undefined => { + const present = values.filter(isPresent) + + if (present.length === 0) { + return undefined + } + + if (present.every((value) => value === "")) { + return "" + } + + return present.filter((value) => value.length > 0).join(" ") +} + +const serializeStyleBindings = (values: ReadonlyArray): string | undefined => { + const present = values.filter(isPresent).map((value) => value.trim().replace(/;+$/u, "")) + + if (present.length === 0) { + return undefined + } + + if (present.every((value) => value === "")) { + return "" + } + + return present.filter((value) => value.length > 0).join(";") +} + +const applyClassBindingSnapshot = (node: LoomCore.Ast.ElementNode, attributes: Record): void => { + const nextClass = serializeClassBindings([ + attributes.class, + ...node.bindings.flatMap((binding) => binding._tag === "ClassBinding" ? [binding.render()] : []), + ]) + + if (nextClass === undefined) { + delete attributes.class + return + } + + attributes.class = nextClass +} + +const applyStyleBindingSnapshot = (node: LoomCore.Ast.ElementNode, attributes: Record): void => { + const nextStyle = serializeStyleBindings([ + attributes.style, + ...node.bindings.flatMap((binding) => binding._tag === "StyleBinding" ? [binding.render()] : []), + ]) + + if (nextStyle === undefined) { + delete attributes.style + return + } + + attributes.style = nextStyle +} + const commentNodeType = 8 const documentNodeType = 9 const showCommentNodeMask = 128 @@ -363,6 +421,8 @@ const serializeNode = ( } const attributes: Record = { ...node.attributes } + applyClassBindingSnapshot(node, attributes) + applyStyleBindingSnapshot(node, attributes) applyValueBindingSnapshot(node, attributes) const activeBoundaryId = isBoundaryRoot ? `b${state.nextBoundaryId++}` : boundaryId const activeBoundaryNodeId = isBoundaryRoot ? { current: 0 } : nextBoundaryNodeId diff --git a/packages/loom/runtime/tests/control-flow-runtime.test.ts b/packages/loom/runtime/tests/control-flow-runtime.test.ts index cb3b490f..d21a87ee 100644 --- a/packages/loom/runtime/tests/control-flow-runtime.test.ts +++ b/packages/loom/runtime/tests/control-flow-runtime.test.ts @@ -78,4 +78,58 @@ describe("@effectify/loom-runtime control-flow foundations", () => { expect(ifNode._tag).toBe("If") expect(forNode._tag).toBe("For") }) + + it("keeps deferred and static branch composition observable through runtime render output", () => { + let items = ["alpha"] + let visible = false + + const tree = LoomCore.Ast.fragment([ + LoomCore.Ast.element("section", { + attributes: { "data-shell": "true" }, + events: [], + children: [ + LoomCore.Ast.text("before"), + LoomCore.Ast.ifNode( + () => visible, + LoomCore.Ast.element("strong", { + attributes: { "data-branch": "visible" }, + children: [LoomCore.Ast.text("visible")], + events: [], + }), + LoomCore.Ast.element("em", { + attributes: { "data-branch": "fallback" }, + children: [LoomCore.Ast.text("fallback")], + events: [], + }), + ), + LoomCore.Ast.forEach( + () => items, + (item, index) => + LoomCore.Ast.element("span", { + attributes: { "data-item": `${index}` }, + children: [LoomCore.Ast.text(item)], + events: [], + }), + LoomCore.Ast.element("p", { + attributes: { "data-empty": "true" }, + children: [LoomCore.Ast.text("empty")], + events: [], + }), + ), + LoomCore.Ast.text("after"), + ], + }), + ]) + + expect(Runtime.renderToHtml(tree).html).toBe( + '
beforefallbackalphaafter
', + ) + + visible = true + items = [] + + expect(Runtime.renderToHtml(tree).html).toBe( + '
beforevisible

empty

after
', + ) + }) }) diff --git a/packages/loom/runtime/tests/runtime-events.test.ts b/packages/loom/runtime/tests/runtime-events.test.ts index ba7c2f60..a7f2a521 100644 --- a/packages/loom/runtime/tests/runtime-events.test.ts +++ b/packages/loom/runtime/tests/runtime-events.test.ts @@ -4,6 +4,8 @@ import { describe, expect, it } from "vitest" import * as Hydration from "../src/hydration.js" import * as Runtime from "../src/runtime.js" +const effectLike = { _tag: "EffectLike" } as const + const readTagName = (value: EventTarget): string => typeof value === "object" && value !== null && "tagName" in value && typeof value.tagName === "string" ? value.tagName @@ -21,40 +23,81 @@ const visibleBoundary = (children: ReadonlyArray) => children, }) +const createNestedClickFixture = ( + binding: LoomCore.Ast.EventBinding = Runtime.eventBinding( + "click", + ({ currentTarget, target }: Runtime.EventContext) => ({ + _tag: "NestedClickObserved", + currentTarget: readTagName(currentTarget), + target: readTagName(target), + }), + ), +) => { + const render = Runtime.renderToHtml( + visibleBoundary([ + LoomCore.Ast.element("button", { + attributes: { type: "button" }, + events: [binding], + children: [ + LoomCore.Ast.element("span", { + attributes: {}, + events: [], + children: [LoomCore.Ast.text("open")], + }), + ], + }), + ]), + ) + const dom = new JSDOM(`
${render.html}
`) + const root = dom.window.document.getElementById("loom-root") + const button = dom.window.document.querySelector("button") + const span = dom.window.document.querySelector("span") + + if (root === null || button === null || span === null) { + throw new Error("expected hydration fixtures") + } + + return { button, dom, render, root, span } +} + +const expectDiagnosticSummary = ( + activation: Runtime.HydrationActivationResult, + highestSeverity: "error" | "warn" | "info" | "fatal", +) => { + expect(activation.diagnostics[0]).toMatchObject({ + phase: "hydration", + highestSeverity, + }) +} + +const expectMissingDispatcherIssue = (activation: Runtime.HydrationActivationResult) => { + expect(activation.issues).toEqual([ + expect.objectContaining({ + reason: "missing-effect-dispatcher", + }), + ]) + expect(activation.diagnostics[0]?.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: "loom.hydration.activation.missing-effect-dispatcher", + }), + ]), + ) +} + describe("@effectify/loom-runtime event context", () => { it("exposes currentTarget separately from nested click targets during hydration activation", () => { const seen: Array<{ target: string; currentTarget: string }> = [] - const render = Runtime.renderToHtml( - visibleBoundary([ - LoomCore.Ast.element("button", { - attributes: { type: "button" }, - events: [ - Runtime.eventBinding("click", ({ currentTarget, target }: Runtime.EventContext) => { - seen.push({ - target: readTagName(target), - currentTarget: readTagName(currentTarget), - }) - - return { _tag: "CurrentTargetObserved" } - }), - ], - children: [ - LoomCore.Ast.element("span", { - attributes: {}, - events: [], - children: [LoomCore.Ast.text("open")], - }), - ], - }), - ]), - ) - const dom = new JSDOM(`
${render.html}
`) - const root = dom.window.document.getElementById("loom-root") - const span = dom.window.document.querySelector("span") + const { dom, render, root, span } = createNestedClickFixture( + Runtime.eventBinding("click", ({ currentTarget, target }: Runtime.EventContext) => { + seen.push({ + target: readTagName(target), + currentTarget: readTagName(currentTarget), + }) - if (root === null || span === null) { - throw new Error("expected hydration fixtures") - } + return { _tag: "CurrentTargetObserved" } + }), + ) const activation = Runtime.activateHydration(root, render) @@ -70,4 +113,12 @@ describe("@effectify/loom-runtime event context", () => { }, ]) }) + + it("reports missing effect dispatchers as hydration errors", () => { + const { render, root } = createNestedClickFixture(Runtime.eventBinding("click", effectLike)) + const activation = Runtime.activateHydration(root, render) + + expectMissingDispatcherIssue(activation) + expectDiagnosticSummary(activation, "error") + }) }) diff --git a/packages/loom/web/src/component.ts b/packages/loom/web/src/component.ts index 3f8196da..30ce77d0 100644 --- a/packages/loom/web/src/component.ts +++ b/packages/loom/web/src/component.ts @@ -4,7 +4,7 @@ import type * as Effect from "effect/Effect" import type * as Pipeable from "effect/Pipeable" import type * as Diagnostics from "./diagnostics.js" import * as pipeable from "./internal/pipeable.js" -import { trackStateAtomRead } from "./internal/tracked-state.js" +import { brandStateAccessor, type StateAccessor, trackStateAtomRead } from "./internal/tracked-state.js" import * as viewChild from "./internal/view-child.js" import type * as Slot from "./slot.js" import * as Template from "./template.js" @@ -70,7 +70,9 @@ type WritableValue = MaterializedValue extends Atom.Writable export type State = { - readonly [Key in keyof Model]: () => StateValue + readonly [Key in keyof Model]: MaterializedValue extends Atom.Atom + ? StateAccessor + : () => StateValue } export type WriteModel = { @@ -301,10 +303,10 @@ const createState = ( const value = model[property as keyof Model] return Atom.isAtom(value) - ? () => { + ? brandStateAccessor(() => { trackStateAtomRead(value) return registry.get(value) - } + }) : () => value }, has(_target, property) { diff --git a/packages/loom/web/src/internal/tracked-state.ts b/packages/loom/web/src/internal/tracked-state.ts index ccabf799..ad99dfd5 100644 --- a/packages/loom/web/src/internal/tracked-state.ts +++ b/packages/loom/web/src/internal/tracked-state.ts @@ -1,9 +1,28 @@ import type { Atom } from "effect/unstable/reactivity" +const loomStateAccessorSymbol = Symbol.for("@effectify/loom/state-accessor") + +export type StateAccessor = (() => Value) & { + readonly [loomStateAccessorSymbol]: true +} + type Collector = Set> const collectorStack: Array = [] +export const brandStateAccessor = (accessor: () => Value): StateAccessor => { + Object.defineProperty(accessor, loomStateAccessorSymbol, { + value: true, + enumerable: false, + configurable: false, + }) + + return accessor as StateAccessor +} + +export const isStateAccessor = (value: unknown): value is StateAccessor => + typeof value === "function" && value.length === 0 && loomStateAccessorSymbol in value + export const trackStateAtomRead = (atom: Atom.Atom): void => { collectorStack.at(-1)?.add(atom) } diff --git a/packages/loom/web/src/template.js b/packages/loom/web/src/template.js index fd457a7a..ca44442b 100644 --- a/packages/loom/web/src/template.js +++ b/packages/loom/web/src/template.js @@ -2,6 +2,7 @@ import * as LoomCore from "@effectify/loom-core" import * as Hydration from "./hydration.js" import * as internalApi from "./internal/api.js" import * as viewNode from "./internal/view-node.js" +import * as Web from "./web.js" const childMarkerPrefix = "loom-child:" const attributeMarkerPrefix = "__loom_attr_" @@ -29,6 +30,7 @@ const isRenderable = (value) => const isComponentDefinition = (value) => typeof value === "object" && value !== null && value._tag === "Component" const isEventHandler = (value) => typeof value === "function" || (typeof value === "object" && value !== null) const isTemplateThunk = (value) => typeof value === "function" && value.length === 0 +const isZeroArgFunction = (value) => typeof value === "function" && value.length === 0 const isHydrationStrategy = (value) => typeof value === "object" && value !== null && "strategy" in value && "attributeName" in value && "attributeValue" in value @@ -65,6 +67,97 @@ const normalizeStaticInterpolation = (value) => { return [] } +const templateDirectiveError = (name, detail) => new Error(`web:${name} expects ${detail}.`) + +const normalizeClassDirectiveValue = (value) => { + if (value === null || value === undefined || value === false) { + return undefined + } + if (typeof value === "string") { + return Web.serializeClass(value) + } + if (Array.isArray(value)) { + if ( + !value.every((entry) => entry === false || entry === null || entry === undefined || typeof entry === "string") + ) { + throw templateDirectiveError("class", "a string or a flat readonly array of string | false | null | undefined") + } + return Web.serializeClass(value) + } + throw templateDirectiveError("class", "a string or a flat readonly array of string | false | null | undefined") +} + +const isStyleRecord = (value) => + typeof value === "object" && value !== null && !Array.isArray(value) && + Object.values(value).every((entry) => + entry === null || entry === undefined || typeof entry === "string" || typeof entry === "number" + ) + +const normalizeStyleDirectiveValue = (value) => { + if (value === null || value === undefined || value === false) { + return undefined + } + if (typeof value === "string") { + return Web.serializeStyle(value) + } + if (isStyleRecord(value)) { + return Web.serializeStyle(value) + } + throw templateDirectiveError("style", "a string or a style object record") +} + +const validateDirectiveThunk = (name, value) => { + if (value === null || value === undefined || value === false) { + return undefined + } + if (isZeroArgFunction(value)) { + return value + } + if (typeof value === "function") { + throw templateDirectiveError(name, "a string or a zero-arg thunk") + } + return undefined +} + +const applyClassDirective = (interpolation, attributes, bindings) => { + const thunk = validateDirectiveThunk("class", interpolation) + if (thunk !== undefined) { + bindings.push({ + _tag: "ClassBinding", + render: () => normalizeClassDirectiveValue(thunk()), + }) + return + } + const nextClass = normalizeClassDirectiveValue(interpolation) + if (nextClass === undefined) { + return + } + attributes.class = attributes.class === undefined + ? nextClass + : Web.serializeClass([attributes.class, nextClass]) +} + +const applyStyleDirective = (interpolation, attributes, bindings) => { + const thunk = validateDirectiveThunk("style", interpolation) + if (thunk !== undefined) { + bindings.push({ + _tag: "StyleBinding", + render: () => normalizeStyleDirectiveValue(thunk()), + }) + return + } + const nextStyle = normalizeStyleDirectiveValue(interpolation) + if (nextStyle === undefined) { + return + } + attributes.style = attributes.style === undefined + ? nextStyle + : [attributes.style, nextStyle] + .map((value) => value.trim().replace(/;+$/u, "")) + .filter((value) => value.length > 0) + .join(";") +} + const normalizeInterpolationValue = (value) => { if (isTemplateThunk(value)) { return [LoomCore.Ast.computed(() => collapseNodes(normalizeStaticInterpolation(value())))] @@ -342,6 +435,14 @@ const applyWebDirective = (name, interpolation, attributes, bindings, events) => applyAttributeInterpolation("value", interpolation, attributes, bindings) return undefined } + case "class": { + applyClassDirective(interpolation, attributes, bindings) + return undefined + } + case "style": { + applyStyleDirective(interpolation, attributes, bindings) + return undefined + } case "hydrate": { if (!isHydrationStrategy(interpolation)) { throw new Error("web:hydrate expects an explicit Hydration.strategy helper value.") @@ -427,10 +528,6 @@ const convertParsedNode = (node, values) => { export const renderable = (node) => viewNode.wrap(node) export const html = (strings, ...values) => { - for (const value of values) { - assertTemplateValue(value, "Direct array/component interpolation") - } - const source = strings.reduce((current, segment, index) => { if (index >= values.length) { return current + segment diff --git a/packages/loom/web/src/template.ts b/packages/loom/web/src/template.ts index 8226f6c9..f9375f11 100644 --- a/packages/loom/web/src/template.ts +++ b/packages/loom/web/src/template.ts @@ -2,7 +2,9 @@ import * as LoomCore from "@effectify/loom-core" import * as Hydration from "./hydration.js" import type * as Html from "./html.js" import * as internalApi from "./internal/api.js" +import { isStateAccessor, type StateAccessor } from "./internal/tracked-state.js" import * as viewNode from "./internal/view-node.js" +import * as Web from "./web.js" export type Renderable = viewNode.Type & { readonly __error?: E @@ -15,12 +17,19 @@ export type RequirementsOfRenderable = Value extends { readonly __require : never export type PrimitiveInterpolation = string | number | bigint | null | undefined | false +export type TemplateDirectiveValue = Web.ClassInput | Web.StyleInput export type TemplateValue = | LoomCore.Ast.Node | LoomCore.Component.Definition | PrimitiveInterpolation | ReadonlyArray -export type TemplateInterpolation = TemplateValue | Hydration.Strategy | Html.EventHandler | (() => TemplateValue) +export type TemplateInterpolation = + | TemplateValue + | TemplateDirectiveValue + | Hydration.Strategy + | Html.EventHandler + | StateAccessor + | (() => TemplateValue | TemplateDirectiveValue) type InterpolationError = Value extends ReadonlyArray ? InterpolationError : Value extends () => infer Produced ? InterpolationError @@ -92,6 +101,12 @@ const isEventHandler = (value: unknown): value is Html.EventHandler => const isTemplateThunk = (value: TemplateInterpolation): value is () => TemplateValue => typeof value === "function" && value.length === 0 +const isTemplateAccessor = ( + value: TemplateInterpolation, +): value is StateAccessor => isStateAccessor(value) + +const isZeroArgFunction = (value: unknown): value is () => unknown => typeof value === "function" && value.length === 0 + const isHydrationStrategy = (value: unknown): value is Hydration.Strategy => typeof value === "object" && value !== null && "strategy" in value && "attributeName" in value && "attributeValue" in value @@ -109,6 +124,8 @@ const pushEventDirective = ( events.push(internalApi.makeEventBinding(eventName, interpolation)) } +type TemplateClassInput = Web.ClassInput + const asRenderable = (node: LoomCore.Ast.Node): Renderable => viewNode.wrap(node) const collapseNodes = (nodes: ReadonlyArray): LoomCore.Ast.Node => { @@ -135,7 +152,133 @@ const normalizeStaticInterpolation = (value: TemplateValue): ReadonlyArray + new Error(`web:${name} expects ${detail}.`) + +const normalizeClassDirectiveValue = (value: unknown): string | undefined => { + if (value === null || value === undefined || value === false) { + return undefined + } + + if (typeof value === "string") { + return Web.serializeClass(value) + } + + if (Array.isArray(value)) { + if ( + !value.every((entry) => entry === false || entry === null || entry === undefined || typeof entry === "string") + ) { + throw templateDirectiveError("class", "a string or a flat readonly array of string | false | null | undefined") + } + + return Web.serializeClass(value as TemplateClassInput) + } + + throw templateDirectiveError("class", "a string or a flat readonly array of string | false | null | undefined") +} + +const isStyleRecord = (value: unknown): value is Web.StyleRecord => + typeof value === "object" && value !== null && !Array.isArray(value) && + Object.values(value).every((entry) => + entry === null || entry === undefined || typeof entry === "string" || typeof entry === "number" + ) + +const normalizeStyleDirectiveValue = (value: unknown): string | undefined => { + if (value === null || value === undefined || value === false) { + return undefined + } + + if (typeof value === "string") { + return Web.serializeStyle(value) + } + + if (isStyleRecord(value)) { + return Web.serializeStyle(value) + } + + throw templateDirectiveError("style", "a string or a style object record") +} + +const validateDirectiveThunk = (name: "class" | "style", value: unknown): (() => unknown) | undefined => { + if (value === null || value === undefined || value === false) { + return undefined + } + + if (isStateAccessor(value)) { + return value + } + + if (isZeroArgFunction(value)) { + return value + } + + if (typeof value === "function") { + throw templateDirectiveError(name, "a string or a zero-arg thunk") + } + + return undefined +} + +const applyClassDirective = ( + interpolation: TemplateInterpolation, + attributes: Record, + bindings: Array, +): void => { + const thunk = validateDirectiveThunk("class", interpolation) + + if (thunk !== undefined) { + bindings.push({ + _tag: "ClassBinding", + render: () => normalizeClassDirectiveValue(thunk()), + }) + return + } + + const nextClass = normalizeClassDirectiveValue(interpolation) + + if (nextClass === undefined) { + return + } + + attributes.class = attributes.class === undefined + ? nextClass + : Web.serializeClass([attributes.class, nextClass]) +} + +const applyStyleDirective = ( + interpolation: TemplateInterpolation, + attributes: Record, + bindings: Array, +): void => { + const thunk = validateDirectiveThunk("style", interpolation) + + if (thunk !== undefined) { + bindings.push({ + _tag: "StyleBinding", + render: () => normalizeStyleDirectiveValue(thunk()), + }) + return + } + + const nextStyle = normalizeStyleDirectiveValue(interpolation) + + if (nextStyle === undefined) { + return + } + + attributes.style = attributes.style === undefined + ? nextStyle + : [attributes.style, nextStyle] + .map((value) => value.trim().replace(/;+$/u, "")) + .filter((value) => value.length > 0) + .join(";") +} + const normalizeInterpolationValue = (value: TemplateInterpolation): ReadonlyArray => { + if (isTemplateAccessor(value)) { + return [LoomCore.Ast.computed(() => collapseNodes(normalizeStaticInterpolation(value() as TemplateValue)))] + } + if (isTemplateThunk(value)) { return [LoomCore.Ast.computed(() => collapseNodes(normalizeStaticInterpolation(value())))] } @@ -357,7 +500,7 @@ const applyAttributeInterpolation = ( ): void => { assertTemplateValue(interpolation, "Direct array/component interpolation") - if (isTemplateThunk(interpolation)) { + if (isTemplateAccessor(interpolation) || isTemplateThunk(interpolation)) { if (name === "value") { bindings.push({ _tag: "ValueBinding", @@ -439,6 +582,14 @@ const applyWebDirective = ( applyAttributeInterpolation("value", interpolation, attributes, bindings) return undefined } + case "class": { + applyClassDirective(interpolation, attributes, bindings) + return undefined + } + case "style": { + applyStyleDirective(interpolation, attributes, bindings) + return undefined + } case "hydrate": { if (!isHydrationStrategy(interpolation)) { throw new Error("web:hydrate expects an explicit Hydration.strategy helper value.") @@ -532,10 +683,6 @@ export const html = >( strings: TemplateStringsArray, ...values: Values ): Renderable, InterpolationRequirements> => { - for (const value of values) { - assertTemplateValue(value, "Direct array/component interpolation") - } - const source = strings.reduce((current, segment, index) => { if (index >= values.length) { return current + segment diff --git a/packages/loom/web/src/view.d.ts b/packages/loom/web/src/view.d.ts index c7ebc5df..94ed7567 100644 --- a/packages/loom/web/src/view.d.ts +++ b/packages/loom/web/src/view.d.ts @@ -34,11 +34,17 @@ export declare const hstack: (...children: ReadonlyArray) => Type export declare const stack: (...children: ReadonlyArray) => Type /** Compatibility alias for the preferred `View.hstack(...)` primitive. */ export declare const row: (...children: ReadonlyArray) => Type -/** Create a button node with broad child content and click handler support. */ +/** + * @deprecated Prefer html`` for DOM authoring. `View.button(...)` remains supported as a compatibility helper with the same runtime behavior. + */ export declare const button: (content: ViewChild, handler: Html.EventHandler) => Type -/** Create the first text-input primitive backed by a text input element. */ +/** + * @deprecated Prefer html`` or `web:inputValue={...}` for DOM authoring. `View.input()` remains supported as a compatibility helper with the same runtime behavior. + */ export declare const input: () => Type -/** Create a router-neutral link node with broad child content. */ +/** + * @deprecated Prefer html`...` for DOM authoring. `View.link(...)` remains supported as a compatibility helper with the same runtime behavior. + */ export declare const link: (content: ViewChild, target: LinkTarget) => Type /** Render exactly one branch from an explicit boolean condition. */ export declare function ifView(condition: boolean, content: MaybeChild, otherwise?: MaybeChild): Type diff --git a/packages/loom/web/src/view.js b/packages/loom/web/src/view.js index 00372c8e..02094978 100644 --- a/packages/loom/web/src/view.js +++ b/packages/loom/web/src/view.js @@ -52,12 +52,18 @@ export const hstack = (...children) => internal.wrap(Html.el("div", Html.childre export const stack = vstack /** Compatibility alias for the preferred `View.hstack(...)` primitive. */ export const row = hstack -/** Create a button node with broad child content and click handler support. */ +/** + * @deprecated Prefer html`` for DOM authoring. `View.button(...)` remains supported as a compatibility helper with the same runtime behavior. + */ export const button = (content, handler) => internal.wrap(Html.el("button", Html.on("click", handler), Html.children(content))) -/** Create the first text-input primitive backed by a text input element. */ +/** + * @deprecated Prefer html`` or `web:inputValue={...}` for DOM authoring. `View.input()` remains supported as a compatibility helper with the same runtime behavior. + */ export const input = () => internal.wrap(Html.el("input", Html.attr("type", "text"))) -/** Create a router-neutral link node with broad child content. */ +/** + * @deprecated Prefer html`...` for DOM authoring. `View.link(...)` remains supported as a compatibility helper with the same runtime behavior. + */ export const link = (content, target) => internal.wrap(Html.el("a", ...linkTargetModifiers(target), Html.children(content))) const renderIf = (condition, content, otherwise) => { diff --git a/packages/loom/web/src/view.ts b/packages/loom/web/src/view.ts index 8a20e796..27d155a0 100644 --- a/packages/loom/web/src/view.ts +++ b/packages/loom/web/src/view.ts @@ -165,14 +165,20 @@ export const stack = vstack /** Compatibility alias for the preferred `View.hstack(...)` primitive. */ export const row = hstack -/** Create a button node with broad child content and click handler support. */ +/** + * @deprecated Prefer html`` for DOM authoring. `View.button(...)` remains supported as a compatibility helper with the same runtime behavior. + */ export const button = (content: ViewChild, handler: Html.EventHandler): Renderable => asRenderable(Html.el("button", Html.on("click", handler), Html.children(content))) -/** Create the first text-input primitive backed by a text input element. */ +/** + * @deprecated Prefer html`` or `web:inputValue={...}` for DOM authoring. `View.input()` remains supported as a compatibility helper with the same runtime behavior. + */ export const input = (): Renderable => asRenderable(Html.el("input", Html.attr("type", "text"))) -/** Create a router-neutral link node with broad child content. */ +/** + * @deprecated Prefer html`...` for DOM authoring. `View.link(...)` remains supported as a compatibility helper with the same runtime behavior. + */ export const link = (content: ViewChild, target: LinkTarget): Renderable => asRenderable(Html.el("a", ...linkTargetModifiers(target), Html.children(content))) diff --git a/packages/loom/web/src/web.d.ts b/packages/loom/web/src/web.d.ts index 38b951cd..c3cd9990 100644 --- a/packages/loom/web/src/web.d.ts +++ b/packages/loom/web/src/web.d.ts @@ -9,10 +9,14 @@ export type ReactiveAttrValue = ReactiveInput export type ValueInput = string | number | null | undefined export type ReactiveValueInput = ReactiveInput export type AttrRecord = Readonly> +export type ClassToken = string | false | null | undefined +export type ClassInput = string | ReadonlyArray export type StyleValue = string | number | null | undefined export type StyleRecord = Readonly> export type StyleInput = string | StyleRecord export type ReactiveStyleInput = ReactiveInput +export declare const serializeClass: (value: ClassInput) => string +export declare const serializeStyle: (value: string | StyleRecord) => string /** Override the root element tag for an element-backed view. Non-element nodes pass through unchanged. */ export declare const as: (tagName: RootTagName) => Modifier /** Attach a CSS class to a view element. Non-element nodes pass through unchanged. */ diff --git a/packages/loom/web/src/web.js b/packages/loom/web/src/web.js index 9204de73..3206c6ed 100644 --- a/packages/loom/web/src/web.js +++ b/packages/loom/web/src/web.js @@ -14,7 +14,25 @@ const toValueInput = (value) => { return String(value) } const toKebabCase = (value) => value.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`) -const serializeStyle = (value) => { +const normalizeClassToken = (value) => { + if (typeof value !== "string") { + return undefined + } + const token = value.trim() + return token.length === 0 ? undefined : token +} +export const serializeClass = (value) => { + if (typeof value === "string") { + return value.trim() + } + return value + .flatMap((entry) => { + const token = normalizeClassToken(entry) + return token === undefined ? [] : [token] + }) + .join(" ") +} +export const serializeStyle = (value) => { if (typeof value === "string") { return value.trim() } diff --git a/packages/loom/web/src/web.ts b/packages/loom/web/src/web.ts index 69265b66..21333242 100644 --- a/packages/loom/web/src/web.ts +++ b/packages/loom/web/src/web.ts @@ -11,6 +11,8 @@ export type ReactiveAttrValue = ReactiveInput export type ValueInput = string | number | null | undefined export type ReactiveValueInput = ReactiveInput export type AttrRecord = Readonly> +export type ClassToken = string | false | null | undefined +export type ClassInput = string | ReadonlyArray export type StyleValue = string | number | null | undefined export type StyleRecord = Readonly> export type StyleInput = string | StyleRecord @@ -36,7 +38,29 @@ const toValueInput = (value: ValueInput): string | undefined => { const toKebabCase = (value: string): string => value.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`) -const serializeStyle = (value: string | StyleRecord): string => { +const normalizeClassToken = (value: ClassToken): string | undefined => { + if (typeof value !== "string") { + return undefined + } + + const token = value.trim() + return token.length === 0 ? undefined : token +} + +export const serializeClass = (value: ClassInput): string => { + if (typeof value === "string") { + return value.trim() + } + + return value + .flatMap((entry) => { + const token = normalizeClassToken(entry) + return token === undefined ? [] : [token] + }) + .join(" ") +} + +export const serializeStyle = (value: string | StyleRecord): string => { if (typeof value === "string") { return value.trim() } diff --git a/packages/loom/web/tests/state-accessor.test.ts b/packages/loom/web/tests/state-accessor.test.ts new file mode 100644 index 00000000..cf1bae57 --- /dev/null +++ b/packages/loom/web/tests/state-accessor.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest" + +describe("loom state accessors", () => { + it("brands loom-owned accessors without widening plain zero-arg functions", async () => { + const module = await import(new URL("../src/internal/tracked-state.ts", import.meta.url).href) + const { brandStateAccessor, isStateAccessor } = module + const plain = () => "plain" + const accessor = brandStateAccessor(() => "tracked") + + expect(isStateAccessor(plain)).toBe(false) + expect(isStateAccessor(accessor)).toBe(true) + expect(accessor()).toBe("tracked") + }) +}) diff --git a/packages/loom/web/tests/template-first-view-api.test.ts b/packages/loom/web/tests/template-first-view-api.test.ts index 44310bd3..6b39e8e3 100644 --- a/packages/loom/web/tests/template-first-view-api.test.ts +++ b/packages/loom/web/tests/template-first-view-api.test.ts @@ -3,9 +3,43 @@ import * as Result from "effect/Result" import { Atom } from "effect/unstable/reactivity" import { describe, expect, it } from "vitest" -import { Component, html, Hydration, mount, Slot, View } from "../src/index.js" +import { Component, Html, html, Hydration, mount, Slot, View } from "../src/index.js" + +const withDocumentRemoved = (run: () => Result): Result => { + const descriptor = Object.getOwnPropertyDescriptor(globalThis, "document") + + Reflect.deleteProperty(globalThis, "document") + + try { + return run() + } finally { + if (descriptor !== undefined) { + Object.defineProperty(globalThis, "document", descriptor) + } + } +} describe("@effectify/loom template-first view API", () => { + it("renders SSR-safe hydration metadata and lambda values without a global document", () => { + const result = withDocumentRemoved(() => + Html.renderToString( + html` +
+ + "count:1"} /> +

${() => 1}

+
+ `, + ) + ) + + expect(result).toContain('data-loom-hydrate="visible"') + expect(result).toContain('data-loom-events="click"') + expect(result).toContain('value="count:1"') + expect(result).toContain("

1

") + expect(Reflect.has(globalThis, "document")).toBe(true) + }) + it("authors views through imported html with lambda reactivity, falsy normalization, View.for, and phase-1 web directives", () => { const root = document.createElement("div") const inventory = Component.make("template-inventory").pipe( @@ -149,9 +183,95 @@ describe("@effectify/loom template-first view API", () => { handle.dispose() }) - it("rejects unsupported web directives", () => { - expect(() => html`
"active"}>
`).toThrowError( - /Unsupported template directive 'web:class'/, + it("accepts eager web:class and web:style values as snapshot attributes", () => { + const root = document.createElement("div") + const counter = Component.make("template-style-snapshot").pipe( + Component.model({ count: Atom.make(1) }), + Component.actions({ + increment: ({ count }) => count.update((value) => value + 1), + }), + Component.view(({ state }) => + html` +
+
1 ? "armed" : undefined, " ready "]} + web:style=${{ opacity: 1, transform: `translateY(${state.count()}px)` }} + data-counter-card="true" + > + card +
+
+ ` + ), + ) + + const handle = mount({ counter }, { root }) + const card = root.querySelector('[data-counter-card="true"]') + + expect(card?.getAttribute("class")).toBe("counter-card active ready") + expect(card?.getAttribute("style")).toBe("border-color:black;opacity:1;transform:translateY(1px)") + ;(handle.actions as any).increment() + + expect(card?.getAttribute("class")).toBe("counter-card active ready") + expect(card?.getAttribute("style")).toBe("border-color:black;opacity:1;transform:translateY(1px)") + + handle.dispose() + }) + + it("updates reactive web:class and web:style thunks without recreating the element", () => { + const root = document.createElement("div") + const counter = Component.make("template-style-reactive").pipe( + Component.model({ count: Atom.make(0), enabled: Atom.make(false) }), + Component.actions({ + increment: ({ count }) => count.update((value) => value + 1), + toggle: ({ enabled }) => enabled.update((value) => !value), + }), + Component.view(({ state }) => + html` +
+
[state.enabled() ? "active" : undefined, `count-${state.count()}`]} + web:style=${() => ({ opacity: state.enabled() ? 1 : 0.25, transform: `translateY(${state.count()}px)` })} + data-counter-card="true" + > + card +
+
+ ` + ), + ) + + const handle = mount({ counter }, { root }) + const cardBefore = root.querySelector('[data-counter-card="true"]') + + expect(cardBefore?.getAttribute("class")).toBe("counter-card count-0") + expect(cardBefore?.getAttribute("style")).toBe("border-color:black;opacity:0.25;transform:translateY(0px)") + ;(handle.actions as any).toggle() + ;(handle.actions as any).increment() + + const cardAfter = root.querySelector('[data-counter-card="true"]') + + expect(cardAfter).toBe(cardBefore) + expect(cardAfter?.getAttribute("class")).toBe("counter-card active count-1") + expect(cardAfter?.getAttribute("style")).toBe("border-color:black;opacity:1;transform:translateY(1px)") + + handle.dispose() + }) + + it("rejects invalid web:class and web:style value shapes", () => { + expect(() => html`
`).toThrowError(/web:class expects/) + expect(() => html`
`).toThrowError(/web:style expects/) + expect(() => html`
value) as never}>
`).toThrowError(/web:class expects/) + expect(() => html`
value) as never}>
`).toThrowError(/web:style expects/) + }) + + it("keeps rejecting unknown web directives", () => { + expect(() => html`
"active"}>
`).toThrowError( + /Unsupported template directive 'web:foo'/, ) }) @@ -238,6 +358,81 @@ describe("@effectify/loom template-first view API", () => { handle.dispose() }) + it("updates runtime-visible attr, class, reactive string style, click, and inputValue behavior through template directives", () => { + const root = document.createElement("div") + const templateDirectives = Component.make("template-directives-runtime").pipe( + Component.model({ count: Atom.make(1), draft: Atom.make("alpha") }), + Component.actions(({ model }) => ({ + increment: () => model.count.update((value) => value + 1), + syncDraft: (value: string) => model.draft.set(value), + })), + Component.view(({ state, actions }) => + html` +
+ + `${state.draft()}:${state.count()}`} + web:input=${({ currentTarget }) => { + if (currentTarget instanceof HTMLInputElement) { + actions.syncDraft(currentTarget.value) + } + }} + /> +
state.count() > 1 ? "active" : "idle"} + title=${() => `draft:${state.draft()}`} + web:class=${() => ["status-card", state.count() > 1 ? "status-card--active" : "status-card--idle"]} + web:style=${() => `transform:translateY(${state.count()}px);opacity:${state.count() > 1 ? 1 : 0.5}`} + > + ${() => `${state.draft()}#${state.count()}`} +
+
+ ` + ), + ) + + const handle = mount({ templateDirectives }, { root }) + const button = root.querySelector('[data-action="increment"]') + const input = root.querySelector('[data-draft-input="true"]') + const card = root.querySelector('[data-runtime-card="true"]') + + if ( + !(button instanceof HTMLButtonElement) || !(input instanceof HTMLInputElement) || + !(card instanceof HTMLDivElement) + ) { + throw new Error("expected template runtime elements") + } + + expect(input.value).toBe("alpha:1") + expect(card.dataset.tone).toBe("idle") + expect(card.getAttribute("title")).toBe("draft:alpha") + expect(card.getAttribute("class")).toContain("status-card--idle") + expect(card.getAttribute("style")).toBe("transform:translateY(1px);opacity:0.5") + expect(card.style.transform).toBe("translateY(1px)") + expect(card.textContent).toBe("alpha#1") + + button.click() + + expect(input.value).toBe("alpha:2") + expect(card.dataset.tone).toBe("active") + expect(card.getAttribute("class")).toContain("status-card--active") + expect(card.getAttribute("style")).toBe("transform:translateY(2px);opacity:1") + expect(card.style.opacity).toBe("1") + expect(card.textContent).toBe("alpha#2") + + input.value = "beta" + input.dispatchEvent(new Event("input", { bubbles: true })) + + expect(input.value).toBe("beta:2") + expect(card.getAttribute("title")).toBe("draft:beta") + expect(card.textContent).toBe("beta#2") + + handle.dispose() + }) + it("rejects invalid handlers for web:input and web:submit", () => { expect(() => html``).toThrowError(/web:input expects an event handler\./) expect(() => html`
`).toThrowError(/web:submit expects an event handler\./) @@ -318,6 +513,113 @@ describe("@effectify/loom template-first view API", () => { handle.dispose() }) + it("does not auto-track broad expressions or formatted helper results as accessor sugar", () => { + const root = document.createElement("div") + const formatTitle = (value: string) => `title:${value}` + const counter = Component.make("non-reactive-template-expression-counter").pipe( + Component.model({ count: Atom.make(1), title: Atom.make("Count 1") }), + Component.actions({ + increment: ({ count, title }) => { + count.update((value) => value + 1) + title.set(`Count ${count.get()}`) + }, + }), + Component.view(({ state }) => + html` +
+

${state.count() + 1}

+

${formatTitle(state.title())}

+

${state.count}

+
+ ` + ), + ) + + const handle = mount({ counter }, { root }) + const broadExpression = root.querySelector('[data-kind="broad-expression"]') + const formattedHelper = root.querySelector('[data-kind="formatted-helper"]') + const accessor = root.querySelector('[data-kind="accessor"]') + + expect(broadExpression?.textContent).toBe("2") + expect(formattedHelper?.textContent).toBe("title:Count 1") + expect(accessor?.textContent).toBe("1") + ;(handle.actions as any).increment() + + expect(broadExpression?.textContent).toBe("2") + expect(formattedHelper?.textContent).toBe("title:Count 1") + expect(accessor?.textContent).toBe("2") + + handle.dispose() + }) + + it("supports bare state accessors as reactive template sugar in text and approved bindings", () => { + const root = document.createElement("div") + const counter = Component.make("accessor-sugar-counter").pipe( + Component.model({ count: Atom.make(1), title: Atom.make("Count 1") }), + Component.actions({ + increment: ({ count, title }) => { + count.update((value) => value + 1) + title.set(`Count ${count.get()}`) + }, + }), + Component.view(({ state }) => + html` +
+

${state.count}

+

${state.count()}

+ +
+ ` + ), + ) + + const handle = mount({ counter }, { root }) + const accessor = root.querySelector('[data-kind="accessor"]') + const snapshot = root.querySelector('[data-kind="snapshot"]') + const input = root.querySelector("input") + + if (!(input instanceof HTMLInputElement)) { + throw new Error("expected html template input") + } + + expect(accessor?.textContent).toBe("1") + expect(snapshot?.textContent).toBe("1") + expect(input.value).toBe("1") + expect(input.getAttribute("title")).toBe("Count 1") + ;(handle.actions as any).increment() + + expect(accessor?.textContent).toBe("2") + expect(snapshot?.textContent).toBe("1") + expect(input.value).toBe("2") + expect(input.getAttribute("title")).toBe("Count 2") + + handle.dispose() + }) + + it("keeps custom zero-arg functions as explicit reactive lambdas", () => { + const root = document.createElement("div") + const counter = Component.make("custom-lambda-counter").pipe( + Component.model({ count: Atom.make(1) }), + Component.actions({ + increment: ({ count }) => count.update((value) => value + 1), + }), + Component.view(({ state }) => { + const plusOne = () => state.count() + 1 + + return html`

${plusOne}

` + }), + ) + + const handle = mount({ counter }, { root }) + const lambda = root.querySelector('[data-kind="lambda"]') + + expect(lambda?.textContent).toBe("2") + ;(handle.actions as any).increment() + expect(lambda?.textContent).toBe("3") + + handle.dispose() + }) + it("rejects direct component and array interpolation in the template path", () => { const child = Component.make("template-child").pipe( Component.view(() => html`child`), diff --git a/packages/loom/web/tests/template-first-view-api.types.ts b/packages/loom/web/tests/template-first-view-api.types.ts index 744025db..05bc1ec5 100644 --- a/packages/loom/web/tests/template-first-view-api.types.ts +++ b/packages/loom/web/tests/template-first-view-api.types.ts @@ -1,4 +1,5 @@ import * as Result from "effect/Result" +import { Atom } from "effect/unstable/reactivity" import { Component, type ErrorOfRenderable, @@ -104,6 +105,13 @@ const slotUse = View.use(layout, { header: ["head ", html`slot`], }) +const accessorSugar = Component.make("typed-accessor-sugar").pipe( + Component.model({ count: Atom.make(0), title: Atom.make("Count 0") }), + Component.view(({ state }) => + html`

${state.count}

` + ), +) + // @ts-expect-error child shorthand is only valid when required props are absent. const invalidChildShorthand = View.use(requiredPropsChild, html`child`) @@ -162,6 +170,7 @@ export const typecheckSmoke = { taggedMatch, propUse, slotUse, + accessorSugar, invalidChildShorthand, handledSubject, recoveredSubject, diff --git a/packages/loom/web/tests/template-node-safety.test.ts b/packages/loom/web/tests/template-node-safety.test.ts index 91175406..ef6399be 100644 --- a/packages/loom/web/tests/template-node-safety.test.ts +++ b/packages/loom/web/tests/template-node-safety.test.ts @@ -3,6 +3,16 @@ import { describe, expect, it } from "vitest" import { Html, html, Hydration } from "../src/index.js" +const expectParserErrorWithoutDocument = ( + render: () => unknown, + message: RegExp, +): void => { + Reflect.deleteProperty(globalThis, "document") + + expect(render).toThrowError(message) + expect(Reflect.has(globalThis, "document")).toBe(false) +} + describe("@effectify/loom template parsing in node", () => { it("authors SSR-safe templates without installing a global document", () => { Reflect.deleteProperty(globalThis, "document") @@ -24,21 +34,38 @@ describe("@effectify/loom template parsing in node", () => { expect(Reflect.has(globalThis, "document")).toBe(false) }) - it("keeps unsupported directive errors stable when document is unavailable", () => { + it("serializes web:class and web:style during SSR without installing a global document", () => { Reflect.deleteProperty(globalThis, "document") - expect(() => html`
"active"}>
`).toThrowError( - /Unsupported template directive 'web:class'/, + const result = Html.renderToString( + html` +
["active", "ready"]} + web:style=${() => ({ opacity: 1, transform: "translateY(2px)" })} + > + card +
+ `, ) + + expect(result).toContain('class="counter-card active ready"') + expect(result).toContain('style="border-color:black;opacity:1;transform:translateY(2px)"') expect(Reflect.has(globalThis, "document")).toBe(false) }) - it("fails fast for malformed templates without installing a global document", () => { - Reflect.deleteProperty(globalThis, "document") + it("rejects unsupported template directives without installing a global document", () => { + expectParserErrorWithoutDocument( + () => html`
["active"]}>
`, + /Unsupported template directive 'web:foo'/, + ) + }) - expect(() => html`
broken
`).toThrowError( + it("fails fast for malformed templates without installing a global document", () => { + expectParserErrorWithoutDocument( + () => html`
broken
`, /Invalid html template: expected <\/span> but found <\/section>\./, ) - expect(Reflect.has(globalThis, "document")).toBe(false) }) }) diff --git a/packages/loom/web/tests/vnext-public-api.test.ts b/packages/loom/web/tests/vnext-public-api.test.ts index 1c2ad23b..c21e016f 100644 --- a/packages/loom/web/tests/vnext-public-api.test.ts +++ b/packages/loom/web/tests/vnext-public-api.test.ts @@ -1,5 +1,7 @@ // @vitest-environment jsdom +import { readFileSync } from "node:fs" +import { resolve } from "node:path" import * as Effect from "effect/Effect" import { Atom, AtomRegistry } from "effect/unstable/reactivity" import { describe, expect, it } from "vitest" @@ -7,6 +9,7 @@ import { DuplicateControlFlowKeyError } from "../src/internal/control-flow-error import { Component, Html, Hydration, mount, Slot, View, Web } from "../src/index.js" const effectLike = { _tag: "EffectLike" } as const +const workspaceRoot = resolve(import.meta.dirname, "../../../../") describe("@effectify/loom vNext public surface", () => { it("re-exports the vNext namespaces and mount seam from the package root", () => { @@ -591,6 +594,56 @@ describe("@effectify/loom vNext public surface", () => { handle.dispose() }) + it("marks legacy View DOM helpers as deprecated in public source surfaces while keeping them callable", () => { + const viewSource = readFileSync(resolve(workspaceRoot, "packages/loom/web/src/view.ts"), "utf8") + const viewRuntime = readFileSync(resolve(workspaceRoot, "packages/loom/web/src/view.js"), "utf8") + const viewDeclarations = readFileSync(resolve(workspaceRoot, "packages/loom/web/src/view.d.ts"), "utf8") + + expect(viewSource).toContain("@deprecated Prefer html``") + expect(viewSource).toContain("@deprecated Prefer html`` or `web:inputValue={...}`") + expect(viewSource).toContain('@deprecated Prefer html`...`') + expect(viewRuntime).toContain("@deprecated Prefer html``") + expect(viewRuntime).toContain("@deprecated Prefer html`` or `web:inputValue={...}`") + expect(viewRuntime).toContain('@deprecated Prefer html`...`') + expect(viewDeclarations).toContain("@deprecated Prefer html``") + expect(viewDeclarations).toContain("@deprecated Prefer html`` or `web:inputValue={...}`") + expect(viewDeclarations).toContain('@deprecated Prefer html`...`') + + const deprecatedButton = View.button("Legacy", effectLike) + const deprecatedInput = View.input() + const deprecatedLink = View.link("Docs", "/docs") + + expect(deprecatedButton).toMatchObject({ _tag: "Element", tagName: "button" }) + expect(deprecatedInput).toMatchObject({ _tag: "Element", tagName: "input" }) + expect(deprecatedLink).toMatchObject({ _tag: "Element", tagName: "a" }) + }) + + it("teaches template-first DOM authoring and migration guidance in public docs", () => { + const readme = readFileSync(resolve(workspaceRoot, "packages/loom/README.md"), "utf8") + const prd = readFileSync(resolve(workspaceRoot, "docs/loom-prd.md"), "utf8") + const rfc = readFileSync(resolve(workspaceRoot, "docs/loom-rfc.md"), "utf8") + + expect(readme).toContain("compatibility-only") + expect(readme).toContain("View.button(...)") + expect(readme).toContain('