Problem
html`<form action=${importedAction}>`silently leaks server source into the SSR'd HTML. At SSR a'use server'import is the REAL function (the RPC stub only exists in the browser), andaction=is an ordinary escaped attribute hole, so the rendererString()s the function into the attribute. Verified against render-server.js` on 2026-07-28:
<form action="async function createTodo(input) { const DB_SECRET = 'postgres://user:pw@host'; return { success: true }; }" method="post">
This is exactly what a Next.js-trained agent writes on day one (<form action={serverAction}> is the canonical Next form), so it is a footgun with a security payload: whatever the action closure shows (connection strings, internal paths, query shapes) ships to every visitor. Quoted (action="${fn}") and unquoted (action=${fn}) holes both leak identically (verified).
Design / approach
Make a function-valued action/formaction attribute hole a hard error at render time, never a stringify. This is the standalone security floor; the companion issue (unify form submissions on <form action=${fn}>, filed alongside, same PR) then turns the same hole shape into the real feature. The error message should point at that feature once it exists ("a form action must be a 'use server' action; got a plain function" or similar), and in the interim just refuse loudly.
Scope the claim precisely:
<form action=${fn}> and <form action="${fn}"> with a function value: throw (both commit through the same sites, verified byte-identical today).
<button formaction=${fn}> / <input formaction=${fn}>: same treatment, same leak class.
- A function inside a MIXED hole (
action="/x/${fn}"): throw too; there is no sane serialization.
- A string/URL value: byte-identical passthrough to today (regression-gate this).
- Any OTHER attribute receiving a function still stringifies as today; do not widen the claim beyond
action/formaction in this issue (the generic case is arguably also a bug, but changing it here risks collateral behaviour change).
Implementation notes (for the implementing agent)
- Where to edit (server renderer):
packages/core/src/render-server.js, the plain-attribute commit in the after-eq state: L326 (quoted, out += `"${escapeAttr(String(val ?? ''))}"`) and L331 (unquoted). The current tag name is available as currentTagand the attribute name asattrNamein that state machine; branch ontypeof val === 'function'+ attr nameaction/formactionbefore theString()`.
- Where to edit (client renderer):
packages/core/src/render-client.js L674 (part.el.setAttribute(part.name, String(value))) is the client-side commit for attribute parts; a client re-render of the same template would set the leaked source as the live DOM attribute. Same guard there (the part knows its element and name).
- Landmines:
- The SSR renderer is a single-pass string state machine, not a DOM builder; the attribute name at L326/331 carries no binding prefix (plain attribute), so you are in the
kind === undefined fall-through, not the event/prop/bool branches above it.
escape.js escapeAttr does not escape ' (documented at render-server.js:568); irrelevant once you throw, but do not "fix" it in passing.
- Do NOT throw on
webjs-suspense's .fallback or other prop bindings; those route through earlier branches and never reach L326.
- Error surfacing: a page-render throw goes through the existing error-boundary path and (dev) the overlay via
ssrOpts.onDevError; throwing a plain Error with a clear message is enough, no new plumbing.
- Invariants: AGENTS.md invariant 4 (unquoted holes for
@/./?) is untouched; action= is a plain attribute. The differential-elision invariant is untouched (no elision change).
- Tests:
- Unit (core):
packages/core/test/rendering/, new test: function-valued action/formaction (quoted, unquoted, mixed) throws; string action="/x" renders byte-identically (counterfactual: the throw test fails when the guard is reverted).
- Client side: a browser test asserting the client renderer refuses the same commit (
test/**/browser/ layer, run via npm run test:browser).
- Bun parity: the renderer is runtime-neutral core; no listener/serializer surface touched, so no new
test/bun/* file is required for THIS issue (the unification issue does require one).
- Docs surfaces:
.agents/skills/webjs/references/muscle-memory-gotchas.md (the Next-habit table: <form action={fn}> entry) + references/data-and-actions.md. Root AGENTS.md only if the unification issue does not land in the same PR (it will; let that issue own the doc rewrite).
Acceptance criteria
Problem
html`<form action=${importedAction}>`silently leaks server source into the SSR'd HTML. At SSR a'use server'import is the REAL function (the RPC stub only exists in the browser), andaction=is an ordinary escaped attribute hole, so the rendererString()s the function into the attribute. Verified againstrender-server.js` on 2026-07-28:This is exactly what a Next.js-trained agent writes on day one (
<form action={serverAction}>is the canonical Next form), so it is a footgun with a security payload: whatever the action closure shows (connection strings, internal paths, query shapes) ships to every visitor. Quoted (action="${fn}") and unquoted (action=${fn}) holes both leak identically (verified).Design / approach
Make a function-valued
action/formactionattribute hole a hard error at render time, never a stringify. This is the standalone security floor; the companion issue (unify form submissions on<form action=${fn}>, filed alongside, same PR) then turns the same hole shape into the real feature. The error message should point at that feature once it exists ("a form action must be a'use server'action; got a plain function" or similar), and in the interim just refuse loudly.Scope the claim precisely:
<form action=${fn}>and<form action="${fn}">with a function value: throw (both commit through the same sites, verified byte-identical today).<button formaction=${fn}>/<input formaction=${fn}>: same treatment, same leak class.action="/x/${fn}"): throw too; there is no sane serialization.action/formactionin this issue (the generic case is arguably also a bug, but changing it here risks collateral behaviour change).Implementation notes (for the implementing agent)
packages/core/src/render-server.js, the plain-attribute commit in theafter-eqstate: L326 (quoted,out +=`"${escapeAttr(String(val ?? ''))}"`) and L331 (unquoted). The current tag name is available ascurrentTagand the attribute name asattrNamein that state machine; branch ontypeof val === 'function'+ attr nameaction/formactionbefore theString()`.packages/core/src/render-client.jsL674 (part.el.setAttribute(part.name, String(value))) is the client-side commit for attribute parts; a client re-render of the same template would set the leaked source as the live DOM attribute. Same guard there (the part knows its element and name).kind === undefinedfall-through, not theevent/prop/boolbranches above it.escape.jsescapeAttrdoes not escape'(documented atrender-server.js:568); irrelevant once you throw, but do not "fix" it in passing.webjs-suspense's.fallbackor other prop bindings; those route through earlier branches and never reach L326.ssrOpts.onDevError; throwing a plainErrorwith a clear message is enough, no new plumbing.@/./?) is untouched;action=is a plain attribute. The differential-elision invariant is untouched (no elision change).packages/core/test/rendering/, new test: function-valuedaction/formaction(quoted, unquoted, mixed) throws; stringaction="/x"renders byte-identically (counterfactual: the throw test fails when the guard is reverted).test/**/browser/layer, run vianpm run test:browser).test/bun/*file is required for THIS issue (the unification issue does require one)..agents/skills/webjs/references/muscle-memory-gotchas.md(the Next-habit table:<form action={fn}>entry) +references/data-and-actions.md. RootAGENTS.mdonly if the unification issue does not land in the same PR (it will; let that issue own the doc rewrite).Acceptance criteria
renderToString(html`<form action=${fn}>`)throws with a message naming the fix; no function source ever reaches the emitted HTMLformactiononbutton/inputactionoutput is byte-identical to before (regression test)