Proposal: Islands architecture + streaming SSR
On this page 11
Tracking issue: stacksjs/stx#1746 Status: planning · Effort: XL (Phase 0+1 are the shippable first PR)
This plan is grounded in a file:line audit of the current runtime, serve, component, streaming, and script-classification subsystems. It is a roadmap, not a committed implementation.
Goal
Today stx hydrates the entire page: any signal syntax on a page injects the full client runtime and a client scope for everything. For a content site, ~70% of the DOM is static after first paint, so that JS is wasted. Islands make hydration opt-in and per-unit; streaming flushes HTML as data resolves.
The core problem, in current-architecture terms
Hydration is binary and page-global:
hasSignalsSyntax()(packages/stx/src/signal-processing.ts:183) scans 17 patterns; any match makesprocessSignalsinject the ~150KB runtime (runtime-injection.ts:109) and emit a client scope IIFE (includes.ts:276) +data-stx-scopefor the whole page.- There is no per-component or per-file gate.
Islands = make that gate exist and default it to off.
Foundational reframing (vs the issue's phase order)
The issue lists per-file 'use client' as Phase 1 and per-component client:*
as Phase 2. We recommend inverting them. Per-component opt-in is additive
and safe (reuses deferHydration, breaks nothing); flipping the per-file
default to server-only has huge blast radius. Ship the safe, high-value win
first; flip the default last, behind a config flag.
A second, load-bearing finding: there are already three partial hydration
mechanisms — deferHydration/stx-hydrate (signals.ts:1395), a
partial-hydration.ts with @client:* directives, and an unused hydration.ts
(setupVisibleHydration/...). These must be consolidated before adding a
fourth, or the framework ships competing, confusing hydration models.
Phase 0 — Consolidate + build the gate (prerequisite; highest risk, no user-visible feature)
Finding (verified 2026-06 — DEFERRED, do not build the suppression gate yet). A multi-agent audit + direct corpus measurement settled the open question of whether the runtime ships wastefully today. It does not:
hasClientInteractivity()would save zero bytes on the current corpus. Scanning all 302 source.stxfiles (packages/+examples/): 0 files havehasSignalsSyntax()=truewhile lacking genuine client interactivity, and 0 usestate()/derived()/effect()only inside<script server>(the sole theoretical gap). The prior assumption of server-only-signal pages shipping the runtime is unfounded — those pages do not exist in the codebase.- The detection split isn't even a clean subset. A prototype
hasClientInteractivity()matched 51 files vshasSignalsSyntax()'s 47 — they disagree in both directions, so wiring the narrower predicate into the pipeline's most load-bearing early-exit (signal-processing.ts:1477) is pure regression risk for zero current benefit, and creates a second client/server-boundary predicate (the drift trap of CLAUDE.md item 40).- The 4 "reverse-divergence" components are NOT broken (
Dialog/Tooltip/Drawer:show,drivly/footerx-model). Hypothesis:hasSignalsSyntaxhas no:show/x-modelpattern, so a component using only those + auseReactivePropscript (nostate()) might ship with no runtime. Refuted end-to-end: driving the realprocessDirectivespipeline, all such pages inject the runtime —usesSignalsInScript's:/x-clause stampsdata-stx-autoand wraps<script client>asdata-stx-scopedbeforehasSignalsSyntaxruns, so the gate sees those markers and returns true.- Consolidation (item 1 below) is not cheap hygiene.
hydration.tsis public, barrel-exported (index.ts:115), and tested;partial-hydration.ts'sprocessPartialHydrationDirectivesis actively called atprocess.ts:1062. Unifying them is a real refactor of live APIs, not dead-code removal.Conclusion: the byte win #1746 targets lives entirely in per-island chunking (Phase 1's remaining increment), not in page-level runtime suppression. Revisit this gate only if #1739 Phase B begins evaluating
derived()server-side (which would create the first real population of server-only signals). The stranding analysis below is sound and preserved for that future — but a sound mitigation for a non-existent problem isn't worth editing the pipeline's single most consequential early-exit today.
Goal: one hydration mechanism, plus the ability to render a component's HTML while suppressing its scope/runtime.
- Unify the 3 partial impls into
deferHydration(signals.ts:1395— which already has all triggers). Deprecatehydration.ts's unused helpers; foldpartial-hydration.ts's@client:*into the same path. - Split detection: add
hasClientInteractivity()distinct fromhasSignalsSyntax(). A page usingstate()only for SSR variable extraction should not ship the runtime. (signal-processing.ts:183is the seam.) - The suppression gate: thread a per-render hydration decision through
processSignals(signal-processing.ts:1476) →injectSignalsRuntime(runtime-injection.ts:109) →addScopeToRootElement/transformSignalScript(includes.ts:326,276). Server-only unit ⇒ skip the scope IIFE, thedata-stx*attrs, and (if nothing hydrates) the runtime itself. - The make-or-break risk — don't strand directives. When the runtime is
suppressed,
@if/@forover server data must still render viaprocessConditionals/processLoops.convertSignalDirectivesToAttributes(signal-processing.ts:471) already decides per-condition whether something is client-reactive (cf. #1748). Phase 0 must guarantee suppress runtime ⇒ all remaining@if/@forare provably server-evaluable, else bare unprocessed markup ships (silent failure).
Verify: a page with only server @if/@foreach + {{ }} ships zero
data-stx* attrs and no runtime, yet renders identically (snapshot + a
"no <script data-stx-scoped> emitted" assertion).
Phase 1 — Per-component client="load|visible|idle|interaction|media:…" (the headline win)
Shipped (three increments): the
client="…"directive defers a component's hydration to the trigger by stampingstx-hydrateon its scope wrapper (utils.tsrenderComponentWithSlot, via the existingdeferHydration). (1) the reactive wire-up (processElement) is deferred; (2)onMountnow fires on the trigger, not at page load; (3) the component's setup script is emitted inert (<script type="stx/island">) and the runtime executes it only on the trigger — so side-effectful setup (afetchin the<script client>body) runs when the island hydrates, not at load. Opt-in, zero blast radius.Download-level suppression shipped (first slice).
generateStaticSite({ chunkIslands: true })now lifts each island's inline IIFE into a content-hashed chunk underdist/_stx/islands/<hash>.js(pure emitter:island-chunking.tsextractIslandChunks; written from the SSG per-page hook) and rewrites the tag todata-stx-src="…"with an empty body. The runtime (deferHydration.run()) loads a chunked island via a real<script src>on the trigger — CSP-clean (nonew Function), async-correct (afinishHydrateclosure runs from the script'sloadevent). Flag defaults off → builds are byte-identical; dev/SSR stay inline. Still to do in this phase: cross-build dedup manifest, a dev-server chunk endpoint, SPA-nav chunk cache,modulepreloadhints, and SRI/integrityfor full CSP hardening. See docs.
Goal: <CommentsList client="visible" /> ships HTML now, hydrates on trigger.
- Seam:
processCustomElementTags(component-renderer.ts:791) parses theclientattr;renderComponentWithSlot(utils.ts:956) currently unconditionally wraps signal components indata-stx-scope. Gate that: emit the HTML, but instead of an eager scope, stampstx-hydrate="<trigger>"on the wrapper (already honored bydeferHydration) and mark the scope script deferred. - Reuse:
deferHydration(all triggers done),findElementScope(signals.ts:1230), thestx:hydratedevent (signals.ts:1407), the existingdata-stx-propsserialization. - Risks / mitigations:
- Shared state — a component exporting via
defineExpose/providecan't be deferred (dependents readundefined). Restrict deferral to leaf islands; validate at compile time. - Callback props can't be JSON-serialized (
utils.ts:965already fails silently) → reject non-serializable props on islands, or add ascopeId-keyed callback registry (later). - SPA-nav cleanup — cancel pending observers/timers when
cleanupContainertears a scope down.
- Shared state — a component exporting via
Verify: DOM-shim tests that a client="visible" component is inert until an
IntersectionObserver fires, then processes. Acceptance metric: the
bench-review article page JS drops toward the ~50KB target.
Phase 2 — Per-file 'use client' + server-only default (the default flip; behind a flag)
- Seam: detect the marker in
build-mode-detector.ts:27/ before TS-strip invariable-extractor.ts; setcontext.__stx_useClientat top level only (don't let a layout's mode infect children —isTopLevel,process.ts:290). Gate the client-script block (process.ts:1302–1435) + runtime injection on it. - Risk: this flips the default → ship behind an
islands: trueproject config, with a codemod that adds'use client'to existing interactive files.validateComponentScripts(build-mode-detector.ts:84) already rejects mixed server+client — extend it to reject'use client'+<script server>.
Phase 3 — Streaming SSR (independent track; builds on the shipped <Suspense>)
- Wire-in seam:
serve-app.ts:751returns a synchronousResponse. When a page has suspense queries, callstreamTemplate(streaming.ts:180) +streamToResponse(streaming.ts:709, currently unused) instead. - The hard gap:
useQuery({suspense})registers synchronously but onlyfetchData()s ononMount(client-only), andregisterSuspensereads__STX_CURRENT_ELEMENT__(no DOM on server). Streaming needs server-side eager query execution + a context-based (not DOM-based) boundary registry, then a deterministic-ID handshake so the clientbindSuspense(signals.ts:1872) recognizes already-streamed content (mark itdata-stx-streamedto skip re-processing) and the resolver sets the client error signal on failure. - Reuse:
buildSuspenseResolveScript(escaping/error handling),window.__stxSuspense.resolve, the three-region Suspense markup (#1742). - Verify: mostly needs a real browser / HTTP harness (chunked transfer, backpressure) — the one phase that can't be fully unit-tested in happy-dom.
Phase 4 — Resumability (Qwik-style) — research spike only
Out of near-term scope; a separate design exercise (serialize state + lazy event wiring instead of hydration). Do not block 0–3 on it.
Decisions that need a maintainer call
- Default flip: opt-in via
islands: trueconfig (recommended) or flip globally at a major version? - Island callback props: reject at compile time, or build a
scopeIdcallback registry? @island/streaming vsclient:*: the existing@islandstreaming directive (streaming.ts:622) overlaps conceptually — converge them or keep orthogonal (docs-only)?- Marker syntax:
client="visible"(attribute) vs Astro'sclient:visible— the:collides with stx's directive prefix, so the attribute form is safer.
Effort & sequencing
| Phase | Estimate | Notes |
|---|---|---|
| 0 — consolidate + gate | ~2–3 wks | the correctness gate is the crux |
1 — per-component client= | ~2 wks | mostly reuse of deferHydration |
2 — 'use client' default flip | ~1–2 wks + migration | behind a flag |
| 3 — streaming SSR | ~3–4 wks | browser-validated |
| 4 — resumability | research | out of near-term scope |
Phase 0 + 1 alone deliver most of the bundle-size win and are independently shippable — that's the natural first PR.
Related
Pairs with #1739 (SSR :if branch elimination) and #1742's <Suspense> /
useQuery({suspense}) (both shipped). All three are the same direction: render
more on the server, ship less to the client.