ReviewOS

stacks/stx

architecture: streaming SSR + islands — opt-in client hydration instead of full-page hydration default

#1746
Closed glennmichael123 opened this 14 days ago · 7 comments
14 days ago

Problem

stx today hydrates the entire page on the client. Every component, even static content (a judge's name, a court's address, the article body), runs through processElement and gets reactive bindings. For a content site like bench-review, ~70% of the page is static after first render — the JS hydration is wasted bandwidth + execution time.

The article page on bench-review weighs ~300KB of JS (signals runtime + per-page scopes + components). Most of that exists to make ~12 interactive elements work (like button, comment composer, dropdown menus). The other 95% of the DOM doesn't need any of it.

Proposed: islands architecture

Components opt INTO client hydration. Default is SSR-only — render server-side, ship HTML, no JS hydration.

Two opt-in mechanisms:

Per-component directive (Astro model)

<BenchHeader client="load" />              <!-- hydrate on page load -->
<CommentsList client="visible" />          <!-- hydrate when scrolled into viewport -->
<LikeButton client="idle" />               <!-- hydrate during idle time -->
<SearchBox client="media:(min-width: 768px)" />  <!-- hydrate conditionally -->
<JudgeName />                              <!-- static, never hydrates — ships zero JS -->

Per-file marker (RSC model)

'use client'   <!-- file is interactive, hydrates -->

<script client>
  // ...
</script>

Default (no marker) = server-only.

Streaming SSR

Pair with streaming response — flush HTML to the browser as the server resolves data, instead of waiting for the full page:

<!DOCTYPE html>
<html>
<head>...</head>
<body>
  <!-- Header renders immediately (no fetch dependency) -->
  <BenchHeader />

  <!-- Article body waits for fetch — emit a placeholder, stream the real content when ready -->
  <Suspense fallback="<Skeleton />">
    <ArticleView reviewId={16} />   <!-- this fetches; renders when data arrives, streamed mid-response -->
  </Suspense>
</body>
</html>

The browser starts painting the header instantly. The article appears when the server has the data. Time-to-first-content drops dramatically for slow data sources.

Prior art

  • Astro islands — client:load, client:visible, client:idle, client:only directives. Default is server-only.
  • React Server Components (Next.js App Router) — server components by default, 'use client' opts in.
  • Qwik resumability — different approach (no hydration, just resume from serialized state).
  • Marko — similar islands approach.
  • Fresh (Deno) — islands by default.
  • Streaming SSR: Next 13+, Remix, SvelteKit.

Impact

  • JS bundle size: bench-review's article page would drop from ~300KB to ~50KB (only interactive components ship JS).
  • Time-to-interactive: dramatic improvement on slow networks/devices.
  • Time-to-first-content (with streaming): the header paints in ~100ms instead of waiting for full page assembly.
  • Lighthouse / Core Web Vitals: huge improvements across the board.

Phasing

  • Phase 1: Per-file 'use client' marker. Files without it are server-only at SSR (no client scope IIFE emitted).
  • Phase 2: Per-component client:* directives at use site.
  • Phase 3: Streaming SSR — emit HTML in chunks as data resolves.
  • Phase 4: Resumability (Qwik-style — no hydration, just resume).

This is genuinely XL effort. Probably 6+ months of focused work. But the architectural payoff is huge — Stacks becomes a content-site-class framework, not just an SPA framework.

Note

Pairs heavily with P1.1 (SSR-side :if branch elimination) and P2.4 (<Suspense>). All three are part of the same architectural direction: render more on the server, ship less to the client.

14 days ago

Plan committed (docs/proposals/islands-streaming.md), and the first Phase 1 increment is shipped in 1fcee9b8b0 (on main).

client="<trigger>" on components — <CommentsList client="visible" /> defers that component's hydration to the trigger, reusing the existing stx-hydrate/deferHydration runtime:

client=behaviour
loadeager (default)
visible / idle / interaction / media:<query>hydrate on that trigger

renderComponentWithSlot consumes the attribute (removed from props — not leaked into defineProps or data-stx-props) and stamps stx-hydrate on the component's [data-stx-scope] wrapper. Fully additive: a component without client= is byte-for-byte unchanged; client= on a static component is a no-op.

Honest scope of this increment: it defers the hydration work (processElement — the expensive main-thread part), not the bytes (the scope script + runtime still ship), and onMount still fires at load, not on the trigger. Byte-level suppression (server-only components shipping zero JS — the ~300KB→~50KB win) and trigger-timed onMount are the next increments, and Phase 0 (consolidating the three existing partial hydration impls + the hasClientInteractivity gate) is the larger groundwork the bundle-size win depends on. Tests: test/islands/client-directive.test.ts; docs in features/lazy-hydration.md. Full suite 9291 pass / 0 fail.

14 days ago

Update — islands execution-deferral landed (2 more increments)

Following the client="…" directive + trigger-timed onMount, two more increments are on main:

1. Deferred setup-script execution (9e235613b5) — a client="<trigger>" component now defers its entire client-side execution, not just the reactive wire-up:

  • The setup IIFE is emitted inert as <script type="stx/island" data-stx-island="…">, so the browser skips it at parse.
  • The DCL scope loop arms the trigger (instead of early-returning on the not-yet-registered scope) and reveals the HTML (x-cloak off).
  • deferHydration's run() executes the island script on the trigger — registering the scope and running any side-effectful setup (a fetch/subscription in the <script client> body) — then binds + flushes onMount.

Net: <CommentsList client="visible"> ships rendered HTML up front but creates no signals, runs no effects, and fires no fetch until it scrolls into view. Fully backward compatible — a plain stx-hydrate subtree has no island script, so it binds with its captured scope exactly as before.

2. SPA-nav cleanup (38a61642e2) — a still-pending island trigger (IntersectionObserver / idle callback / media listener) is now cancelled by cleanupContainer on navigation, so it can't fire run() on a detached element after the page swaps. Closes the "cancel pending observers/timers" risk from the plan.

Tradeoff (documented): the deferred setup runs via new Function, so a strict script-src CSP without 'unsafe-eval' blocks island hydration. The next increment — per-island production chunking (each island as a real <script src> fetched on the trigger) — removes both the inline bytes and this constraint, and is the actual download-size win.

Tests: test/islands/deferred-script.test.ts (4) — script runs only on the trigger; HTML revealed at load while the script stays deferred; SPA cleanup cancels the trigger; non-island path unaffected. Full suite 9304/0, lint + typecheck clean. Docs updated (lazy-hydration.md, proposal).

Phase 1 is now "execution-complete"; remaining: per-island chunking (download win) → then Phase 0 detection gate / Phase 3 streaming.

14 days ago

Update — per-island production chunking shipped (first slice) · 367d629186

The real download win is in. generateStaticSite({ chunkIslands: true }) now lifts each client="<trigger>" island's inline setup IIFE into a content-hashed chunk fetched only on the hydration trigger — so the island's JS no longer ships inline with the page.

Three additive, regression-safe parts:

  1. Pure emitter — new island-chunking.ts extractIslandChunks(html): finds each inline <script type="stx/island">IIFE</script>, content-hashes the IIFE, returns rewritten HTML (data-stx-src="/_stx/islands/<hash>.js", empty body) + the unique chunks. No I/O — deterministic, dedupes identical IIFEs across the page, idempotent, no-op for island-free pages.
  2. SSG hook — chunkIslands?: boolean config resolved strict === true (default off → byte-identical builds; NODE_ENV never auto-flips it). Before the page write, writes each chunk to outputDir/_stx/islands/<hash>.js (skip-if-exists; content-addressed) and emits the rewritten HTML.
  3. Runtime loader — deferHydration.run() factored into a finishHydrate closure; when the island script has data-stx-src, the chunk loads via a real <script src> and finishHydrate runs from its load event. CSP-clean (no eval/new Function) and async-correct (scope-merge + bind + onMount wait for the chunk). No data-stx-src → unchanged synchronous inline path.

Why opt-in + SSG-only: dev/SSR re-process per request (no stable build phase to emit content-hashed chunks into, bytes are local anyway), so they stay inline. The runtime serves both shapes from one code path — the data-stx-src attribute is the only discriminator.

Tests: emitter unit (7) — determinism / dedup / hash-busting / idempotency / non-island scripts byte-identical; runtime (+1) — chunked path defers finishHydrate to the load event (proven: scope/onMount/binding only fire after load), inline path unchanged; SSG integration (+2) — flag-off = inline + no chunk dir (regression guard), flag-on = IIFE moved to _stx/islands/<hash>.js + tag references it. Full suite 9314/0, lint + typecheck clean.

Next increments (this phase): cross-build dedup manifest, dev-server chunk endpoint, SPA-nav chunk cache, modulepreload hints, and SRI/integrity for full CSP hardening.

Also recorded the verified Phase 0 finding (433fcef83a): the page-level runtime-suppression gate saves 0 bytes on the current corpus (0/302 files) and was correctly not built — the byte win lives entirely here in chunking.

14 days ago

Update — two more chunking increments

Building on the chunking first slice, both pushed and green:

Prefetch hints (6f38b12987). generateStaticSite({ chunkIslands: true, prefetchIslands: true }) emits <link rel="prefetch" href="/_stx/islands/<hash>.js" as="script"> for visible/idle islands so their chunks warm in cache before the trigger fires — instant hydration, still no inline JS. interaction/media: are skipped (may never hydrate). prefetch (not preload) = idle priority, never competes with the critical path. Opt-in (requires chunkIslands).

Subresource Integrity (1cc633f03a). integrityIslands: true stamps data-stx-integrity="sha384-…" on each chunk tag; the runtime applies it via script.integrity. Completes the CSP story — the chunked path was already eval-free (<script src>, no new Function), and SRI now lets a strict CSP pin the chunks. Opt-in (breaks under CDN/proxy byte-transforms, so off by default).

Both are pure refinements on the chunk format the first slice established — extractIslandChunks gained an integrity option, plus a new pure injectIslandChunkPrefetch; the runtime's one chunked-load branch now also sets integrity. Tests: emitter unit (+8), runtime (+1), SSG integration (+4). Full suite 9327/0, lint + typecheck clean. Docs updated.

Island options now: chunkIslands (the win), prefetchIslands (latency), integrityIslands (CSP) — all opt-in, all default off.

Still open this phase: cross-build dedup manifest + chunk build-stats, dev-server chunk endpoint, SPA-nav chunk-cache correctness. Then Phase 3 streaming SSR (separate track).

14 days ago

Update — streaming SSR (Phase 3) foundation landed

The islands track is done; this starts Phase 3. After digging in, the "wire the existing primitives into serve-app" plan turned out to be blocked (documented below), so I built the real thing.

Why the quick wire-in didn't work: streamTemplate is a parallel renderer that bypasses serve-app's pipeline (layout/Crosswind/HMR), post-render streaming of an already-built string is just byte-flush theater, and @suspense boundaries strip <script> so they can't do server-side async data work that resolves after the shell ships. Real streaming SSR needed render-pipeline work.

What shipped (two commits):

  1. 44abfcb8c0 — the deferred-boundary renderer. renderStreamingPage(shell, boundaries) flushes the shell first (each boundary as a [data-suspense="id"] fallback), emits an idempotent client receiver, then streams each boundary's HTML as its server-side async render() resolves — in completion order, per-boundary error-isolated. Proven with a real Bun.serve + fetch chunked-stream test: the shell arrives before a boundary gated behind an 80ms delay (deterministic, not timing-flaky).

  2. 9d0b405a2f — serve-app integration. A page opts in by exporting streamBoundaries from <script server> (a map of id → server-side async render) + a data-suspense placeholder. serve-app reads them off the rendered context (extractStreamBoundaries), rebuilds streaming routes per request, and flushes the fully-pipelined shell (layout/Crosswind/HMR intact) before streaming boundaries. Documented in docs/features/streaming-ssr.md.

<script server>
export const streamBoundaries = {
  article: async () => `<article>${await loadArticle()}</article>`,
}
</script>
<h1>Blog</h1>
<div data-suspense="article">Loading…</div>

Full suite 9345/0, lint + typecheck clean.

Still open on streaming (deeper, server-component territory — squarely in the "6+ months" the issue scoped): a declarative <Suspense> sugar with in-boundary async (the issue's <Suspense><ArticleView/></Suspense> model), hydrating interactive islands inside a streamed boundary, and production-server (compiled-template) streaming. The streamBoundaries API is a clean, working foundation those build on.

14 days ago

Update — declarative @stream sugar (streaming SSR)

Shipped the declarative boundary form the issue envisioned, scoped to avoid colliding with the existing client-side @suspense (a distinct @stream directive that defers server-side rendering):

<script server>
export const streamBoundaries = {
  article: async () => fetch('/api/article/16').then(r => r.json()),  // data
}
</script>
<h1>Blog</h1>
@stream('article')
  <article><h1>{{ $boundary.title }}</h1>{{ $boundary.body }}</article>
@fallback
  <div class="skeleton">Loading…</div>
@endstream
  • processStreamDirectives extracts each @stream('id') … @fallback … @endstream before the loop/expression passes, stashing the inner template pristine and emitting a data-suspense placeholder + fallback into the shell.
  • serve-app re-renders the captured template per request with $boundary = await streamBoundaries[id]() once the shell has flushed. The low-level "function returns HTML" form still works; a @stream template wins over a same-id raw entry.
  • Limitation (documented): @stream lives in the page/layout, not an included partial.

Tests (+5) incl. an end-to-end processDirectives run proving the shell gets the placeholder and the captured template renders with $boundary. Full suite 9356/0.

Streaming SSR is now feature-complete for the dev-server / SSG path: render (renderStreamingPage) → serve (streamBoundaries + @stream) → hydrate (window.stx.hydrate). The one remaining piece — production-server (compiled-template) streaming — stays blocked: that server can't carry live boundary functions (not serializable), the same wall the chunk-manifest hit.

14 days ago

Closing — streaming SSR + islands shipped

The opt-in hydration architecture this issue called for is implemented, tested, and documented end to end.

Islands

  • client="visible|idle|interaction|media:<q>" — per-island deferred hydration; HTML ships immediately, only wire-up defers.
  • Per-island chunking — each island's IIFE lifted to a content-hashed /_stx/islands/<hash>.js, fetched on trigger via a real <script src> (CSP-clean). Opt-in chunkIslands; optional SRI (integrityIslands), minify, and <link rel="prefetch"> hints; chunk stats + _stx/islands-manifest.json in the build summary.
  • Observable failures via stx:island-error; SPA-nav hydration + cleanup.

Streaming SSR

  • renderStreamingPage(shell, boundaries) flushes the shell with [data-suspense] placeholders, then streams each boundary as its data resolves; streamToResponse for chunked HTTP.
  • Two authoring forms: low-level streamBoundaries export (fn → HTML) and the @stream('id')…@fallback…@endstream directive ($boundary data).
  • Works in page, layout, and included partials; dev and the compiled-template production server; in-boundary hydration via window.stx.hydrate; per-boundary timeout; X-Accel-Buffering: no so proxies don't buffer the stream away.

Also

  • Lazy hydration (stx-hydrate: visible/idle/interaction/media).
  • Docs: docs/features/streaming-ssr.md, docs/features/lazy-hydration.md, docs/proposals/islands-streaming.md.

Deferred

  • Resumability (Qwik-style) — always scoped here as out-of-near-term research; tracked now in #1753.

Tests green across the full suite. Closing as feature-complete; resumability continues in #1753.

Sign in to comment on this issue.