0025: asyncRoute runtime composer — manifest-driven view assembly
Status: Accepted Date: 2026-05-11
Context
ADRs 0019-0024 ship the file-system-routing manifest, layout chains,
error boundaries, root 404, loader detection, and SSR-aware lazy
fetching. The examples/ssr/ migration proves the pieces compose
correctly — but the user-land composer it wrote
(examples/ssr/src/app.ts) is ~60 lines of loadStack + 30 lines
of renderEntry / renderNotFound that every Purity app
consuming purity:routes will re-implement identically:
async function loadStack(entry, params) {
const ctx = { request: …, params, signal: … };
try {
const [routeMod, ...layoutMods] = await Promise.all([
entry.importFn(),
...entry.layouts.map((l) => l.importFn()),
]);
const [routeData, ...layoutsData] = await Promise.all([
entry.hasLoader ? routeMod.loader(ctx) : undefined,
...entry.layouts.map((l, i) => l.hasLoader ? layoutMods[i].loader(ctx) : undefined),
]);
return () => {
let view = () => routeMod.default(params, routeData);
for (let i = layoutMods.length - 1; i >= 0; i--) {
const layout = layoutMods[i];
const data = layoutsData[i];
const inner = view;
view = () => layout.default(inner, data);
}
return view();
};
} catch (err) {
if (entry.errorBoundary) {
const errMod = await entry.errorBoundary.importFn();
return () => errMod.default(err);
}
throw err;
}
}
function renderEntry(entry, params) {
const stack = lazyResource(() => loadStack(entry, params), {
key: `route:${entry.pattern}`,
});
stack.fetch();
return when(
() => stack() !== undefined,
() => stack()(),
() => html`<p class="loading">…</p>`,
);
}
Every line is mechanical. The pattern is what ADRs 0019-0024 intended — but until it's a single primitive, every app re-derives it. The migration to the manifest is incomplete in practice.
The shipping ecosystem's equivalent: Remix's <Outlet>, Next App
Router's nested page.tsx + layout.tsx machinery, SvelteKit's
auto-generated +page.svelte runtime. They're each a router and a
view composer wrapped together. We've split the router (manifest +
matchRoute) from the view composer; this ADR ships the view
composer.
Decision
Add asyncRoute(entry, params, options?) and
asyncNotFound(notFound, options?) to @purityjs/core. Both
return the rendered view (or a fallback during loading). Internally
each:
- Creates a
lazyResource(() => loadStack(...), { key: '…' })— ADR 0024 gives us SSR multipass for free via thekeyoption. - Calls
.fetch()synchronously (server: registers withpendingPromises; client: reactively triggers). - Returns
when(() => r() !== undefined, () => r()(), fallback)— ADR 0023 makes this isomorphic across SSR + client.
The loadStack helper inside asyncRoute does the layout chain
- loader-await + error-boundary fallback exactly like the
hand-rolled version in
examples/ssr/src/app.ts. The layout composer threads loader data into the component via the second positional arg (route:(params, data) => view; layout:(children, data) => view), matching ADR 0022's documented shape.
import { asyncNotFound, asyncRoute, html, matchRoute } from '@purityjs/core';
import { notFound, routes } from 'purity:routes';
export function App() {
for (const entry of routes) {
const m = matchRoute(entry.pattern);
if (m) return asyncRoute(entry, m.params);
}
return notFound ? asyncNotFound(notFound) : html`<h1>404</h1>`;
}
examples/ssr/src/app.ts shrinks from 128 lines to ~12 — the
manifest-driven app pattern is now one short loop.
Concretely:
asyncRoute(entry, params, options?)—entryis shaped likeRouteEntry & { importFn, layouts, errorBoundary? }. Param type is structural, not nominal: any object matching the manifest's emitted shape works (lets the helper live in core without depending on@purityjs/vite-plugin).asyncNotFound(entry, options?)—entryis shaped likeLayoutEntry & { importFn }(the manifest'snotFoundfield). Renders the page with no params or layout — same shape as the hand-rolledrenderNotFound.AsyncRouteOptionsfields:fallback?: () => unknown— view rendered while the loader pipeline resolves. Default: emptyhtml\`(no flash). Apps pass() => html`<p>loading…</p>`` for a visible spinner.keyPrefix?: string— prepended to the route'spatternfor the lazyResource key. Default'route:'forasyncRoute,'notFound:'forasyncNotFound. Override only when a single page hosts multiple manifest consumers that would collide.request?: () => Request— overrides the default request construction. The default usesgetRequest()(ADR 0009) on the server and constructs anew Request(window.location.href)on the client. Apps with a custom request shape pass an override.
- Loader signature matches ADR 0022:
loader({ request, params, signal }) => data | Promise<data>. The composer constructs the context from the args. TheAbortSignalis from a freshAbortController; on the server it never aborts (the renderer awaits to completion); on the client a future ADR can wire it to navigation aborts. - Component data shape is positional, matching ADR 0022's
documented user-land convention:
- Route:
(params: Record<string, string>, data: unknown) => view - Layout:
(children: () => unknown, data: unknown) => view - Error boundary:
(error: unknown) => view(ADR 0021) - 404:
() => view
- Route:
- Error handling:
- Fetcher rejection at any step (route import, layout import,
loader call) is caught inside
loadStack. - If
entry.errorBoundaryis set, the boundary's default export is rendered with the caught error. - Otherwise the error re-throws and bubbles to the consumer.
- In SSR, ADR 0024's pass-2 cached-error re-throw triggers the same path on the second pass — apps see the boundary view rendered into the SSR HTML.
- Fetcher rejection at any step (route import, layout import,
loader call) is caught inside
Explicit non-features
- No automatic
App()wrapper. Apps still write thefor (const entry of routes) {…}loop. ArouterDispatch(routes, notFound?)helper would shrink it further but locks app authors out of pre-/post-route hooks (auth gates, logging, A/B routing). Keep the loop visible. - No client-side route prefetch. The composer fetches lazily
on render. Hover-prefetch (Next/SvelteKit pattern) is a future
ADR — needs a
<link rel="prefetch">hook intointerceptLinks()(ADR 0013). - No per-route caching policy.
asyncRoutealways re-fetches on navigation (the lazyResource is fresh per render). Resource revalidation / stale-while-revalidate per the route's loader is the next ADR (paired withloaderData()). - No
loaderData()accessor. Loader data threads via the positional arg as documented. A future ADR shipsloaderData()for components that prefer reading from a per-render context slot — drops in alongsideasyncRoutewithout changing it. - No streaming (per-route suspense boundaries). The composer
awaits the entire loadStack before rendering. Apps that want
per-section streaming wrap parts of their route view in
suspense()(ADR 0006). The composer doesn't auto-thread suspense boundaries around layouts. - No support for layout chains under
asyncNotFound. Phase 1 shipsnotFoundas a single page rendered without chrome (the manifest's top-levelnotFounddoesn't carry layouts). Apps that want a layout-wrapped 404 either wrap their App's return value or wait for nested-404 support (deferred per ADR 0021). - No bundled custom-element variant. A future
<purity-route entry=…>Custom Element could compose with<head>/ suspense in ways the function form can't. Skip — adds surface area before the function form has wear marks. - No automatic
RequestfromIncomingMessageshim. The defaultrequestbuilder uses the Web PlatformRequestconstructor and eithergetRequest()(server) orwindow.location.href(client). Nodehttpusers still convert in their server entry per ADR 0009.
Consequences
Positive:
- Closes the bigger gap from the migration: the user-land composer
becomes a one-line call. Apps consuming the manifest stop
reinventing the same
loadStack+renderEntrymachinery. - Composes cleanly with ADRs 0023 + 0024 — both surfaces designed for this composer to call. The composer is ~50 LOC of orchestration on top of primitives that already work.
- The example shrinks from 128 lines to ~12. The pattern becomes apparent rather than buried in user-land machinery.
- Apps that want a custom composer keep writing one — the helpers are pure functions over the manifest entries; nothing hard-binds the framework to them.
Negative:
- New surface area in
@purityjs/core(2 functions + an options type). Tree-shaken when unused. Pulls some weight in the bundle for apps that don't use the manifest. - The structural type for
entryaccepts any matching shape — TypeScript can't catch passing the wrong manifest's entry. Acceptable: the manifest's emitted shape is documented in ADR 0019 + the plugin'sRouteEntryinterface; mismatched consumers fail at the firstimportFn()call. - Loader data threads positional — a future
loaderData()helper ships under a separate ADR. Apps that adoptasyncRoutefirst andloaderData()later have to refactor route signatures from(params, data) => viewto(params) => view + loaderData(). Trade-off documented; both shapes will coexist (the helper just passes the data positionally regardless).
Neutral:
- Helpers live in
@purityjs/corerather than a separate@purityjs/routerpackage. Reasoning: they depend onlazyResource,when,getRequest— all in core. Splitting them out would require new package + shared types + wiring; no benefit until the framework grows multiple router options. - The composer always fetches on render — no caching layer between
lazyResourceand the manifest. Apps that want caching wrap the helpers themselves or useresource()'s revalidation primitives (when they ship — see "non-features"). - Tests cover both helpers in unit form plus an integration test
via
renderToStringagainst a stub manifest. The example's smoke-test (4 routes via prod server) doubles as integration coverage.
Alternatives considered
Bundle the dispatch into one routerDispatch(routes, notFound?, options?) helper. Replaces the consumer's whole for loop with
one call. Rejected: locks app authors out of pre-/post-route hooks
(auth gates, A/B routing, logging). The visible for loop is
three lines and worth keeping.
Custom-element form <purity-route entry=…>. Wraps the
function form in a Custom Element. Composes with <head> /
suspense in declarative templates. Rejected for Phase 1: function
form is what the existing example already uses; adding the
element form before usage data shows it's worth doubles surface
area without proven payoff.
Merge asyncRoute + asyncNotFound into one helper that takes
either entry shape. Single name, two paths. Rejected: 404 has no
params and no layouts; encoding both contracts in one helper
forces every caller to pass null somewhere. Two named helpers
read better.
**Take a manifest argument that bundles routes + notFound
- a fallback.**
asyncRoute(manifest, params)introspects the URL itself. Rejected: hides the matchRoute call. Apps that want full control of routing (which they always will eventually) lose visibility. Composing the for-loop with the per-entry helper is the right granularity.
Accept entry as the user's import path ('./pages/users/[id].ts')
and import internally. Replaces importFn with a string path.
Rejected: defeats the manifest's purpose (the plugin already
generated the lazy importFn). Strings would also force the
helper to resolve paths — that's bundler magic that breaks at
runtime.
Make the loader-context shape configurable. Pass a
buildContext(args) => unknown option that the helper passes to
the loader instead of { request, params, signal }. Rejected:
the documented LoaderContext shape is the convention from ADR
0022; deviating per-call defeats the convention. Apps with a
different shape build a thin wrapper around asyncRoute or
write their own composer.
Implement caching in the composer (per-pattern memoization).
A Map<pattern, ViewFactory> so the second navigation to the
same route reuses the resolved factory. Rejected: the composer
holds no persistent state across renders; caching belongs in
resource() revalidation. Adding a cache here is premature.