Ssr Streaming Renderer
Design Log #94 — SSR Streaming Renderer
Written for AI agents. See Log Methodology Note below for details.
Background
Jay currently does NOT render HTML on the server. The server produces ViewState data (slow + fast phases), then sends an empty <div id="target"></div> plus a <script> that imports the compiled element and creates the entire DOM on the client (see generate-client-script.ts).
This means:
- Users see a blank page until JavaScript loads and executes
- Search engines see an empty page (no SEO)
- Time to First Contentful Paint is slow
We need the server to render the full HTML from compiled jay-html, streaming it directly to the response without materializing the full HTML string in memory.
Related Design Logs
- #11 — SSR (original concept, mentions stream support)
- #34 — Jay Stack (3-phase rendering pipeline)
- #50 — Rendering phases in contracts
- #75 — Slow rendering jay-html to jay-html
- #93 — Client hydration (companion — how client connects to this HTML)
Problem
- No server HTML rendering exists. The compiler produces
generated-element.tswhich callsdocument.createElement— browser-only APIs. - The fast phase only produces data, not HTML.
renderFastChangingData()returns ViewState objects. - We need a new compilation target that renders jay-html to an HTML stream on the server, without DOM APIs.
Questions and Answers
Q1: Should we compile jay-html into a streaming render function, or interpret jay-html at runtime on the server?
Compilation is preferred — it avoids parsing jay-html at request time and produces optimal code.
A: Confirmed — compile. The compiled output will also be used for production builds later, not just dev server.
Q2: What format should the compiled server render function output?
A stream of strings (or chunks). The function yields/writes HTML fragments sequentially, allowing the HTTP response to start immediately.
A: Confirmed — stream of string chunks via write() callback.
Q3: How do slow-rendered (pre-rendered) jay-html templates work with SSR?
After slow rendering (Design Log #75), we have pre-rendered jay-html with slow data baked in. The SSR compiler should compile this pre-rendered jay-html into the streaming render function, binding only fast + interactive data at request time.
A: Confirmed.
Q4: Where does the SSR render function live — in the compiler output or in jay-stack server runtime?
In the compiler output. The compiler produces a generated-server-element.ts (or similar) that exports a render function. Jay-stack's server runtime calls it.
A: Confirmed — compiler output.
Q5: How do headless components render on the server?
Headless components that have slow/fast phases already produce ViewState. The SSR render function needs the merged ViewState to render the HTML. The headless component resolution happens before SSR rendering.
A: Confirmed.
Q6: How do we handle interactive if and forEach in SSR?
if: evaluate the condition with the current ViewState, render the matching branch. For the client, include hydration markers (see Design Log #93).forEach: iterate the array, render each item. Include markers for hydration.
A: Confirmed. Uses jay-coordinate attributes on elements for hydration targeting, consistent with Design Log #93. No comment boundaries needed — the runtime's Kindergarten handles DOM positioning via offset counting.
Q7: How do we handle ViewState with Promise / async data?
See Design Log #45 for async types (when-resolved, when-loading, when-rejected).
A: Render the when-pending (loading) variant immediately into the stream. Do NOT close the final </html> tag yet — keep the stream open. Once the promise resolves (or rejects), write inline <script> that replaces the pending content with the resolved/rejected variant. This is similar to React Suspense streaming.
Flow:
- When SSR hits an
asyncproperty, render thewhen-loadingvariant inline - Mark it with a placeholder:
<template jay-async="p1:pending">...</template>wrapper (or similar) - Continue streaming other HTML
- When the promise settles, append an inline
<script>at the end of the stream that:- Contains the resolved/rejected HTML as a string
- Swaps the pending placeholder with the resolved/rejected content
- Only close
</html>after all promises settle (or after a timeout)
This means renderToStream becomes async — it returns a Promise that resolves when all async ViewState properties have settled.
Design
Architecture Overview
Build Time Request Time
───────── ────────────
jay-html ──→ slow render ──→ pre-rendered jay-html ──→ SSR compiler output
│
┌───────┴────────┐
│ │
generated-server generated-element
-element.ts -hydrate.ts
(server render) (client hydrate)
│ │
▼ ▼
HTML stream ──→ Client hydration
(to response) (Design Log #93)
Compilation Target: generated-server-element.ts
New compiler target that produces a function rendering HTML to a stream:
// generated-server-element.ts
import { escapeHtml, type ServerRenderContext } from '@jay-framework/ssr-runtime';
export function renderToStream(vs: ViewState, ctx: ServerRenderContext): void {
const { write: w } = ctx;
w('<div>');
w('<h1 jay-coordinate="0">');
w(escapeHtml(vs.title));
w('</h1>');
w('<div jay-coordinate="content">');
w(escapeHtml(vs.text));
w('</div>');
w('<p>Static footer</p>');
w('</div>');
}
Key properties:
- No DOM APIs — pure string concatenation via
write()calls - Streaming — each
write()can flush to the HTTP response - No intermediate string — never builds the full HTML in memory
jay-coordinate— marks elements that need client hydration (Design Log #93's coordinate system)- Escaping — all dynamic values are HTML-escaped
onAsync— registers promises for streaming async resolution
Rendering Rules by Jay-HTML Construct
| Construct | SSR Behavior |
|---|---|
| Static HTML | Write directly: w('<p>Hello</p>') |
{binding} |
Evaluate + escape + coordinate: w('<h1 jay-coordinate="0">'); w(escapeHtml(vs.title)); w('</h1>') |
ref="name" |
Add jay-coordinate="refName" to element. Ref itself is client-only. |
style binding |
Evaluate and inline: w('style="color:' + escapeAttr(vs.color) + '"') |
if="cond" (interactive) |
Evaluate condition, render matching branch with jay-coordinate on dynamic elements. No comment markers — Kindergarten offset counting handles positioning. |
if="cond" (slow/fast only) |
Evaluate condition, render or skip. No markers needed. |
forEach (interactive) |
Add jay-coordinate (auto-index) on container element; iterate items with jay-coordinate="trackByKey" on each. |
forEach (slow) |
Already unrolled by slow render (Design Log #75) |
when-loading (async) |
Render pending variant inline with jay-async="propName:pending" wrapper. Register promise with ctx.onAsync. |
when-resolved (async) |
Not rendered initially. Written via inline <script> when promise resolves. |
when-rejected (async) |
Not rendered initially. Written via inline <script> when promise rejects. |
| headless component | Already resolved to ViewState before SSR. Render its jay-html with merged ViewState. |
| child component | Render component's server element recursively |
Markers for Interactive Elements
Uses the jay-coordinate attribute system from Design Log #93. No comment boundaries — the runtime's Kindergarten class handles DOM positioning through offset counting (each dynamic child gets a KindergartenGroup, and getOffsetFor() computes insertion position by summing children.size of preceding groups).
<!-- Interactive if (cond=true at SSR) -->
<div jay-coordinate="details">Content when true</div>
<!-- Interactive if (cond=false at SSR) — nothing rendered -->
<!-- Interactive forEach (container gets auto-index coordinate for Kindergarten setup) -->
<ul jay-coordinate="1">
<li jay-coordinate="abc">
<span jay-coordinate="abc/0">Item ABC</span>
<button jay-coordinate="abc/addBtn">Add</button>
</li>
<li jay-coordinate="def">
<span jay-coordinate="def/0">Item DEF</span>
<button jay-coordinate="def/addBtn">Add</button>
</li>
</ul>
<!-- Async promise (pending) -->
<div jay-async="po1:pending">
<span>Still loading the object</span>
</div>
Markers:
jay-coordinate="..."on elements — for hydration targeting (Design Log #93)jay-async="propName:state"attribute — async promise placeholder (replaced by inline script when settled)
Compiled Output Example
Given this jay-html:
<div>
<h1>{title}</h1>
<div if="showDetails" ref="details">
<span>{description}</span>
</div>
<ul>
<li forEach="items" trackBy="id"><span>{name}</span> - <span>{price}</span></li>
</ul>
</div>
Compiled generated-server-element.ts:
import { escapeHtml } from '@jay-framework/ssr-runtime';
interface ViewState {
title: string;
showDetails: boolean;
description: string;
items: Array<{ id: string; name: string; price: number }>;
}
export function renderToStream(vs: ViewState, ctx: ServerRenderContext): void {
const { write: w } = ctx;
w('<div>');
// {title} — dynamic text, coordinate for hydration
w('<h1 jay-coordinate="0">');
w(escapeHtml(String(vs.title)));
w('</h1>');
// if="showDetails" — interactive conditional
if (vs.showDetails) {
w('<div jay-coordinate="details">'); // ref="details"
w('<span jay-coordinate="details/0">');
w(escapeHtml(String(vs.description)));
w('</span>');
w('</div>');
}
// forEach="items" — interactive collection
w('<ul jay-coordinate="1">'); // container for forEach, auto-index
for (const item of vs.items) {
const key = escapeHtml(String(item.id));
w('<li jay-coordinate="' + key + '">');
w('<span jay-coordinate="' + key + '/0">');
w(escapeHtml(String(item.name)));
w('</span>');
w(' - ');
w('<span jay-coordinate="' + key + '/1">');
w(escapeHtml(String(item.price)));
w('</span>');
w('</li>');
}
w('</ul>');
w('</div>');
}
Compiled Output Example: Async Properties
Given this jay-html with async data (Design Log #45):
<div>
<span>{s1}</span>
<span when-resolved="p1">{.}</span>
<span when-loading="p1">Still loading</span>
<span when-rejected="p1">Error: {message}</span>
</div>
Compiled generated-server-element.ts:
export function renderToStream(vs: ViewState, ctx: ServerRenderContext): void {
const { write: w, onAsync } = ctx;
w('<div>');
w('<span jay-coordinate="0">');
w(escapeHtml(String(vs.s1)));
w('</span>');
// Async p1: render when-loading immediately, register promise for later
w('<div jay-async="p1:pending" jay-coordinate="p1">');
w('<span>Still loading</span>');
w('</div>');
// Register the promise — when it settles, the framework writes inline JS
onAsync(vs.p1, 'p1', {
resolved: (val) => '<span jay-coordinate="p1">' + escapeHtml(String(val)) + '</span>',
rejected: (err) =>
'<span jay-coordinate="p1">Error: ' + escapeHtml(String(err.message)) + '</span>',
});
w('</div>');
}
When the promise resolves, the framework appends to the stream:
<script>
(function () {
var t = document.querySelector('[jay-async="p1:pending"]');
var d = document.createElement('div');
d.innerHTML = '<span jay-coordinate="p1">Hello World</span>';
t.replaceWith(d.firstChild);
// Trigger hydration update for this coordinate
window.__jay?.hydrateAsync?.('p1');
})();
</script>
This pattern:
- Renders pending content immediately — user sees loading state
- Keeps the HTTP stream open until all promises settle
- When a promise settles, writes an inline
<script>that does a DOM swap - The swap happens before the hydration script runs (scripts execute in order)
- By the time hydration runs, the DOM already has the resolved content
Integration with Jay-Stack Server
The flow changes from:
Current: slow phase → fast phase → empty HTML + client script + JSON ViewState
To:
New: slow phase → fast phase → SSR render (streamed HTML) + async scripts + hydration script + JSON ViewState
In the dev server / production server:
// Current (generate-client-script.ts)
return `<div id="target"></div><script>...</script>`;
// New (generate-ssr-response.ts)
async function generateSSRResponse(res: ServerResponse, viewState, jayHtmlPath, ...) {
// 1. Write HTML head
res.write('<!doctype html><html><head>...</head><body>');
res.write('<div id="target">');
// 2. Stream the rendered component HTML
const { renderToStream } = await import(serverElementPath);
const pendingPromises: Array<Promise<void>> = [];
const ctx: ServerRenderContext = {
write: (chunk) => res.write(chunk),
onAsync: (promise, id, templates) => {
pendingPromises.push(
promise.then(
(val) => res.write(`<script>(function(){
var t=document.querySelector('[jay-async="${id}:pending"]');
var d=document.createElement('div');
d.innerHTML='${templates.resolved(val)}';
t.replaceWith(d.firstChild);
})()</script>`),
(err) => res.write(`<script>(function(){
var t=document.querySelector('[jay-async="${id}:pending"]');
var d=document.createElement('div');
d.innerHTML='${templates.rejected(err)}';
t.replaceWith(d.firstChild);
})()</script>`),
)
);
},
};
renderToStream(viewState, ctx);
// 3. Close target div
res.write('</div>');
// 4. Wait for all async promises to settle (scripts stream as they resolve)
await Promise.allSettled(pendingPromises);
// 5. Add hydration script (after all async swaps)
res.write(`<script type="module">
import { hydrate } from '${hydrateElementPath}';
const viewState = ${JSON.stringify(viewState)};
const target = document.getElementById('target');
hydrateCompositeComponent(hydrate, viewState, target.firstElementChild, ...);
</script>`);
res.write('</body></html>');
res.end();
}
The async flow means the HTTP response stays open while promises resolve. Each resolved promise writes an inline <script> that swaps the pending placeholder. The browser executes these scripts as they arrive (streaming). The hydration script comes last, after all async content is in the DOM.
New Package: @jay-framework/ssr-runtime
Minimal server-side utilities (no DOM dependency):
// packages/runtime/ssr-runtime/lib/index.ts
/** HTML-escape a string for safe embedding in HTML content */
export function escapeHtml(str: string): string { ... }
/** HTML-escape a string for safe embedding in attribute values */
export function escapeAttr(str: string): string { ... }
/** Context passed to compiled renderToStream functions */
export interface ServerRenderContext {
write: (chunk: string) => void;
onAsync: (
promise: Promise<any>,
id: string,
templates: {
resolved: (val: any) => string;
rejected: (err: any) => string;
},
) => void;
}
/** Generate the inline <script> for async promise swap */
export function asyncSwapScript(id: string, html: string): string { ... }
This package must be very small — the compiled server elements import from it.
Compiler Changes
In compiler-jay-html:
- New render function:
renderServerNode(node, context)— similar torenderElementNodeandrenderElementBridgeNode - New file generator:
generateServerElementFile(jayFile)— producesgenerated-server-element.ts - Coordinate generation: Assign
jay-coordinatevalues using same coordinate system as Design Log #93 (ref names, auto-index for non-ref elements, trackBy keys for forEach, auto-index for container elements wrapping forEach/conditional) - Async handling:
when-loading→ render inline withjay-asyncwrapper;when-resolved/when-rejected→ generate template functions forctx.onAsync - escapeHtml calls: Wrap all dynamic text and attribute bindings with
escapeHtml()/escapeAttr()
When to Compile SSR vs Client-Only
| Scenario | Generate server element? | Generate hydrate element? | Generate client element? |
|---|---|---|---|
| Component has slow/fast + interactive | Yes | Yes | No (hydrate replaces it) |
| Component is server-only (no interactive) | Yes | No | No |
| Component is client-only (no slow/fast) | No | No | Yes (current behavior) |
Implementation Plan
Phase 1: SSR Runtime Package
- Create
packages/runtime/ssr-runtime - Implement
escapeHtml(),escapeAttr(),asyncSwapScript() - Define
ServerRenderContextinterface - Tests: escape edge cases (HTML entities, quotes, null bytes), async swap script generation
Phase 2: Compiler — Server Element Target (basics)
- Add
renderServerNode()injay-html-compiler.ts - Handle static HTML, dynamic text, attributes, style bindings
- Generate
jay-coordinateattributes (same coordinate system as Design Log #93) - Generate
generated-server-element.tsfiles - Tests: fixture-based, starting with simple cases (static text, dynamic text, refs)
Phase 3: Compiler — Conditionals and forEach
- Add
ifhandling — evaluate condition, render matching branch withjay-coordinateon dynamic elements - Add
forEachhandling — iterate items withjay-coordinate="trackByKey"on each - Handle nested conditionals and forEach
- Tests: conditions fixture, collections fixture
Phase 4: Compiler — Async Promise Streaming
- Add
when-loading→ render inline withjay-async="propName:pending"wrapper - Add
when-resolved/when-rejected→ generate template functions forctx.onAsync renderToStreamsignature usesServerRenderContext(withonAsync)- Tests: async fixtures (async-simple-types, async-objects, async-arrays)
Phase 5: Jay-Stack Integration
- Create
generate-ssr-response.tsinstack-server-runtime - Modify dev server to use SSR rendering
- Stream HTML response, wait for async promises, then write hydration script
- Embed ViewState JSON for hydration script
- Tests: dev server integration tests
Phase 6: Production Optimizations
- Concatenate adjacent static
w()calls at compile time:w('<div><h1 jay-coordinate="0">')instead of separate calls - Pre-compute static portions as template literals
- Async timeout: close stream after N seconds even if promises haven't settled
Examples
Before (current)
Browser receives:
<!doctype html>
<html>
<body>
<div id="target"></div>
<script type="module">
// ... imports ...
const viewState = {"title":"Hello","items":[...]};
const instance = pageComp({});
target.appendChild(instance.element.dom);
</script>
</body>
</html>
User sees: blank page → flash → content
After (with SSR)
Browser receives (streamed):
<!doctype html>
<html>
<body>
<div id="target">
<div>
<h1 jay-coordinate="0">Hello</h1>
<ul jay-coordinate="1">
<li jay-coordinate="w1">
<span jay-coordinate="w1/0">Widget</span> - <span jay-coordinate="w1/1">9.99</span>
</li>
<li jay-coordinate="g2">
<span jay-coordinate="g2/0">Gadget</span> - <span jay-coordinate="g2/1">19.99</span>
</li>
</ul>
</div>
</div>
<script type="module">
// ... hydration imports ...
const viewState = {"title":"Hello","items":[...]};
hydrateCompositeComponent(hydrate, viewState, target.firstElementChild, ...);
</script>
</body>
</html>
User sees: content immediately → interactive after hydration
After (with SSR + async promise)
Browser receives (streamed progressively):
<!doctype html>
<html>
<body>
<div id="target">
<div>
<span jay-coordinate="0">Hello</span>
<!-- p1 is still pending, show loading state -->
<div jay-async="p1:pending" jay-coordinate="p1">
<span>Still loading</span>
</div>
</div>
</div>
<!-- Promise p1 resolves while streaming — inline script swaps content -->
<script>
(function () {
var t = document.querySelector('[jay-async="p1:pending"]');
var d = document.createElement('div');
d.innerHTML = '<span jay-coordinate="p1">World</span>';
t.replaceWith(d.firstChild);
})();
</script>
<!-- All promises settled, now hydrate -->
<script type="module">
const viewState = {"s1":"Hello","p1":"World"};
hydrateCompositeComponent(hydrate, viewState, target.firstElementChild, ...);
</script>
</body>
</html>
User sees: "Hello" + "Still loading" → "Hello" + "World" (swap) → interactive after hydration
Trade-offs
| Decision | Pro | Con |
|---|---|---|
Compile to write() calls |
Streaming, no memory accumulation, reusable for production | More compiler complexity |
jay-coordinate on dynamic elements |
Consistent with DL#93 hydration and automation API | Small HTML overhead |
| No comment markers (Kindergarten offset counting) | Cleaner HTML, less output, simpler compiler | Relies on Kindergarten internals for position correctness |
| Separate ssr-runtime package | Minimal server dependency | Another package to maintain |
| SSR at fast phase (not slow) | Slow data already baked in, fast = per-request | Must re-render on every request (cacheable) |
| Async: render pending inline, swap via script | Progressive loading, no re-render of entire page | Inline scripts add complexity; stream stays open |
| Async: wait for all promises before hydration | Hydration sees final DOM, no race conditions | Slow promises delay interactivity |
Verification Criteria
- Streaming: HTML response starts before full render completes (verify with chunked transfer encoding)
- No memory accumulation: Server does not build full HTML string (verify with memory profiling on large pages)
- Correct HTML: Server-rendered HTML matches what client would produce (verify with DOM comparison)
- Coordinate consistency:
jay-coordinatevalues in server output match what DL#93 hydration expects (same coordinate system) - Hydration compatible: Design Log #93 hydration can find all coordinates and adopt all nodes
- Async streaming: Pending content renders immediately; resolved content swaps in via inline script before hydration
- Async timeout: Stream closes after timeout even if promises haven't settled (graceful degradation)
- Performance: SSR response is faster than client-side render for First Contentful Paint
- SEO: HTML content is visible without JavaScript (verify with curl)
- escapeHtml: No XSS vectors in server-rendered dynamic content
Implementation Results
Phase 1 — SSR Runtime Package (Completed)
Package created: packages/runtime/ssr-runtime (@jay-framework/ssr-runtime)
Files created:
lib/escape.ts—escapeHtml()andescapeAttr()using map-based regex replacement (escapes& < > " ')lib/server-render-context.ts—ServerRenderContextinterface withwriteandonAsyncmemberslib/async-swap-script.ts—asyncSwapScript(id, html)generates inline<script>for async promise swaplib/index.ts— re-exports all public APIpackage.json,tsconfig.json,vite.config.ts— package scaffolding (modeled on list-compare)
Tests added (22 total, all passing):
test/escape.test.ts— 15 tests (P1-P15): escapeHtml + escapeAttr covering all 5 HTML entities, multiple entities, empty string, non-string coercion, XSS preventiontest/async-swap-script.test.ts— 7 tests (P16-P20 + extras): script tag generation, quote/backslash escaping, placeholder targeting, replaceWith usage, hydration callback trigger
No deviations from design. Implementation matches DL#94 Phase 1 specification exactly.
Phase 2 & 3 — Server Element Target: Basics, Conditionals, forEach (Completed)
Files modified:
packages/compiler/compiler-jay-html/lib/jay-target/jay-html-compiler.ts— AddedServerContextinterface,renderServerNode(),renderServerElement(),renderServerElementContent(),renderServerOpenTag(),renderServerAttributes(),generateServerElementFile(). Handles static HTML, dynamic text/attributes, conditionals (if=), andforEachwithjay-coordinateattributes.packages/compiler/compiler-shared/lib/imports.ts— AddedImport.escapeHtml,Import.escapeAttr,Import.ServerRenderContextpackages/compiler/compiler-jay-html/lib/index.ts— ExportedgenerateServerElementFilepackages/compiler/compiler-jay-html/test/test-utils/file-utils.ts— AddedreadFixtureServerElementFile(),readFileAndGenerateServerElementFile()
Tests added (6 total, all passing):
test/jay-target/generate-server-element.test.ts:- basics: simple-dynamic-text, composite, refs, attributes
- conditions: conditions
- collections: collections (forEach)
Golden fixtures created: generated-server-element.ts in each fixture directory.
Phase 4 — Async Promise Streaming (Completed)
Files modified:
packages/compiler/compiler-jay-html/lib/jay-target/jay-html-compiler.ts— Added async handling to server element target:renderServerNodeAsString()/renderServerElementAsString()— Template string rendering mode that produces string concatenation expressions instead ofw()calls. Used insideonAsynctemplate callbacks.renderServerForEachAsString()— HandlesforEachinside resolved templates using.map().join('').renderServerAttributesAsString()— Attribute rendering for template string mode.collectAsyncGroups()— Groupswhen-loading,when-resolved,when-rejectedsiblings by property name.renderServerAsyncGroup()— Renders loading content inline with<div jay-async="propName:pending">wrapper, then emitsonAsync()call with resolved/rejected template functions.- Updated
renderServerElementContent()— Children processing now detects async groups, renders loading inline, skips resolved/rejected siblings, and emitsonAsync()after loading. - Updated
generateServerElementFile()— DestructuresonAsyncfrom ctx when async directives are present. - Updated
renderServerAttributes()— Filters async directive attributes. - Updated
hasInteractiveChildElements()— Includes async check.
Tests added (3 new, 9 total, all passing):
test/jay-target/generate-server-element.test.ts:- async: async-simple-types —
Promise<string>with loading/resolved/rejected - async: async-objects —
Promise<{ps2, pn2}>with loading/resolved - async: async-arrays —
Promise<Array<{ps3, pn3}>>with loading/resolved containing forEach
- async: async-simple-types —
Golden fixtures created: generated-server-element.ts in async-simple-types, async-objects, async-arrays directories.
Key design decisions:
- Template functions use string concatenation (
'<span>' + escapeHtml(String(val)) + '</span>') — notw()calls — because they're passed toonAsyncwhich returns the HTML string. - The resolved/rejected template's root element uses the property name as its
jay-coordinate(e.g.,jay-coordinate="p1"), matching the hydration convention. forEachinside resolved templates renders as.map((item) => ...).join('').- The
jay-asyncwrapper div is always emitted around loading content, even when the loading element itself is a div.
No deviations from design. Implementation matches DL#94 Phase 4 specification.
Phase 5 — Jay-Stack Integration (SSR + Hydration in Dev Server)
Files created:
packages/jay-stack/stack-server-runtime/lib/generate-ssr-response.ts—generateSSRPageHtml()functionpackages/jay-stack/stack-client-runtime/lib/hydrate-composite-component.ts—hydrateCompositeJayComponent()
Files modified:
packages/compiler/compiler-shared/lib/runtime-mode.ts— addedJAY_QUERY_HYDRATEconstantpackages/compiler/compiler-shared/lib/jay-module-specifier.ts— addedisHydratetoParsedJayModuleSpecifier, added hydrate pattern toJAY_QUERY_PATTERNSpackages/compiler/rollup-plugin/lib/runtime/generate-code-from-structure.ts— detect?jay-hydratequery, callgenerateElementHydrateFilewhen hydrate target requestedpackages/jay-stack/stack-client-runtime/lib/index.ts— exporthydrateCompositeJayComponentpackages/jay-stack/stack-server-runtime/lib/index.ts— exportgenerateSSRPageHtmlpackages/jay-stack/stack-server-runtime/package.json— added@jay-framework/ssr-runtimedependencypackages/jay-stack/dev-server/lib/dev-server.ts—sendResponse()now tries SSR first, falls back to client-only rendering on errorpackages/jay-stack/dev-server/test/dev-server.test.ts— updated test expectations for SSR output
Test results: 67/67 packages pass. TSC clean.
SSR flow (implemented):
sendResponse()reads jay-html, callsgenerateSSRPageHtml()generateSSRPageHtml()parses jay-html viaparseJayFile(), generates server element code viagenerateServerElementFile()- Writes server element TS to
<buildFolder>/server-elements/, loads viavite.ssrLoadModule() - Executes
renderToStream()with bufferedwrite()andonAsynchandler - Builds hydration script importing
hydratefrom?jay-hydratetarget and usinghydrateCompositeJayComponent - Returns full HTML page, processed by
vite.transformIndexHtml() - On SSR failure, falls back to client-only rendering via
generateClientScript()
Hydration flow:
- Client imports
hydratefrompage.jay-html?jay-hydrate(vite plugin generates hydrate target code) hydrateCompositeJayComponent()adapts the hydrate function signature(rootElement, options?) => [Refs, Render]to thePreRenderElementsignature expected bymakeJayComponentby bindingrootElement- No
target.appendChild— DOM is already present from SSR
Deviations from plan:
generateSSRPageHtmlacceptsprojectRootandtsConfigFilePathinstead of the fullJayRollupConfig(to avoid adding@jay-framework/rollup-pluginas a dependency of stack-server-runtime)- The
?jay-hydratequery is recognized directly in theparseJayModuleSpecifierinfrastructure (viaisHydratefield) rather than using a separate detection mechanism - Test pages that use
{{expr}}syntax (double braces) or have multiple root elements in body correctly fall back to client-only rendering — SSR requires valid jay-html single-brace syntax and single root element
Phase 5 Bug Fix — Coordinate Alignment Between Server Element and Hydrate Targets
Problem: Product pages crashed with Cannot read properties of undefined (reading 'dom') in hydrateConditional. Two root causes:
Coordinate counter divergence. The server element target only assigned
jay-coordinateto elements with dynamic content (text, attributes, refs). Conditional elements with static content (e.g.,<div if="cond">static text</div>) got no coordinate. The hydrate target always assigns coordinates to conditionals viacontext.coordinateCounter.count++in its conditional handler. After the first static-content conditional, all subsequent coordinates were misaligned between the two targets — the hydrate code tried to adopt elements at wrong coordinates.hydrateConditionalcrash on static-content conditionals. When a conditional has only static content,renderHydrateElementContentdeterminedneedsAdoption = falseand returned empty content. The adopt callback became() => {}(returnsundefined), andhydrateConditionalcrashed accessing.domonundefined.
Fix:
- Server element target: pass
forceCoordinate: truetorenderServerElementContentfor conditional elements (jay-html-compiler.ts:2528) - Hydrate target: pass
forceAdopt: truetorenderHydrateElementContentfor conditional elements (jay-html-compiler.ts:2052) - Runtime:
hydrateConditionaldefensively handlesadopted === undefined(hydrate.ts:178)
Key insight: The server element and hydrate targets share a coordinate counter convention. Any element that receives a coordinate in one target MUST receive the same coordinate in the other. Conditionals always consume a coordinate in the hydrate target (via the handler's count++), so the server target must do the same.
Additional finding: The homepage SSR falls back to client rendering because enum values (e.g., CurrentMood from if="mt.currentMood === happy") are not imported in the generated server element file. This is a known limitation for Phase 5 — enum support in SSR will need the server element generator to emit enum imports.
Phase 5 Bug Fix — Enum Types in Server Element Target (Resolved)
Problem: SSR failed with [type] is not defined for pages using headless contracts with enum types. The generated server-element.ts referenced enum values (e.g., Selected.selected, StockStatus.IN_STOCK, IsPositive.positive) in conditions and class expressions, but never made them available. The client-side generateElementFile handled this correctly by importing enum types from contract modules.
Root cause: generateServerElementFile() only generated an import for @jay-framework/ssr-runtime. It had no logic to include enum types from headless contract imports (jayFile.headlessImports).
Why imports don't work for SSR: The SSR output lives in build/server-elements/ while the module paths in jayFile.imports are relative to the original source file location. These paths don't resolve from the server-element output directory. Attempting to import enums with the source-relative paths causes ENOENT: no such file or directory.
Solution: Inline enum definitions in the server element file instead of importing them. This makes the SSR file self-contained and avoids path resolution issues. The approach mirrors how inline data enums already work via generateTypes().
Files modified:
packages/compiler/compiler-jay-html/lib/jay-target/jay-html-compiler.ts— Added enum collection loop ingenerateServerElementFile()(lines 3321-3339). IteratesjayFile.headlessImports, finds allJayEnumTypenames viaisEnumType(), deduplicates withseenEnumsset, generates inlineenum Name { value1, value2 }definitions, and includes them in the output between the ssr-runtime import and the ViewState interface.
Tests added (1 new, 10 total server element tests passing):
test/jay-target/generate-server-element.test.ts—conditions > for headless enum conditions: uses existingcontracts/page-using-counterfixture with headless counter contract containingIsPositiveenum. Verifies the generated server element file includes an inlineenum IsPositive { positive, negative }definition and uses it inif (vs.counter?.isPositive === IsPositive.positive)conditions.
Golden fixture created: test/fixtures/contracts/page-using-counter/generated-server-element.ts
Test results: 537 passing (533 + 4 skipped), 0 regressions.
Phase 5 Bug Fix — CSS and Head Links Missing from SSR Response (Resolved)
Problem: After the enum fix enabled SSR to render successfully, the page lost all CSS styling. Two separate issues:
SSR
<head>was hardcoded —generateSSRPageHtml()used a static<head>with only<meta charset>,<meta viewport>, and<title>. The jay-html's<link rel="stylesheet">tags and inline<style>blocks were ignored entirely.Hydrate file missing CSS import —
generateElementHydrateFile()did not includeimport './page.css'(the compiled inline CSS), whilegenerateElementFile()did. When the client imported the hydrate module, Vite couldn't discover the CSS import chain.Vite plugin didn't recognize hydrate importer for CSS —
hasCssImportedByJayHtml()in the rollup plugin only recognized.jay-htmland.jay-html?jay-mainSandboximporters. The hydrate target's.jay-html?jay-hydratewas not recognized, causing the CSS import to fail withFailed to resolve import "./page.css".
How CSS works in the client-only path (for reference):
- Inline
<style>blocks are extracted by the compiler into a.cssfile and imported viaimport './page.css'ingenerateElementFile()output - External
<link rel="stylesheet">tags are parsed asheadLinksand injected dynamically viainjectHeadLinks()at runtime inside therender()function - Vite's
transformIndexHtml()follows the module import chain, discovers the CSS import, and injects<style>tags into the HTML
Solution — three files changed:
packages/compiler/compiler-jay-html/lib/jay-target/jay-html-compiler.ts— AddedgenerateCssImport(jayFile)togenerateElementHydrateFile()output array, matching whatgenerateElementFile()already does.packages/compiler/rollup-plugin/lib/runtime/resolve-id.ts— AddedJAY_QUERY_HYDRATEto thehasCssImportedByJayHtml()check so the Vite plugin recognizes CSS imports frompage.jay-html?jay-hydrateimporters and resolves them to the jay-html's extracted CSS.packages/jay-stack/stack-server-runtime/lib/generate-ssr-response.ts— ExtendedCachedServerModuleto storeheadLinksandcssfrom the parsed jay-html. UpdatedcompileAndLoadServerElement()to return these alongsiderenderToStream. UpdatedgenerateSSRPageHtml()to render<link>tags and inline<style>blocks from the jay-html into the SSR response's<head>section.
CSS now reaches the browser through two channels:
- Initial paint: SSR
<head>includes inline<style>and<link>tags directly — no JavaScript needed - Hydration: Vite discovers CSS via the hydrate module's
import './page.css'and processes it throughtransformIndexHtml()
Key architectural insight: The SSR output directory (build/server-elements/) is separate from the source file tree. Any generated code that lives in this directory cannot use source-relative import paths (this principle also applies to the enum fix above). For CSS, the solution is to include it directly in the HTML <head> rather than rely on import resolution. For the hydrate target (which Vite generates on-the-fly at the source location), CSS imports work but only if the plugin's import resolver recognizes the ?jay-hydrate query suffix.
Phase 5 Bug Fix — Duplicate Ref Declarations in Hydrate forEach
Problem: Pages with refs inside forEach produced flat ref trees in the hydrate output instead of nested ones, causing incorrect ref manager structure and potential duplicate declarations.
Root cause: The hydrate forEach handler was missing nestRefs(forEachAccessPath, ...) which the standard element target uses to nest child refs under the forEach access path. It was also missing dynamicRef: true. Additionally, deDuplicateRefsTree had a broken comparison (refsMap[ref.ref] === ref.ref — object vs string, always false), and equalJayTypes had bugs in JayObjectType comparison (a[prop] instead of a.props[prop], .map() instead of .every()).
Fix: Added nestRefs, dynamicRef: true, and adopt-only refs to the hydrate forEach handler. Fixed deDuplicateRefsTree to use proper Map-based comparison. Fixed equalJayTypes in compiler-shared. See DL#93 "Phase 3 Bug Fix — Duplicate Ref Declarations in Hydrate forEach" for full details and tests.
Bug Fix — Dynamic Style Attributes Not Rendered in SSR
Problem: style="background-color: {colorCode}" rendered as the literal string background-color: {colorCode} in SSR output instead of interpolating the variable. Discovered on the golf project's product page where color swatches had style="background-color: {colorCode}".
Root cause: In jay-html-compiler-server.ts, both renderServerAttributes() and renderServerAttributesAsString() had a special case for style attributes that treated them as completely static — escaping and emitting the raw string without ever calling parseServerTemplateExpression() to detect {variable} bindings:
} else if (attrCanonical === 'style') {
const escaped = attrValue.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
parts.push(w(indent, `' style="${escaped}"'`));
}
All other attributes (data-, aria-, custom) correctly used parseServerTemplateExpression() to check for dynamic bindings. The class attribute also had proper dynamic handling via parseClassExpression(). Only style was broken.
Fix: Both locations now use parseServerTemplateExpression() to check for dynamic bindings, matching how other attributes are handled. Static styles still output directly; dynamic styles interpolate variables via escapeAttr(String(...)).
Files modified:
packages/compiler/compiler-jay-html/lib/jay-target/jay-html-compiler-server.ts— Two locations:renderServerAttributes()andrenderServerAttributesAsString(). Both now parse style values for dynamic expressions.
Test added:
test/jay-target/generate-server-element.test.ts— "for style bindings with dynamic values": fully dynamic, mixed static+dynamic, kebab-case properties, fully static, complex real-world styles.test/fixtures/basics/style-bindings/generated-server-element.ts— Golden fixture.
Test results: 627/627 passing, 0 regressions.
Resolved — CSS Served via Vite <link> Tag (was: Duplicate CSS in SSR Response)
Previously CSS loaded twice in SSR dev mode: once as inline <style> in the SSR <head>, and again via the hydrate module's CSS import.
Fix: compileAndLoadServerElement writes extracted CSS to a file in the build folder (beside the server element), then emits a <link rel="stylesheet" href="/@fs/..."> in the SSR <head>. The /@fs/ prefix is needed because Vite's root is pagesRoot, and the build folder is outside it. Removed the CSS import from the hydrate target to eliminate duplication.
Changes:
stack-server-runtime/lib/generate-ssr-response.ts— ChangedCachedServerModule.css→cssHref, write CSS file tobuild/pre-rendered/{routeDir}/, emit<link>with/@fs/URL instead of inline<style>compiler-jay-html/lib/jay-target/jay-html-compiler.ts— RemovedgenerateCssImport()fromgenerateElementHydrateFile()
Result: Single CSS load via real file — no FOUC (render-blocking link), no duplication.
Test results: 627/627 compiler-jay-html tests passing, 47/47 rollup-plugin tests passing.
Bug Fix — forEach on Nested Optional Path Crashes SSR (Resolved)
Problem: When a forEach iterates over a nested property path like p.extendedFields.sizesExtraData, the Accessor.render() method produces vs.p?.extendedFields?.sizesExtraData (using optional chaining between segments). When an intermediate property like extendedFields is undefined, the optional chaining correctly returns undefined — but for...of undefined throws TypeError: undefined is not iterable.
Root cause: The server element compiler generated for (const vs1 of vs.p?.extendedFields?.sizesExtraData) without guarding against undefined. The client-side and hydrate compilers don't have this issue because they pass the accessor as a callback to the runtime forEach function, which handles undefined internally.
Fix: In jay-html-compiler-server.ts, both for...of generation (line 180) and .map().join('') generation (line 569) now detect optional chaining in the array expression and wrap it with ?? []:
for (const vs1 of (vs.p?.extendedFields?.sizesExtraData ?? []))— iterates empty array if path is undefined(vs.p?.extendedFields?.sizesExtraData ?? []).map(...)— maps over empty array if path is undefined
The guard is only added when the expression contains ?. — simple paths like vs.things are left unchanged.
Files modified:
packages/compiler/compiler-jay-html/lib/jay-target/jay-html-compiler-server.ts— Two locations:renderServerElement()forEach handler andrenderServerForEachAsString().
Test added:
test/jay-target/generate-server-element.test.ts— "for forEach on nested optional path emits ?? [] guard"test/fixtures/collections/foreach-nested-optional/— jay-html withforEach="p.extendedFields.sizesExtraData"and golden fixture verifying?? []in output.
Test results: 648/648 passing, 0 regressions.
Log Methodology Note
Note: These design logs are written primarily for AI agents as part of the Design Log methodology and made accessible here for human readers. The language and structure are optimized for machine consumption — expect precise, specification-style prose rather than narrative documentation.