0023: Isomorphic conditional primitives — when / match / each SSR auto-detect
Status: Accepted Date: 2026-05-11
Context
Purity ships two parallel families of control-flow helpers:
- Client / hydration-aware:
match(),when(),each(). They build DOM viadocument.createCommentand friends; from inside ahydrate()call they return deferred handles that the hydrate factory routes through marker-walking adoption. - Server-only:
matchSSR(),whenSSR(),eachSSR(). They emit string-builder output (SSRHtmltagged strings) suitable for the SSR codegen pipeline. No DOM reference.
The split predates ADR 0019's file-system manifest. While the
template compiler picks the right one inside html template
interpolations (SSR codegen swaps each → eachSSR and so on),
user code outside templates always reaches for the unsuffixed
name. The recent examples/ssr/ migration to the file-system
manifest (ADRs 0019-0022) hit this directly:
function App() {
for (const entry of routes) {
const m = matchRoute(entry.pattern);
if (m) {
const stack = lazyResource(() => loadStack(entry, m.params));
return when(
// ← crashes on the server
() => stack.data(),
(Stack) => Stack(),
() => html`<p>loading…</p>`,
);
}
}
}
when() calls document.createComment immediately on the SSR pass
and crashes with ReferenceError: document is not defined. The fix
is straightforward — call whenSSR instead — but discoverability is
poor. Users reach for when first; the framework error is opaque
("document is not defined at line 1285 of compiled bundle"); the
fix requires reading the ADR / source / SSR docs.
The right Phase-1 step: make the unsuffixed names auto-detect the
SSR render context and dispatch to the SSR variant when present.
Existing explicit whenSSR / matchSSR / eachSSR keep working
(they're the canonical names the template codegen targets).
Decision
Add an SSR-context check to match(), when(), and each() that
dispatches to matchSSR / whenSSR / eachSSR when getSSRRender Context() is non-null. Existing *SSR exports stay (the template
compiler still emits them; user code that already uses them keeps
working). The unsuffixed names become isomorphic — safe to call
from any execution context.
The dispatch order inside match() (and the parallel logic in
when() / each()) becomes:
- Hydration mode —
isHydrating()→ return a deferred handle so the hydrate factory can adopt against SSR markers (unchanged). - SSR render context —
getSSRRenderContext() !== null→ dispatch to the SSR variant (matchSSR/whenSSR/eachSSR) returningSSRHtml. New. - Default — client DOM construction (unchanged).
Concretely:
- Type signature change is additive: each function's return type
grows
| SSRHtml.match():DocumentFragment | DeferredMatch<T>→DocumentFragment | DeferredMatch<T> | SSRHtmlwhen():DocumentFragment→DocumentFragment | SSRHtmleach():DocumentFragment | DeferredEach<T>→DocumentFragment | DeferredEach<T> | SSRHtml
- No runtime cost when not in SSR: one extra
getSSRRenderContext()call per invocation, which is a single module-scope variable read. Negligible vs. the surroundingdocument.createCommentpath. - No behavior change for explicit
*SSRcallers: the codegen and any user code that imports the suffixed names continues to call them directly without going through the dispatch. - Hydration check still fires first:
isHydrating()precedes the SSR check so deferred-handle adoption (ADR 0005) keeps working in the hydration codepath. Hydration only ever runs on the client; the SSR check below it is reached only outside hydration. - Cases / mapFn signature compatibility:
MatchView(the client case type) is() => Node | DocumentFragment | string;matchSSRaccepts() => unknown. The intersection is the client type, so passing client-shaped cases intomatchSSRtypechecks. Same foreach'smapFn.
Explicit non-features
- No deprecation of
whenSSR/matchSSR/eachSSR. The template codegen still emits them; the explicit names remain useful for code that wants a guaranteed syncSSRHtmlreturn without theDocumentFragment | SSRHtmlunion. Keep both. - No SSR-aware
lazyResource. Theexamples/ssr/migration surfaced TWO gaps; this ADR addresses only the conditional- primitive one.lazyResource()not registering with the SSR multipass context is a separate ADR (Phase 2 of the runtime composer story). Without that fix,when()is now safe in SSR but the user-land async-route pattern still ships the suspense fallback because the resource doesn't block the renderer. - No
asyncRoute()/loaderData()runtime helpers. Those are the higher-level composer + context-data primitives the manifest migration's static-import workaround motivates. They depend on thelazyResourceSSR fix landing first. - No automatic
list()SSR dispatch.list()(and itslistSSRcounterpart) follows the same pattern but isn't called from outside-template user code in the wild yet. Add when needed — the same dispatch line drops in trivially. - No
each()mapFn return-type widening toSSRHtml. Inside the SSR dispatch path,eachSSRalready accepts a() => unknownmapFn. The unsuffixedeach()still types its mapFn as returningNode | DocumentFragment | string; passing a function that returnsSSRHtml(e.g. nestedhtml\`` template output on the server) was always allowed at runtime via the string branch and stays that way.
Consequences
Positive:
- Closes one of the two ergonomics gaps the manifest migration
surfaced. Users writing manifest-driven composers now reach for
when()and get correct SSR behavior automatically. - Zero breakage. Both name sets keep working; existing tests + examples don't change.
- Simplifies the mental model. The
*SSRnames become an optimization tool ("I want a guaranteed syncSSRHtmlreturn") rather than a correctness requirement. - Composes cleanly with ADR 0005's deferred-handle adoption — the hydration check keeps firing first, so client-side adoption of per-row / per-case SSR markers is unchanged.
Negative:
- Return types widen to a 3-way / 2-way union. Most call sites are
inside
htmltemplate interpolations where the union is consumed byvalueToHtml(or the DOM-builder equivalent), which already handles every member. Top-level user calls that need a specific concrete type need a cast or pick the explicit*SSRvariant. - One extra function-call per invocation in the client path
(
getSSRRenderContext()returns null synchronously). Negligible but real on hot paths. - Doesn't fully fix the manifest migration. The static-import
workaround in
examples/ssr/src/app.tsstays —lazyResourcenot registering with SSR multipass is the harder-to-fix half.
Neutral:
- No new exports. The change is to the bodies of three existing
functions plus their TS signatures. The
*SSRnames already shipped (ADR 0005); they keep their public-API status. - The SSR detection adds a new code path in three functions but doesn't add a new module / file. Tests for the dispatch live alongside the existing each/match/when test files.
Alternatives considered
Single match / when / each set with the SSR variants
deleted. Cleanest from an API-surface standpoint; rejected because
the template codegen targets the explicit *SSR names. Renaming
those would require rewriting the compiler's call sites and breaking
any user code that already imports the SSR names (used in
examples/ssr/src/app.ts's old layout via eachSSR).
**Compiler-time rewrite of when / match / each in user code
(outside html\`templates) for SSR builds.** The Vite plugin already swaps codegen modes viatransformOpts.ssr`. Extending it to
rewrite top-level user code calls would close the gap at build time.
Rejected: the rewrite needs an AST parser pass to identify top-level
vs. inside-template calls vs. references in dead code, and gives no
benefit over the runtime dispatch which is one-line. A future ADR
can add it as an optimization if call-site overhead matters.
Throw a clearer error in the client when() when document is
undefined. Detect the missing global, throw Error("when() called during SSR — use whenSSR or import { when } from '@purityjs/core/ssr'"). Rejected: still requires the user to fix
each call site. Auto-dispatch is the same one-line check that
does the right thing instead of just diagnosing.
Move the SSR variants to a @purityjs/core/ssr subpath import.
Forces explicit "I want SSR" via the import path. Rejected: the
codegen's SSR mode still needs to emit the names; bare-string
imports are easier to maintain than subpath imports. The ADR-0005
explicit-name convention is fine.
Make each()'s SSR dispatch use listSSR instead of eachSSR
when the mapFn produces a flat list of single-tag rows. Skip — the
detection is itself non-trivial (inspect the mapFn's return shape?
require an opt-in flag?). Apps that want listSSR's codegen
optimization opt in by calling list() explicitly.