You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
A function interpolated into an on* inline-handler attribute is stringified into the served HTML, exactly like the <form action=${fn}> leak that #1154 / #1167 closed. If that function is an imported 'use server' action, its whole body ships to every visitor.
Reproduced against packages/core/src on the #1167 branch:
onsubmit=${fn} and camelCase onClick=${fn} leak identically, verified for all three. The correct @click=${fn} form is unaffected (an event listener, dropped at SSR).
It also fails silently. A browser parses that attribute as an inline handler body containing only a function DECLARATION, so nothing happens on click and the author gets no signal that anything is wrong. There is no webjs check rule for a hole in an on* attribute and no dev warning.
Severity, stated honestly. This is a sibling of #1169 rather than a repeat of #1167, and it is lower severity than the action= leak. The counter-argument raised in review is correct and worth carrying: React's onClick={fn} takes a CLIENT handler, whose source already ships to the browser inside its component module, so nothing is disclosed in the ordinary case. The catastrophic case needs an author to bind an imported 'use server' action to an inline on* attribute, which is not a Next idiom the way <form action={serverAction}> is. Still a real source-disclosure path with nothing guarding it.
Design / approach
The guard added by #1167 lives in packages/core/src/form-action.js and is name-based on purpose: isFormActionAttr matches action / formaction only, so every other attribute keeps today's stringify behaviour. #1167 pins that scope boundary deliberately with a test (functions in OTHER attributes keep the existing stringify behaviour), so widening the predicate means updating that test rather than working around it.
Two directions, and they are not exclusive:
Extend the refusal to on* names, in the same isFormActionAttr (rename it) so all four commit sites inherit it at once. Consistent with the existing guard and cheap. The error message should say to use @click=${fn}, since that is what the author meant.
A webjs check rule, catching a function-valued hole in an on* attribute statically. Better authoring feedback than a render-time throw, but it cannot see a value that is only function-typed at runtime.
Option 1 is the one that actually closes the disclosure; option 2 is the nicer teaching surface. Note that unlike action=, an on* attribute with a STRING value is legitimate HTML that some authors do write, so the refusal must key on the value being a function, which is what carriesFunction already does.
Consider doing this together with #1169 so the three leak paths (attribute, reflected prop, inline handler) get one coherent answer rather than three differently-shaped ones.
Implementation notes (for the implementing agent)
Where to edit:
packages/core/src/form-action.js: isFormActionAttr() L87-91 is the name predicate, assertNotFunctionActionAttr() L35-39 the entry point, formActionError() L99-104 the message. carriesFunction() L65-72 already handles the array-wrapped case and a cyclic array, reuse it unchanged.
packages/core/src/render-server.js L341 (quoted attr) and L349 (unquoted) in the buffered machine renderTemplate
packages/core/src/render-server.js L1928 / L1934, the same two branches in the streaming machine streamTemplate
packages/core/src/render-client.jsapplyPart L673 case 'attr', L684 case 'prop', L696 case 'bool', L713 case 'attr-mixed'
If adding a check rule: packages/server/src/check.js (rule list; there is currently no on* rule of any kind).
Landmines:
The SSR state machines accumulate the AUTHORED attribute name, so casing survives to the predicate and only its .toLowerCase() collapses it. That call had NO test until fix: refuse a function in form action=, it leaked server action source #1167's later rounds; do not remove or reorder it. camelCase onClick= is a real arrival shape, so test it explicitly.
A quoted binding hole keeps its sigil, so @click="${fn}" arrives at the predicate as @click with the sigil attached. isFormActionAttr strips one leading sigil via isBindingPrefix before comparing. An on* extension has to decide what @onclick means; do not let sigil-stripping accidentally turn a legitimate @click listener into a refusal, which is the carve-out that matters most here.
The UNQUOTED @click=${fn} event binding must stay legal, it is the correct API. fix: refuse a function in form action=, it leaked server action source #1167 has explicit carve-out tests for exactly this shape on the action name; mirror them, because a guard that refuses everything would still pass a naive test suite.
AGENTS.md invariant 4 (event / property / boolean holes must be unquoted) is the reason the quoted form is refusable at all.
AGENTS.md invariant 1 (server-only code never reaches the browser) is the property being defended.
Tests + docs surfaces:
Unit, both SSR machines and the client: extend packages/core/test/rendering/form-action-attr-guard.test.js and form-action-attr-guard-client.test.js. The scope-boundary tests there (functions in OTHER attributes keep the existing stringify behaviour, and its streaming twin) assert the CURRENT narrow claim and will red by design; update them rather than deleting them, so the new boundary stays pinned.
Bun parity: test/bun/form-action-guard.mjs carries a refused table and an allowed carve-out table; add rows to both.
Browser: packages/core/test/rendering/browser/form-action-guard.test.js if the reflected-DOM path is involved.
Docs: website/app/docs/troubleshooting/page.ts (the action= entry is the model), .agents/skills/webjs/references/muscle-memory-gotchas.md, and website/app/docs/components/page.ts, whose binding-prefix section already carries an action= carve-out note that would need widening.
Acceptance criteria
A function in onclick=, onsubmit=, and camelCase onClick= is refused on the buffered SSR machine, the streaming machine, and the client renderer
An unquoted @click=${fn} event listener still works, on every renderer
A string-valued on* attribute renders byte-identically to today
The thrown message never embeds the source it is withholding
A counterfactual proves each new call site's test reds when that site alone is reverted
Bun parity row added to test/bun/form-action-guard.mjs and green on Node and Bun
Problem
A function interpolated into an
on*inline-handler attribute is stringified into the served HTML, exactly like the<form action=${fn}>leak that #1154 / #1167 closed. If that function is an imported'use server'action, its whole body ships to every visitor.Reproduced against
packages/core/srcon the #1167 branch:onsubmit=${fn}and camelCaseonClick=${fn}leak identically, verified for all three. The correct@click=${fn}form is unaffected (an event listener, dropped at SSR).It also fails silently. A browser parses that attribute as an inline handler body containing only a function DECLARATION, so nothing happens on click and the author gets no signal that anything is wrong. There is no
webjs checkrule for a hole in anon*attribute and no dev warning.Severity, stated honestly. This is a sibling of #1169 rather than a repeat of #1167, and it is lower severity than the
action=leak. The counter-argument raised in review is correct and worth carrying: React'sonClick={fn}takes a CLIENT handler, whose source already ships to the browser inside its component module, so nothing is disclosed in the ordinary case. The catastrophic case needs an author to bind an imported'use server'action to an inlineon*attribute, which is not a Next idiom the way<form action={serverAction}>is. Still a real source-disclosure path with nothing guarding it.Design / approach
The guard added by #1167 lives in
packages/core/src/form-action.jsand is name-based on purpose:isFormActionAttrmatchesaction/formactiononly, so every other attribute keeps today's stringify behaviour. #1167 pins that scope boundary deliberately with a test (functions in OTHER attributes keep the existing stringify behaviour), so widening the predicate means updating that test rather than working around it.Two directions, and they are not exclusive:
on*names, in the sameisFormActionAttr(rename it) so all four commit sites inherit it at once. Consistent with the existing guard and cheap. The error message should say to use@click=${fn}, since that is what the author meant.webjs checkrule, catching a function-valued hole in anon*attribute statically. Better authoring feedback than a render-time throw, but it cannot see a value that is only function-typed at runtime.Option 1 is the one that actually closes the disclosure; option 2 is the nicer teaching surface. Note that unlike
action=, anon*attribute with a STRING value is legitimate HTML that some authors do write, so the refusal must key on the value being a function, which is whatcarriesFunctionalready does.Consider doing this together with #1169 so the three leak paths (attribute, reflected prop, inline handler) get one coherent answer rather than three differently-shaped ones.
Implementation notes (for the implementing agent)
Where to edit:
packages/core/src/form-action.js:isFormActionAttr()L87-91 is the name predicate,assertNotFunctionActionAttr()L35-39 the entry point,formActionError()L99-104 the message.carriesFunction()L65-72 already handles the array-wrapped case and a cyclic array, reuse it unchanged.packages/core/src/render-server.jsL341 (quoted attr) and L349 (unquoted) in the buffered machinerenderTemplatepackages/core/src/render-server.jsL1928 / L1934, the same two branches in the streaming machinestreamTemplatepackages/core/src/render-client.jsapplyPartL673case 'attr', L684case 'prop', L696case 'bool', L713case 'attr-mixed'packages/server/src/check.js(rule list; there is currently noon*rule of any kind).Landmines:
.toLowerCase()collapses it. That call had NO test until fix: refuse a function in form action=, it leaked server action source #1167's later rounds; do not remove or reorder it. camelCaseonClick=is a real arrival shape, so test it explicitly.@click="${fn}"arrives at the predicate as@clickwith the sigil attached.isFormActionAttrstrips one leading sigil viaisBindingPrefixbefore comparing. Anon*extension has to decide what@onclickmeans; do not let sigil-stripping accidentally turn a legitimate@clicklistener into a refusal, which is the carve-out that matters most here.@click=${fn}event binding must stay legal, it is the correct API. fix: refuse a function in form action=, it leaked server action source #1167 has explicit carve-out tests for exactly this shape on the action name; mirror them, because a guard that refuses everything would still pass a naive test suite.Invariants to respect:
Tests + docs surfaces:
packages/core/test/rendering/form-action-attr-guard.test.jsandform-action-attr-guard-client.test.js. The scope-boundary tests there (functions in OTHER attributes keep the existing stringify behaviour, and its streaming twin) assert the CURRENT narrow claim and will red by design; update them rather than deleting them, so the new boundary stays pinned.test/bun/form-action-guard.mjscarries arefusedtable and anallowedcarve-out table; add rows to both.packages/core/test/rendering/browser/form-action-guard.test.jsif the reflected-DOM path is involved.website/app/docs/troubleshooting/page.ts(theaction=entry is the model),.agents/skills/webjs/references/muscle-memory-gotchas.md, andwebsite/app/docs/components/page.ts, whose binding-prefix section already carries anaction=carve-out note that would need widening.Acceptance criteria
onclick=,onsubmit=, and camelCaseonClick=is refused on the buffered SSR machine, the streaming machine, and the client renderer@click=${fn}event listener still works, on every rendereron*attribute renders byte-identically to todaytest/bun/form-action-guard.mjsand green on Node and Bun