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/app-config.ts b/apps/loom-example-app/src/app-config.ts deleted file mode 100644 index e0001df3..00000000 --- a/apps/loom-example-app/src/app-config.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const appRootId = "loom-root" -export const appBuildId = "loom-example-app" -export const appPayloadElementId = "__loom_payload__" -export const counterInitialCount = 2 diff --git a/apps/loom-example-app/src/document.ts b/apps/loom-example-app/src/document.ts deleted file mode 100644 index 55851e0e..00000000 --- a/apps/loom-example-app/src/document.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type * as Loom from "@effectify/loom" -import { Html } from "@effectify/loom" -import { appPayloadElementId, appRootId } from "./app-config.js" - -export interface DocumentOptions { - readonly title: string - readonly body: Loom.View.Child -} - -export const createDocument = (options: DocumentOptions): Loom.View.Child => - Html.el( - "html", - Html.attr("lang", "en"), - Html.children( - Html.el( - "head", - Html.children( - Html.el("meta", Html.attr("charset", "utf-8")), - Html.el("meta", Html.attr("name", "viewport"), Html.attr("content", "width=device-width, initial-scale=1")), - Html.el("title", Html.children(`Loom Example App · ${options.title}`)), - ), - ), - Html.el( - "body", - Html.children( - Html.el("div", Html.attr("id", appRootId), Html.children(options.body)), - Html.el("script", Html.attr("type", "application/json"), Html.attr("id", appPayloadElementId)), - Html.el("script", Html.attr("type", "module"), Html.attr("src", "/src/entry-browser.ts")), - ), - ), - ), - ) diff --git a/apps/loom-example-app/src/entry-browser.ts b/apps/loom-example-app/src/entry-browser.ts deleted file mode 100644 index ab333806..00000000 --- a/apps/loom-example-app/src/entry-browser.ts +++ /dev/null @@ -1,8 +0,0 @@ -import "./app.css" -import { startClientApp } from "./entry-client.js" - -export const bootClientDocument = (document: Document) => startClientApp(document) - -if (typeof window !== "undefined" && typeof document !== "undefined") { - void bootClientDocument(document) -} diff --git a/apps/loom-example-app/src/entry-client.ts b/apps/loom-example-app/src/entry-client.ts index 828f2187..bd1e2125 100644 --- a/apps/loom-example-app/src/entry-client.ts +++ b/apps/loom-example-app/src/entry-client.ts @@ -1,31 +1,25 @@ +import "./app.css" import { Html, mount } from "@effectify/loom" import { LoomVite } from "@effectify/loom-vite" -import { appBuildId, appPayloadElementId, appRootId } from "./app-config.js" -import { prepareRouteRuntime } from "./router-runtime.js" -import { bodyForResult, resolveAppRequest, titleForResult, todoRoutePath } from "./router.js" +import { bodyForResult, prepareAppRequest, resolveAppRequest, titleForResult, todoRoutePath } from "./router.js" import * as counterRouteModule from "./routes/counter-route.js" import * as todoRouteModule from "./routes/todo-route.js" export const bootstrapClient = ( document: Document, options?: LoomVite.LoomBootstrapOptions, -): Promise => - LoomVite.bootstrap(document, { - ...options, - expectedBuildId: options?.expectedBuildId ?? appBuildId, - payloadElementId: options?.payloadElementId ?? appPayloadElementId, - }) +): Promise => LoomVite.bootstrap(document, options) const defaultClientUrl = "https://effectify.dev/" const mountClientRoute = (pathname: string, root: HTMLElement): boolean => { if (pathname === "/") { - mount({ counterRoute: counterRouteModule.component }, { root }) + mount({ counterRoute: counterRouteModule.default }, { root }) return true } if (pathname === todoRoutePath) { - mount({ todoRoute: todoRouteModule.component }, { root }) + mount({ todoRoute: todoRouteModule.default }, { root }) return true } @@ -33,14 +27,14 @@ const mountClientRoute = (pathname: string, root: HTMLElement): boolean => { } const renderClientFallback = async (document: Document): Promise => { - const root = document.getElementById(appRootId) + const root = document.getElementById(LoomVite.defaultLoomRootId) if (!(root instanceof HTMLElement) || root.innerHTML.trim() !== "") { return false } const requestUrl = new URL(document.location?.href ?? defaultClientUrl, defaultClientUrl) - await prepareRouteRuntime(requestUrl) + await prepareAppRequest(requestUrl) const result = resolveAppRequest(requestUrl) if (requestUrl.pathname === "/" || requestUrl.pathname === todoRoutePath) { @@ -71,3 +65,7 @@ export const startClientApp = async ( return result } + +if (typeof window !== "undefined" && typeof document !== "undefined") { + void startClientApp(document) +} diff --git a/apps/loom-example-app/src/entry-server.ts b/apps/loom-example-app/src/entry-server.ts index b367e414..d9beef39 100644 --- a/apps/loom-example-app/src/entry-server.ts +++ b/apps/loom-example-app/src/entry-server.ts @@ -1,49 +1,22 @@ import { LoomNitro } from "@effectify/loom-nitro" -import { Resumability } from "@effectify/loom" -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 { bodyForResult, prepareAppRequest, resolveAppRequest, statusForResult, titleForResult } from "./router.js" const applicationBaseUrl = "https://effectify.dev" const normalizeRequestUrl = (input: string): URL => new URL(input, applicationBaseUrl) -const payloadPlaceholder = `` - -const injectResumabilityPayload = ( - html: string, - payload: LoomNitro.LoomResumabilityPayload | undefined, -): string => { - if (payload === undefined) { - return html - } - - const encodedPayload = Resumability.encodeContract(payload) - .replace(/${encodedPayload}`, - ) -} - -export const createServerRenderer = (): LoomNitro.LoomNitroRenderer => { - const renderer = LoomNitro.renderer({ - buildId: appBuildId, - rootId: appRootId, +export const createServerRenderer = (): LoomNitro.LoomNitroRenderer => + LoomNitro.renderer({ render: (request) => { const requestUrl = normalizeRequestUrl(request.url) - return prepareRouteRuntime(requestUrl).then(() => { + return prepareAppRequest(requestUrl).then(() => { const result = resolveAppRequest(requestUrl) - return createDocument({ - title: titleForResult(result), + return { + title: `Loom Example App · ${titleForResult(result)}`, body: bodyForResult(result), - }) + } }) }, response: (request) => { @@ -54,16 +27,3 @@ export const createServerRenderer = (): LoomNitro.LoomNitroRenderer => { } }, }) - - return { - name: renderer.name, - render: async (request) => { - const result = await renderer.render(request) - - return { - ...result, - html: injectResumabilityPayload(result.html, result.resumability), - } - }, - } -} diff --git a/apps/loom-example-app/src/router-runtime.ts b/apps/loom-example-app/src/router-runtime.ts deleted file mode 100644 index 5c985fbb..00000000 --- a/apps/loom-example-app/src/router-runtime.ts +++ /dev/null @@ -1,204 +0,0 @@ -import * as Effect from "effect/Effect" -import { Router, Runtime } from "@effectify/loom-router" -import { todoPageRoute, todoRouteId, todoRoutePath } from "./router.js" -import { - resetTodoRouteViewState, - setTodoActionStatus, - setTodoFeedback, - setTodoItems, - setTodoLoaderStatus, -} from "./routes/todo-route-state.js" -import { makeTodoService, type TodoItem, type TodoServiceApi } from "./todo-service.js" - -type TodoRouteServices = Readonly<{ - todoService: TodoServiceApi -}> - -type TodoLoaderState = Runtime.LoaderState -type TodoActionState = Runtime.ActionState - -export interface TodoRouteRuntime { - readonly load: (input?: string | URL) => Promise - readonly reset: () => void - readonly submit: ( - options: { readonly input?: string | URL; readonly submission: Runtime.Submission }, - ) => Promise<{ - readonly action: TodoActionState - readonly loader?: TodoLoaderState - }> -} - -const todoServices = (): TodoRouteServices => ({ - todoService: makeTodoService(), -}) - -const todoRuntimeRouter = Router.make({ - routes: [todoPageRoute], -}) - -type TodoResolvedRoute = - & Router.ResolveSuccess - & { - readonly route: typeof todoPageRoute - } - -const isTodoResolvedRoute = (value: Router.ResolveResult): value is TodoResolvedRoute => - Router.isResolveSuccess(value) && value.route.identifier === todoRouteId - -const resolveTodoRoute = (input: string | URL = todoRoutePath): TodoResolvedRoute => { - const resolved = Router.resolve(todoRuntimeRouter, input) - - if (!isTodoResolvedRoute(resolved)) { - throw new Error(`Expected '${todoRoutePath}' to resolve successfully for the Loom todo runtime`) - } - - return resolved -} - -const staleTodoData = (state: TodoLoaderState | undefined): ReadonlyArray | undefined => { - if (state === undefined) { - return undefined - } - - switch (state._tag) { - case "success": - case "revalidating": - return state.data - case "failure": - return state.data - default: - return undefined - } -} - -const syncTodoLoaderState = (state: TodoLoaderState): void => { - switch (state._tag) { - case "success": - setTodoItems(state.data) - setTodoLoaderStatus("loaded") - setTodoFeedback(undefined) - return - case "revalidating": - setTodoItems(state.data) - setTodoLoaderStatus("revalidating") - return - case "failure": - setTodoItems(state.data ?? []) - setTodoLoaderStatus("failure") - setTodoFeedback(state.error.message) - return - case "loading": - setTodoLoaderStatus("loading") - return - case "idle": - setTodoLoaderStatus("idle") - } -} - -const syncTodoActionState = (state: TodoActionState): void => { - switch (state._tag) { - case "success": - setTodoActionStatus("success") - setTodoFeedback(`Action '${state.result.intent}' completed and revalidated.`) - return - case "failure": - setTodoActionStatus("failure") - setTodoFeedback(state.error.message) - return - case "invalid-input": - setTodoActionStatus("invalid-input") - setTodoFeedback(state.issues[0].message) - return - case "submitting": - setTodoActionStatus("submitting") - return - case "idle": - setTodoActionStatus("idle") - setTodoFeedback(undefined) - } -} - -export const createTodoRouteRuntime = (services: TodoRouteServices = todoServices()): TodoRouteRuntime => { - let latestLoaderState: TodoLoaderState | undefined - - return { - load: async (input = todoRoutePath) => { - const loaded = await Runtime.load({ - resolved: resolveTodoRoute(input), - services, - }) - - latestLoaderState = loaded - - return loaded - }, - reset: () => { - Effect.runSync(services.todoService.reset()) - latestLoaderState = undefined - }, - submit: async ({ input = todoRoutePath, submission }) => { - const resolved = resolveTodoRoute(input) - const action = await Runtime.submit({ - resolved, - services, - submission, - }) - - if (action._tag !== "success") { - return { action } - } - - const previous = staleTodoData(latestLoaderState) - const loader = previous === undefined - ? await Runtime.load({ resolved, services }) - : await Runtime.revalidate({ previous, resolved, services }) - - latestLoaderState = loader - - return { - action, - loader, - } - }, - } -} - -export const todoRouteRuntime = createTodoRouteRuntime() - -export const prepareRouteRuntime = async (input: URL): Promise => { - if (input.pathname === todoRoutePath) { - await loadTodoRouteState(input) - } -} - -export const loadTodoRouteState = async (input: string | URL = todoRoutePath): Promise => { - setTodoLoaderStatus("loading") - const loaded = await todoRouteRuntime.load(input) - - syncTodoLoaderState(loaded) - return loaded -} - -export const submitTodoRuntimeAction = async ( - submission: Runtime.Submission, -): Promise<{ - readonly action: TodoActionState - readonly loader?: TodoLoaderState -}> => { - setTodoActionStatus("submitting") - - const result = await todoRouteRuntime.submit({ submission }) - - syncTodoActionState(result.action) - - if (result.loader !== undefined) { - syncTodoLoaderState(result.loader) - } - - return result -} - -export const resetTodoExampleState = (): void => { - todoRouteRuntime.reset() - resetTodoRouteViewState() -} diff --git a/apps/loom-example-app/src/router.ts b/apps/loom-example-app/src/router.ts index 2a11b0cd..38cbdd3e 100644 --- a/apps/loom-example-app/src/router.ts +++ b/apps/loom-example-app/src/router.ts @@ -1,13 +1,25 @@ 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 { pipe } from "effect" 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 { + prepareTodoRoute, + resetTodoRouteExampleState, + todoPageRoute, + todoRouteId, + todoRoutePath, + todoRouteTitle, +} from "./routes/todo-route.js" -export const todoRouteId = "todo" -export const todoRoutePath = "/todos" -export const todoRouteTitle = "Todo app" +export { todoPageRoute, todoRouteId, todoRoutePath, todoRouteTitle } from "./routes/todo-route.js" + +const ShellBody = Component.make().pipe( + Component.view(({ props }: { readonly props: Readonly<{ content: Loom.View.ViewChild | undefined }> | undefined }) => + View.fragment(props?.content ?? "") + ), +) export const counterPageRoute = RouteModule.compile({ identifier: counterRouteId, @@ -15,41 +27,28 @@ export const counterPageRoute = RouteModule.compile({ path: counterRoutePath, }) -export const todoPageRoute = RouteModule.compile({ - identifier: todoRouteId, - module: { - ...todoRouteModule, - component: () => Component.use(todoRouteModule.component), - }, - path: todoRoutePath, -}) - -const AppShell = Component.make("AppShell").pipe( +const AppShell = Component.make().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)), - routes: [counterPageRoute, todoPageRoute] as const, - fallback: { - notFound: Fallback.make(notFoundView), - }, -}) +export const appRouter = pipe( + Router.make("app"), + Router.layout(Layout.make(({ child }) => View.use(AppShell, View.use(ShellBody, { content: child })))), + Router.notFound(Fallback.make(notFoundView)), + Router.route(counterPageRoute), + Router.route(todoPageRoute), +) const matchedRouteTitle = (result: Router.ResolveSuccess): string => { switch (result.route.identifier) { @@ -64,6 +63,14 @@ const matchedRouteTitle = (result: Router.ResolveSuccess): string => { export const resolveAppRequest = (input: string | URL): Router.ResolveResult => Router.resolve(appRouter, input) +export const prepareAppRequest = async (input: URL): Promise => { + await prepareTodoRoute(input) +} + +export const resetExampleState = (): void => { + resetTodoRouteExampleState() +} + export const titleForResult = (result: Router.ResolveResult): string => Router.isResolveSuccess(result) ? matchedRouteTitle(result) : "Not Found" @@ -80,4 +87,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..1b92264c 100644 --- a/apps/loom-example-app/src/routes/counter-route.ts +++ b/apps/loom-example-app/src/routes/counter-route.ts @@ -1,6 +1,7 @@ import { Atom } from "effect/unstable/reactivity" -import { Component, View, Web } from "@effectify/loom" -import { counterInitialCount } from "../app-config.js" +import { Component, html, Web } from "@effectify/loom" + +const counterInitialCount = 2 export const counterRouteId = "counter" export const counterRoutePath = "/" @@ -46,7 +47,7 @@ const counterCueStyle = (count: number): Web.StyleRecord => { } } -export const CounterRoute = Component.make("CounterRoute").pipe( +export const CounterRoute = Component.make().pipe( Component.state({ count: () => Atom.make(counterInitialCount).pipe(Atom.keepAlive), }), @@ -56,68 +57,60 @@ 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 + counterCueTone(state.count())} + title=${() => `Reactive cue tone: ${counterCueTone(state.count())} (${state.count()})`} + web:class=${() => [`counter-reactive-cue--${counterCueTone(state.count())}`]} + web:style=${() => 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. + +
+
+ ` ), ) -export const component = CounterRoute +export default CounterRoute diff --git a/apps/loom-example-app/src/routes/todo-route-submission.ts b/apps/loom-example-app/src/routes/todo-route-submission.ts deleted file mode 100644 index 7135930f..00000000 --- a/apps/loom-example-app/src/routes/todo-route-submission.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { Runtime } from "@effectify/loom-router" - -export interface TodoRouteSubmissionResult { - readonly action: { - readonly _tag: string - } - readonly loader?: unknown -} - -export const submitTodoRouteSubmission = async ( - submission: Runtime.Submission, -): Promise => { - const { submitTodoRuntimeAction } = await import("../router-runtime.js") - - return submitTodoRuntimeAction(submission) -} diff --git a/apps/loom-example-app/src/routes/todo-route.ts b/apps/loom-example-app/src/routes/todo-route.ts index 91e636d0..2831c2e9 100644 --- a/apps/loom-example-app/src/routes/todo-route.ts +++ b/apps/loom-example-app/src/routes/todo-route.ts @@ -1,8 +1,14 @@ import * as Effect from "effect/Effect" -import { pipe } from "effect/Function" -import { Route } from "@effectify/loom-router" -import { TodoRoute } from "./todo-route/todo-route-component.js" +import { View } from "@effectify/loom" +import { Route, RouteModule, Router, Runtime } from "@effectify/loom-router" import { todoRegistry } from "./todo-route-state.js" +import { + resetTodoRouteViewState, + setTodoActionStatus, + setTodoFeedback, + setTodoItems, + setTodoLoaderStatus, +} from "./todo-route-state.js" import { TodoCommandResultSchema, TodoCommandSchema, @@ -10,28 +16,240 @@ import { TodoRouteErrorSchema, type TodoRouteServices, } from "./todo-route/todo-route-shared.js" +import { TodoRoute } from "./todo-route/todo-route-component.js" +import { makeTodoService, type TodoItem } from "../todo-service.js" + +export const todoRouteId = "todo" +export const todoRoutePath = "/todos" +export const todoRouteTitle = "Todo app" + +export default Object.assign(TodoRoute, { registry: todoRegistry }) + +const todoLoaderOptions = { + output: TodoItemsSchema, + services: Route.services(), +} as const + +export const loader = Route.loader({ + ...todoLoaderOptions, + load: ({ services }) => services.todoService.list(), +}) + +const todoActionOptions = { + input: TodoCommandSchema, + output: TodoCommandResultSchema, + error: TodoRouteErrorSchema, + services: Route.services(), +} as const + +export const action = Route.action({ + ...todoActionOptions, + handle: ({ input, services }) => services.todoService.dispatch(input), +}) + +const todoRouteModule = { + action, + default: () => View.use(TodoRoute), + loader, +} + +export const todoPageRoute = RouteModule.compile({ + identifier: todoRouteId, + module: todoRouteModule, + path: todoRoutePath, +}) + +type TodoLoaderState = Runtime.LoaderState +type TodoActionState = Runtime.ActionState + +type TodoRuntimeServices = Readonly<{ + todoService: TodoRouteServices["todoService"] +}> + +export interface TodoRouteRuntime { + readonly load: (input?: string | URL) => Promise + readonly reset: () => void + readonly submit: ( + options: { readonly input?: string | URL; readonly submission: Runtime.Submission }, + ) => Promise<{ + readonly action: TodoActionState + readonly loader?: TodoLoaderState + }> +} + +export interface TodoRouteSubmissionResult { + readonly action: { + readonly _tag: string + } + readonly loader?: unknown +} + +const todoServices = (): TodoRuntimeServices => ({ + todoService: makeTodoService(), +}) + +const todoRuntimeRouter = Router.make({ + routes: [todoPageRoute], +}) + +type TodoResolvedRoute = + & Router.ResolveSuccess + & { + readonly route: typeof todoPageRoute + } + +const isTodoResolvedRoute = (value: Router.ResolveResult): value is TodoResolvedRoute => + Router.isResolveSuccess(value) && value.route.identifier === todoRouteId + +const resolveTodoRoute = (input: string | URL = todoRoutePath): TodoResolvedRoute => { + const resolved = Router.resolve(todoRuntimeRouter, input) + + if (!isTodoResolvedRoute(resolved)) { + throw new Error(`Expected '${todoRoutePath}' to resolve successfully for the Loom todo runtime`) + } + + return resolved +} + +const staleTodoData = (state: TodoLoaderState | undefined): ReadonlyArray | undefined => { + if (state === undefined) { + return undefined + } + + switch (state._tag) { + case "success": + case "revalidating": + return state.data + case "failure": + return state.data + default: + return undefined + } +} + +const syncTodoLoaderState = (state: TodoLoaderState): void => { + switch (state._tag) { + case "success": + setTodoItems(state.data) + setTodoLoaderStatus("loaded") + setTodoFeedback(undefined) + return + case "revalidating": + setTodoItems(state.data) + setTodoLoaderStatus("revalidating") + return + case "failure": + setTodoItems(state.data ?? []) + setTodoLoaderStatus("failure") + setTodoFeedback(state.error.message) + return + case "loading": + setTodoLoaderStatus("loading") + return + case "idle": + setTodoLoaderStatus("idle") + } +} + +const syncTodoActionState = (state: TodoActionState): void => { + switch (state._tag) { + case "success": + setTodoActionStatus("success") + setTodoFeedback(`Action '${state.result.intent}' completed and revalidated.`) + return + case "failure": + setTodoActionStatus("failure") + setTodoFeedback(state.error.message) + return + case "invalid-input": + setTodoActionStatus("invalid-input") + setTodoFeedback(state.issues[0].message) + return + case "submitting": + setTodoActionStatus("submitting") + return + case "idle": + setTodoActionStatus("idle") + setTodoFeedback(undefined) + } +} + +export const createTodoRouteRuntime = (services: TodoRuntimeServices = todoServices()): TodoRouteRuntime => { + let latestLoaderState: TodoLoaderState | undefined + + return { + load: async (input = todoRoutePath) => { + const loaded = await Runtime.load({ + resolved: resolveTodoRoute(input), + services, + }) + + latestLoaderState = loaded + + return loaded + }, + reset: () => { + Effect.runSync(services.todoService.reset()) + latestLoaderState = undefined + }, + submit: async ({ input = todoRoutePath, submission }) => { + const resolved = resolveTodoRoute(input) + const action = await Runtime.submit({ + resolved, + services, + submission, + }) + + if (action._tag !== "success") { + return { action } + } + + const previous = staleTodoData(latestLoaderState) + const loader = previous === undefined + ? await Runtime.load({ resolved, services }) + : await Runtime.revalidate({ previous, resolved, services }) + + latestLoaderState = loader + + return { + action, + loader, + } + }, + } +} + +const todoRouteRuntime = createTodoRouteRuntime() + +export const loadTodoRouteState = async (input: string | URL = todoRoutePath): Promise => { + setTodoLoaderStatus("loading") + const loaded = await todoRouteRuntime.load(input) + + syncTodoLoaderState(loaded) + return loaded +} + +export const prepareTodoRoute = async (input: URL): Promise => { + if (input.pathname === todoRoutePath) { + await loadTodoRouteState(input) + } +} + +export const submitTodoRoute = async (submission: Runtime.Submission): Promise => { + setTodoActionStatus("submitting") + + const result = await todoRouteRuntime.submit({ submission }) + + syncTodoActionState(result.action) + + if (result.loader !== undefined) { + syncTodoLoaderState(result.loader) + } + + return result +} -export const component = Object.assign(TodoRoute, { registry: todoRegistry }) - -export const loader = pipe( - Effect.fn(function*({ services }: { readonly services: TodoRouteServices }) { - return yield* services.todoService.list() - }), - Route.loader({ - output: TodoItemsSchema, - }), -) - -export const action = pipe( - Effect.fn(function*({ - input, - services, - }: { readonly input: typeof TodoCommandSchema.Type; readonly services: TodoRouteServices }) { - return yield* services.todoService.dispatch(input) - }), - Route.action({ - input: TodoCommandSchema, - output: TodoCommandResultSchema, - error: TodoRouteErrorSchema, - }), -) +export const resetTodoRouteExampleState = (): void => { + todoRouteRuntime.reset() + resetTodoRouteViewState() +} 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..d994ca37 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,10 +1,15 @@ import { Atom } from "effect/unstable/reactivity" -import { Component, 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 { submitTodoRoute } from "../todo-route.js" import { TodoPanel } from "./todo-route-shared.js" -export const TodoComposer = Component.make("TodoComposer").pipe( +const readTodoTitleInput = (form: HTMLFormElement): string | undefined => { + const titleInput = form.elements.namedItem("title") + return titleInput instanceof HTMLInputElement ? titleInput.value : undefined +} + +export const TodoComposer = Component.make().pipe( Component.state({ actionStatus: todoActionStatusAtom, draft: todoDraftAtom, @@ -13,7 +18,7 @@ export const TodoComposer = Component.make("TodoComposer").pipe( Component.actions(({ model }) => ({ submitDraft: async (titleInput?: string): Promise => { const title = titleInput ?? model.draft.get() - const result = await submitTodoRouteSubmission({ + const result = await submitTodoRoute({ intent: "create", title, }) @@ -28,48 +33,60 @@ 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() + View.use(TodoPanel, [ + html` +

Composer

+ + The Add button now submits normalized action input through the Loom runtime before the loader revalidates the list. + + `, + html` +
+
{ + 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")), + if (currentTarget instanceof HTMLFormElement) { + void actions.submitDraft(readTodoTitleInput(currentTarget)) + } + }} + > + state.actionStatus() === "submitting"} + web:value=${() => state.draft()} + web:input=${({ currentTarget }) => { + if (currentTarget instanceof HTMLInputElement) { + actions.syncDraft(currentTarget.value) + } + }} + /> + +
+
+ `, + 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..a6c556e3 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,42 +1,42 @@ -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" -export const TodoHero = Component.make("TodoHero").pipe( +export const TodoHero = Component.make().pipe( Component.state({ draft: todoDraftAtom, todos: todoItemsAtom, 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 plus View.of 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..52c7acba 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,50 +1,48 @@ -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" -export const TodoInsights = Component.make("TodoInsights").pipe( +export const TodoInsights = Component.make().pipe( Component.state({ actionStatus: todoActionStatusAtom, feedback: todoFeedbackAtom, 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..bdd90854 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,76 +1,96 @@ -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 { submitTodoRoute } from "../todo-route.js" import { hasCompletedTodos, TodoPanel } from "./todo-route-shared.js" -export const TodoList = Component.make("TodoList").pipe( +export const TodoList = Component.make().pipe( Component.state({ actionStatus: todoActionStatusAtom, todos: todoItemsAtom, }), Component.actions(() => ({ clearCompleted: async (): Promise => { - await submitTodoRouteSubmission({ intent: "clear-completed" }) + await submitTodoRoute({ intent: "clear-completed" }) }, removeTodo: async (id: number): Promise => { - await submitTodoRouteSubmission({ intent: "remove", id: String(id) }) + await submitTodoRoute({ intent: "remove", id: String(id) }) }, toggleTodo: async (id: number): Promise => { - await submitTodoRouteSubmission({ intent: "toggle", id: String(id) }) + await submitTodoRoute({ intent: "toggle", id: String(id) }) }, })), 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..402e7812 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,27 @@ -import { Component, View, Web } from "@effectify/loom" +import { Component, html, View } from "@effectify/loom" 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" -export const TodoRoute = Component.make("TodoRoute").pipe( +export const TodoRoute = Component.make().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.of(TodoHero)} +
+ ${View.of(TodoInsights)} + ${View.of(TodoComposer)} +
+ ${View.of(TodoList)} + ${View.of(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..f7f530d7 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,5 +1,5 @@ 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" export type TodoRouteServices = Readonly<{ @@ -46,25 +46,25 @@ 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"))), +export const TodoPanel = Component.make().pipe( + Component.view(({ children }) => html`
${children}
`), ) -export const TodoNotes = Component.make("TodoNotes").pipe( +export const TodoNotes = Component.make().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 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. + +
+ ` ), ) -export const TodoPageShell = Component.make("TodoPageShell").pipe( - Component.view(({ children }) => - View.vstack(children).pipe(Web.className("loom-example-layout"), Web.data("route-view", "todo")) - ), +export const TodoPageShell = Component.make().pipe( + Component.view(({ children }) => html`
${children}
`), ) 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..e12b049b --- /dev/null +++ b/apps/loom-example-app/tests/app-route-import-safety.test.ts @@ -0,0 +1,85 @@ +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 +} + +type EntryServerModule = typeof import("../src/entry-server.js") + +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<{ + readonly CounterRoute: unknown + readonly counterRouteId: string + readonly counterRoutePath: string + readonly counterRouteTitle: string + readonly default: unknown + }>("../src/routes/counter-route.ts") + const todoRouteModule = await importFresh<{ + readonly action: unknown + readonly createTodoRouteRuntime: unknown + readonly default: unknown + readonly loader: unknown + readonly prepareTodoRoute: unknown + readonly resetTodoRouteExampleState: unknown + readonly submitTodoRoute: unknown + readonly todoPageRoute: Record + readonly todoRouteId: string + readonly todoRoutePath: string + readonly todoRouteTitle: string + }>("../src/routes/todo-route.ts") + const routerModule = await importFresh<{ + readonly appRouter: Record + readonly prepareAppRequest: unknown + readonly resetExampleState: unknown + readonly resolveAppRequest: unknown + readonly todoRouteId: string + readonly todoRoutePath: string + readonly todoRouteTitle: string + }>("../src/router.ts") + + expect(counterRouteModule.counterRouteId).toBe("counter") + expect(counterRouteModule.counterRoutePath).toBe("/") + expect(counterRouteModule.counterRouteTitle).toBe("Counter") + expect(counterRouteModule.default).toBe(counterRouteModule.CounterRoute) + + expect(todoRouteModule.todoRouteId).toBe("todo") + expect(todoRouteModule.todoRoutePath).toBe("/todos") + expect(todoRouteModule.todoRouteTitle).toBe("Todo app") + expect(todoRouteModule.default).toHaveProperty("registry") + expect(todoRouteModule.todoPageRoute.identifier).toBe(todoRouteModule.todoRouteId) + expect(todoRouteModule.todoPageRoute.path).toBe(todoRouteModule.todoRoutePath) + expect(typeof todoRouteModule.action).toBe("object") + expect(typeof todoRouteModule.createTodoRouteRuntime).toBe("function") + expect(typeof todoRouteModule.loader).toBe("object") + expect(typeof todoRouteModule.prepareTodoRoute).toBe("function") + expect(typeof todoRouteModule.resetTodoRouteExampleState).toBe("function") + expect(typeof todoRouteModule.submitTodoRoute).toBe("function") + + expect(routerModule.todoRouteId).toBe(todoRouteModule.todoRouteId) + expect(routerModule.todoRoutePath).toBe(todoRouteModule.todoRoutePath) + expect(routerModule.todoRouteTitle).toBe(todoRouteModule.todoRouteTitle) + expect(typeof routerModule.prepareAppRequest).toBe("function") + expect(typeof routerModule.resetExampleState).toBe("function") + expect(typeof routerModule.resolveAppRequest).toBe("function") + expect(routerModule.appRouter).toHaveProperty("identifier") + expect(Reflect.has(globalThis, "document")).toBe(false) + }) + + it("imports the server entry without installing a global document", async () => { + const entryServerModule = await importFresh("../src/entry-server.ts") + + expect(typeof entryServerModule.createServerRenderer).toBe("function") + expect(entryServerModule.createServerRenderer()).toMatchObject({ + name: "effectify:loom-nitro", + render: expect.any(Function), + }) + expect(Reflect.has(globalThis, "document")).toBe(false) + }) +}) diff --git a/apps/loom-example-app/tests/entry-client.test.ts b/apps/loom-example-app/tests/entry-client.test.ts index 94b91249..c536df41 100644 --- a/apps/loom-example-app/tests/entry-client.test.ts +++ b/apps/loom-example-app/tests/entry-client.test.ts @@ -1,9 +1,14 @@ // @vitest-environment jsdom -import { beforeEach, describe, expect, it } from "vitest" +import { beforeEach, describe, expect, it, vi } from "vitest" import { bootstrapClient, startClientApp } from "../src/entry-client.js" import { createServerRenderer } from "../src/entry-server.js" -import { resetTodoExampleState } from "../src/router-runtime.js" +import { resetExampleState } from "../src/router.js" + +const importFresh = async (relativePath: string): Promise => { + const moduleUrl = new URL(relativePath, import.meta.url) + return import(`${moduleUrl.href}?t=${Date.now()}`) as Promise +} const yieldToEventLoop = async (): Promise => { await new Promise((resolve) => setTimeout(resolve, 0)) @@ -27,7 +32,7 @@ const expectInputElement = (value: Element | null, name: string): HTMLInputEleme describe("loom example app client entry", () => { beforeEach(() => { - resetTodoExampleState() + resetExampleState() }) it("reports a missing payload while leaving the SSR shell untouched", async () => { @@ -48,7 +53,9 @@ describe("loom example app client entry", () => { headers: {}, }) - document.documentElement.innerHTML = result.html + document.open() + document.write(result.html) + document.close() const before = document.body.innerHTML const bootstrap = await startClientApp(document) @@ -57,6 +64,24 @@ describe("loom example app client entry", () => { expect(document.body.innerHTML).toBe(before) }) + it("uses the default server payload marker without requiring explicit bootstrap overrides", async () => { + const renderer = createServerRenderer() + const result = await renderer.render({ + method: "GET", + url: "/", + headers: {}, + }) + + document.documentElement.innerHTML = result.html + + const bootstrap = await bootstrapClient(document) + + expect(result.html).toContain('id="__loom_payload__"') + expect(bootstrap.status).toBe("missing-payload") + expect(bootstrap.diagnostics[0]?.issues[0]?.subject).toBe("__loom_payload__") + expect(document.body.textContent).toContain("Loom vNext counter") + }) + it("accepts explicit bootstrap options for missing payload diagnostics", async () => { document.body.innerHTML = '
server shell
' @@ -94,16 +119,13 @@ 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") 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 +137,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 +154,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() @@ -260,7 +284,7 @@ describe("loom example app client entry", () => { expect(document.title).toBe("Loom Example App · Todo app") }) - it("shows invalid action feedback when the todo action input fails validation", async () => { + 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 @@ -272,17 +296,73 @@ describe("loom example app client entry", () => { await startClientApp(document) - const addButton = document.querySelector('[data-todo-add-action="true"]') + const input = expectInputElement(document.querySelector('[data-todo-input="true"]'), "todo input") + 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() + input.focus() + input.value = "Ship Enter-key parity" + input.dispatchEvent(new Event("input", { bubbles: true })) + form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })) + 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 template-authored submit path fails validation", async () => { + document.documentElement.innerHTML = ` + Loom Example App + +
+ + + ` + window.history.replaceState({}, "", "/todos") + + await startClientApp(document) + + const form = document.querySelector("form") + + if (!(form instanceof HTMLFormElement)) { + throw new Error("expected todo composer form") + } + + 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") expect(document.querySelector('[data-todo-open-count="true"]')?.textContent?.trim()).toBe("2") expect(document.querySelector('[data-todo-action-status="true"]')?.textContent?.trim()).toBe("invalid-input") }) + + it("self-starts from the browser entry module without requiring entry-browser.ts", async () => { + vi.resetModules() + document.documentElement.innerHTML = ` + Loom Example App + +
+ + + ` + window.history.replaceState({}, "", "/") + + await importFresh("../src/entry-client.ts") + await yieldToEventLoop() + + expect(document.querySelector('[data-app-shell="loom-example-app"]')).not.toBeNull() + expect(document.querySelector('[data-counter-value="true"]')?.textContent?.replace(/\s+/g, " ").trim()).toBe( + "Count: 2", + ) + }) }) diff --git a/apps/loom-example-app/tests/entry-server.test.ts b/apps/loom-example-app/tests/entry-server.test.ts index 6c2adee1..c235a224 100644 --- a/apps/loom-example-app/tests/entry-server.test.ts +++ b/apps/loom-example-app/tests/entry-server.test.ts @@ -1,10 +1,26 @@ import { beforeEach, describe, expect, it } from "vitest" import { createServerRenderer } from "../src/entry-server.js" -import { resetTodoExampleState } from "../src/router-runtime.js" +import { resetExampleState } from "../src/router.js" describe("loom example app server entry", () => { beforeEach(() => { - resetTodoExampleState() + Reflect.deleteProperty(globalThis, "document") + resetExampleState() + }) + + 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 () => { @@ -16,15 +32,17 @@ 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"') expect(result.html).toContain('id="__loom_payload__"') + expect(result.html).toContain('src="/src/entry-client.ts"') expect(result.html).toContain('data-counter-action="increment"') 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") }) @@ -37,6 +55,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"') @@ -44,8 +63,12 @@ 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(" { @@ -57,6 +80,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/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..ccd57b7a 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 { existsSync, 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,128 +47,71 @@ 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 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", + 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(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`.", ) - 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") + }) - 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("Web.as") - expect(counterRouteSource).toContain("View.vstack") - expect(counterRouteSource).toContain("View.hstack") - expect(counterRouteSource).toContain("Web.data") - 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 }) {", + it("exports behavior-first route contracts through public entry points without extra bootstrap files", 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 entryServerModule = await import( + pathToFileURL(new URL("../src/entry-server.ts", import.meta.url).pathname).href ) - 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("Component.use(TodoHero") - expect(todoRouteComponentSource).toContain("Component.use(TodoList") - 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("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("View.input()") - expect(todoComposerSource).toContain("Web.value(") - expect(todoComposerSource).not.toContain("registry: todoRegistry") - expect(todoListSource).toContain('export const TodoList = Component.make("TodoList").pipe(') - expect(todoListSource).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("Component.use(AppShell") - expect(routerSource).toContain("Fallback.make(notFoundView)") - expect(routerSource).toContain("RouteModule.compile({") - expect(routerSource).toContain("component: () => Component.use(todoRouteModule.component)") - 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 })") - 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 renderer = entryServerModule.createServerRenderer() + const documentResult = await renderer.render({ method: "GET", url: "/", headers: {} }) - 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.default).toBe(counterRouteModule.CounterRoute) + expect(counterRouteModule.counterRoutePath).toBe("/") + expect(todoRouteModule.default.registry).toBe(todoRouteStateModule.todoRegistry) + expect(typeof todoRouteModule.createTodoRouteRuntime).toBe("function") + expect(typeof todoRouteModule.submitTodoRoute).toBe("function") + expect(typeof routerModule.prepareAppRequest).toBe("function") + expect(typeof routerModule.resetExampleState).toBe("function") + 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(documentResult.html).toContain('') + expect(documentResult.html).toContain('id="loom-root"') + expect(documentResult.html).toContain('id="__loom_payload__"') + expect(documentResult.html).toContain('src="/src/entry-client.ts"') + expect(existsSync(new URL("../src/entry-browser.ts", import.meta.url))).toBe(false) + expect(existsSync(new URL("../src/jsdom.d.ts", import.meta.url))).toBe(false) + expect(existsSync(new URL("../src/router-runtime.ts", import.meta.url))).toBe(false) + expect(existsSync(new URL("../src/routes/todo-route-submission.ts", import.meta.url))).toBe(false) + expect(existsSync(new URL("../src/document.ts", import.meta.url))).toBe(false) + expect(existsSync(new URL("../src/app-config.ts", import.meta.url))).toBe(false) }) }) diff --git a/apps/loom-example-app/tests/public-api.types.ts b/apps/loom-example-app/tests/public-api.types.ts index 49d69685..1cf6854a 100644 --- a/apps/loom-example-app/tests/public-api.types.ts +++ b/apps/loom-example-app/tests/public-api.types.ts @@ -1,8 +1,17 @@ import type * as Loom from "@effectify/loom" import { Router } from "@effectify/loom-router" import { CounterRoute } from "../src/routes/counter-route.js" -import { appRouter, bodyForResult, resolveAppRequest, todoPageRoute, todoRouteId } from "../src/router.js" +import { + appRouter, + bodyForResult, + prepareAppRequest, + resetExampleState, + resolveAppRequest, + todoPageRoute, + todoRouteId, +} from "../src/router.js" import { counterRouteId } from "../src/routes/counter-route.js" +import { createTodoRouteRuntime, submitTodoRoute } from "../src/routes/todo-route.js" type Equal = (() => Value extends Left ? 1 : 2) extends () => Value extends Right ? 1 : 2 ? true @@ -14,11 +23,21 @@ const todoHref = Router.href(appRouter, todoPageRoute) const homeBody: Loom.View.Child = bodyForResult(resolveAppRequest("https://effectify.dev/")) const todoBody: Loom.View.Child = bodyForResult(resolveAppRequest("https://effectify.dev/todos")) const counterComponent: Loom.Component.Component = CounterRoute +const todoRuntime = createTodoRouteRuntime() +const prepareRequestPromise = prepareAppRequest(new URL("https://effectify.dev/todos")) +const submitTodoPromise = submitTodoRoute({ intent: "clear-completed" }) +const resetResult = resetExampleState() type HomeHrefContract = Expect> type TodoHrefContract = Expect> type HomeBodyContract = Expect> type TodoBodyContract = Expect> +type TodoRuntimeContract = Expect void>> +type PrepareRequestContract = Expect>> +type SubmitTodoContract = Expect< + Equal> +> +type ResetContract = Expect> // @ts-expect-error unknown route identifiers must fail before runtime Router.href(appRouter, "settings") @@ -29,9 +48,22 @@ export const typecheckSmoke = { counterRouteId, homeBody, homeHref, + prepareRequestPromise, + resetResult, + submitTodoPromise, todoBody, todoHref, + todoRuntime, todoRouteId, } -export type { HomeBodyContract, HomeHrefContract, TodoBodyContract, TodoHrefContract } +export type { + HomeBodyContract, + HomeHrefContract, + PrepareRequestContract, + ResetContract, + SubmitTodoContract, + TodoBodyContract, + TodoHrefContract, + TodoRuntimeContract, +} diff --git a/apps/loom-example-app/tests/router-runtime.test.ts b/apps/loom-example-app/tests/router-runtime.test.ts index 9c8373f7..5663326c 100644 --- a/apps/loom-example-app/tests/router-runtime.test.ts +++ b/apps/loom-example-app/tests/router-runtime.test.ts @@ -1,7 +1,9 @@ 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 { createTodoRouteRuntime } from "../src/router-runtime.js" +import { createTodoRouteRuntime } from "../src/routes/todo-route.js" +import { bodyForResult, resetExampleState, resolveAppRequest, statusForResult, titleForResult } from "../src/router.js" import { type TodoItem, TodoNotFoundError, type TodoServiceApi } from "../src/todo-service.js" const makeTestTodoService = (seed: ReadonlyArray) => { @@ -59,49 +61,21 @@ 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).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("Component.use(TodoHero") - expect(todoRouteComponentSource).toContain("Component.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).not.toContain("internal/route-modules") + it("resolves / and /todos through the public router contract instead of source-shape assertions", () => { + resetExampleState() + 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-runtime-status="true"') }) it("executes the initial loader through the route runtime", async () => { 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": { diff --git a/apps/loom-example-app/vite.config.mts b/apps/loom-example-app/vite.config.mts index 66d5a1f3..97585d1e 100644 --- a/apps/loom-example-app/vite.config.mts +++ b/apps/loom-example-app/vite.config.mts @@ -1,30 +1,11 @@ import { nxViteTsPaths } from "@nx/vite/plugins/nx-tsconfig-paths.plugin" -import type { Plugin } from "vite" +import { LoomVite } from "../../packages/loom/vite/src/index.ts" import { defineConfig } from "vitest/config" -import { appPayloadElementId } from "./src/app-config.js" - -const injectBeforeClosingBody = (html: string, fragment: string): string => { - const closingBodyTagIndex = html.indexOf("") - - return closingBodyTagIndex === -1 - ? `${html}${fragment}` - : `${html.slice(0, closingBodyTagIndex)}${fragment}${html.slice(closingBodyTagIndex)}` -} - -const loomExampleVitePlugin = (): Plugin => ({ - name: "effectify:loom-vite", - transformIndexHtml(html) { - return injectBeforeClosingBody( - html, - ``, - ) - }, -}) export default defineConfig({ root: __dirname, cacheDir: "../../node_modules/.vite/apps/loom-example-app", - plugins: [nxViteTsPaths(), loomExampleVitePlugin()], + plugins: [nxViteTsPaths(), LoomVite.loom()], test: { name: "@effectify/loom-example-app", watch: false, 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 cb8a9cf2..3de1de83 100644 --- a/packages/loom/README.md +++ b/packages/loom/README.md @@ -25,9 +25,9 @@ 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( +export const CounterRoute = Component.make().pipe( Component.state({ count: Atom.make(0), draft: () => Atom.make(""), @@ -36,18 +36,125 @@ 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}
+ +
+ ` ), ) mount({ CounterRoute }) ``` +Use `Component.make()` as the default authoring path. Keep `Component.make("Name")` for observability, diagnostics, or any place where authored metadata matters. + +### 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().pipe( + Component.view(() => html`
Count card
`), +) + +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().pipe( + Component.model({ count: Atom.make(0) }), + Component.actions({ + increment: ({ count }) => count.update((value) => value + 1), + }), + Component.view(({ state, actions }) => + html` +
+ +

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

+ ${View.of(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.of(...)` for trivial no-props/no-slots composition. +- Use `View.use(...)` for props, children, and slot objects. 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. + +Migration checklist for explicit APIs: + +- Keep `Component.make("Name")` when observability or diagnostics need stable authored names. +- Keep `View.use(...)` when the component needs props, children, or slots. +- Prefer `View.of(...)` only for trivial leaf usage with no required props and no slot inputs. + +### 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 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. +- 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. + - 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. @@ -58,11 +165,14 @@ mount({ CounterRoute }) ```ts import * as Effect from "effect/Effect" import * as Schema from "effect/Schema" +import { pipe } from "effect" import { Route, RouteModule, Router } from "@effectify/loom-router" import { CounterRoute } from "./counter-route.js" const TodoItem = Schema.Struct({ id: Schema.Number, title: Schema.String, completed: Schema.Boolean }) +export default CounterRoute + export const loader = Route.loader({ output: Schema.Array(TodoItem), load: Effect.fn(function*() { @@ -72,19 +182,31 @@ export const loader = Route.loader({ export const counterPageRoute = RouteModule.compile({ identifier: "counter", - module: { component: CounterRoute, loader }, + module: { default: CounterRoute, loader }, path: "/", }) -export const appRouter = Router.make({ - routes: [counterPageRoute], -}) +export const appRouter = pipe( + Router.make("app"), + Router.route(counterPageRoute), +) ``` -- Teach route modules through `component` / optional `loader` / optional `action` exports. -- Prefer `Route.loader({...})` and `Route.action({...})` inline schema-first helpers. +- Teach route modules through `export default` plus optional named `loader` / `action` exports. +- `component` remains fully supported for legacy modules and still wins if a module exports both `component` and `default`. +- Teach router assembly through `Router.make("app")` plus incremental operators like `Router.route(...)`, `Router.layout(...)`, and `Router.notFound(...)`. +- Prefer `Route.loader({...})` and `Route.action({...})` inline, passing service requirements via `services: Route.services<...>()` so `params`, `search`, `input`, and `services` infer automatically. +- Keep `Route.ModuleLoaderContext` / `Route.ModuleActionContext` as explicit compatibility escapes when you prefer to annotate handler params. - Keep descriptor-style route assembly and manual registry propagation out of the public examples. +Migration checklist: + +- New modules: prefer `export default` for route content. +- Existing modules: keep `export const component = ...` if changing exports is noisy. +- Mixed modules: if both `component` and `default` exist, Loom will use `component` for backward-compatible resolution. + +Compatibility note: `Router.from({ routes, layout, fallback })` and `Router.make({ ... })` remain available for existing code, but builder-first composition is the primary public story. + ## Compatibility and advanced seams - `Html` — compatibility-first low-level AST / SSR seam; prefer `View` + `Web` for new authoring @@ -92,6 +214,62 @@ export const appRouter = Router.make({ - `Hydration` — advanced hydration helpers layered after the primary interactive path - `Resumability` — advanced resumability helpers layered after the primary interactive path +## Zero-extra-file Loom bootstrap + +For the default SSR/browser path, start with the adapters and DO NOT create app-local `document.ts` or bootstrap-constants files up front. + +```ts +// entry-server.ts +import { LoomNitro } from "@effectify/loom-nitro" + +export const server = LoomNitro.renderer({ + render: () => ({ + title: "Counter", + body: CounterRoute, + }), +}) + +// vite.config.mts +import { LoomVite } from "@effectify/loom-vite" + +export default defineConfig({ + plugins: [LoomVite.loom()], +}) +``` + +Default ownership now lives in the framework adapters: + +- Nitro renders the HTML shell, root container, payload marker, and browser entry script. +- Vite injects the default root container / payload placeholder / browser module script in dev HTML. +- Browser bootstrap reads `__loom_payload__`, expects `loom-root`, and uses the default build id unless you override them. + +### Advanced bootstrap overrides + +If you need a custom shell or marker ids, keep the overrides explicit instead of reintroducing global app-local ceremony: + +```ts +LoomNitro.renderer({ + bootstrap: { + rootId: "custom-root", + payloadElementId: "custom-payload", + clientEntry: "/src/custom-entry.ts", + }, + document: { + render: ({ bodyHtml, payloadHtml, title }) => + `${title}
${bodyHtml}
${payloadHtml}`, + }, + render: () => ({ title: "Custom", body: CounterRoute }), +}) + +LoomVite.loom({ + rootId: "custom-root", + payloadElementId: "custom-payload", + clientEntry: "/src/custom-entry.ts", +}) +``` + +Teach the simple path first. Reach for the overrides only when the default document shell or marker ids are genuinely insufficient. + ## Internal-only packages - `@effectify/loom-core` — neutral AST and composition contracts 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/nitro/src/internal/document-shell.ts b/packages/loom/nitro/src/internal/document-shell.ts new file mode 100644 index 00000000..f033eba1 --- /dev/null +++ b/packages/loom/nitro/src/internal/document-shell.ts @@ -0,0 +1,26 @@ +import { type LoomResumabilityPayload, renderLoomPayloadElement } from "./payload.js" + +export interface LoomNitroResolvedBootstrap { + readonly buildId: string + readonly clientEntry: string + readonly payloadElementId: string + readonly rootId: string +} + +export interface LoomNitroDocumentShellInput { + readonly title?: string + readonly bodyHtml: string + readonly payload: LoomResumabilityPayload | undefined + readonly bootstrap: LoomNitroResolvedBootstrap +} + +export const renderDefaultLoomNitroDocument = (input: LoomNitroDocumentShellInput): string => { + const bodyHtml = input.bodyHtml ?? "" + const payloadHtml = input.payload === undefined + ? `` + : renderLoomPayloadElement(input.payload, input.bootstrap.payloadElementId) + + return `${ + input.title ?? "Loom" + }
${bodyHtml}
${payloadHtml}` +} diff --git a/packages/loom/nitro/src/internal/ssr-adapter.ts b/packages/loom/nitro/src/internal/ssr-adapter.ts index 6627dcd4..1c1b8d08 100644 --- a/packages/loom/nitro/src/internal/ssr-adapter.ts +++ b/packages/loom/nitro/src/internal/ssr-adapter.ts @@ -1,9 +1,12 @@ import { Html } from "@effectify/loom" import type * as Loom from "@effectify/loom" -import { createLoomResumabilityPayload, type LoomResumabilityPayload } from "./payload.js" +import { type LoomNitroResolvedBootstrap, renderDefaultLoomNitroDocument } from "./document-shell.js" +import { createLoomResumabilityPayload, type LoomResumabilityPayload, renderLoomPayloadElement } from "./payload.js" export const defaultLoomNitroRootId = "loom-root" export const defaultLoomNitroBuildId = "loom-dev" +export const defaultLoomNitroPayloadElementId = "__loom_payload__" +export const defaultLoomNitroClientEntry = "/src/entry-client.ts" export interface LoomNitroRequest { readonly method: string @@ -26,14 +29,54 @@ export interface LoomNitroRenderResult { readonly diagnosticSummary: ReadonlyArray } +export interface LoomBootstrapDefaults { + readonly buildId?: string + readonly clientEntry?: string + readonly payloadElementId?: string + readonly rootId?: string +} + +export interface LoomNitroRenderOutputShape { + readonly body?: Html.Child + readonly title?: string +} + +export type LoomNitroRenderOutput = Html.Child | LoomNitroRenderOutputShape + +export interface LoomNitroDocumentRenderInput { + readonly title?: string + readonly bodyHtml: string + readonly payloadHtml: string + readonly payload: LoomResumabilityPayload | undefined + readonly bootstrap: LoomNitroResolvedBootstrap +} + +export interface LoomNitroDocumentOptions { + readonly render: (input: LoomNitroDocumentRenderInput) => string +} + export interface LoomNitroOptions { + readonly bootstrap?: LoomBootstrapDefaults readonly rootId?: string readonly buildId?: string - readonly render: (request: LoomNitroRequest) => Html.Child | Promise + readonly clientEntry?: string + readonly payloadElementId?: string + readonly document?: LoomNitroDocumentOptions + readonly render: (request: LoomNitroRequest) => LoomNitroRenderOutput | Promise readonly response?: (request: LoomNitroRequest) => LoomNitroResponseInit | Promise readonly ssr?: Html.SsrOptions | ((request: LoomNitroRequest) => Html.SsrOptions | Promise) } +const isRenderOutputShape = (value: LoomNitroRenderOutput): value is LoomNitroRenderOutputShape => + typeof value === "object" && value !== null && ("body" in value || "title" in value) + +const resolveBootstrap = (options: LoomNitroOptions): LoomNitroResolvedBootstrap => ({ + buildId: options.bootstrap?.buildId ?? options.buildId ?? defaultLoomNitroBuildId, + clientEntry: options.bootstrap?.clientEntry ?? options.clientEntry ?? defaultLoomNitroClientEntry, + payloadElementId: options.bootstrap?.payloadElementId ?? options.payloadElementId ?? defaultLoomNitroPayloadElementId, + rootId: options.bootstrap?.rootId ?? options.rootId ?? defaultLoomNitroRootId, +}) + const resolveSsrOptions = async ( request: LoomNitroRequest, options: LoomNitroOptions, @@ -49,16 +92,37 @@ export const renderLoomNitroResponse = async ( options: LoomNitroOptions, request: LoomNitroRequest, ): Promise => { - const root = await options.render(request) + const output = await options.render(request) const response = options.response === undefined ? undefined : await options.response(request) - const render = Html.ssr(root, await resolveSsrOptions(request, options)) - const rootId = options.rootId ?? defaultLoomNitroRootId - const buildId = options.buildId ?? defaultLoomNitroBuildId + const resolved = isRenderOutputShape(output) ? output : { body: output } + const body = resolved.body + const ssrOptions = await resolveSsrOptions(request, options) + const render = body === undefined ? Html.ssr(Html.fragment(), ssrOptions) : Html.ssr(body, ssrOptions) + const bodyHtml = body === undefined ? "" : render.html + const bootstrap = resolveBootstrap(options) - const resumability = await createLoomResumabilityPayload({ buildId, rootId }, render) + const resumability = await createLoomResumabilityPayload( + { buildId: bootstrap.buildId, rootId: bootstrap.rootId }, + render, + ) + const payloadHtml = resumability === undefined + ? `` + : renderLoomPayloadElement(resumability, bootstrap.payloadElementId) + const html = options.document?.render({ + title: resolved.title, + bodyHtml, + payloadHtml, + payload: resumability, + bootstrap, + }) ?? renderDefaultLoomNitroDocument({ + title: resolved.title, + bodyHtml, + payload: resumability, + bootstrap, + }) return { - html: render.html, + html, status: response?.status, headers: response?.headers, activation: resumability, diff --git a/packages/loom/nitro/src/loom-nitro.ts b/packages/loom/nitro/src/loom-nitro.ts index 87780757..315a3b02 100644 --- a/packages/loom/nitro/src/loom-nitro.ts +++ b/packages/loom/nitro/src/loom-nitro.ts @@ -1,5 +1,12 @@ import { + defaultLoomNitroBuildId, + defaultLoomNitroClientEntry, + defaultLoomNitroPayloadElementId, + defaultLoomNitroRootId, + type LoomBootstrapDefaults, + type LoomNitroDocumentOptions, type LoomNitroOptions, + type LoomNitroRenderOutput, type LoomNitroRenderResult, type LoomNitroRequest, renderLoomNitroResponse, @@ -16,12 +23,22 @@ export interface LoomNitroRenderer { export type { LoomActivationPayload, + LoomBootstrapDefaults, + LoomNitroDocumentOptions, LoomNitroOptions, + LoomNitroRenderOutput, LoomNitroRenderResult, LoomNitroRequest, LoomResumabilityPayload, } +export { + defaultLoomNitroBuildId, + defaultLoomNitroClientEntry, + defaultLoomNitroPayloadElementId, + defaultLoomNitroRootId, +} + /** Create the initial Loom Nitro adapter surface. */ export const renderer = (options: Options): LoomNitroRenderer => ({ name: "effectify:loom-nitro", diff --git a/packages/loom/nitro/tests/loom-nitro.test.ts b/packages/loom/nitro/tests/loom-nitro.test.ts index e5a7f71a..ffdcfb2b 100644 --- a/packages/loom/nitro/tests/loom-nitro.test.ts +++ b/packages/loom/nitro/tests/loom-nitro.test.ts @@ -6,7 +6,7 @@ import { encodeLoomResumabilityPayload, renderLoomPayloadElement, } from "../src/internal/payload.js" -import { renderLoomNitroResponse } from "../src/internal/ssr-adapter.js" +import { defaultLoomNitroClientEntry, renderLoomNitroResponse } from "../src/internal/ssr-adapter.js" import { renderer } from "../src/loom-nitro.js" describe("@effectify/loom-nitro", () => { @@ -39,12 +39,13 @@ describe("@effectify/loom-nitro", () => { .toBeUndefined() }) - it("renders the Nitro handoff shape with request metadata and activation payload", async () => { + it("renders the default Nitro document shell with request metadata and activation payload", async () => { const result = await renderLoomNitroResponse( { - rootId: "loom-root", - render: (request) => - Html.el("main", Html.hydrate(Hydration.visible()), Html.children(`${request.method}:${request.url}`)), + render: (request) => ({ + title: `Page ${request.url}`, + body: Html.el("main", Html.hydrate(Hydration.visible()), Html.children(`${request.method}:${request.url}`)), + }), response: () => ({ status: 201, headers: { "x-loom": "ready" }, @@ -67,6 +68,13 @@ describe("@effectify/loom-nitro", () => { rootId: "loom-root", }), }) + expect(result.html).toContain("") + expect(result.html).toContain('') + expect(result.html).toContain("Page /demo") + expect(result.html).toContain('
') + expect(result.html).toContain("GET:/demo") + expect(result.html).toContain('id="__loom_payload__"') + expect(result.html).toContain(``) }) it("surfaces canonical resumability diagnostics through the Nitro adapter result", async () => { @@ -112,8 +120,39 @@ describe("@effectify/loom-nitro", () => { headers: {}, }), ).resolves.toMatchObject({ - html: "
/static
", + html: expect.stringContaining("
/static
"), resumability: undefined, }) }) + + it("uses an explicit custom document override while keeping bootstrap metadata configurable", async () => { + const result = await renderLoomNitroResponse( + { + bootstrap: { + rootId: "custom-root", + payloadElementId: "custom-payload", + clientEntry: "/src/custom-entry.ts", + }, + document: { + render: ({ bodyHtml, payloadHtml, bootstrap, title }) => + `${title}
${bodyHtml}
${payloadHtml}`, + }, + render: () => ({ + title: "Custom shell", + body: Html.el("section", Html.hydrate(Hydration.visible()), Html.children("body")), + }), + }, + { + method: "GET", + url: "/custom", + headers: {}, + }, + ) + + expect(result.html).toContain("custom-shell") + expect(result.html).toContain('id="custom-root"') + expect(result.html).toContain('id="custom-payload"') + expect(result.html).toContain('data-client-entry="/src/custom-entry.ts"') + expect(result.resumability?.rootId).toBe("custom-root") + }) }) diff --git a/packages/loom/nitro/tests/public-api.types.ts b/packages/loom/nitro/tests/public-api.types.ts index 9e87823c..226e84e5 100644 --- a/packages/loom/nitro/tests/public-api.types.ts +++ b/packages/loom/nitro/tests/public-api.types.ts @@ -7,12 +7,18 @@ type Equal = (() => Value extends Left ? 1 : 2) extends = Value const descriptor = LoomNitro.renderer({ - rootId: "loom-root", - render: (request) => Html.el("main", Html.children(request.url)), + render: (request) => ({ + title: request.url, + body: Html.el("main", Html.children(request.url)), + }), }) LoomNitro.renderer({ - rootId: "loom-root", + bootstrap: { + clientEntry: "/src/entry-client.ts", + payloadElementId: "loom-payload", + rootId: "loom-root", + }, render: (request) => Html.el("main", Html.children(request.url)), // @ts-expect-error non-web renderers remain out of scope for this web-only adapter renderer: "native", diff --git a/packages/loom/nitro/tests/resumability-payload.test.ts b/packages/loom/nitro/tests/resumability-payload.test.ts index 80128ea2..2600b39a 100644 --- a/packages/loom/nitro/tests/resumability-payload.test.ts +++ b/packages/loom/nitro/tests/resumability-payload.test.ts @@ -6,6 +6,7 @@ import { encodeLoomResumabilityPayload, renderLoomPayloadElement, } from "../src/internal/payload.js" +import { renderLoomNitroResponse } from "../src/internal/ssr-adapter.js" const effectLike = { _tag: "EffectLike" } as const @@ -72,4 +73,25 @@ describe("@effectify/loom-nitro resumability payload", () => { rootId: "loom-root", }, render)).resolves.toBeUndefined() }) + + it("keeps the default shell and bootstrap markers when the route body is missing", async () => { + const result = await renderLoomNitroResponse( + { + render: () => ({ + title: "Broken route", + body: undefined, + }), + }, + { + method: "GET", + url: "/broken", + headers: {}, + }, + ) + + expect(result.html).toContain("Broken route") + expect(result.html).toContain('
') + expect(result.html).toContain('id="__loom_payload__"') + expect(result.html).toContain('src="/src/entry-client.ts"') + }) }) diff --git a/packages/loom/router/src/internal/route-modules.ts b/packages/loom/router/src/internal/route-modules.ts index f002f661..d8438596 100644 --- a/packages/loom/router/src/internal/route-modules.ts +++ b/packages/loom/router/src/internal/route-modules.ts @@ -2,6 +2,9 @@ import * as Effect from "effect/Effect" import * as Route from "../route.js" import * as RouteErrors from "./route-errors.js" +export const ROUTE_MODULE_EXPORTS_GUIDANCE = + "Route modules must export either `component` or `default` alongside optional `loader` and `action` helpers. Prefer `export default` for new modules." + type CompiledLoader = Loader extends Route.AnyModuleLoader ? Route.LoaderDescriptor< Route.ModuleLoaderParamsOf, @@ -155,24 +158,39 @@ export function extractRouteModule< Action extends Route.AnyModuleAction | undefined = undefined, >(exports: { readonly component: Content + readonly default?: unknown + readonly loader?: Loader + readonly action?: Action +}): Route.RouteModule +export function extractRouteModule< + Content, + Loader extends Route.AnyModuleLoader | undefined = undefined, + Action extends Route.AnyModuleAction | undefined = undefined, +>(exports: { + readonly default: Content + readonly component?: undefined readonly loader?: Loader readonly action?: Action }): Route.RouteModule export function extractRouteModule(exports: { readonly component?: Content + readonly default?: Content readonly loader?: unknown readonly action?: unknown }): Route.RouteModule export function extractRouteModule(exports: { readonly component?: Content + readonly default?: Content readonly loader?: unknown readonly action?: unknown }): Route.RouteModule { - if (!("component" in exports) || exports.component === undefined) { + const component = exports.component ?? exports.default + + if (component === undefined) { throw new RouteErrors.RouteModuleExportError({ exportName: "component", input: exports, - message: "Route modules must export `component` alongside optional `loader` and `action` helpers.", + message: ROUTE_MODULE_EXPORTS_GUIDANCE, }) } @@ -196,7 +214,7 @@ export function extractRouteModule(exports: { const action = exports.action return { - component: exports.component, + component, loader, action, } diff --git a/packages/loom/router/src/internal/router.ts b/packages/loom/router/src/internal/router.ts index f697f941..7f7f9ca8 100644 --- a/packages/loom/router/src/internal/router.ts +++ b/packages/loom/router/src/internal/router.ts @@ -108,6 +108,28 @@ const makeDefinition = >(options: { fallback: normalizeFallbacks(options.fallback), }) +const copyRouter = < + Entries extends ReadonlyArray, + NextEntries extends ReadonlyArray = Entries, +>( + self: RouterDefinition, + options: { + readonly entries?: NextEntries + readonly annotations?: Route.Annotations + readonly pathPrefix?: Route.AbsolutePath + readonly layout?: Layout.Definition + readonly fallback?: Fallback.Config + }, +): RouterDefinition => + makeDefinition({ + identifier: self.identifier, + entries: options.entries ?? (self.entries as unknown as NextEntries), + annotations: options.annotations ?? self.annotations, + pathPrefix: options.pathPrefix ?? self.pathPrefix, + layout: options.layout ?? self.layout, + fallback: options.fallback ?? self.fallback, + }) + const isResolver = (value: unknown): value is Renderable.Resolver => typeof value === "function" const isObjectChild = (value: unknown): value is Exclude> => @@ -358,13 +380,11 @@ const resolveHrefTarget = ( export const makeRouter = >( options: Router.Options, -): RouterDefinition => - makeDefinition({ - identifier: "compat", - entries: options.routes, - layout: options.layout, - fallback: options.fallback, - }) +): RouterDefinition => { + const withMetadata = withFallback(withLayout(makeEmptyRouter("compat"), options.layout), options.fallback) + + return withEntries(withMetadata, options.routes) +} export const makeEmptyRouter = (identifier: string): RouterDefinition => makeDefinition({ @@ -376,26 +396,42 @@ export const addEntryToRouter = , Ent self: RouterDefinition, entry: Entry, ): RouterDefinition<[...Entries, Entry]> => - makeDefinition({ - identifier: self.identifier, - entries: [...self.entries, self.pathPrefix === undefined ? entry : applyPrefixToEntry(entry, self.pathPrefix)], - annotations: self.annotations, - pathPrefix: self.pathPrefix, - layout: self.layout, - fallback: self.fallback, + withEntries(self, [ + ...self.entries, + self.pathPrefix === undefined ? entry : applyPrefixToEntry(entry, self.pathPrefix), + ]) + +export const withEntries = , NextEntries extends ReadonlyArray>( + self: RouterDefinition, + entries: NextEntries, +): RouterDefinition => + copyRouter(self, { + entries, + }) + +export const withLayout = >( + self: RouterDefinition, + layout: Layout.Definition | undefined, +): RouterDefinition => + copyRouter(self, { + layout, + }) + +export const withFallback = >( + self: RouterDefinition, + fallback: Fallback.Config | undefined, +): RouterDefinition => + copyRouter(self, { + fallback, }) export const prefixRouter = >( self: RouterDefinition, prefix: Route.AbsolutePath, ): RouterDefinition => - makeDefinition({ - identifier: self.identifier, + copyRouter(self, { entries: self.entries.map((entry) => applyPrefixToEntry(entry, prefix)) as unknown as Entries, - annotations: self.annotations, pathPrefix: self.pathPrefix === undefined ? prefix : joinPathnames(self.pathPrefix, prefix), - layout: self.layout, - fallback: self.fallback, }) export const annotateRouter = , I, S>( @@ -403,26 +439,16 @@ export const annotateRouter = , I, S> tag: Context.Service, value: S, ): RouterDefinition => - makeDefinition({ - identifier: self.identifier, - entries: self.entries, + copyRouter(self, { annotations: annotateValue(self.annotations, tag, value), - pathPrefix: self.pathPrefix, - layout: self.layout, - fallback: self.fallback, }) export const annotateRouterMerge = >( self: RouterDefinition, annotations: Route.Annotations, ): RouterDefinition => - makeDefinition({ - identifier: self.identifier, - entries: self.entries, + copyRouter(self, { annotations: mergeAnnotations(self.annotations, annotations), - pathPrefix: self.pathPrefix, - layout: self.layout, - fallback: self.fallback, }) export const findRouteByIdentifier = >( diff --git a/packages/loom/router/src/route-module.ts b/packages/loom/router/src/route-module.ts index eded798c..16a467ac 100644 --- a/packages/loom/router/src/route-module.ts +++ b/packages/loom/router/src/route-module.ts @@ -38,16 +38,36 @@ type CompiledRouteModule< CompiledAction > -export type Exports< +export const ROUTE_MODULE_EXPORTS_GUIDANCE = InternalRouteModules.ROUTE_MODULE_EXPORTS_GUIDANCE + +type ExplicitComponentExports< Content = unknown, Loader extends Route.AnyModuleLoader | undefined = undefined, Action extends Route.AnyModuleAction | undefined = undefined, > = { readonly component: Content + readonly default?: unknown + readonly loader?: Loader + readonly action?: Action +} + +type DefaultComponentExports< + Content = unknown, + Loader extends Route.AnyModuleLoader | undefined = undefined, + Action extends Route.AnyModuleAction | undefined = undefined, +> = { + readonly default: Content + readonly component?: undefined readonly loader?: Loader readonly action?: Action } +export type Exports< + Content = unknown, + Loader extends Route.AnyModuleLoader | undefined = undefined, + Action extends Route.AnyModuleAction | undefined = undefined, +> = ExplicitComponentExports | DefaultComponentExports + export const extract = InternalRouteModules.extractRouteModule export function compile< @@ -58,7 +78,17 @@ export function compile< >(options: { readonly path: Route.AbsolutePath readonly identifier?: Identifier - readonly module: Exports + readonly module: ExplicitComponentExports +}): CompiledRouteModule +export function compile< + Content, + Loader extends Route.AnyModuleLoader | undefined = undefined, + Action extends Route.AnyModuleAction | undefined = undefined, + Identifier extends string | undefined = undefined, +>(options: { + readonly path: Route.AbsolutePath + readonly identifier?: Identifier + readonly module: DefaultComponentExports }): CompiledRouteModule export function compile(options: { readonly path: Route.AbsolutePath diff --git a/packages/loom/router/src/route.ts b/packages/loom/router/src/route.ts index 2a34a562..6e47e7de 100644 --- a/packages/loom/router/src/route.ts +++ b/packages/loom/router/src/route.ts @@ -58,20 +58,42 @@ type ExecuteEffectErrorOf = Execute extends (...args: ReadonlyArray) => Effect.Effect ? Value : never -type ModuleLoaderServicesOfExecute = Execute extends ( - input: ModuleLoaderInput, -) => Effect.Effect ? Services - : never +type ExecuteInputOf = Execute extends (input: infer Input) => Effect.Effect ? Input : never -type ModuleActionInputOfExecute = Execute extends ( - input: ModuleActionInput, -) => Effect.Effect ? Input - : never +type ServicesOfInput = Input extends { readonly services: infer Services } ? Services : never -type ModuleActionServicesOfExecute = Execute extends ( - input: ModuleActionInput, -) => Effect.Effect ? Services - : never +type ActionValueOfInput = Input extends { readonly input: infer Value } ? Value : never + +export interface ServiceBinding { + readonly _tag: "LoomRouterServiceBinding" + readonly _services?: Services +} + +export const services = (): ServiceBinding => ({ + _tag: "LoomRouterServiceBinding", +} as ServiceBinding) + +type IsNever = [Value] extends [never] ? true : false + +type ModuleLoaderServicesFromExecute = ServicesOfInput> + +type ModuleLoaderServicesFromOptions> = + Options["services"] extends ServiceBinding ? Services : never + +type ModuleLoaderInferredServices> = + IsNever> extends true ? ModuleLoaderServicesFromExecute + : ModuleLoaderServicesFromOptions + +type ModuleActionInputOfExecute = ActionValueOfInput> + +type ModuleActionServicesFromExecute = ServicesOfInput> + +type ModuleActionServicesFromOptions> = + Options["services"] extends ServiceBinding ? Services : never + +type ModuleActionInferredServices> = + IsNever> extends true ? ModuleActionServicesFromExecute + : ModuleActionServicesFromOptions export type Awaitable = Value | PromiseLike @@ -152,38 +174,29 @@ export interface ModuleLoaderOptions< SearchDecoder extends Decode.DecoderLike | undefined = undefined, OutputSchema extends RouteSchema | undefined = undefined, ErrorSchema extends RouteSchema | undefined = undefined, + Services = never, > { readonly params?: ParamsDecoder readonly search?: SearchDecoder readonly output?: OutputSchema readonly error?: ErrorSchema + readonly services?: ServiceBinding } -type ModuleLoaderExecute< - ParamsDecoder extends Decode.DecoderLike | undefined, - SearchDecoder extends Decode.DecoderLike | undefined, - Services, -> = ( - input: ModuleLoaderInput, SearchOutputOf, Services>, -) => Effect.Effect - export interface ModuleLoaderDefinition< Services = never, ParamsDecoder extends Decode.DecoderLike | undefined = undefined, SearchDecoder extends Decode.DecoderLike | undefined = undefined, OutputSchema extends RouteSchema | undefined = undefined, ErrorSchema extends RouteSchema | undefined = undefined, - Execute extends ModuleLoaderExecute = ModuleLoaderExecute< - ParamsDecoder, - SearchDecoder, - Services - >, -> extends ModuleLoaderOptions { - readonly load: Execute +> extends ModuleLoaderOptions { + readonly load: ( + input: ModuleLoaderInput, SearchOutputOf, Services>, + ) => Effect.Effect } export type ModuleLoaderContext< - Options extends ModuleLoaderOptions = ModuleLoaderOptions, + Options extends ModuleLoaderOptions = ModuleLoaderOptions, Services = never, > = ModuleLoaderInput< ParamsOutputOf, @@ -227,28 +240,16 @@ export interface ModuleActionOptions< InputDecoder extends SubmissionDecode.DecoderLike | undefined = undefined, OutputSchema extends RouteSchema | undefined = undefined, ErrorSchema extends RouteSchema | undefined = undefined, + Services = never, > { readonly params?: ParamsDecoder readonly search?: SearchDecoder readonly input?: InputDecoder readonly output?: OutputSchema readonly error?: ErrorSchema + readonly services?: ServiceBinding } -type ModuleActionExecute< - ParamsDecoder extends Decode.DecoderLike | undefined, - SearchDecoder extends Decode.DecoderLike | undefined, - InputDecoder extends SubmissionDecode.DecoderLike | undefined, - Services, -> = ( - input: ModuleActionInput< - ParamsOutputOf, - SearchOutputOf, - ActionInputOutputOf, - Services - >, -) => Effect.Effect - export interface ModuleActionDefinition< Services = never, ParamsDecoder extends Decode.DecoderLike | undefined = undefined, @@ -256,18 +257,19 @@ export interface ModuleActionDefinition< InputDecoder extends SubmissionDecode.DecoderLike | undefined = undefined, OutputSchema extends RouteSchema | undefined = undefined, ErrorSchema extends RouteSchema | undefined = undefined, - Execute extends ModuleActionExecute = ModuleActionExecute< - ParamsDecoder, - SearchDecoder, - InputDecoder, - Services - >, -> extends ModuleActionOptions { - readonly handle: Execute +> extends ModuleActionOptions { + readonly handle: ( + input: ModuleActionInput< + ParamsOutputOf, + SearchOutputOf, + ActionInputOutputOf, + Services + >, + ) => Effect.Effect } export type ModuleActionContext< - Options extends ModuleActionOptions = ModuleActionOptions, + Options extends ModuleActionOptions = ModuleActionOptions, Services = never, > = ModuleActionInput< ParamsOutputOf, @@ -751,39 +753,35 @@ export function loader< SearchDecoder extends Decode.DecoderLike | undefined = undefined, OutputSchema extends RouteSchema | undefined = undefined, ErrorSchema extends RouteSchema | undefined = undefined, - Execute extends ModuleLoaderExecute = ModuleLoaderExecute< - ParamsDecoder, - SearchDecoder, - Services - >, >( - definition: ModuleLoaderDefinition, + definition: ModuleLoaderDefinition, ): ModuleLoader< ParamsOutputOf, SearchOutputOf, - SchemaOutputOf>, - SchemaOutputOf>, + SchemaOutputOf>, + SchemaOutputOf>, Services > export function loader< - ParamsDecoder extends Decode.DecoderLike | undefined = undefined, - SearchDecoder extends Decode.DecoderLike | undefined = undefined, - OutputSchema extends RouteSchema | undefined = undefined, - ErrorSchema extends RouteSchema | undefined = undefined, + Options extends ModuleLoaderOptions, >( - options: ModuleLoaderOptions, + options: Options, ): < Execute extends ( - input: ModuleLoaderInput, SearchOutputOf, any>, + input: ModuleLoaderInput< + ParamsOutputOf, + SearchOutputOf, + ModuleLoaderInferredServices + >, ) => Effect.Effect, >( execute: Execute, ) => ModuleLoader< - ParamsOutputOf, - SearchOutputOf, - SchemaOutputOf>, - SchemaOutputOf>, - ModuleLoaderServicesOfExecute + ParamsOutputOf, + SearchOutputOf, + SchemaOutputOf>, + SchemaOutputOf>, + ModuleLoaderInferredServices > export function loader( descriptor: LoaderDescriptor, @@ -826,8 +824,8 @@ export function loader< Action > export function loader( - first?: AnyDefinition | AnyLoaderDescriptor | ModuleLoaderOptions | ModuleLoaderDefinition, - second?: AnyLoaderDescriptor, + first?: any, + second?: any, ): any { if (isRouteDefinition(first) && second !== undefined) { return internal.attachLoader(first, second) @@ -877,12 +875,6 @@ export function action< InputDecoder extends SubmissionDecode.DecoderLike | undefined = undefined, OutputSchema extends RouteSchema | undefined = undefined, ErrorSchema extends RouteSchema | undefined = undefined, - Execute extends ModuleActionExecute = ModuleActionExecute< - ParamsDecoder, - SearchDecoder, - InputDecoder, - Services - >, >( definition: ModuleActionDefinition< Services, @@ -890,43 +882,38 @@ export function action< SearchDecoder, InputDecoder, OutputSchema, - ErrorSchema, - Execute + ErrorSchema >, ): ModuleAction< ParamsOutputOf, SearchOutputOf, - ActionInputOutputOf>, - SchemaOutputOf>, - SchemaOutputOf>, + ActionInputOutputOf>, + SchemaOutputOf>, + SchemaOutputOf>, Services > export function action< - ParamsDecoder extends Decode.DecoderLike | undefined = undefined, - SearchDecoder extends Decode.DecoderLike | undefined = undefined, - InputDecoder extends SubmissionDecode.DecoderLike | undefined = undefined, - OutputSchema extends RouteSchema | undefined = undefined, - ErrorSchema extends RouteSchema | undefined = undefined, + Options extends ModuleActionOptions, >( - options: ModuleActionOptions, + options: Options, ): < Execute extends ( input: ModuleActionInput< - ParamsOutputOf, - SearchOutputOf, - ActionInputOutputOf, - any + ParamsOutputOf, + SearchOutputOf, + ActionInputOutputOf, + ModuleActionInferredServices >, ) => Effect.Effect, >( execute: Execute, ) => ModuleAction< - ParamsOutputOf, - SearchOutputOf, - ActionInputOutputOf>, - SchemaOutputOf>, - SchemaOutputOf>, - ModuleActionServicesOfExecute + ParamsOutputOf, + SearchOutputOf, + ActionInputOutputOf>, + SchemaOutputOf>, + SchemaOutputOf>, + ModuleActionInferredServices > export function action( descriptor: ActionDescriptor, @@ -970,8 +957,8 @@ export function action< ActionDescriptor > export function action( - first?: AnyDefinition | AnyActionDescriptor | ModuleActionOptions | ModuleActionDefinition, - second?: AnyActionDescriptor, + first?: any, + second?: any, ): any { if (isRouteDefinition(first) && second !== undefined) { return internal.attachAction(first, second) diff --git a/packages/loom/router/src/router.ts b/packages/loom/router/src/router.ts index 507e4ada..6ae1edb5 100644 --- a/packages/loom/router/src/router.ts +++ b/packages/loom/router/src/router.ts @@ -154,13 +154,20 @@ export type Definition = ReadonlyArra export type HrefResolutionError = internal.HrefResolutionError export const HrefResolutionError = internal.HrefResolutionError -/** Create either a compatibility router or an algebra-first empty router. */ +/** + * Create a builder-first router with `make("app")`, or normalize legacy object-literal input. + * New code should prefer `make(identifier)` plus incremental operators. + */ export function make>(options: Options): Definition -export function make(identifier: string): Definition +export function make(identifier: string): Definition export function make(input: string | Options): Definition { - return (typeof input === "string" ? internal.makeEmptyRouter(input) : internal.makeRouter(input)) as Definition + return (typeof input === "string" ? internal.makeEmptyRouter(input) : from(input)) as Definition } +/** Normalize the compatibility object-literal router shape through the builder core. */ +export const from = >(options: Options): Definition => + internal.makeRouter(options) as Definition + /** Add a route or route group to a Loom router in data-first or pipeable form. */ export const add: { (entry: Entry): >( @@ -175,6 +182,53 @@ export const add: { ]> } = dual(2, internal.addEntryToRouter) +/** Add a single route to a Loom router in data-first or pipeable form. */ +export const route: { + (route: CurrentRoute): >( + self: Definition, + ) => Definition<[...Entries, CurrentRoute]> + , CurrentRoute extends KnownRoute>( + self: Definition, + route: CurrentRoute, + ): Definition<[ + ...Entries, + CurrentRoute, + ]> +} = dual(2, internal.addEntryToRouter) + +/** Set or replace the app-level layout without changing known route entries. */ +export const layout: { + ( + value: Layout.Definition, + ): >(self: Definition) => Definition + >(self: Definition, value: Layout.Definition): Definition +} = dual(2, internal.withLayout) + +/** Set or replace app-level fallback boundaries without changing known route entries. */ +export const fallback: { + ( + value: Fallback.Config, + ): >(self: Definition) => Definition + >(self: Definition, value: Fallback.Config): Definition +} = dual(2, internal.withFallback) + +/** Set or replace the app-level not-found boundary without changing known route entries. */ +export const notFound: { + ( + value: Fallback.Definition, + ): >(self: Definition) => Definition + >( + self: Definition, + value: Fallback.Definition, + ): Definition +} = dual( + 2, + >( + self: Definition, + value: Fallback.Definition, + ): Definition => internal.withFallback(self, { ...self.fallback, notFound: value }) as Definition, +) + /** Prefix all routes already known to the router, and future additions. */ export const prefix: { ( diff --git a/packages/loom/router/tests/public-api.types.ts b/packages/loom/router/tests/public-api.types.ts index fe8a3b5a..cad38cb5 100644 --- a/packages/loom/router/tests/public-api.types.ts +++ b/packages/loom/router/tests/public-api.types.ts @@ -83,11 +83,27 @@ const nestedRoute = Route.make({ const renderable: Loom.View.Child = View.stack(View.text("router-renderable")) const layout = Layout.make(({ child }) => View.main(child)) const fallback = Fallback.make(({ pathname }: { readonly pathname: string }) => View.stack(View.text(pathname))) -const router = Router.make({ - routes: [route, Route.make({ path: "/component", content: routeComponent })], +const routeOnlyRouter = Router.make("app") +const componentRoute = Route.make({ path: "/component", content: routeComponent }) +const router = pipe( + routeOnlyRouter, + Router.layout(layout), + Router.notFound(fallback), + Router.route(route), + Router.route(componentRoute), +) +const compatRouter = Router.from({ + routes: [route, componentRoute], + layout, + fallback, +}) +const legacyCompatRouter = Router.make({ + routes: [route, componentRoute], layout, fallback, }) +const builderLayoutRouter = Router.layout(layout)(routeOnlyRouter) +const builderFallbackRouter = Router.notFound(fallback)(routeOnlyRouter) const result = Router.match(router, new URL("https://effectify.dev/users/42?tab=profile")) const identityDecoder = Decode.identity() const routeContent: Route.Content = "user-screen" @@ -129,6 +145,8 @@ const linkIntercepted = Link.intercept({ }) const algebraRoute = Router.find(algebraRouter, "users.detail") const compatRoutePath = Router.pathFor(router, route) +const compatRoutePathFrom = Router.pathFor(compatRouter, route) +const compatRoutePathFromLegacy = Router.pathFor(legacyCompatRouter, route) const algebraRoutePath = Router.pathFor(algebraRouter, "users.detail") const algebraRouteHref = Router.href(algebraRouter, "users.detail", { params: { userId: "42" }, @@ -147,6 +165,7 @@ const nestedTypedRouter = Router.make({ }), ], }) +const typedRouteBuilder = pipe(Router.make("typed"), Router.route(route), Router.add(routeGroup)) const nestedIndexHref = Router.href(nestedTypedRouter, "posts.index") const nestedLeafHref = Router.href(nestedTypedRouter, "posts.detail", { params: { postId: "42" }, @@ -179,21 +198,31 @@ const moduleLoaderWithServicesOptions = { params: Schema.Struct({ userId: Schema.String }), search: Schema.Struct({ tab: Schema.String }), output: Schema.Struct({ id: Schema.String, tab: Schema.String, requestId: Schema.String }), + services: Route.services<{ readonly requestId: string }>(), } as const -const moduleLoaderWithServices = pipe( - Effect.fn(function*({ +const moduleLoaderWithServices = Route.loader({ + ...moduleLoaderWithServicesOptions, + load: ({ params, search, services }) => + Effect.succeed({ + id: params.userId, + requestId: services.requestId, + tab: search.tab, + }), +}) + +const moduleLoaderWithServicesExplicit = Route.loader({ + ...moduleLoaderWithServicesOptions, + load: ({ params, search, services, - }: Route.ModuleLoaderContext) { - return { + }: Route.ModuleLoaderContext) => + Effect.succeed({ id: params.userId, requestId: services.requestId, tab: search.tab, - } - }), - Route.loader(moduleLoaderWithServicesOptions), -) + }), +}) const moduleAction = Route.action({ input: Schema.Struct({ title: Schema.String }), output: Schema.Struct({ savedTitle: Schema.String }), @@ -203,25 +232,47 @@ const moduleAction = Route.action({ }) const moduleActionWithServicesOptions = { input: Schema.Struct({ title: Schema.String }), + params: Schema.Struct({ userId: Schema.String }), + search: Schema.Struct({ tab: Schema.String }), output: Schema.Struct({ savedTitle: Schema.String }), error: Schema.TaggedStruct("SaveFailure", { message: Schema.String }), + services: Route.services<{ readonly requestId: string }>(), } as const -const moduleActionWithServices = pipe( - Effect.fn(function*({ +const moduleActionWithServices = Route.action({ + ...moduleActionWithServicesOptions, + handle: ({ input, params, search, services }) => + Effect.fail({ + _tag: "SaveFailure" as const, + message: input.title.length > 0 ? `${params.userId}:${search.tab}:${services.requestId}` : "empty", + }), +}) + +const moduleActionWithServicesExplicit = Route.action({ + ...moduleActionWithServicesOptions, + handle: ({ input, + params, + search, services, - }: Route.ModuleActionContext) { - return yield* Effect.fail({ + }: Route.ModuleActionContext) => + Effect.fail({ _tag: "SaveFailure" as const, - message: input.title.length > 0 ? services.requestId : "empty", - }) - }), - Route.action(moduleActionWithServicesOptions), -) + message: input.title.length > 0 ? `${params.userId}:${search.tab}:${services.requestId}` : "empty", + }), +}) +const compiledRouteWithServices = RouteModule.compile({ + identifier: "users.module-with-services", + module: { + default: "module-screen-with-services", + loader: moduleLoaderWithServices, + action: moduleActionWithServices, + }, + path: "/module-with-services/:userId", +}) const compiledRoute = RouteModule.compile({ identifier: "users.module", module: { - component: "module-screen", + default: "module-screen", loader: moduleLoader, action: moduleAction, }, @@ -230,10 +281,18 @@ const compiledRoute = RouteModule.compile({ const componentOnlyCompiledRoute = RouteModule.compile({ identifier: "users.component-only", module: { - component: routeComponent, + default: routeComponent, }, path: "/component-only", }) +const explicitComponentCompiledRoute = RouteModule.compile({ + identifier: "users.component-explicit", + module: { + component: "explicit-screen", + default: "fallback-screen", + }, + path: "/component-explicit", +}) if (Match.isSuccess(result)) { const content: unknown = Route.content(result.route) @@ -293,6 +352,10 @@ type RouteHrefContract = Expect> type LinkHrefContract = Expect> type LinkInterceptContract = Expect> type CompatRoutePathContract = Expect> +export type CompatRoutePathFromContract = Expect> +export type CompatRoutePathFromLegacyContract = Expect< + Equal +> type AlgebraRoutePathContract = Expect> type AlgebraRouteHrefContract = Expect> type NestedIndexHrefContract = Expect> @@ -335,6 +398,12 @@ type ModuleLoaderWithServicesDataContract = Expect< type ModuleLoaderWithServicesContract = Expect< Equal, { readonly requestId: string }> > +type ModuleLoaderWithServicesExplicitContract = Expect< + Equal, { readonly requestId: string }> +> +type CompiledRouteLoaderServicesContract = Expect< + Equal, { readonly requestId: string }> +> type ModuleActionInputContract = Expect< Equal, { readonly title: string }> > @@ -350,6 +419,12 @@ type ModuleActionErrorContract = Expect< type ModuleActionWithServicesInputContract = Expect< Equal, { readonly title: string }> > +export type ModuleActionWithServicesParamsContract = Expect< + Equal, { readonly userId: string }> +> +export type ModuleActionWithServicesSearchContract = Expect< + Equal, { readonly tab: string }> +> type ModuleActionWithServicesResultContract = Expect< Equal, { readonly savedTitle: string }> > @@ -360,6 +435,12 @@ type ModuleActionWithServicesErrorContract = Expect< type ModuleActionWithServicesContract = Expect< Equal, { readonly requestId: string }> > +type ModuleActionWithServicesExplicitContract = Expect< + Equal, { readonly requestId: string }> +> +type CompiledRouteActionServicesContract = Expect< + Equal, { readonly requestId: string }> +> type CompiledRouteIdentifierContract = Expect, "users.module">> type CompiledRouteParamsContract = Expect, { readonly userId: string }>> type CompiledRouteLoadedDataContract = Expect, { readonly id: string }>> @@ -372,6 +453,14 @@ type ComponentOnlyCompiledRouteIdentifierContract = Expect< type ComponentOnlyCompiledRouteContentContract = Expect< Equal, typeof routeComponent> > +export type ExplicitComponentCompiledRouteContentContract = Expect< + Route.Content extends string ? true : false +> +export type BuilderLayoutContract = Expect>> +export type BuilderFallbackContract = Expect>> +export type BuilderRouteContract = Expect< + Equal> +> // @ts-expect-error route paths must start with a slash Route.make({ path: "users/:userId", content: "broken" }) @@ -388,6 +477,20 @@ Router.href(nestedTypedRouter, "posts.detail", { query: { mode: 1 } }) // @ts-expect-error href query cannot include unknown keys for typed routes Router.href(nestedTypedRouter, "posts.detail", { query: { tab: "oops" } }) +const conflictingModuleLoaderOptions = { + services: Route.services<{ readonly requestId: string }>(), +} as const + +const conflictingModuleLoader = Route.loader({ + ...conflictingModuleLoaderOptions, + load: ({ services }) => Effect.succeed({ requestId: services.requestId }), +}) + +// @ts-expect-error inferred services stay aligned to the bound route services contract +type ConflictingModuleLoaderServicesContract = Expect< + Equal, { readonly traceId: string }> +> + export const typecheckSmoke = { fallback, renderable, @@ -429,10 +532,12 @@ export const typecheckSmoke = { loaderState, actionState, compiledRoute, + compiledRouteWithServices, componentOnlyCompiledRoute, invalidActionState, moduleAction, moduleActionWithServices, + conflictingModuleLoader, moduleLoader, moduleLoaderWithServices, attachedLoader, @@ -445,8 +550,10 @@ export type { AlgebraRoutePathContract, CompatRoutePathContract, CompiledRouteActionInputContract, + CompiledRouteActionServicesContract, CompiledRouteIdentifierContract, CompiledRouteLoadedDataContract, + CompiledRouteLoaderServicesContract, CompiledRouteParamsContract, ComponentOnlyCompiledRouteContentContract, ComponentOnlyCompiledRouteIdentifierContract, @@ -463,12 +570,14 @@ export type { ModuleActionResultContract, ModuleActionWithServicesContract, ModuleActionWithServicesErrorContract, + ModuleActionWithServicesExplicitContract, ModuleActionWithServicesInputContract, ModuleActionWithServicesResultContract, ModuleLoaderDataContract, ModuleLoaderParamsContract, ModuleLoaderWithServicesContract, ModuleLoaderWithServicesDataContract, + ModuleLoaderWithServicesExplicitContract, ModuleLoaderWithServicesParamsContract, ModuleLoaderWithServicesSearchContract, NavigationSnapshotContract, diff --git a/packages/loom/router/tests/route-modules.test.ts b/packages/loom/router/tests/route-modules.test.ts index 51ee4ea0..a037e794 100644 --- a/packages/loom/router/tests/route-modules.test.ts +++ b/packages/loom/router/tests/route-modules.test.ts @@ -6,7 +6,7 @@ import * as RouteModule from "../src/route-module.js" import * as RouteErrors from "../src/internal/route-errors.js" describe("@effectify/loom-router route modules", () => { - it("extracts and compiles route-module exports into route definitions", () => { + it("extracts and compiles default-export route modules into route definitions", () => { const params = Schema.Struct({ userId: Schema.String }) const search = Schema.Struct({ tab: Schema.String }) const input = Schema.Struct({ title: Schema.String }) @@ -14,7 +14,7 @@ describe("@effectify/loom-router route modules", () => { const actionResult = Schema.Struct({ savedTitle: Schema.String }) const actionError = Schema.TaggedStruct("SaveFailure", { message: Schema.String }) const routeModule = RouteModule.extract({ - component: "user-screen", + default: "user-screen", loader: Route.loader({ params, search, @@ -43,8 +43,45 @@ describe("@effectify/loom-router route modules", () => { expect(Route.getAction(route)?.input).toBeDefined() }) - it("rejects missing component exports and undecorated loader/action exports", () => { + it("prefers explicit component over default when both exports are present", () => { + const route = RouteModule.compile({ + identifier: "users.detail", + module: RouteModule.extract({ + component: "explicit-screen", + default: "fallback-screen", + }), + path: "/users/:userId", + }) + + expect(Route.content(route)).toBe("explicit-screen") + }) + + it("compiles legacy component-only route modules without requiring a default export", () => { + const route = RouteModule.compile({ + identifier: "users.legacy", + module: { + component: "legacy-screen", + loader: Route.loader({ + load: () => Effect.succeed("ok"), + }), + }, + path: "/users/legacy", + }) + + expect(Route.content(route)).toBe("legacy-screen") + expect(Route.hasLoader(route)).toBe(true) + }) + + it("rejects missing component/default exports and undecorated loader/action exports with migration guidance", () => { expect(() => RouteModule.extract({})).toThrowError(RouteErrors.RouteModuleExportError) + expect(() => + RouteModule.extract({ + loader: Route.loader({ + load: () => Effect.succeed("ok"), + }), + title: "Users", + } as never) + ).toThrowError(RouteModule.ROUTE_MODULE_EXPORTS_GUIDANCE) expect(() => RouteModule.extract({ component: "user-screen", loader: { load: () => Promise.resolve("ok") } })) .toThrowError( RouteErrors.RouteModuleExportError, diff --git a/packages/loom/router/tests/router-algebra.test.ts b/packages/loom/router/tests/router-algebra.test.ts index 6f1753e2..b9024a5d 100644 --- a/packages/loom/router/tests/router-algebra.test.ts +++ b/packages/loom/router/tests/router-algebra.test.ts @@ -74,7 +74,7 @@ describe("@effectify/loom-router algebra", () => { it("keeps the legacy Router.make({ routes }) seam working while exposing reflection", () => { const compatRoute = Route.make({ path: "/users/:userId", content: "user-screen" }) - const compatRouter = Router.make({ + const compatRouter = Router.from({ routes: [compatRoute], }) const reflectedPaths: Array = [] @@ -91,6 +91,41 @@ describe("@effectify/loom-router algebra", () => { expect(Router.find(compatRouter, "missing")).toBeUndefined() }) + it("supports builder-first route and metadata operators without widening entries", () => { + const homeRoute = Route.make({ path: "/", content: "home-screen" }) + const settingsRoute = Route.make({ path: "/settings", content: "settings-screen" }) + const rootLayout = Layout.make("app-layout") + const appMissing = Fallback.make("app-missing") + const routeMissing = Fallback.make("route-missing") + const router = pipe( + Router.make("app"), + Router.layout(rootLayout), + Router.notFound(appMissing), + Router.prefix("/app"), + Router.route(homeRoute), + Router.route(settingsRoute), + ) + const replaced = pipe(router, Router.fallback({ notFound: routeMissing })) + + expect(Router.routes(router).map((route) => route.path)).toEqual(["/app", "/app/settings"]) + expect(Router.pathFor(router, Router.routes(router)[0]!)).toBe("/app") + expect(Router.pathFor(router, Router.routes(router)[1]!)).toBe("/app/settings") + expect(Router.render(router, "/app/missing")).toEqual({ + _tag: "Fragment", + children: [ + { _tag: "Text", value: "app-layout" }, + { _tag: "Text", value: "app-missing" }, + ], + }) + expect(Router.render(replaced, "/app/missing")).toEqual({ + _tag: "Fragment", + children: [ + { _tag: "Text", value: "app-layout" }, + { _tag: "Text", value: "route-missing" }, + ], + }) + }) + it("tracks optional route identifiers and resolves effective paths through the router seam", () => { const settingsRoute = Route.child({ identifier: "settings", diff --git a/packages/loom/router/tests/router-runtime-actions.test.ts b/packages/loom/router/tests/router-runtime-actions.test.ts index 3bda3410..22181e64 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,22 +299,56 @@ describe("@effectify/loom-router loaders/actions runtime", () => { expect(actionCalls).toBe(0) }) - it("executes compiled route-module loaders and actions through the runtime boundary", async () => { + 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 default-export route-module loaders and actions through the runtime boundary", async () => { const route = RouteModule.compile({ identifier: "users.detail", module: { - component: "user-screen", + default: "user-screen", loader: Route.loader({ params: Schema.Struct({ userId: Schema.String }), + search: Schema.Struct({ tab: Schema.String }), load: ({ params, + search, services, }: { readonly params: { readonly userId: string } + readonly search: { readonly tab: string } readonly services: { readonly prefix: string } - }) => Effect.succeed(`${services.prefix}:${params.userId}`), + }) => Effect.succeed(`${services.prefix}:${params.userId}:${search.tab}`), }), action: Route.action({ + params: Schema.Struct({ userId: Schema.String }), + search: Schema.Struct({ tab: Schema.String }), input: Submission.make((submission) => typeof submission.title === "string" ? Submission.succeed({ title: submission.title }) @@ -316,17 +356,21 @@ describe("@effectify/loom-router loaders/actions runtime", () => { ), handle: ({ input, + params, + search, services, }: { readonly input: { readonly title: string } + readonly params: { readonly userId: string } + readonly search: { readonly tab: string } readonly services: { readonly suffix: string } - }) => Effect.succeed(`${input.title}:${services.suffix}`), + }) => Effect.succeed(`${input.title}:${params.userId}:${search.tab}:${services.suffix}`), }), }, path: "/users/:userId", }) const router = Router.make({ routes: [route] }) - const resolved = Router.resolve(router, "/users/42") + const resolved = Router.resolve(router, "/users/42?tab=profile") if (!Router.isResolveSuccess(resolved)) { throw new Error("expected a resolved route") @@ -344,12 +388,12 @@ describe("@effectify/loom-router loaders/actions runtime", () => { expect(loaded).toEqual({ _tag: "success", - data: "user:42", + data: "user:42:profile", route, }) expect(submitted).toEqual({ _tag: "success", - result: "save:done", + result: "save:42:profile:done", revalidated: false, route, }) diff --git a/packages/loom/router/tests/router-runtime.test.ts b/packages/loom/router/tests/router-runtime.test.ts index 2dc19a68..b476e60c 100644 --- a/packages/loom/router/tests/router-runtime.test.ts +++ b/packages/loom/router/tests/router-runtime.test.ts @@ -1,5 +1,6 @@ import * as Effect from "effect/Effect" import { SchemaGetter } from "effect" +import { pipe } from "effect" import * as Schema from "effect/Schema" import { Component, Html, Hydration, Slot, View, Web } from "@effectify/loom" import { describe, expect, it } from "vitest" @@ -12,6 +13,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 +47,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 +178,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 +205,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,19 +274,52 @@ 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" }) expect(render(router, "/posts/42")).toBe("
42:none:1
") }) + it("keeps builder and compatibility constructors runtime-equivalent", () => { + const postsRoute = Route.make({ + path: "/posts/:postId", + content: (context: Router.Context) => Html.el("main", Html.children(context.params.postId)), + fallback: { + notFound: Fallback.make(() => Html.el("p", Html.children("route-missing"))), + }, + }) + const shell = Layout.make(({ child }) => Html.el("section", Html.attr("data-shell", "app"), Html.children(child))) + const appMissing = Fallback.make(() => Html.el("p", Html.children("app-missing"))) + const builder = pipe( + Router.make("app"), + Router.layout(shell), + Router.notFound(appMissing), + Router.route(postsRoute), + ) + const compat = Router.from({ + layout: shell, + routes: [postsRoute], + fallback: { + notFound: appMissing, + }, + }) + const legacyCompat = Router.make({ + layout: shell, + routes: [postsRoute], + fallback: { + notFound: appMissing, + }, + }) + + expect(Router.resolve(builder, "/posts/42")).toEqual(Router.resolve(compat, "/posts/42")) + expect(Router.resolve(builder, "/posts/42")).toEqual(Router.resolve(legacyCompat, "/posts/42")) + expect(render(builder, "/missing")).toBe(render(compat, "/missing")) + expect(render(builder, "/missing")).toBe(render(legacyCompat, "/missing")) + expect(render(builder, "/posts/42/unknown")).toBe(render(compat, "/posts/42/unknown")) + expect(render(builder, "/posts/42/unknown")).toBe(render(legacyCompat, "/posts/42/unknown")) + }) + it("renders component-only route modules without requiring loader or action hooks", () => { const componentOnlyRoute = RouteModule.compile({ identifier: "component.only", @@ -301,15 +335,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/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": { @@ -105,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 @@ -167,6 +218,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 +233,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 +288,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 +308,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": { @@ -258,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 db77df88..a41bbcbf 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": { @@ -129,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 @@ -242,6 +308,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 +324,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 +390,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 +411,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": { @@ -347,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/vite/src/internal/diagnostics.ts b/packages/loom/vite/src/internal/diagnostics.ts index 9a48482f..0dea6d7e 100644 --- a/packages/loom/vite/src/internal/diagnostics.ts +++ b/packages/loom/vite/src/internal/diagnostics.ts @@ -81,20 +81,17 @@ export const makeMissingRootDiagnostics = (input: { ] export const makeEnabledStateDiagnostics = (state: LoomViteState): ReadonlyArray => { - if (state.options.root === undefined) { - return [] - } - return [ makeAdapterReport({ severity: "info", code: "loom.adapter.vite.enabled", message: "Loom Vite is enabled for the configured browser entry.", - subject: state.options.root, + subject: state.options.rootId, details: { - root: state.options.root, + buildId: state.options.buildId, clientEntry: state.options.clientEntry, payloadElementId: state.options.payloadElementId, + rootId: state.options.rootId, }, }), ] diff --git a/packages/loom/vite/src/internal/html-transform.ts b/packages/loom/vite/src/internal/html-transform.ts index f2808e86..d86bbfe5 100644 --- a/packages/loom/vite/src/internal/html-transform.ts +++ b/packages/loom/vite/src/internal/html-transform.ts @@ -7,34 +7,34 @@ const injectBeforeClosingTag = (html: string, closingTag: string, fragment: stri } const renderClientEntryTag = (state: LoomViteState): string => { - if (state.options.root === undefined) { - return "" - } - - return `` + return `` } const renderPayloadMarkerTag = (state: LoomViteState): string => { - if (state.options.root === undefined) { - return "" - } - - return `` + return `` } +const renderRootContainer = (state: LoomViteState): string => `
` + +const hasRootContainer = (html: string, state: LoomViteState): boolean => html.includes(`id="${state.options.rootId}"`) + export const transformLoomIndexHtml = (html: string, state: LoomViteState): string => { + const withRoot = hasRootContainer(html, state) + ? html + : injectBeforeClosingTag(html, "", renderRootContainer(state)) + if (!state.enabled) { - return html + return withRoot } - return injectBeforeClosingTag(html, "", `${renderClientEntryTag(state)}${renderPayloadMarkerTag(state)}`) + return injectBeforeClosingTag(withRoot, "", `${renderPayloadMarkerTag(state)}${renderClientEntryTag(state)}`) } export const logLoomDevDiagnostics = ( info: (message: string) => void, state: LoomViteState, ): void => { - if (!state.enabled || state.options.root === undefined) { + if (!state.enabled) { return } diff --git a/packages/loom/vite/src/internal/plugin-state.ts b/packages/loom/vite/src/internal/plugin-state.ts index 74ff9d31..a2f85147 100644 --- a/packages/loom/vite/src/internal/plugin-state.ts +++ b/packages/loom/vite/src/internal/plugin-state.ts @@ -1,21 +1,26 @@ import type * as Loom from "@effectify/loom" -export const defaultLoomClientEntry = "/src/loom-client.ts" +export const defaultLoomBuildId = "loom-dev" +export const defaultLoomClientEntry = "/src/entry-client.ts" export const defaultLoomPayloadElementId = "__loom_payload__" +export const defaultLoomRootId = "loom-root" export type LoomResumabilityPayload = Loom.Resumability.LoomResumabilityContract export type LoomActivationPayload = LoomResumabilityPayload export interface LoomViteOptions { + readonly buildId?: string readonly root?: string + readonly rootId?: string readonly clientEntry?: string readonly payloadElementId?: string } export interface ResolvedLoomViteOptions { - readonly root: string | undefined + readonly buildId: string readonly clientEntry: string readonly payloadElementId: string + readonly rootId: string } export interface LoomViteState { @@ -30,9 +35,10 @@ const normalizeOptionalString = (value: string | undefined): string | undefined } export const normalizeLoomViteOptions = (options: LoomViteOptions = {}): ResolvedLoomViteOptions => ({ - root: normalizeOptionalString(options.root), + buildId: normalizeOptionalString(options.buildId) ?? defaultLoomBuildId, clientEntry: normalizeOptionalString(options.clientEntry) ?? defaultLoomClientEntry, payloadElementId: normalizeOptionalString(options.payloadElementId) ?? defaultLoomPayloadElementId, + rootId: normalizeOptionalString(options.rootId) ?? normalizeOptionalString(options.root) ?? defaultLoomRootId, }) export const resolveLoomViteState = ( @@ -45,7 +51,7 @@ export const resolveLoomViteState = ( return { configRoot: config.root, - enabled: normalized.root !== undefined, + enabled: true, options: normalized, } } diff --git a/packages/loom/vite/src/loom-vite.ts b/packages/loom/vite/src/loom-vite.ts index f8c5460f..4ecf73de 100644 --- a/packages/loom/vite/src/loom-vite.ts +++ b/packages/loom/vite/src/loom-vite.ts @@ -2,6 +2,10 @@ import type { Plugin } from "vite" import { bootstrapLoomBrowser, type LoomBootstrapOptions, type LoomBootstrapResult } from "./internal/bootstrap.js" import { logLoomDevDiagnostics, transformLoomIndexHtml } from "./internal/html-transform.js" import { + defaultLoomBuildId, + defaultLoomClientEntry, + defaultLoomPayloadElementId, + defaultLoomRootId, type LoomActivationPayload, type LoomResumabilityPayload, type LoomViteOptions, @@ -19,6 +23,8 @@ export type { LoomViteOptions, } +export { defaultLoomBuildId, defaultLoomClientEntry, defaultLoomPayloadElementId, defaultLoomRootId } + const assertWebOnlyRenderer = (options: Options): void => { if (!("renderer" in options)) { return diff --git a/packages/loom/vite/tests/bootstrap-resumability.test.ts b/packages/loom/vite/tests/bootstrap-resumability.test.ts index 631196ff..aa55f2c5 100644 --- a/packages/loom/vite/tests/bootstrap-resumability.test.ts +++ b/packages/loom/vite/tests/bootstrap-resumability.test.ts @@ -13,15 +13,15 @@ describe("@effectify/loom-vite resumability bootstrap", () => { const dispatched: Array = [] const nitro = LoomNitro.renderer({ - buildId: "build-123", - rootId: "loom-root", - render: () => - Html.el( + render: () => ({ + title: "Ready", + body: Html.el( "section", Html.hydrate(Hydration.visible()), Html.on("click", Resumability.handler(clickRef, effectLike)), Html.children("ready"), ), + }), }) const result = await nitro.render({ @@ -36,20 +36,9 @@ describe("@effectify/loom-vite resumability bootstrap", () => { Resumability.registerHandler(localRegistry, clickRef, effectLike) - const dom = new JSDOM( - `
${result.html}
`, - ) - const payloadElement = dom.window.document.getElementById("loom-payload") - - if (payloadElement === null) { - throw new Error("expected payload element") - } - - payloadElement.textContent = JSON.stringify(result.resumability) + const dom = new JSDOM(result.html) const bootstrapResult = await bootstrap(dom.window.document, { - payloadElementId: "loom-payload", - expectedBuildId: "build-123", localRegistry, onEffect: (effect) => { dispatched.push(effect._tag) @@ -76,15 +65,15 @@ describe("@effectify/loom-vite resumability bootstrap", () => { const clickRef = Resumability.makeExecutableRef("app/counter", "onClick") const nitro = LoomNitro.renderer({ - buildId: "build-123", - rootId: "loom-root", - render: () => - Html.el( + render: () => ({ + title: "Fresh start", + body: Html.el( "section", Html.hydrate(Hydration.visible()), Html.on("click", Resumability.handler(clickRef, effectLike)), Html.children("ready"), ), + }), }) const result = await nitro.render({ @@ -97,20 +86,10 @@ describe("@effectify/loom-vite resumability bootstrap", () => { throw new Error("expected resumability payload") } - const dom = new JSDOM( - `
${result.html}
`, - ) - const payloadElement = dom.window.document.getElementById("loom-payload") - - if (payloadElement === null) { - throw new Error("expected payload element") - } - - payloadElement.textContent = JSON.stringify(result.resumability) + const dom = new JSDOM(result.html) const bootstrapResult = await bootstrap(dom.window.document, { - payloadElementId: "loom-payload", - expectedBuildId: "build-123", + expectedBuildId: "mismatched-build-id", localRegistry: Resumability.makeLocalRegistry(), }) @@ -132,11 +111,11 @@ describe("@effectify/loom-vite resumability bootstrap", () => { severity: "warn", code: "loom.adapter.bootstrap.fresh-start", message: "Loom bootstrap fell back to a fresh client start instead of resumability.", - subject: "loom-root", + subject: "__loom_payload__", details: { - payloadElementId: "loom-payload", - rootId: "loom-root", - validationStatus: "fresh-start", + payloadElementId: "__loom_payload__", + rootId: null, + validationStatus: "invalid", issueCount: 1, }, }, @@ -152,12 +131,11 @@ describe("@effectify/loom-vite resumability bootstrap", () => { }, ]) expect(bootstrapResult.validation).toEqual({ - status: "fresh-start", - contract: result.resumability, + status: "invalid", issues: [ expect.objectContaining({ - path: "handlers[0].ref", - reason: "missing-local-handler-ref", + path: "buildId", + reason: "build-id-mismatch", }), ], }) @@ -167,15 +145,15 @@ describe("@effectify/loom-vite resumability bootstrap", () => { const clickRef = Resumability.makeExecutableRef("app/counter", "onClick") const localRegistry = Resumability.makeLocalRegistry() const nitro = LoomNitro.renderer({ - buildId: "build-123", - rootId: "loom-root", - render: () => - Html.el( + render: () => ({ + title: "Diagnostics", + body: Html.el( "section", Html.hydrate(Hydration.visible()), Html.on("click", Resumability.handler(clickRef, effectLike)), Html.children("ready"), ), + }), }) const result = await nitro.render({ method: "GET", @@ -189,20 +167,9 @@ describe("@effectify/loom-vite resumability bootstrap", () => { Resumability.registerHandler(localRegistry, clickRef, effectLike) - const dom = new JSDOM( - `
${result.html}
`, - ) - const payloadElement = dom.window.document.getElementById("loom-payload") - - if (payloadElement === null) { - throw new Error("expected payload element") - } - - payloadElement.textContent = JSON.stringify(result.resumability) + const dom = new JSDOM(result.html) const bootstrapResult = await bootstrap(dom.window.document, { - payloadElementId: "loom-payload", - expectedBuildId: "build-123", localRegistry, }) diff --git a/packages/loom/vite/tests/loom-vite.test.ts b/packages/loom/vite/tests/loom-vite.test.ts index b66e0bc2..9eab07f8 100644 --- a/packages/loom/vite/tests/loom-vite.test.ts +++ b/packages/loom/vite/tests/loom-vite.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it, vi } from "vitest" import { + defaultLoomBuildId, defaultLoomClientEntry, defaultLoomPayloadElementId, + defaultLoomRootId, normalizeLoomViteOptions, resolveLoomViteState, } from "../src/internal/plugin-state.js" @@ -9,42 +11,44 @@ import { logLoomDevDiagnostics, transformLoomIndexHtml } from "../src/internal/h import { bootstrap, loom } from "../src/loom-vite.js" describe("@effectify/loom-vite", () => { - it("normalizes options without enabling Loom for unrelated projects", () => { + it("normalizes default bootstrap options for the zero-config Loom path", () => { expect(normalizeLoomViteOptions({})).toEqual({ - root: undefined, + buildId: defaultLoomBuildId, clientEntry: defaultLoomClientEntry, payloadElementId: defaultLoomPayloadElementId, + rootId: defaultLoomRootId, }) }) - it("injects a client entry and payload marker for Loom-owned html", () => { + it("injects the default root container, client entry, and payload marker for Loom html", () => { const state = resolveLoomViteState({ root: "/workspace" }, { - root: "src/app.ts", clientEntry: "/src/entry-client.ts", payloadElementId: "loom-payload", + rootId: "custom-root", }) expect(transformLoomIndexHtml("
ready
", state)).toBe( - '
ready
', + '
ready
', ) }) - it("preserves unrelated html when no Loom root is configured", () => { + it("preserves an existing root container instead of duplicating it", () => { const state = resolveLoomViteState({ root: "/workspace" }, {}) - const html = "
static
" + const html = '
static
' - expect(transformLoomIndexHtml(html, state)).toBe(html) + expect(transformLoomIndexHtml(html, state)).toContain('
static
') + expect(transformLoomIndexHtml(html, state).match(/id="loom-root"/g)).toHaveLength(1) }) it("emits dev diagnostics only for enabled Loom state", () => { const info = vi.fn() - const enabled = resolveLoomViteState({ root: "/workspace" }, { root: "src/app.ts" }) - const disabled = resolveLoomViteState({ root: "/workspace" }, {}) + const enabled = resolveLoomViteState({ root: "/workspace" }, {}) + const aliased = resolveLoomViteState({ root: "/workspace" }, { root: "legacy-root" }) logLoomDevDiagnostics(info, enabled) - logLoomDevDiagnostics(info, disabled) + logLoomDevDiagnostics(info, aliased) - expect(info).toHaveBeenCalledTimes(1) + expect(info).toHaveBeenCalledTimes(2) expect(JSON.parse(info.mock.calls[0]?.[0] ?? "{}")).toEqual({ scope: "loom", report: { @@ -62,11 +66,12 @@ describe("@effectify/loom-vite", () => { severity: "info", code: "loom.adapter.vite.enabled", message: "Loom Vite is enabled for the configured browser entry.", - subject: "src/app.ts", + subject: "loom-root", details: { - root: "src/app.ts", - clientEntry: "/src/loom-client.ts", + buildId: "loom-dev", + clientEntry: "/src/entry-client.ts", payloadElementId: "__loom_payload__", + rootId: "loom-root", }, }, ], @@ -82,9 +87,9 @@ describe("@effectify/loom-vite", () => { it("runs the Vite hook callbacks end to end for a Loom-owned project", () => { const plugin = loom({ - root: "src/app.ts", clientEntry: "/src/entry-client.ts", payloadElementId: "loom-payload", + rootId: "custom-root", }) const info = vi.fn() const configResolved = typeof plugin.configResolved === "function" @@ -117,7 +122,7 @@ describe("@effectify/loom-vite", () => { }, ]) - expect(transformed).toContain('data-loom-client-entry="src/app.ts"') + expect(transformed).toContain('id="custom-root"') expect(transformed).toContain('id="loom-payload"') expect(JSON.parse(info.mock.calls[0]?.[0] ?? "{}")).toEqual({ scope: "loom", @@ -136,11 +141,12 @@ describe("@effectify/loom-vite", () => { severity: "info", code: "loom.adapter.vite.enabled", message: "Loom Vite is enabled for the configured browser entry.", - subject: "src/app.ts", + subject: "custom-root", details: { - root: "src/app.ts", + buildId: "loom-dev", clientEntry: "/src/entry-client.ts", payloadElementId: "loom-payload", + rootId: "custom-root", }, }, ], @@ -155,7 +161,7 @@ describe("@effectify/loom-vite", () => { }) it("rejects unsupported renderer options at the Vite adapter boundary", () => { - const invalidOptions = { root: "src/app.ts", renderer: "native" } + const invalidOptions = { rootId: "loom-root", renderer: "native" } expect(() => Reflect.apply(loom, undefined, [invalidOptions])).toThrow( "Loom Vite only supports the web renderer in this slice", @@ -163,7 +169,7 @@ describe("@effectify/loom-vite", () => { }) it("exposes the initial Vite plugin hook surface", () => { - const plugin = loom({ root: "src/app.ts" }) + const plugin = loom() expect(typeof bootstrap).toBe("function") expect(plugin.name).toBe("effectify:loom-vite") diff --git a/packages/loom/vite/tests/public-api.types.ts b/packages/loom/vite/tests/public-api.types.ts index 1342a2ad..aea9b689 100644 --- a/packages/loom/vite/tests/public-api.types.ts +++ b/packages/loom/vite/tests/public-api.types.ts @@ -8,9 +8,10 @@ type Equal = (() => Value extends Left ? 1 : 2) extends = Value const options: LoomVite.Options = { - root: "src/app.ts", + buildId: "build-123", clientEntry: "/src/entry-client.ts", payloadElementId: "loom-payload", + rootId: "loom-root", } const plugin: Plugin = LoomVite.loom(options) @@ -20,7 +21,7 @@ const bootstrap = LoomVite.bootstrap(document, { }) const unsupportedRendererOptions: LoomVite.Options = { - root: "src/app.ts", + rootId: "loom-root", // @ts-expect-error non-web renderers remain out of scope for this web-only adapter renderer: "native", } diff --git a/packages/loom/vite/tests/web-integrations.test.ts b/packages/loom/vite/tests/web-integrations.test.ts index 0345aed1..c160dddb 100644 --- a/packages/loom/vite/tests/web-integrations.test.ts +++ b/packages/loom/vite/tests/web-integrations.test.ts @@ -8,17 +8,30 @@ import { Html, Hydration, Resumability } from "@effectify/loom" import { LoomNitro } from "../../nitro/src/index.js" import { LoomVite } from "../src/index.js" +const effectLike = { _tag: "EffectLike" } as const + const makeSerializableTextAtom = (key: string, value: string) => Atom.serializable(Atom.make(value), { key, schema: Schema.String, }) -const writePayloadDocument = (html: string, payload: unknown, payloadElementId = "loom-payload"): void => { - document.body.innerHTML = - `
${html}

outside

` +const writePayloadDocument = (html: string): void => { + document.documentElement.innerHTML = html + document.body.insertAdjacentHTML("beforeend", '

outside

') +} + +const writePayloadContract = ( + contract: LoomRuntime.Resumability.LoomResumabilityContract, + payloadElementId = "__loom_payload__", +): void => { + const payloadElement = document.getElementById(payloadElementId) + + if (!(payloadElement instanceof HTMLScriptElement)) { + throw new Error(`expected payload element ${payloadElementId}`) + } + + payloadElement.textContent = JSON.stringify(contract) } const recreateContract = ( @@ -55,10 +68,9 @@ describe("loom web integrations browser bootstrap", () => { ) const nitro = LoomNitro.renderer({ - buildId: "build-123", - rootId: "loom-root", - render: () => - Html.el( + render: () => ({ + title: "Demo", + body: Html.el( "section", Html.hydrate(Hydration.visible()), Html.children( @@ -71,6 +83,7 @@ describe("loom web integrations browser bootstrap", () => { ), ), ), + }), ssr: { registry: serverRegistry }, }) @@ -84,11 +97,9 @@ describe("loom web integrations browser bootstrap", () => { throw new Error("expected Nitro activation payload") } - writePayloadDocument(result.html, result.resumability) + writePayloadDocument(result.html) const bootstrap = await LoomVite.bootstrap(document, { - payloadElementId: "loom-payload", - expectedBuildId: "build-123", localRegistry, registry: clientRegistry, }) @@ -115,8 +126,6 @@ describe("loom web integrations browser bootstrap", () => { it("reports root drift without mutating the DOM when the payload root cannot be found", async () => { const nitro = LoomNitro.renderer({ - buildId: "build-123", - rootId: "loom-root", render: () => Html.el("section", Html.hydrate(Hydration.visible()), Html.children("ready")), }) @@ -130,12 +139,11 @@ describe("loom web integrations browser bootstrap", () => { throw new Error("expected Nitro activation payload") } - writePayloadDocument(result.html, await recreateContract(result.resumability, { rootId: "missing-root" })) + writePayloadDocument(result.html) + writePayloadContract(await recreateContract(result.resumability, { rootId: "missing-root" })) const before = document.body.innerHTML const bootstrap = await LoomVite.bootstrap(document, { - payloadElementId: "loom-payload", - expectedBuildId: "build-123", localRegistry: Resumability.makeLocalRegistry(), }) @@ -146,8 +154,6 @@ describe("loom web integrations browser bootstrap", () => { it("surfaces boundary drift issues from a stale payload manifest", async () => { const nitro = LoomNitro.renderer({ - buildId: "build-123", - rootId: "loom-root", render: () => Html.el("section", Html.hydrate(Hydration.visible()), Html.children("ready")), }) @@ -161,19 +167,16 @@ describe("loom web integrations browser bootstrap", () => { throw new Error("expected Nitro activation payload") } - writePayloadDocument( - result.html, - await recreateContract(result.resumability, { - boundaries: result.resumability.boundaries.map((boundary) => ({ - ...boundary, - id: `${boundary.id}-stale`, - })), - }), - ) + const staleContract = await recreateContract(result.resumability, { + boundaries: result.resumability.boundaries.map((boundary) => ({ + ...boundary, + id: `${boundary.id}-stale`, + })), + }) + writePayloadDocument(result.html) + writePayloadContract(staleContract) const bootstrap = await LoomVite.bootstrap(document, { - payloadElementId: "loom-payload", - expectedBuildId: "build-123", localRegistry: Resumability.makeLocalRegistry(), }) @@ -185,4 +188,47 @@ describe("loom web integrations browser bootstrap", () => { reason: "missing-runtime-boundary", }) }) + + it("keeps custom shell and custom marker overrides working through the explicit adapter seams", async () => { + const localRegistry = Resumability.makeLocalRegistry() + const clickRef = Resumability.makeExecutableRef("app/custom", "onClick") + + Resumability.registerHandler(localRegistry, clickRef, effectLike) + + const nitro = LoomNitro.renderer({ + bootstrap: { + buildId: "custom-build", + rootId: "custom-root", + payloadElementId: "custom-payload", + clientEntry: "/src/custom-entry.ts", + }, + document: { + render: ({ bodyHtml, payloadHtml }) => + `
shell
${bodyHtml}
${payloadHtml}`, + }, + render: () => ({ + title: "Custom", + body: Html.el( + "section", + Html.hydrate(Hydration.visible()), + Html.on("click", Resumability.handler(clickRef, effectLike)), + Html.children("ready"), + ), + }), + }) + + const result = await nitro.render({ method: "GET", url: "/custom", headers: {} }) + + writePayloadDocument(result.html) + + const bootstrap = await LoomVite.bootstrap(document, { + expectedBuildId: "custom-build", + localRegistry, + payloadElementId: "custom-payload", + }) + + expect(document.querySelector('[data-shell="custom"]')?.textContent).toContain("shell") + expect(bootstrap.status).toBe("resumed") + expect(bootstrap.payload?.rootId).toBe("custom-root") + }) }) diff --git a/packages/loom/web/src/component.d.ts b/packages/loom/web/src/component.d.ts index 3e9663b4..355e3ae7 100644 --- a/packages/loom/web/src/component.d.ts +++ b/packages/loom/web/src/component.d.ts @@ -167,6 +167,7 @@ export declare const instantiate: < compositionInput?: InstanceCompositionInput, ) => Instance /** Create a component from a neutral AST node or a named vNext component seam. */ +export declare function make(): Type export declare function make(name: string): Type export declare function make(node: LoomCore.Ast.Node): Type /** Backwards-compatible alias kept while the public API settles. */ diff --git a/packages/loom/web/src/component.js b/packages/loom/web/src/component.js index 2f665be8..485dd40a 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, @@ -208,7 +213,7 @@ const patch = (component, update) => ...update, })) export function make(input) { - const definition = LoomCore.Component.make(isNode(input) ? input : emptyNode) + const definition = LoomCore.Component.make(input !== undefined && isNode(input) ? input : emptyNode) return reconcile(makePipeable({ ...definition, name: typeof input === "string" ? input : undefined, diff --git a/packages/loom/web/src/component.ts b/packages/loom/web/src/component.ts index 0db3dd64..da9ab898 100644 --- a/packages/loom/web/src/component.ts +++ b/packages/loom/web/src/component.ts @@ -4,9 +4,10 @@ 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" import type * as View from "./view.js" export type ModelValueInput = unknown | Atom.Atom | (() => unknown) @@ -69,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 = { @@ -151,7 +154,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 @@ -300,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) { @@ -349,7 +352,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 +364,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 +449,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 +495,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, @@ -532,10 +542,11 @@ const patch = < })) /** Create a component from a neutral AST node or a named vNext component seam. */ +export function make(): Type export function make(name: string): Type export function make(node: LoomCore.Ast.Node): Type -export function make(input: string | LoomCore.Ast.Node): Type { - const definition = LoomCore.Component.make(isNode(input) ? input : emptyNode) +export function make(input?: string | LoomCore.Ast.Node): Type { + const definition = LoomCore.Component.make(input !== undefined && isNode(input) ? input : emptyNode) return reconcile( makePipeable({ @@ -774,27 +785,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 +844,7 @@ export function view( } return patch(selfOrRender, { render }) -} +}) as never /** Attach slot contracts to a component definition. */ export function slots( @@ -949,78 +985,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 +1048,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/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/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..fdcc265b --- /dev/null +++ b/packages/loom/web/src/template.js @@ -0,0 +1,548 @@ +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_" +const attributeMarkerPattern = /^__loom_attr_(\d+)__$/u +const attributeContextPattern = /[A-Za-z0-9_:-]+\s*=\s*$/u +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" +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 + +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([]) + } + + 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 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())))] + } + + return normalizeStaticInterpolation(value) +} + +const assertTemplateValue = (value, owner) => { + if (isComponentDefinition(value)) { + throw new Error( + `${owner} is invalid in html templates. Use View.of(...) for simple components or View.use(...) for props, children, or slots.`, + ) + } + + 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 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) => { + 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": { + 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": + case "inputValue": { + 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.") + } + + const hydration = Hydration.boundary(interpolation) + Object.assign(attributes, hydration.attributes) + return hydration + } + default: + throw new Error(`Unsupported template directive 'web:${name}'.`) + } +} + +const convertParsedNode = (node, values) => { + if (node.kind === "comment") { + 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 (node.kind === "text") { + const textNode = textToNode(node.data) + return textNode === undefined ? [] : [textNode] + } + + const attributes = {} + const bindings = [] + const events = [] + let hydration + + for (const attribute of 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: node.children.flatMap((child) => convertParsedNode(child, values)), + events, + hydration, + })] +} + +export const renderable = (node) => viewNode.wrap(node) + +export const html = (strings, ...values) => { + 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 + }, "") + + return renderable( + collapseNodes(parseTemplateSource(source).flatMap((child) => convertParsedNode(child, values))), + ) +} diff --git a/packages/loom/web/src/template.ts b/packages/loom/web/src/template.ts new file mode 100644 index 00000000..6d27055c --- /dev/null +++ b/packages/loom/web/src/template.ts @@ -0,0 +1,703 @@ +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 + 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 TemplateDirectiveValue = Web.ClassInput | Web.StyleInput +export type TemplateValue = + | LoomCore.Ast.Node + | LoomCore.Component.Definition + | PrimitiveInterpolation + | ReadonlyArray +export type TemplateInterpolation = + | TemplateValue + | TemplateDirectiveValue + | Hydration.Strategy + | Html.EventHandler + | StateAccessor + | (() => TemplateValue | TemplateDirectiveValue) + +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 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" + +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 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 + +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)) +} + +type TemplateClassInput = Web.ClassInput + +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 templateDirectiveError = (name: "class" | "style", detail: string): Error => + 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())))] + } + + 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.of(...) for simple components or View.use(...) for props, children, or slots.`, + ) + } + + 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 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) + +const applyAttributeInterpolation = ( + name: string, + interpolation: TemplateInterpolation, + attributes: Record, + bindings: Array, +): void => { + assertTemplateValue(interpolation, "Direct array/component interpolation") + + if (isTemplateAccessor(interpolation) || 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": { + 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": + case "inputValue": { + 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.") + } + + const hydration = Hydration.boundary(interpolation) + Object.assign(attributes, hydration.attributes) + return hydration + } + default: + throw new Error(`Unsupported template directive 'web:${name}'.`) + } +} + +const convertParsedNode = ( + node: ParsedNode, + values: ReadonlyArray, +): ReadonlyArray => { + if (node.kind === "comment") { + 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 (node.kind === "text") { + const textNode = textToNode(node.data) + return textNode === undefined ? [] : [textNode] + } + + const attributes: Record = {} + const bindings: Array = [] + const events: Array = [] + let hydration: LoomCore.Ast.HydrationMetadata | undefined + + for (const attribute of 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: node.children.flatMap((child) => convertParsedNode(child, values)), + events, + hydration, + }), + ] +} + +export const renderable = (node: LoomCore.Ast.Node): Renderable => asRenderable(node) + +export const html = >( + strings: TemplateStringsArray, + ...values: Values +): Renderable, InterpolationRequirements> => { + 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 + }, "") + + return asRenderable( + collapseNodes(parseTemplateSource(source).flatMap((child) => convertParsedNode(child, values))), + ) +} diff --git a/packages/loom/web/src/view.d.ts b/packages/loom/web/src/view.d.ts index c7ebc5df..82c88f35 100644 --- a/packages/loom/web/src/view.d.ts +++ b/packages/loom/web/src/view.d.ts @@ -1,4 +1,5 @@ import * as LoomCore from "@effectify/loom-core" +import type * 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" @@ -9,6 +10,13 @@ export type ViewChild = viewChild.ViewChild export type Child = ViewChild export type MaybeChild = ViewChild export type SlotDefinition = Slot.Definition +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 export interface ForOptions { readonly key: (item: Item, index: number) => Key readonly render: (item: Item, index: number) => MaybeChild @@ -34,11 +42,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 @@ -56,6 +70,16 @@ export declare function forView( options: ForOptions, ): Type export { forView as for, ifView as if, whenView as when } +export declare function of< + Props, + Err, + Requirements, + Model extends Component.ModelShape, + Actions extends Component.ActionShape, +>( + component: NoRequiredProps extends true ? Component.Type + : never, +): Type /** Create a semantic main region. */ export declare const main: (content: MaybeChild) => Type /** Create a semantic aside region. */ diff --git a/packages/loom/web/src/view.js b/packages/loom/web/src/view.js index e0edc848..70cc6c9e 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) { @@ -48,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) => { @@ -92,6 +102,64 @@ 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)) +export const of = (component) => use(component) /** 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..d4548f63 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 @@ -97,20 +165,26 @@ 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. */ -export const button = (content: ViewChild, handler: Html.EventHandler): Type => - internal.wrap(Html.el("button", Html.on("click", handler), Html.children(content))) +/** + * @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. */ -export const input = (): Type => internal.wrap(Html.el("input", Html.attr("type", "text"))) +/** + * @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. */ -export const link = (content: ViewChild, target: LinkTarget): Type => - internal.wrap(Html.el("a", ...linkTargetModifiers(target), Html.children(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))) -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 +197,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 +237,220 @@ 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 +} + +export const of = < + Props, + Err, + Requirements, + Model extends Component.ModelShape, + Actions extends Component.ActionShape, +>(component: ChildShorthandComponent): Renderable => + use(component) + /** 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/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 new file mode 100644 index 00000000..861820b2 --- /dev/null +++ b/packages/loom/web/tests/template-first-view-api.test.ts @@ -0,0 +1,641 @@ +// @vitest-environment jsdom + +import * as Result from "effect/Result" +import { Atom } from "effect/unstable/reactivity" +import { describe, expect, it } from "vitest" +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( + 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("guides simple component interpolation toward View.of and advanced composition toward View.use", () => { + const child = Component.make().pipe( + Component.view(() => html`child`), + ) + + expect(() => html`
${child}
`).toThrowError( + /Use View\.of\(\.\.\.\) for simple components or View\.use\(\.\.\.\) for props, children, or slots\./, + ) + }) + + 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("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'/, + ) + }) + + 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("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\./) + }) + + 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("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`), + ) + + 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..16f065f5 --- /dev/null +++ b/packages/loom/web/tests/template-first-view-api.types.ts @@ -0,0 +1,185 @@ +import * as Result from "effect/Result" +import { Atom } from "effect/unstable/reactivity" +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.of(child)), +) + +const used = View.of(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`], +}) + +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`) + +// @ts-expect-error View.of only supports components without required props. +const invalidSimpleUse = View.of(requiredPropsChild) + +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, + accessorSugar, + invalidChildShorthand, + invalidSimpleUse, + handledSubject, + recoveredSubject, + providedSubject, + recoveredBoundary, + providedBoundary, + typeContracts, +} 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..ef6399be --- /dev/null +++ b/packages/loom/web/tests/template-node-safety.test.ts @@ -0,0 +1,71 @@ +// @vitest-environment node + +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") + + 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("serializes web:class and web:style during SSR without installing a global document", () => { + Reflect.deleteProperty(globalThis, "document") + + 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("rejects unsupported template directives without installing a global document", () => { + expectParserErrorWithoutDocument( + () => html`
["active"]}>
`, + /Unsupported template directive 'web:foo'/, + ) + }) + + it("fails fast for malformed templates without installing a global document", () => { + expectParserErrorWithoutDocument( + () => html`
broken
`, + /Invalid html template: expected <\/span> but found <\/section>\./, + ) + }) +}) diff --git a/packages/loom/web/tests/vnext-public-api.test.ts b/packages/loom/web/tests/vnext-public-api.test.ts index 1c2ad23b..3fa71aae 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", () => { @@ -104,6 +107,23 @@ describe("@effectify/loom vNext public surface", () => { registry.dispose() }) + it("supports unnamed component definitions and View.of simple composition", () => { + const badge = Component.make().pipe( + Component.view(() => View.text("Docs")), + ) + + const simplePage = Component.make("simple-page").pipe( + Component.view(() => View.fragment(View.of(badge), View.use(badge))), + ) + + const handle = mount({ simplePage }) + + expect(badge.name).toBeUndefined() + expect(handle.html).toContain("DocsDocs") + + handle.dispose() + }) + it("materializes Atom factories per mount and keeps read-friendly state isolated per instance", () => { const counter = Component.make("counter").pipe( Component.model({ @@ -591,6 +611,58 @@ 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("Component.make()") + expect(readme).toContain("View.of(...)") + expect(readme).toContain("View.button(...)") + expect(readme).toContain('