ReviewOS

stacks/stx

renderTemplate() drops the entire <script client> block when a server-block string literal contains a complete <script> tag

#1904
Closed glennmichael123 opened this 14 days ago · 1 comment
14 days ago

Summary

If a string literal in a <script server> block contains a complete opening script tag, renderTemplate() silently drops the page's entire <script client> block. No setup function is emitted, no server→client bridge is generated, and the response is still 200.

The server block itself runs correctly — template expressions reading its bindings resolve and escape properly — so the page looks right and is completely inert. This is the "answered 200 and did nothing" class from the August sweep handoff, in a new consumer.

The serve path handles the same input correctly. This is specific to renderTemplate().

Reproduction

<script server>
const alpha = "VAL <script>x"
defineClientPayload({ alpha })
</script>
<script client>
const n = state(0)
const use = alpha
</script>
<div :text="n()">0</div>
const html = await renderTemplate('repro.stx', {})
/const n = state\(0\)/.test(html)          // false — client block gone
/__stx_setup_\d+_\d+/.test(html)           // false — no setup function
/server -> client data bridge/.test(html)  // false — no bridge

Replace the value with "VAL <div>x" and all three are true.

Exact trigger

Only a complete opening tag triggers it. Measured on the same template, varying only the string contents:

value containsclient block
<script>xdropped
<SCRIPT>xdropped (also logs 0 opened, 1 closed — 1 stray </script>)
<scriptok
</scriptok
a<scriptbok
script>xok
<div>xok

Scope

  • Affects both the declared bridge (defineClientPayload) and the implicit name-scraping bridge. Both were tested; both lose everything, so it is not a payload-declaration bug.
  • The server block still executes: {⁠{ alpha }} in the body renders VAL &lt;script&gt;x, correctly escaped.
  • Not reproducible on the serve path. The same template served by the dev server bridges both a <script>-containing value and a plain one, and ships the client block. So process.ts gets this right and render.ts does not.

renderTemplate is not a dead export — native-build.ts:197, menubar.ts:201 and the documented edge-runtime usage all go through it. An app rendered at the edge or built natively loses every @click, :text and signal on any page whose server block happens to mention a script tag in a string.

Why it is easy to hit

The natural cases are exactly the ones an app has: an install/onboarding snippet, a docs example, an embed code generator, a CSP or analytics tag builder. A settings page that hands the user a copy-paste browser snippet is the canonical shape.

Same family as the closed component-scanner issue where JS string literals like '<v:shape>' were mis-resolved as components, and as the open typecheck issue where a script tag inside an HTML comment opens a real block. The pattern each time is a tag scanner reading text that is inside a string or a comment. The handoff's own note — one rule, one implementation — looks relevant: whatever process.ts uses to find script boundaries is getting this right, and render.ts is not.

14 days ago

Fixed in 8a01763789. All three of your checks are true now.

renderTemplate removed server blocks by depth-counting <script occurrences, so a complete opening tag inside a string literal counted as a nested element and demanded a second closing tag. The range then ran past the server block and swallowed the whole <script client>.

Script elements do not nest, and a browser's parser does not treat them as nesting either — once in script-data state only an end tag leaves it, which is exactly why the emitters in this codebase escape < as < when writing a value into a script body. So an element ends at its first </script>, full stop.

Two changes, and the second is what actually finished it. Removing the depth count alone left your repro still failing: the opening-tag scan then matched the <script> inside the string as a second server block and pushed an overlapping removal range into the block below. The scan now resumes after each element it consumes.

Your "exact trigger" table was what made this quick — that only a complete opening tag fires it points straight at a tag scan rather than at anything to do with string parsing.

4 tests, including that the server block is still removed (a fix that stopped removing it would ship server source to the browser) and that a </script> in a string is handled too.

Sign in to comment on this issue.