ReviewOS

stacks/stx

compileTemplate executes server scripts at build time and freezes their values into the HTML, against its own stated contract

#1895
Closed glennmichael123 opened this 11 days ago ·
11 days ago

template-compiler.ts says, at the point it builds the context:

// Create a recording context — server scripts are NOT executed at build time
// (they may depend on request data like session, DB queries, etc.)
const { context } = createRecordingContext({ … })

They are executed, and whatever they produce is baked into the compiled HTML. Two builds of the same unchanged file, thirty milliseconds apart:

a timestamp    placeholders=0  build1=<p>2026-08-10T12:05:07.563Z</p>  build2=<p>2026-08-10T12:05:07.635Z</p>
a random id    placeholders=0  build1=<p>4zlq0bm1fth</p>              build2=<p>jbi2puezz6c</p>
<script server>
const builtAt = new Date().toISOString()
</script>
<p>{{ builtAt }}</p>

Every visitor to that page in production sees the moment the build ran, forever. No placeholder is emitted, so hydrateTemplateStream has nothing to fill and the request-time execution of the same server block cannot reach the output.

What decides it

A placeholder is only created for an expression the compiler could not resolve:

server var declared in the block   placeholders=0   html="<h1>built</h1>"
var never declared anywhere        placeholders=1   html="<h1><!--__STX_EXPR_0__--></h1>"
value read off __stxServeContext   placeholders=1   html="<h1><!--__STX_EXPR_0__--></h1>"

So the rule in practice is whether the value happened to be computable at build time, not whether it is request-dependent. A page reading __stxServeContext is safe by accident — the value is undefined during the build. A page computing anything itself is frozen.

Why it is easy to miss

The failure is invisible for the majority of pages, because most server blocks compute something genuinely static — a title, a nav list, a count. Those bake correctly and the page is right. It only shows for a value that must differ per request, and then it shows as a stale value rather than a missing one, which reads as a caching problem rather than a build problem.

How I hit it

Wiring the production half of #1847. A page written the natural way:

<script server>
export async function action({ form }) {
  if (!String(form.email).includes('@'))
    return { errors: 'Enter a valid email address.', values: form.email }
  return { redirect: '/welcome' }
}
const errors = ''
const values = ''
</script>
<form method="POST">
  <input name="email" value="">
  <p>{{ errors }}</p>
</form>

const errors = '' is what makes the GET render clean. It also freezes both expressions to the empty string at build time, so the action runs, returns its message, merges it into the render context — and the template it merges into no longer has anywhere to put it. The redirect branch works, because that path never renders.

Removing the two initialisers makes it work, which is not a rule anyone would guess.

Asks

  1. Decide what the contract actually is, and make the code and the comment agree. The comment describes the behaviour I would expect; the code does something else.
  2. If server scripts are meant to run at build time, a page needs a way to say a value is request-dependent. If they are not, createRecordingContext needs to be what actually feeds the pipeline.
  3. Either way, a page that exports action is request-dynamic by definition and should not have its expressions frozen.
  4. A test that compiles the same source twice and asserts the output is identical would have caught this, and would keep catching it.

Found while working on #1847; blocks its production path for the natural authoring shape.

11 days ago

Fixed in 925964a2af.

An expression that reads a name the server block declares is now a placeholder, resolved per request by hydrateTemplateStream — which re-runs the same block — instead of a baked build-time value. Two builds of an unchanged page are byte-identical:

a timestamp   before: build1=<p>2026-08-10T12:05:07.563Z</p>  build2=<p>…07.635Z</p>
              after:  both <p><!--__STX_EXPR_0__--></p>, resolved per request

On ask 1 — which way to resolve the contradiction

Not the way the comment claimed. Server scripts have to run at build time: loops and conditionals need real values to produce the page's structure, and a placeholder cannot stand in for markup that does not exist yet. Deferring them would lose the page's shape, not just its values — so the block still runs, the comment is corrected to say so, and only the values defer. Pinned by a test that a @foreach still emits its rows at compile time.

That also answers ask 2 without needing new syntax: a page does not have to mark a value request-dependent, because reading a server-declared name IS the marker. car.range does not count as reading the server's range — the check is on free identifiers, with comments and string literals blanked first, the same rule the data bridge uses.

Ask 3 comes free: a page exporting action reads its own server block by construction, so its expressions were already covered.

Ask 4 — a test compiling the same source twice and asserting identical output — is in, over a timestamp and a random value.

Scope

compileTemplate is used only by production-builder.ts, whose server always hydrates, so nothing renders a placeholder to a browser. ssg.ts does not use this path.

It lifts the limitation #1847 shipped with

const errors = ''    // for a clean GET render
const values = ''

was the shape that silently did not repopulate: both expressions froze to the empty string, so a page action ran, returned its message, and had nowhere to put it. The test that asserted that broken behaviour with this issue number on it has been flipped to assert the fix — which is what it was written for.

7 new tests here, plus the 13 on the action path.

Sign in to comment on this issue.