Tu Language Reference
A practical reference for every Tu syntactic form that compiles today. Covers
the current pre-alpha line (0.1.0-alpha.8): async / Suspense / streaming
SSR, runtime type metadata, structured Exceptions, filtered catches,
Tu-native router, and the shared LSP path used by VS Code and the playground.
See DEFERRED for work still in flight.
Tu is a single-pass, expression-oriented language that compiles to a small
ESM module per .tu file. The compiler also emits a TypeScript shadow with
preserved type annotations and an inline V3 source map. Every top-level
binding can be read from JS / TS via standard import.
File structure
A .tu file is a sequence of top-level statements separated by whitespace.
There’s no statement terminator (no ; at the top level; ; is reserved
for type-spans only).
Allowed top-level forms:
let X = …— module-private value bindingexport let X = …— public value bindinglet X: T = …/export let X: T = …— annotated bindinginterface X { … }/export interface X { … }— object shapetype X = …/export type X = …— erased alias for non-object unions/tuplesenum X { … }/export enum X { … }— frozen value object + TS value-union typeException X { … }/export Exception X { … }— Error-compatible factory + runtime descriptorimport { … } from "./other.tu"— named importimport X from "./other.tu"— default importexport { … } from "./other.tu"— named re-export
Comments use // (line) — there is no block-comment form yet.
// A complete file:
import { Card } from "./Card.tu"
export let count = 0
export interface Pair { x: number, y: number }
export let App = () => div { Card(title: "hi") }
Bindings
let X = value
let declares a top-level binding. The value is evaluated at module
initialization. The classification (state / computed / function) drives how
reads compile:
let X = (…) => …— lambda.Xreads as a plain identifier; calls invoke the function as expected.let X = computed(expr)— computed cell.X.get()returns the derived value; the runtime auto-tracks dependencies and invalidates on change.let X = anything else— state cell. Wraps the value inSignal.State;X.get()reads,X = newValwrites (codegen rewrites toX.set(newVal)).
let count = 0 // Signal.State<number>
let doubled = computed(count * 2) // Signal.Computed<number>
let inc = () => count = count + 1 // function
let { a, b } = source // two state cells: a, b
Reads inside a lambda body that reach back to a top-level state / computed
cell get an automatic .get() injection:
export let App = () => p { count }
// emits: () => h("p", {}, [count.get()])
A lambda parameter shadowing a top-level cell stays as a plain identifier:
let name = "outer"
export let G = (name: string) => p { name }
// emits: (name) => h("p", {}, [name]) -- no .get()
export let X = value
Same as let, but the binding is part of the module’s public surface. Only
export let declarations appear in the emitted .d.ts.
Annotated bindings
Add : T between the name and = to give the binding an explicit TS type.
The annotation is captured as a raw source slice and threaded into the TS
shadow, with appropriate Signal wrapping:
| Tu source | Emitted TS const type |
|---|---|
let App: () => string = … |
const App: () => string |
let count: number = 0 |
const count: Signal.State<number> |
let total: number = computed(…) |
const total: Signal.Computed<number> |
let cell: Signal.State<MyShape> = … |
const cell: Signal.State<MyShape> (no double-wrap — codegen detects the explicit Signal prefix) |
The annotation is erased in JS-mode emission.
Module-scope destructuring
let { a, b } = source is a flat object destructure at the top level.
The RHS evaluates once, then each field becomes its own state cell. Reads
use normal .get() injection. MVP scope is private bindings only: no
export let { … }, nested patterns, renames, defaults, or arrays.
Local let inside a block
A let X = expr written inside a block body declares a block-scoped
const — it does NOT become a Signal cell. Useful for closures and small
computations:
let Greet = (name: string) => {
let greeting = "Hello, " + name + "!"
let upper = greeting // any plain JS expression
p { upper }
}
Local lets shadow same-named top-level cells inside that block (reads
emit as bare idents, no .get() injection). Type annotations are
supported via the same raw-slice mechanism.
Interfaces and aliases
interface Pair { x: number, y: number }
type RGB = readonly [number, number, number]
export enum Color { Red = "red", Green = "green", Blue = "blue" }
Use enum when the values are meaningful at runtime (Color.Red) and a
type annotation should accept those values. Use interface for object
shapes. Type aliases still work for tuples and ad hoc unions; they erase
entirely from JS-mode output.
type is a contextual keyword: it triggers only when followed by Ident =
at statement boundary. So a lambda param named type still works:
export let f = (type: string) => p { type }
Enums
export enum Tone {
Neutral
Accent = "accent"
Danger = 3
}
Enums emit a frozen runtime object and, in TS shadow output, a like-named
value-union type. Use Tone.Accent at runtime and tone: Tone in
annotations. Omitted member values default to the member name as a string.
Exceptions and runtime type metadata
import { type } from "@tu-lang/std"
interface User { id: number; name: string }
Exception ValidationError { field: string }
let parseUser = (raw: unknown): User ? ValidationError => {
if (type.is(raw, User)) { raw }
else { throw ValidationError("Invalid user", { field: "user" }) }
}
let label = (raw: unknown) => try {
parseUser(raw).name
} catch if ValidationError as e {
"ValidationError on " + e.field + ": " + e.message
} catch e {
"Error: " + e.message
}
Interfaces and Exceptions emit runtime descriptors in addition to TS shadow
types. type.is(value, User) is a structural runtime check and a TS/LSP type
predicate, so values narrow inside guarded blocks. Anonymous object shapes
that exactly match a nearby named interface are reused in editor hovers when
possible.
Exception Name { field: T } emits an Error-compatible factory. The first
argument is always the default message: string; the optional second object
supplies custom fields. A lambda may declare possible structured throws with
(): Result ? ValidationError | NotFoundError. The throws clause is
documentation and LSP signal; JS output still uses normal throw.
Prefer filtered catches:
catch if ValidationError as e { e.field }
catch e { e.message }
The fallback catch e binding defaults to an Error-like base type with
message: string. Legacy catch (e: T) remains accepted for compatibility,
but new code should use catch if T as e.
Values
Literals
"a string" // StringLit
42 // NumberLit
[1, 2, 3] // ArrayLit
[] // empty ArrayLit
{ x: 1, y: 2 } // ObjectLit
{ "data-id": 7 } // ObjectLit with string key
{} // empty ObjectLit
Strings support common escapes: \n, \t, \r, \", \\. Numbers are
integers only at the syntactic level (decimal-point parsing not in V1).
{ … } is disambiguated against the block form (see Blocks):
an opener of { }, { Ident :, { String :, { [expr] :, or
{ ...expr } parses as an object literal. Anything else ({ x },
{ let y = 1; y }, { tag(...) }) stays a Block. Shorthand
({ x } → { x: x }) is still not recognized; write the key explicitly.
When an object literal appears immediately after => in a lambda body, the
codegen wraps it in parens (() => ({ x: 1 })) so JS doesn’t read it as a
block.
Modern JS expression forms
`Hello ${name}` // template literal
user?.profile?.name ?? "Guest" // optional chaining + nullish coalesce
items[index] // indexed access
{ [field]: value, ...base } // computed key + object spread
[...items, next] // array spread
total += 1 // compound assignment
await loadUser(id) // async expressions
import("./Plugin.tu") // dynamic import
Tu supports these JS expression forms where they preserve Tu’s grammar
shape. instanceof is intentionally banned; use type.is(value, T).
Identifiers
A bare identifier reads the binding by that name. Resolution follows
JS-style lexical scope (lambda params, for binders, then top-level lets).
Member access
obj.x
make(n).field
nested.outer.inner
Postfix .Ident reads a property from any expression that yields a value.
The . here doesn’t collide with the prefix-dot ClassRef syntax —
class: .card keeps its existing meaning because the dot sits at
expression head, not after a returned value. Cell reads inject .get()
on the leaf ident only: origin.x compiles to origin.get().x when
origin is a state cell.
Arithmetic and comparison
a + b a - b a * b a / b a % b
a == b a != b // -- compile to JS strict ===, !==
a < b a <= b a > b a >= b
Pratt precedence: * / % > + - > comparison > equality.
Lambdas
(x) => x + 1
(x: number) => x + 1
(name: string, age: number) => p { name }
() => p { "hi" }
(x: number): string => "ok" // return-type annotation
(): Map<string, { v: number }> => empty // generics + nested types OK
The body is any expression (including a Block). Param and return type annotations preserve through TS-mode emission for inference. JS-mode strips both.
Calls
foo(arg, another) // CallExpr — positional args
Identifiers followed by ( and positional args (no Ident: at the front)
parse as call expressions. The result is whatever the function returns.
Blocks
{
someStmt
anotherStmt
finalExpr // value of the block
}
Blocks compile to an IIFE when there are 2+ statements (each non-final
statement evaluated for side effects, the final one is the block’s value).
A 1-statement block compiles to (stmt). Note that {} parses as an
empty object literal, not an empty block — write a one-statement block
explicitly (e.g. { null }) if you really mean “evaluate to an
intentional empty value.”
Markup (tag-calls)
Markup uses a trailing-closure DSL. Capitalization is the discriminator between HTML tags and user components (React/JSX convention):
- Lowercase identifier →
h("tag", props, children)(HTML element) - Uppercase identifier →
Callee(props)(component function call). tsserver sees the call as a real function — hover, goto-definition, and completion all work on the component name.
Bare tag with children
div { "hello" }
ul {
li { "a" }
li { "b" }
}
Tag with named props
button(onClick: handler, disabled: false) { "click" }
img(src: "logo.svg", alt: "logo")
A (Ident: …) opener disambiguates tag-calls from positional calls. With
no explicit-prop opener (and no children block), it parses as a positional
call instead.
Component invocation
Components use named props. Legacy positional calls still compile during
the alpha cycle, but the LSP and tu check warn so code can migrate
before removal.
1. Named-arg form — props delivered as a single object, like HTML:
let App = () => Card(title: "Alice", footer: "© 2026") {
p { "Body content" }
}
The receiver gets a single props arg; trailing children block is
auto-merged in as props.children:
let Card = (props) => .card() {
h2 { props.title }
props.children
if (props.footer) { footer { props.footer } }
}
All props are optional by construction — call Card(), Card(title: "x"), or Card(title: "x") { p { "body" } } all work.
Legacy positional form — deprecated M5.x shape. Calls like
Card("Alice") { ... } still emit unchanged for backward compatibility,
but editor and tu check diagnostics point at Card and ask for named
props. The parser
disambiguates by peeking the first token after (: an Ident : opener
triggers named-arg; anything else stays positional.
children is an array of children that the runtime’s flatten step
splices into the parent’s children list at render time. Use Child[]
(not VNode[]) for the type — Child = VNode | string | number | null | undefined | Child[] reflects the runtime contract, and a component
body that ends in a style block returns an array fragment, which
VNode[] would reject. Both Child and VNode are auto-imported from
@tu-lang/runtime in TS-mode emit, so the annotation resolves without an
explicit user import.
Fragment
Fragment { … } from @tu-lang/runtime lets a component return multiple
sibling vnodes without an enclosing wrapper element (React’s <>…</>):
import { Fragment } from "@tu-lang/runtime"
let Layout = (title: string, children: Child[]) => Fragment {
header { h1 { title } }
main { children }
footer { "© 2026" }
}
Pug-style class shorthand
.card { "body" } // <div class="card-tu-h">body</div>
.card.shadow() { "body" } // multi-class — class="card-tu-h shadow-tu-h"
.card(tag: "section") { "body" } // override default `div`
The leading .foo chains gather class refs; an optional (tag: "literal")
overrides the synthetic tag (must be a string literal). Adding an explicit
class: prop is a compile error — the shorthand already binds class.
Children
Inside a { … } children block, allowed shapes are:
- text (
StringLit,NumberLit) - identifier reads
- nested tag-calls / call-exprs
- arithmetic / comparison
if/for/style/ClassRef- array literals (flatten via the runtime)
Lambdas, bare blocks, and assignments are NOT valid as direct children — Tu rejects them at parse time.
Control flow
if / else
if (count == 0) { "no items" }
else if (count == 1) { "1 item" }
else { "many items" }
if is an expression. else is optional; a missing branch renders as no
child. Use else { null } when you want the empty case to be explicit.
Else-if chains stay flat.
for
for item in items {
li { item }
}
item is the loop binding. items must be iterable at runtime. Compiles
to Array.from(items, (item) => …). The iter-expression’s tail { … } is
NOT treated as a tag-call children block during this parse — the trailing
brace belongs to the loop body.
try / catch / finally
try {
risky()
} catch if ValidationError as e {
e.field
} catch e {
e.message
} finally {
cleanup()
}
try is an expression. Filtered catches lower to a single JS catch with
type.is dispatch, and the LSP narrows the bound error inside each branch.
Use throw SomeException("message", { field: "x" }) for Tu Exceptions or
normal JS Error values for fallback errors.
Reactivity
Tu wraps non-lambda top-level lets in TC39 Signal.State. Reads inside
lambda bodies auto-inject .get(); writes via = rewrite to .set(…):
let count = 0
let inc = () => count = count + 1 // emits: count.set((count.get() + 1))
computed(expr) cells are invalidated when any cell read by expr
changes. Assigning to a computed cell is a compile error.
mount(thunk, container) from @tu-lang/dom wires a thunk into the DOM
and re-renders on cell mutation. The keyed diff reuses element identity
across renders (M1.7); LIS-based reorder minimizes moves on long-range
list shuffles (M1.15).
hydrate(thunk, container) (M4 V1) is the SSR counterpart: instead of
creating new DOM, it adopts an existing subtree (typically rendered by
renderToString on the server) and only attaches event listeners /
DOM-property props that SSR couldn’t serialize. Subsequent renders run
the normal patchChildren diff. Adjacent text vnodes that the server
fused into a single Text node are split during hydration so cell updates
can target individual fragments (p { "count = " count } keeps the
static prefix Text node untouched when count ticks).
defineCustomElement(thunk, tagName) (M4.1) registers a Tu thunk as a
standard Custom Element. The element mounts on connectedCallback,
stops on disconnectedCallback, and re-renders reactively while
connected. V1 caveats: the thunk’s reactive scope is the module’s
top-level cells (multiple instances share state), and HTML attributes
don’t auto-bind to Tu cells yet.
Style block
let App = () => {
div(class: .card) { "hi" }
style {
.card { padding: 1rem; }
}
}
A style { … } block emits an <style> HTML element sibling to the main
component vnode. The CSS body is preserved verbatim (Tu doesn’t parse CSS).
Top-level rules must be class-rooted (M5/D)
Every top-level rule’s selector list must start with . (a class
selector), :global(…) (escape hatch), or @ (at-rule like @media).
Element selectors at top level (p { … }) raise a compile error to
prevent global bleed:
style {
.card { padding: 1rem; } // ✅ class-rooted
.card .title { font-size: 1.25rem; } // ✅ compound, still rooted
:global(.legacy-modal) { z-index: 9999; } // ✅ escape hatch
@media (min-width: 600px) { … } // ✅ at-rule
p { color: red; } // ❌ compile error
}
For element selectors that only apply within a class, switch to CSS4 nesting (modern browsers handle natively):
.card {
padding: 1rem;
> h2 { font-size: 1.25rem; } // applies to .card > h2
&:hover { background: #eee; } // applies to .card:hover
}
Scoped classes (dual-name injection — M5/F)
When a component contains a style block AND any .classRef references
in the markup, Tu hashes every declared class with a per-component FNV-1a
suffix (-tu-{6 hex}). The markup carries both the original name and
the hashed one (space-joined); CSS selectors use the hashed form only:
let Card = () => {
div(class: .card) { "hi" }
// → class="card card-tu-a1b2c3" (original + hashed)
style { .card { padding: 1rem; } }
// → .card-tu-a1b2c3 { padding: 1rem; } (hashed only)
}
Two components declaring the same class name get different hashes — the
component-scoped styles don’t bleed. The unhashed name on markup lets
global CSS / dev-tool inspection / framework theming layers still target
.card if needed.
:global(.foo) escape hatch
style {
.card { padding: 1rem; }
:global(.legacy-modal) { z-index: 9999; }
.card :global(.icon) { color: red; }
}
Classes inside a :global(...) wrapper bypass the hash. The wrapper itself
is stripped from the output. Compound selectors mix freely:
.card :global(.icon) becomes .card-tu-h .icon.
ClassRef syntax
Outside a style block, .foo is a reference to a declared class. Three
shapes:
class: .foo— bind a single classclass: .foo.bar— bind multiple classes (space-joined at runtime).foo() { … }— pug shorthand: synthesizesdiv(class: .foo) { … }
ClassRefs to classes NOT declared in the surrounding component’s style
block raise a compile error. Outside any scoped component, ClassRefs are
also disallowed (no place to hash to).
Markdown block (M6.3)
markdown { … } is a special-form block — sibling shape to style { … } —
that lets Tu source mix prose alongside markup. The body is raw markdown;
the compiler runs it through markdown-it at build time and emits the
result as a single static-HTML vnode (M6.0 path), so there’s no markdown
parser at runtime.
let About = () => .page() {
h1 { "About" }
markdown {
Tu is a **reactive UI language** built around the trailing-closure
DSL pattern. See the [language reference](/LANGUAGE) for the full
syntax map.
- One bullet
- Another bullet
\`\`\`ts
const x: number = 42
\`\`\`
}
style { .page { padding: 1rem; } }
}
Notes:
- The block body is dedented before parsing so 4-space indents from surrounding Tu source don’t trip CommonMark’s “indented = code block” rule.
- Brace-balanced lexing:
{and}characters inside the markdown body must come in matching pairs; backtick-fenced code blocks (```) and inline backticks are skipped over so braces inside code don’t unbalance the count. - The output is wrapped in
<article class="tu-markdown">so consumers can style the prose container with one selector. - No interpolation in V1 — the markdown body is purely literal. Mixing
Tu cells into prose comes later (likely via a
${expr}form that splits the block into static + dynamic spans).
Runtime + platform packages
Tu’s runtime is split into two packages so a .tu file that doesn’t intend to run in a browser can never accidentally pull DOM impls (M6.10):
@tu-lang/runtime— the universal half.Signal,h,Fragment,VNode,Child,renderToString,renderPage,renderPageHtml,renderToStringAsync,renderPageAsync,renderToStream,Suspense,TuRenderError. Compiled Tu auto-importsSignal/h/Fragmentfrom here. Safe to use from Node, edge runtimes, Cloudflare Workers — anywhere without adocument.@tu-lang/dom— the browser half.mount,hydrate,defineCustomElement, plus typed re-exports of the standard DOM types your Tu code touches (Event,MouseEvent,HTMLInputElement,Node,Element,RequestInit,AbortController, …). Anything that touchesdocumentlives behind an explicitimport { … } from "@tu-lang/dom".@tu-lang/router— the DOM-free route layer.createRouterhandles static,:param, and*splatpatterns with deployment base stripping;renderRoute,renderRouteToString, andrenderRouteToStreamconnect matched handlers to the SSR runtime.
Typical browser entry:
import { mount } from "@tu-lang/dom"
import { App } from "./App.tu"
mount(() => App(), document.getElementById("app"))
Typical SSR entry:
import { renderPageAsync } from "@tu-lang/runtime"
import { Page } from "./Page.tu"
let html = await renderPageAsync(() => Page(), { title: "Hi" })
Typical routed SSR entry:
import { createRouter, renderRoute } from "@tu-lang/router"
const router = createRouter([{ path: "/users/:id", handler: ({ params }) => User({ id: params.id }) }])
const html = await renderRoute(router, "/users/alice", { title: "User" })
The split is enforced at the runtime-function layer today. Strict type-level isolation (dropping lib.dom from the LSP shadow so unused DOM globals can’t sneak in) is tracked in DEFERRED.
External JS escape hatch (M6.9 / M6.10.1)
For interop with browser APIs, third-party JS, or anything outside Tu’s grammar, declare a typed bridge with external JS:
let inputValueOf = external JS (e: Event): string {
const t = e.target
return t && 'value' in t ? String(t.value) : ''
}
let App = () =>
input(onInput: (e: Event) => state = inputValueOf(e))
The block body is raw JavaScript — Tu doesn’t parse it, just emits it verbatim into the compiled module. The signature (e: Event): string is a regular Tu type annotation and propagates into the TS shadow, so call sites get full type checking without Tu having to understand the body.
Use cases:
- DOM-level extraction that needs a cast Tu doesn’t have yet (
event.target→HTMLInputElement.value). - Native browser APIs not surfaced through
@tu-lang/dom(URL,crypto.subtle,IntersectionObserver, …). - Interop with non-Tu npm packages whose shape Tu’s TypeScript emit doesn’t import-friendly.
- Performance escape hatches where you want to drop into hand-written JS without leaving the file.
Inline object-shape return types are supported (M6.10.1):
let measure = external JS (xs: any[]): { ms: number; out: any[] } {
const t0 = performance.now()
const out = xs.slice().sort()
return { ms: performance.now() - t0, out }
}
The { after : is parsed as a type literal, not a body opener — the parser disambiguates by the immediately-preceding token.
Imports / exports
Named import
import { Card, Header } from "./Card.tu"
import App from "./App.tu"
Relative .tu paths are resolved by the compiler. Named and default
imports map to standard ESM import in the JS output (and .ts extension
in the TS shadow so tsserver resolves the sibling shadow file).
Re-export
export { Card } from "./Card.tu"
Same as import { Card } followed by export { Card }, but in one
statement and without binding Card locally.
Cross-.tu reactivity (M2.3)
When the LSP, tu check, or the @tu-lang/vite plugin compile a graph of
.tu files, they pre-classify each file’s export let bindings (state /
computed / function). The compiler uses that classification to inject
.get() for imported state cells, so:
// cell.tu
export let count = 0
// App.tu
import { count } from "./cell.tu"
export let App = () => p { count } // emits: count.get()
works as expected. Outside a graph context (the standalone compile()
call from a string), imports default to “function” classification —
which emits a bare ident at the read site.
Type system
Tu’s type system delegates to TypeScript via an emitted shadow file. Each
.tu compiles to:
- A JS module (runtime) — types erased
- A TS module (typecheck /
.d.tsemit) — types preserved
The TS shadow looks like normal TypeScript: Signal.State<T> cells,
Signal.Computed<T> cells, function lambdas with declared param types,
interfaces with typed runtime descriptors, Exception factories, type X = …
aliases, and (for exported lambdas with all-typed params) a synthesized
interface ${Name}Props { … }.
tu check <file> runs the shadow through tsserver and pretty-prints
diagnostics back at the .tu source. The @tu-lang/lsp package exposes the
same logic plus hover, completion, definition, and rename — all token-
ranged so squiggles / underlines target individual identifiers.
.d.ts emission via tsc --emitDeclarationOnly over the shadow gives
downstream TS consumers a clean declaration file with only the public
export let / export type surface.
What’s not in V1
See DEFERRED for the live list. As of M9 the notable gaps are:
- Generic syntax on Tu declarations —
interface Box<T>and generic component declarations remain deferred. - Lifecycle hooks + element ref sugar — no
onMount/onUnmount; no Vue-2-style explicitref. - File-based app router —
@tu-lang/routerprovides route matching and SSR helpers; Next.js-styleapp/[slug]/page.tudiscovery, layouts, loaders, and server functions remain deferred. - Per-component fine-grained HMR — full module re-import on save.
- Local reactivity — full thunk re-runs on cell mutation; per-binding patches are deeper rework.
- Style ↔ JS state interop — design pending; cells don’t auto-bind to CSS variables yet.
- Qwik-style resumability — hydrate re-runs the first-frame thunk; serialized listener references are future work.
Recent landed features
- Tu-native
@tu-lang/router— static, dynamic (:id), and catch-all (*slug) matching; deployment-base stripping; query parsing; fallback handlers; and SSR helpers. The playground now uses shareable URLs such as/tu/playground/types. - Shared browser/workspace LSP — VS Code and the live playground editor
use the same
@tu-lang/lspshadow-graph implementation for diagnostics, hover, completion, definition, references, and rename. - Runtime type metadata —
interfaceandExceptiondeclarations emit descriptors consumed bytype.of,type.is,type.as, andtype.tryFrom.type.isnarrows in editor hovers and TS diagnostics. - Filtered catch narrowing —
catch if ValidationError as enarrowseto the matching Exception, andcatch efalls back to an Error-like base. - Modern JS expression compatibility — template literals, optional chaining, nullish coalescing, indexed access, spread, computed object keys, dynamic import, async/await, compound assignment, exponentiation, and bitwise operators are supported.
What landed in M6.11 (async + SSR)
renderToStringAsync(node)— awaits anyPromisechild during the SSR walk.Childgained aPromise<Child>member; syncrenderToStringnow throwsTuRenderErroron Promise children (was silently emitting[object Promise]).renderPageAsync(thunk, options)— thunk may be a sync function or an async lambda; the body is rendered viarenderToStringAsyncand assembled into a complete HTML document.Suspense({ fallback, children })— boundary primitive that catches Promise rejections inside its body and emits thefallbackChild instead. Boundaries compose; syncrenderToStringof a Suspense renders fallback verbatim. Tu call-site is named-arg form:Suspense(fallback: div { "Loading…" }) { AsyncChild() }.renderToStream(thunk, options)— WebReadableStream<Uint8Array>ready to pipe into aResponse. Shell + sync body + per-boundary<div data-tu-suspense="N">…fallback…</div>placeholders flush first; resolved bodies stream later as<template id="S:N">…</template><script>$tu_replace("N")</script>chunks in resolution order. Rejected boundaries leave the placeholder (with its fallback content) in place.
Examples:
examples/ssr/— syncrenderToString+ client-sidehydrateround-trip.examples/suspense/— async + Suspense + streaming, both pipelines exercised end-to-end.
See docs/SSR-ASYNC-DESIGN.md for the design context behind these primitives.