Async SSR + Suspense — design
Status: design / pre-implementation
Owner: M6.3+ ladder (closes the Async / Suspense / streaming SSR row in docs/DEFERRED.tu)
Tasks: #59 (this doc) → #60 (async render) → #61 (Suspense) → #62 (streaming)
1. Why now
The language already has async / await / dynamic import() (landed M6.6). The SSR runtime does not — renderToString, renderPage, renderPageHtml walk the vnode tree synchronously and have no concept of “this child is a promise”. A Tu component written as
let UserCard = async (id: string) => async {
let user = await fetchUser(id)
div { user.name }
}
compiles cleanly today (M6.6), but invoking UserCard("alice") returns Promise<VNode>. renderToString would coerce that promise to [object Promise] via String(node) and emit literal garbage.
Closing the gap unlocks tu-shu pages that load data at build time, a real-world SSR story for Tu apps, and a clear path to streaming. tu-shu/build.ts is already an async function — its IO + import() of compiled .mjs are async — but the actual render call (renderToString(Page())) is sync, so a Page that wants to await something has nowhere to land that promise.
2. Non-goals (this ladder)
- Resumability — Qwik-style “client never re-runs the first-frame thunk after hydration” is its own deferred row. Tu’s hydrate adopts DOM but still re-executes the thunk. This design keeps that contract.
- Per-cell-read fine-grained reactivity — orthogonal; stays on its own row.
- Compiler changes — this design is a runtime-only change. M6.6’s async lambdas already compile
async () => …to TSasync () => Promise<…>andawait xtoawait x. The renderer only needs to learn to await Promise children. - DOM-side
mount(asyncThunk)— interesting but separable; CSR async loading is closer to Suspense-on-mount and goes in a follow-up row, not this ladder.
3. Architecture in one paragraph
Child grows one new variant: Promise<Child>. The sync renderer rejects it (typed error, not silent garbage). A new renderToStringAsync walks the same shape but awaits any promise it meets. A Suspense primitive — implemented as a sentinel vnode tag: '$suspense' carrying { fallback, children } — gives the async renderer a place to fall back to when its subtree has unresolved work, and gives the streaming renderer a flushable boundary. Streaming layers on top: same render walk, but yields ready segments early and emits resolved boundaries later as <template> + replacement script (the React-18 / Marko pattern).
4. Data model
4.1 Child becomes promise-aware
// @tu-lang/runtime
export type Child =
| VNode
| string
| number
| null
| undefined
| Child[]
| Promise<Child> // NEW
Sync renderToString (unchanged signature, unchanged perf):
export function renderToString(node: Child): string {
// … existing branches …
if (isPromise(node)) {
throw new TuRenderError(
'renderToString hit a Promise child. Use renderToStringAsync, ' +
'or wrap the boundary in <Suspense>.'
)
}
// …
}
The throw is intentional: we never want a sync caller to silently emit <div>[object Promise]</div>. Sync callers (the playground’s compile-and-mount, every existing test) keep their sync contract; if they hit a promise it’s a bug surfaced at the right moment.
4.2 $suspense sentinel
Same shape pattern as $static (M6.0): the runtime understands a magic tag:
// runtime helper, exported as `Suspense`
export function Suspense(opts: { fallback: Child }, children: Child[]): VNode {
return { tag: '$suspense', props: { fallback: opts.fallback }, children }
}
Tu call-site (M6.1 named-arg form):
import { Suspense } from "@tu-lang/runtime"
Suspense(fallback: div { "Loading…" }) {
UserCard("alice")
}
The body of Suspense { … } is the children arg by Tu’s component-block convention; fallback: is a named prop, evaluated eagerly to a Child (promise here is allowed and would itself resolve before fallback is needed — matches React semantics).
5. renderToStringAsync
export async function renderToStringAsync(node: Child): Promise<string> {
if (node == null) return ''
if (typeof node === 'string') return escapeText(node)
if (typeof node === 'number') return String(node)
if (isPromise(node)) return renderToStringAsync(await node)
if (Array.isArray(node)) {
// Resolve children in parallel — independent subtrees should not serialize.
const parts = await Promise.all(node.map(renderToStringAsync))
return parts.join('')
}
return renderVNodeAsync(node)
}
renderVNodeAsync mirrors renderVNode but awaits child rendering. The static-HTML fast path (tag === '$static') stays sync — those subtrees are by-construction promise-free.
The Suspense branch in renderVNodeAsync:
if (node.tag === '$suspense') {
const fallback = node.props.fallback as Child
try {
return await renderToStringAsync(node.children)
} catch (err) {
// v1: any throw inside the boundary → emit fallback. (#61 widens this:
// distinguish "render error" from "intentional pending throw" so the
// streaming variant can flush the resolved body later.)
return await renderToStringAsync(fallback)
}
}
Parallelism: a single Suspense’s body subtree resolves in parallel via the Array.isArray branch’s Promise.all. Two siblings each containing their own Suspense run in parallel too. The only serialization is across textually-nested awaits — same as raw JS.
6. renderPageAsync / tu-shu wiring
export async function renderPageAsync(
thunk: () => Child | Promise<Child>,
options: RenderPageOptions = {}
): Promise<string> {
const body = await renderToStringAsync(await thunk())
return assemblePage(body, options)
}
Note thunk itself may be async — Tu’s let Page = async () => … compiles to an async lambda, so calling it returns a promise.
tu-shu/build.ts change: replace renderToString(Page()) with await renderToStringAsync(Page()) and switch to renderPageHtmlAsync if we add one (probably not — assemblePage is already pure-string and only the body needs awaiting).
tu-shu/tu-page.ts already lives inside an async function. The current code calls Page() and passes the result through sync renderToString. After #60 it becomes:
const vnode = Page()
const html = await renderToStringAsync(vnode as never)
— one keyword. Pages that don’t await still walk through the async path with no extra microtask cost beyond the outer await (the inner walk hits no promises and returns its string immediately).
7. Streaming (#62 outline)
export function renderToStream(
thunk: () => Child | Promise<Child>,
options: RenderPageOptions & { onShellReady?: () => void } = {}
): ReadableStream<Uint8Array>
Strategy (mirrors React 18 / Marko):
- Open the stream by emitting the assembled
<!doctype html><html>…<body>shell +<div id="tu-root">. - Walk the tree synchronously up to each
$suspenseboundary. For each pending boundary:- Emit the fallback content wrapped in
<div data-tu-suspense="N">…</div>. - Schedule the boundary’s body resolution; on completion, push:
<template id="S:N">…resolved body…</template> <script>$tu_replace("N")</script>
- Emit the fallback content wrapped in
- When all boundaries resolve, close the body + html.
The replacement script $tu_replace is a tiny client polyfill (~15 lines) that runs in the user’s HTML before hydration; it replaces the data-tu-suspense="N" div’s contents with the template’s children. Hydration (in @tu-lang/dom) is unaware — by the time hydrate(thunk, root) runs on DOMContentLoaded (or on a streamReady event we emit), the DOM looks like a full SSR result.
Out of scope for #62: backpressure tuning, abort-controller plumbing (cancel mid-stream), <Suspense> revealOrder modes — all on a follow-up row.
8. Hydration story
hydrate(thunk, container) re-executes thunk on the client. If thunk is an async component, the first frame is the SSR DOM (already adopted), and the second frame happens after the client thunk’s promise resolves. Two cases:
Case A: client data matches server data. The async resolution produces the same vnode tree the server emitted. patchChildren is a no-op on identity. Net effect: SSR DOM intact, listeners attached, no flash.
Case B: client data diverges (e.g. user-personalized vs static). Standard hydration mismatch path applies — runtime logs [@tu-lang/runtime] hydration mismatch: … and patches to the new tree. Same as today.
For non-streaming async SSR (#60+#61 only, no #62), the client never sees a fallback — server already awaited everything. So hydration is unchanged from today.
For streaming SSR (#62), the client might see a fallback if it executes scripts before the boundary’s <template> arrives. Mitigation: the auto-injected $tu_replace runs on each template’s parse, so by the time the user’s hydrate() call fires (at end-of-body or DOMContentLoaded), all reachable templates have replaced their fallbacks. Boundaries that take longer than DOMContentLoaded keep their fallback in the SSR DOM and get adopted as-is by hydrate — fine, the next async resolution will swap them.
9. Error handling
Two failure modes inside an async render:
- Promise rejection inside a Suspense boundary — caught, fallback emitted (v1). Future: an
onErrorprop. Nothing escapes the boundary; the rest of the page renders. - Promise rejection outside any Suspense boundary — propagates out of
renderToStringAsync.tu-shu/build.tswill see it as a thrown error, log the page path, and exit non-zero. We do NOT auto-wrap the page root in a Suspense; that’s the user’s call.
Synchronous throws (existing behavior) are unchanged.
10. Backwards compatibility
renderToString: signature unchanged. New behavior: hitting aPromisechild throws (was: silentlyString(promise)-stringified to[object Promise]). This is a strict improvement and the only way to discover an accidentally-async component in a sync caller.renderPage/renderPageHtml: unchanged. Sync callers keep working.Childtype: gainsPromise<Child>. Existing TS consumers who narrowed on the sync variants need to add aisPromiseguard if they want to handle the new case; otherwisetscwill widen their unions and the existing unreachable branches stay unreachable.@tu-lang/dommount/hydrate: NO change in #60-#61. Streaming hydration glue (#62) adds the$tu_replaceinline script; nothing breaks for non-streaming users.
11. Test plan
Per phase:
#60 — renderToStringAsync
- Promise resolving to a vnode — renders inner.
- Nested promise (Promise → vnode containing Promise) — both await.
- Array of promises —
Promise.all-style parallelism (verify with timed promises). - Reject — propagates.
- Sync
renderToStringon a Promise child — throwsTuRenderError. - tu-shu loadTuPage — async Page export round-trips end-to-end.
#61 — Suspense
- Suspense wrapping a resolving promise — renders body, never fallback.
- Suspense wrapping a rejecting promise — renders fallback, body suppressed.
- Nested Suspense — outer fallback never fires when inner already covers.
- Sibling Suspense boundaries resolve independently.
#62 — streaming
- Single boundary, stream emits shell → fallback → template + replace.
- Two parallel boundaries — interleaved templates in resolution order.
- Stream consumed via
for await (const chunk of stream)round-trips identical bytes torenderPageAsynconce all boundaries resolve. - jsdom integration test: parse stream, run scripts, assert final DOM equals
renderPageAsyncoutput.
12. DEFERRED.tu impact
After #60+#61 land, the open row at docs/DEFERRED.tu line 30 narrows from
Async / Suspense / streaming SSR — M6.2 ships sync
renderPageonly. Tu has noasync/awaitsyntax. Streaming, per-route data prefetch, and Qwik-style resumability are future work.
to
Streaming SSR + resumability —
renderToStringAsyncandSuspenseship in M6.11. Streaming flush + Qwik resumability are future work.
After #62, the row narrows again to just resumability (or splits into two rows). Each phase removes the closed sub-bullet in the same commit, per the project rule.
13. Open questions (revisit during implementation)
- Should
Suspense.fallbackitself be allowed to throw / contain promises? React: yes, fallbacks can render — but our v1 emits fallback viarenderToStringAsync(fallback)which already handles both cases. Free. - Do we need a
renderPageAsyncAT ALL or justawait renderToStringAsync(thunk())+ existingrenderPageHtml? Yes — keeping symmetry with the sync API ladder. Implementing it as a 3-line wrapper is cheap. - Should the static-HTML optimization (
$statictag) participate in async — i.e. could a$staticbody be a promise? No:$staticis by definition compile-time-resolved markup. The compiler’sisStaticTreepredicate already excludes any subtree containing component invocations, so it can’t accidentally include an async component. - Compile-time error if a Tu component is
asyncbut the call-site is in a sync renderer? Not for v1 — runtime throw is sufficient and the error message can recommendrenderToStringAsync. Compile-time check would need cross-module flow analysis (M3-territory) and isn’t worth blocking on.