0024: SSR-aware lazyResource.fetch() — register pending promises with multipass
Status: Accepted Date: 2026-05-11
Context
resource() already participates in the SSR multipass cycle: when
called inside a renderToString / renderToStream / renderStatic
pass, it pushes its fetcher's promise onto
ssrCtx.pendingPromises and stashes the resolved value in
ssrCtx.resolvedDataByKey (or the positional resolvedData for
unkeyed instances). The renderer awaits the pending set between
passes; pass 2 reads the cached value and renders synchronously.
lazyResource() is built on top of resource():
export function lazyResource<T, A = void>(fetcher, options) {
const argsState = state<{ value: A } | null>(null);
const r = resource(
() => argsState(), // ← source = argsState; null skips fetch
(wrapped, info) => fetcher(wrapped.value, info),
options,
);
r.fetch = (a: A) => argsState({ value: a });
return r;
}
The lazy gate (argsState starts null → resource() sees falsy
source → skip) is the whole point: nothing fetches until the user
calls .fetch(args). But on the server this is a silent footgun:
- Pass 1:
resource()seesargsState()is null →skip = true→ no promise registered. - User code calls
r.fetch(args). This setsargsState, queues a microtask to fire the reactive watch. The SSR pass returns before the microtask flushes. - Renderer awaits
pendingPromises(still empty), assumes nothing to wait for, ships the synchronous pass-1 markup — which is the suspense fallback becauser.data()was stillinitialValue.
The examples/ssr/ manifest migration hit this directly. The
user-land composer pattern
const stack = lazyResource(() => loadStack(entry, params));
stack.fetch();
return suspense(
() => when(() => stack.data(), Stack),
() => fallback,
);
ships the fallback on every SSR response. ADR 0023 made when()
SSR-safe, but the underlying resource doesn't block the renderer.
Hand-waved past in ADRs 0020-0022; documented as a Migration
Finding in the handoff. This ADR closes it.
Decision
When r.fetch(args) is called inside an SSR render context,
bypass the argsState/watch plumbing and engage the SSR multipass
path directly. Fire the fetcher synchronously, push the resulting
promise onto ssrCtx.pendingPromises, and cache the resolved value
in ssrCtx.resolvedDataByKey. On the second pass, the same
r.fetch(args) call sees the cached value and writes it through
r.mutate(). Outside SSR, the existing argsState-based behavior is
unchanged.
// User-side, unchanged:
const stack = lazyResource(() => loadStack(entry, params), {
key: `route:${entry.pattern}`, // required for SSR support
});
stack.fetch();
// Now correctly blocks the SSR renderer until loadStack resolves.
// Pass 2 sees the resolved factory; the view renders with real data.
Concretely:
- A
keyoption is required for SSR registration. Without one, the lazy fetch silently falls back to the existing client-only behavior (argsState gate, no SSR registration). Positional indices (resourceCounter) don't work here: theresource()underneath ran during creation when argsState was null and incremented the counter for itself; reusing that index for the lazy fetch would collide. - Behavior under the SSR path:
- Pass 1 — key absent from
ssrCtx.resolvedDataByKey. Callfetcher(args, { signal })(a freshAbortControlleris created — the abort never fires during SSR but the signature is preserved). Wrap inPromise.resolve(…)so sync returns are handled. Push the promise ontopendingPromises. The promise's.thenwritesssrCtx.resolvedDataByKey[key] = value(and theErrorsmirror on rejection). - Pass 2 — key present in
ssrCtx.resolvedDataByKey. Callr.mutate(value)immediately so the lazyResource'sdataaccessor returns the resolved value within the synchronous pass. The argsState write is skipped (it would fire the client watch, which is harmless but redundant).
- Pass 1 — key absent from
- Outside SSR — unchanged.
argsState({ value })fires the reactive watch;resource()underneath runs its fetcher; the client navigates / readsr.data()reactively. - Errors propagate. On pass 1 rejection, the error is stored
in
ssrCtx.resolvedErrorsByKey[key]parallel toresource()'s existing behavior. Pass 2 callsr.mutate(undefined)and the resource'serror()accessor surfaces the cached error through the same mechanismresource()uses.
Explicit non-features
- No automatic key generation. Apps that want SSR support
pass
key. Generating a stable key from the fetcher function's source / identity would work for unique closures but breaks when the same lazyResource is created in multiple call sites (e.g. inside a route-iteration loop), so explicit keys are the only contract that scales. Documented; consistent withresource()advice from ADR 0004. - No retroactive registration. If the user creates a
lazyResourcebut doesn't call.fetch()during the SSR pass, no promise is registered. That matches the lazy contract — the framework can't fetch on the user's behalf. - No streaming-fetcher support. The fetcher returns
T | Promise<T>. Async iterators / ReadableStreams aren't recognised. Apps that need streaming usesuspense()(ADR 0006) inside the component, not loader-style pre-fetch. - No cross-resource dependency tracking. Each lazyResource is
registered independently. The renderer awaits
Promise.all; resources can't declare "wait for resource X before starting." Sequential dependencies happen inside one fetcher (await the earlier value, then derive). - No abort during SSR. The
AbortControlleris created so the fetcher's signature is consistent across server + client, butsignal.abortednever flips on the server. The renderer always awaits to completion; SSR doesn't "navigate away" mid- render the way the client does. - No
argssnapshot caching across passes. The args passed to.fetch()on pass 1 are the args used; pass 2's call has no way to express a different intent because the cache lookup is key-only. Apps that want re-fetch on changed args useresource(sourceFn, fetcher)instead — that's what it's for.
Consequences
Positive:
- Closes gap 2 from the manifest migration. The user-land
manifest-driven composer can stop using static imports — the
per-route lazy
importFn()works end-to-end on both server and client. - Symmetry with
resource(). Both primitives now block the SSR renderer the same way; the user picksresourcefor declarative data +lazyResourcefor imperative refetch without a different SSR story. - Composes with ADR 0023. The user's
when(() => stack.data(), …)pattern (made SSR-safe by 0023) now seesstack.data()return real values on pass 2; the SSR markup includes the resolved view, not the suspense fallback. - ~30 LOC of new code; one new code path inside
r.fetch(). No new exports.
Negative:
- SSR support is opt-in via the
keyoption. Apps that forget to pass a key (or use a non-unique key) will see the same ships-fallback behavior they did before. Documented; the alternative (auto-generated keys) is worse on every dimension. - The lazy gate's "nothing fires until
.fetch()" semantics shifts slightly: on the server,.fetch()synchronously starts the fetcher and pushes a promise. The user sees no observable difference (the resource's.data()accessor is stillundefineduntil the promise resolves) but the timing changes for instrumentation. - One more synchronous fetcher call per pass-1
.fetch(). Negligible — the fetcher would have run on the client anyway; this just moves the start to the SSR render frame.
Neutral:
- No new exports.
lazyResourcekeeps its existing signature. Thekeyoption already exists onResourceOptions<T>(ADR 0004); this ADR extends its responsibility from "client cache key for hydration" to "SSR multipass cache key + hydration". - Tests cover both pass-1 (promise registration) and pass-2 (cached-value mutate) paths, plus the error mirror.
- The example
examples/ssr/src/app.tsmigration documented in the handoff can now drop its static-import workaround. Done in a follow-on edit to this ADR's commit.
Alternatives considered
Auto-register on creation, not on .fetch(). Run the fetcher
during lazyResource(...)'s synchronous body and push the promise
immediately. Rejected: defeats the lazy contract — apps would
fetch data they never use, especially in code paths where the
resource is conditionally returned (e.g. inside a matchRoute
loop's else branches).
Eagerly resolve argsState and let the existing resource()
SSR path handle it. Make r.fetch(args) flush the microtask
queue synchronously so the watch fires within the SSR pass.
Rejected: synchronous flushing isn't safe — it can reorder
unrelated reactive updates and breaks debouncing / batching
invariants. The existing resource() SSR registration would
fire correctly but only after a fragile global state mutation.
Introduce a separate ssrLazyResource(fetcher, opts) primitive
and leave lazyResource alone. Two names, two test surfaces,
same fetcher signature. Rejected: forces the user to know which
to pick. The "register with SSR if SSR context is active" check
is the same one-line dispatch that closed ADR 0023 — applying it
to lazyResource costs nothing and avoids API duplication.
Add an ssrAwait(promise) primitive that pushes a promise onto
pendingPromises regardless of which resource owns it. Lets
users wire SSR-aware behavior into any async machinery, not just
lazyResource. Rejected for Phase 1: the framework can't know how
to thread the resolved value back into the user's render without
the resource's signal plumbing. ssrAwait would only block the
renderer, not feed the result anywhere — apps would re-fetch in
the component to get the resolved value back. Net loss.
Require an explicit ssrAwait: true option on lazyResource().
Make SSR registration opt-in twice — once via key, once via
the flag. Rejected: noise. Having key set + being inside an SSR
context is unambiguous intent.
Auto-generate the key from the fetcher's source string. The
fetcher function's .toString() could seed a hash. Rejected:
fragile (different bundlers minify differently, JS engines might
de-duplicate identical strings into one reference, identical
fetcher bodies at different call sites collide). Explicit key
remains the only correct contract.