fix: refuse a function in form action=, it leaked server action source - #1167
Conversation
At SSR a `'use server'` import resolves to the REAL function (the RPC stub
exists only in the browser), and `action=` is an ordinary escaped attribute
hole, so the renderer stringified the function straight into the served
HTML. A page doing what every Next-trained author writes,
`<form action=${createTodo}>`, published the action's whole body, including
any connection string or internal path its closure showed, to every visitor.
Both hole shapes leaked identically (`action=${fn}` and `action="${fn}"`),
as did a mixed value and `formaction=` on a submit button.
Refuse it instead, on the server and on the client, so a client re-render
cannot write the source into the live DOM either. The guard is name-based
and deliberately narrow: only `action` and `formaction`, where a stringified
function is never useful on any tag. Every other attribute keeps its
existing behaviour, and a string-valued action is byte-identical to before.
The thrown message names the fix without echoing the source.
The first pass guarded `renderTemplate` only. SSR has a SECOND, independent state machine, `streamTemplate`, on the Suspense streaming path, and it had no guard at all, so a streamed page still wrote a bound function's whole source into the response while the buffered path already refused it. Two renderers with one contract between them is what allowed the gap, so the decision now lives in a single `commitFormActionAttr` helper both call, rather than in duplicated logic at each site. The streaming path gets its own tests instead of being assumed to inherit the buffered path's. Also adds the identity plumbing the form binding needs: a server-installed resolver (the same shape as the CSP nonce provider, so the browser bundle never gets one and keeps refusing every function), the buffered hidden-field emission, and the forced method/enctype that a no-JS submission depends on.
The identity resolver, the hidden-field emission and the forced method/enctype belong to the form-submission unification, which is a separate change. They were unreachable here anyway (nothing installs a resolver), and shipping dormant code alongside a security fix makes the fix harder to review and to reason about on its own. What stays is the whole of the leak fix, including the streaming-path guard, so this branch is now exactly the bug fix and is independently mergeable. The removed code is recoverable from cf72805.
It has no consumer outside form-action.js, and the module is internal, so exporting it only widens what a future edit has to consider.
Review found `.action=${fn}` on a native element unguarded. `action` is a
reflected IDL attribute, so assigning a function stringifies its source into
the element's own attribute in a real browser. The linkedom tests could not
see it (linkedom does not reflect), which is why it survived the earlier
passes and why this ships with a real-browser test.
A custom element's `.action` is an ordinary author-defined property that
never reflects, so a function there stays legitimate and is not refused.
Also corrects three things the review caught in this PR's own claims:
the streaming machine is reached only through `renderToStream(v, {ssr:false})`
and no page render uses it, so the earlier "a streamed page leaked" wording
overstated a public-API hole as a live leak; the streaming quoted and mixed
holes had no coverage, so that branch could have been deleted with the suite
still green; and two client assertions were tautological, since the host is
empty whenever the render throws.
|
Decision: the prop-path guard stays in this change I left this open when I closed #1156: Keeping it here. Splitting would mean shipping a security fix that anyone can walk around by typing one extra character, and the two are the same leak with the same cause, a function reaching a reflected The part that deserves a second reader is the carve-out, not the guard: a native element's Worth noting why it needs a real browser to test at all: linkedom does not reflect IDL attributes, so the unit layer physically cannot observe this leak. That is why it survived two of my own passes on #1156 and why the coverage for it is a browser test rather than another linkedom one. |
The guard matched on the RAW attribute name. Both SSR state machines
accumulate the name as authored and only the unquoted `after-eq` branch
splits the binding sigil off, so a QUOTED binding hole reached the guard
still carrying its prefix. `.action` never equals `action`, so the
comparison missed and the renderer fell through to stringifying it, which
is the very leak this is supposed to close, reachable by adding a single
character. `.action="${fn}"`, `?action="${fn}"` and `@action="${fn}"` all
published the function body; verified against the head sources.
The value check had the same shape of hole. It asked `typeof val ===
'function'`, but every commit site does `String(val)`, and
`Array.prototype.toString` runs each element through `String()` too, so
`action=${[serverAction]}` leaked exactly as the bare function did.
Both are fixed where the rule already lives rather than at the call
sites: strip a leading binding prefix before matching, and treat a value
as carrying a function if it is one or if an array holds one at any
depth. Checking the value's shape rather than sniffing the stringified
result keeps a legitimate URL from ever tripping it.
Also refuses `.action=${fn}` on a native element during SSR. That path
never leaked, since a native `.prop` is dropped server-side, but the
client guards the same binding and there `action` reflects, so without
this a page renders clean on the server and throws on hydration. Failing
at the earliest point is the better half of that trade.
The guard lives in the SSR template state machines, so a runtime
divergence here is a divergence in whether a server action's source
reaches the served HTML. That is the one place "it passes on Node" is not
good enough: an app deployed on Bun would leak silently while the Node
suite stayed green.
Writing it immediately paid for itself. The streaming machine drops every
event/prop binding without the native-vs-custom split the buffered machine
makes, so `.action=${fn}` was refused by one SSR renderer and waved
through by the other. No unit test covered that asymmetry because both
machines produce the same empty output either way; only asserting the
THROW across both surfaced it. Guarded there too, so the two machines
agree.
Asserts on the SECRET marker rather than an exact stringification, since
JSC and V8 format function source differently.
vivek7405
left a comment
There was a problem hiding this comment.
Ran a full pass over this before letting it near main, since the last version of it was closed for exactly the reason a security fix gets closed: the fixes were real but nothing had re-read them.
It found three ways to walk straight past the guard, and they are all the same mistake in different clothes. The guard matched on the raw attribute name, but the SSR state machines hand it the name as AUTHORED, and only the unquoted after-eq branch strips the binding sigil. So .action="${fn}" arrives as .action, never equals action, and falls through to being stringified. ?action= and @action= the same. One character, and the leak is back. The value check had the identical shape of hole: it asked typeof val === 'function' while every commit site does String(val), and arrays stringify their elements, so action=${[serverAction]} published the body just as the bare function did.
What I take from that: the guard was written against the shapes I had in mind rather than against the operation it is guarding, which is String(val) reaching the output. Both fixes now sit at that level instead, matching on the value's shape and on the name with any sigil stripped, so a fourth spelling I have not thought of is more likely to be covered than not.
The Bun parity test paid for itself the moment it existed. The streaming machine drops every prop binding without the native-vs-custom split the buffered one makes, so .action=${fn} was refused by one SSR renderer and waved through by the other. No unit test could see it, because both machines emit the same empty output either way; only asserting the throw across both surfaced it. That is the second time on this change that two renderers with one contract between them have drifted, which is worth remembering the next time something looks like it only needs guarding in one place.
Also worth stating plainly, because it is not obvious from the diff: inside a component this error does not surface. Per-component SSR isolation swallows it, so production renders the component empty and the page still returns 200. No leak, which is the part that matters, but a form that has silently vanished is a confusing way to learn you wrote a Next binding. The troubleshooting entry now says so, and there is a test pinning that the isolated path stays leak-free.
|
Audit trail: what was raised and argued down Recording the findings that did not survive scrutiny, so the reasoning is on the PR rather than lost. "No test renders the guard through a component, where SSR error isolation swallows the throw." Rejected as stated, then partly acted on anyway. The claim that this is a coverage hole in the GUARD is wrong: the guard fires identically inside a component, and the isolation is downstream of it. But the underlying observation was correct and worth something, because the isolation makes the refusal invisible in production. So the behaviour is now documented and there is a test pinning that the isolated path emits no source. The finding was wrong about what was broken and right about what was missing. " Two duplicates. The quoted-sigil bypass came back twice at different severities, and the On the |
…dings Three problems, all introduced by the previous commit rather than found in the original fix. `carriesFunction` recursed without tracking what it had already seen, but the thing it models does not: `Array.prototype.join` has a cycle guard, so `String(a)` on a self-referential array is `''`. A render that used to emit `action=""` became a RangeError. Refusing to leak must not turn into a new way to crash, so the walk now tracks visited arrays and matches the stringification it is standing in for. Guarding `kind === 'event' || kind === 'prop'` in the streaming machine was two mistakes at once. It was a false positive, since an event binding drops its value and never stringifies it, and a function is the legitimate thing to pass one, so `<my-el @action=${handler}>` was refused for no reason. It also created a fresh divergence in the opposite direction from the one it claimed to fix: the buffered machine guards only `prop`, so the same template threw on one renderer and rendered on the other. Now `prop` on both, with the native-element carve-out on both. `?action=${fn}` was refused by nothing while both doc surfaces said it was. It never leaked, a boolean binding stringifies nothing, but a truthy function silently emitted a bare `action=""`, which is never what anyone meant. Refused now on all three commit sites, which makes the documented rule true rather than true-only-when-quoted. The docs now state the carve-outs as a table instead of the sweeping "with or without a binding sigil, quoted or not", which was wrong for `@` and for a custom element's own `.action`.
vivek7405
left a comment
There was a problem hiding this comment.
Second pass, scoped to the fixes from the first one. It found eight things and six of them were mine, introduced while fixing the original leak. That is the useful result here, not the original bug.
The one that matters most: carriesFunction recursed without tracking visited arrays, but the thing it models does not. Array.prototype.join has a cycle guard, so String(a) on a self-referential array is ''. I turned a render that used to emit action="" into a stack overflow. A guard against leaking became a way to crash, which is a strictly worse trade than the bug it was closing, and no test caught it because nobody writes a cyclic array on purpose.
Two more of the same kind. Guarding event || prop in the streaming machine was a false positive twice over: an event binding never stringifies its value, a function is exactly what one takes, so <my-el @action=${handler}> was refused for nothing. And because the buffered machine guards only prop, the commit whose message said "so the two machines agree" put them back out of step in the other direction. Third time on this change that the two SSR renderers have drifted. I now think the real lesson is that the guard should not be making per-branch decisions in two hand-written state machines at all, but that is a bigger change than this PR should carry.
The test findings are the ones I would have been most annoyed to have shipped. Five client assertions I added were exactly the assertion the file's own note says proves nothing, because a throwing part leaves the host empty; I had read that note and written the anti-pattern under it anyway. The browser test's reflection checks sat inside an if (form) that was never true, so the one file whose entire reason to exist is observing IDL reflection in a real browser was observing nothing. Both are restructured to render a working form first and swap the bad value in, so there is something live to assert against.
The Bun proof also only enumerated quoted shapes, which is why it could not see the machine divergence it was written to catch. It now covers the unquoted sigils, asserts the streaming refusal matches the right message rather than merely throwing, and pins the carve-outs, so a guard that refused everything would fail it.
… pins
Rewriting these cases around a shared helper quietly dropped
`?action="${fn}"`, because a quoted bool renders a literal `?action`
attribute and does not fit the helper's good-render step. It was dropped
for not fitting, not for being wrong, and that left the shape with no
client coverage at all while the commit presented the test work as a
widening. Restored, written directly instead of through the helper.
The header comment also misdescribed what these tests pin. A quoted
single hole compiles to an `attr-mixed` part, not a plain `attr` one, so
the quoted cases exercise the guard in the piece loop; neutering the
`attr` case leaves them green. Verified by reverting each clause
separately rather than reasoning about it. The comment now says which
clause each group covers, since the obvious reading is backwards and an
edit to the wrong branch would look covered.
vivek7405
left a comment
There was a problem hiding this comment.
Third pass, scoped to the last round's fixes. Two findings, both in the test rewrite, and both the same kind of mistake: the tests looked more thorough than they were.
The first is a coverage regression I caused by refactoring. Pulling those cases onto a shared helper silently dropped ?action="${fn}", because a quoted bool renders a literal ?action attribute and does not fit the helper's good-render step. It was dropped for not fitting, not for being wrong, and the commit message described the work as a widening. That is the failure mode of tidying tests: the diff reads as an improvement and the deletion rides along invisibly.
The second is subtler and I would not have found it by reading. A quoted single hole compiles to an attr-mixed part, not a plain attr one, so the quoted cases were pinning the guard in the piece loop while the comment above them said attr. Confirmed by neutering each clause separately: with the attr case disabled the quoted tests stay green. Everything was covered, but a maintainer trusting the comment would have edited the branch those tests do not watch.
Both are fixed. The comment now names which clause each group pins and says it was verified by reverting rather than reasoned about, since the obvious reading is backwards.
Worth recording what this round confirmed rather than found, because it is the part I was least sure of: the cycle-guard Set was fuzzed against a reference reachability walk over 200k random cyclic and DAG inputs with no divergence, so no false negative is constructible; all three renderers agree on all sixteen shapes; every row of the new docs table matches the code; and the browser file does run in real Chromium, where the custom-element carve-out passes with no definition registered.
Mapped all twelve guard call sites to the tests that fail when each is reverted. Eleven were pinned by the rendering suite. The streaming machine's native-prop clause was not: deleting it left every test in `packages/core/test/rendering` green, and only `test/bun/form-action-guard.mjs` caught it. That is real coverage, so this is not a hole, but a clause guarded by a single cross-runtime script is one file move away from being unguarded, and the script exists to prove parity rather than to be the only proof a branch works at all. Covered in the rendering suite too.
…the helper The previous commit set out to fix an inaccurate comment about which guard clause each test pins, and left three more behind. The quoted-bool case was hand-rolled on a stated reason that is simply false: that a quoted `?action` does not fit the helper's good-render step. It does. Quoting moves the hole into `attr-mixed` exactly like the other quoted shapes, so the good value renders `?action="/submit"` and the helper's default check passes. The reasoning was imported from the UNQUOTED bool case, which is the one that really needs an override, because a boolean binding drops the value and writes a bare `action=""`. Going through the helper also restores the two assertions the hand-rolled body had quietly dropped. The file header still described the client guard as living in the 'attr' and 'attr-mixed' cases. There are four, and the file pins all four, so a reader would have believed 'prop' and 'bool' were unguarded here. The section comment had the same shape of error in the other direction: it credited the unquoted cases with pinning 'attr', which is true of the array-wrapped one and false of the unquoted bool. The header now carries the whole map, and every row of it was established by neutering that clause and recording which tests go red, not by reading the renderer.
vivek7405
left a comment
There was a problem hiding this comment.
Fourth pass. Three findings, all in the file whose previous commit existed to fix exactly this defect class, which is the part worth sitting with.
The sharp one: I hand-rolled the restored quoted-bool test on a reason that is false. I wrote that a quoted ?action does not fit the helper's good-render step. It does. Quoting moves the hole into attr-mixed like every other quoted shape, so the good value renders ?action="/submit" and the helper's default check passes; I imported the reasoning from the UNQUOTED bool case, which is the one that genuinely needs an override. The cost was not stylistic: the hand-rolled body silently dropped two of the helper's assertions, and the false justification went into the commit message too, so the history now argues for a constraint that does not exist.
The other two are stale or overreaching claims about which clause each test pins. The file header still said the client guard lives in the 'attr' and 'attr-mixed' cases when there are four and the file pins all four, so a reader would conclude 'prop' and 'bool' were unguarded here. The section comment erred the other way, crediting the unquoted cases with pinning 'attr', which is true of the array-wrapped one and false of the unquoted bool.
Three rounds in a row have now found a wrong claim about this file rather than a wrong behaviour. The tests were right every time; the prose describing them was not. So the header now carries the full four-clause map, and every row was established by neutering that clause and recording which tests go red. That is also how I checked this fix, rather than by reading the renderer again, which is what produced the wrong claims in the first place.
Also folded in a real coverage gap I hit while mapping the call sites: the streaming machine's native-prop clause was pinned only by the Bun proof, so deleting it left the entire rendering suite green. Covered there now too.
The browser cases mount into the live document, which the reflection assertions need, but never removed their hosts, so `document.body` accumulated every earlier case's markup and the document-wide leak assertions stopped describing the test they sit in. Found by running the counterfactual rather than by reading: with the prop guard reverted, three cases went red across all three engines, and two of them only because the marker from a PREVIOUS case was still in the document. Someone acting on that would have debugged a test that was fine. With each host removed after its test, the same revert fails exactly one case, the reflection one that actually covers the clause. Verified in Chromium, Firefox and WebKit, both green and under the revert.
|
Browser verification: three engines, and what the counterfactual exposed Ran the browser file properly rather than trusting that it passes. It is green in Chromium, Firefox and WebKit, which is worth more than I expected here, since the whole reason this file exists is that Then I reverted the prop guard to check the file is not decorative. It is not: three cases went red on all three engines. But two of them went red for the wrong reason. The hosts mount into the live document, which the reflection assertions genuinely need, and nothing removed them, so Nothing was wrong with the guard, and nothing was wrong with the passing run. The damage would have landed on whoever eventually saw those three failures and started debugging the two tests that were fine. Fixed by removing each host after its test, after which the same revert fails exactly one case, the reflection one that actually covers the clause. Worth noting how this was found, since it is the same lesson as the last three rounds: running the counterfactual surfaced it, reading the file did not, and the file had passed every prior round. |
`functions in OTHER attributes keep the existing stringify behaviour` was the test standing guard over this PR's deliberate narrowness, and it asserted nothing that held it there. Its only check was that the output starts with `<div title="`, which an empty `title=""` satisfies exactly as well as the real stringified function. A change that dropped function values in every attribute, quietly widening the claim beyond action/formaction, would have left it green. It now asserts the source is actually still written out, and I confirmed it goes red against that change. The section comment tried for a second time to restate which clause each group of tests pins, and was wrong for a second time: it counted four quoted cases where the section has three, and said "the array-wrapped case" while the section holds two that land in different clauses. Rather than correct it a third time, it is gone. The header map is keyed by hole shape and every test names its shape, so the map already answers the question. A per-section summary was only ever a second copy of it, and a second copy is what kept drifting.
vivek7405
left a comment
There was a problem hiding this comment.
Fifth pass. Two findings, and one of them is the most consequential test problem on this PR so far.
functions in OTHER attributes keep the existing stringify behaviour is the test standing guard over the whole reason this change is safe to make: that only action and formaction are claimed and nothing else changed. It asserted that the output starts with <div title=", which an empty title="" satisfies exactly as well as the real stringified function. So the one test holding the scope boundary would have stayed green through a change that dropped function values in every attribute. It now asserts the source is genuinely still written out, and I confirmed it goes red against exactly that change.
That is worth separating from the four rounds of comment findings before it. Those were prose describing correct tests. This was a test whose assertion did not cover the claim in its own name, in the place where being wrong would have mattered most.
The second finding is the same section comment as last round, wrong again in a new way: it counted four quoted cases where the section has three, and said "the array-wrapped case" while the section holds two that land in different clauses. I have now had three attempts at a per-section clause summary and been wrong twice, so it is deleted rather than corrected a third time. The header map is keyed by hole shape and every test names its shape, so the map already answers the question; the section summary was only ever a second copy, and the second copy is what kept drifting.
Independently confirmed this round, so it does not need re-deriving: all twelve call sites map to a test that reds on its own revert with no orphans, every row of the header map holds (including the sigil-free action="${fn}" and the uppercase and single-quoted variants), the quoted-bool test reds when its clause is reverted, and the new streaming test is the only thing that reds for render-server.js:1905.
… one Strengthening the scope-boundary test last time fixed it on `renderToString` and nowhere else. Applying the exact widening that test exists to catch, dropping function values in every attribute, still left the whole suite green when applied to the streaming machine or to the client. So the claim this PR rests on, that only action and formaction are touched, was pinned on one of three renderers. Covered on all three now, plus both SSR machines in the cross-runtime proof, whose passthroughs were every one of them action-valued and so could not have noticed either. Each new case verified against the widening it guards. Also fixes the browser file's own inertness check, which was inert. It asserted `form.action`, the IDL getter, which returns whatever was assigned even where nothing reflects (linkedom does exactly that), so a non-reflecting DOM would have satisfied the assertion written to prove reflection happens. Reads the content attribute now, and passes in Chromium, Firefox and WebKit. The comment above it overstated its case too: it said every assertion below would hold vacuously on a fresh host, when the two `assert.ok(form)` checks would fail rather than pass. Narrowed to the absence assertions, which are the ones that actually go hollow.
vivek7405
left a comment
There was a problem hiding this comment.
Sixth pass. Three findings, and the first one shows the previous round's fix was half a fix.
The scope-boundary test is the one holding up this PR's central claim: that only action and formaction are touched and nothing else moved. Last round I found it vacuous and strengthened it. It turns out I strengthened it on renderToString and nowhere else. Applying the exact widening it exists to catch, dropping function values in every attribute, still left the entire suite green on the streaming machine and on the client. The cross-runtime proof could not have caught it either, because every one of its passthroughs was action-valued. So the boundary was pinned on one renderer out of three, and I had already reported it as fixed.
That is the more interesting failure than the original vacuous assertion. Fixing a test at the site where it was reported, rather than everywhere the property it asserts has to hold, is how a fix looks complete and is not. All three renderers and both SSR machines in the proof now carry it, each verified against the widening.
The second finding is nicely circular: the browser file's guard against being inert was itself inert. It asserted form.action to prove the property really reflects in that browser, but the IDL getter returns whatever was assigned even where nothing reflects, which is precisely what linkedom does and precisely the layer this file exists to escape. It reads the content attribute now and passes on all three engines, so reflection is finally being asserted rather than assumed.
Third, the comment above it claimed every assertion below would hold vacuously on a fresh host. Two of them would fail instead. Narrowed to the absence assertions, which are the ones that actually go hollow.
|
A fifth leak path, deliberately not fixed here: #1169 Went looking for whether any OTHER attribute-stringification path could leak the same way, rather than assuming the four commit sites this PR guards are the whole surface. One more exists. A reactive property declared <reflect-probe action="async function leaky(){ const c='REFLECT_SECRET'; return c; }" data-wj-host>It is not an The part I most wanted to know, and the answer is reassuring. This does not chain with the custom-element carve-out this PR introduces. Recorded that negative result in #1169 too, since it is the first thing anyone picking it up would otherwise re-derive. |
…throw `the streaming renderer never emits the source` could not fail. It drained into `drain()`, whose accumulator is a local that the throw discards, so the `out` the test inspected was always the empty string no matter what the renderer put on the wire. Proved it by making the streaming machine enqueue the source and THEN refuse: the whole file stayed green. The distinction matters more here than in most places. A stream can flush bytes before it refuses, and those bytes are already on their way to the client, so "it threw" and "the client received nothing" are different claims and only the second one is worth making. Drains into a caller-owned sink now, asserts on what was actually flushed, and reds against that same probe. The Bun proof had a quieter version of the same problem. Its `leaky` read the marker from a `const` instead of inlining it, and `String(fn)` reproduces source, so the stringified function contained the identifier and never the marker. Every `!includes(BUN_PARITY_SECRET)` assertion in that file was therefore trivially true, including the one checking that a refusal message does not echo what it withholds. Inlined; that assertion now reds when the message embeds the source, and the two passthroughs assert the marker rather than an engine-specific stringification format, which is what the file header already said they would do. Also corrects the browser comment again. The element-scoped assertions do not hold vacuously on an empty host, they throw; the only one that passes is the document-wide check, which is the weakest of them.
vivek7405
left a comment
There was a problem hiding this comment.
Seventh pass. Three findings, and the first is the most serious test defect on this PR.
the streaming renderer never emits the source could not fail. It drained through a helper whose accumulator is a local, so the throw discarded it and the string the test inspected was always empty, whatever the renderer had put on the wire. The reviewer proved it the only way that counts: patched the streaming machine to enqueue the source and then refuse, confirmed a consumer really received 70 bytes containing the marker, and the file stayed green.
The distinction it was failing to make is the one that matters for a streaming renderer. A stream can flush bytes and then refuse, and those bytes are already gone. "It threw" and "the client received nothing" are different claims, and only the second is worth asserting. It drains into a caller-owned sink now and reds against that same probe.
The Bun proof had a quieter version of the same disease. Its leaky read the marker from a const rather than inlining it, and String(fn) reproduces source, so the stringified function carried the identifier and never the marker. Every !includes(BUN_PARITY_SECRET) assertion in that file was trivially true, including the one checking that a refusal message does not echo the source it withholds. I found that by making a legitimate change and watching it fail for the wrong reason. Inlined, and that assertion now reds when the message embeds the source.
Third, the browser comment I narrowed last round was still wrong, in a new direction: the element-scoped assertions do not hold vacuously on an empty host, they throw, and the only one that passes is the document-wide check, which is the weakest. Third attempt at that comment.
The reviewer also enumerated the remaining attribute-stringify paths and confirmed what I filed as #1169: component.js:677 (reflect) and render-server.js:1509 are unpinned by any scope test, but cannot produce a <form action=…> since reflection only writes on a custom-element host, so they are not a #1154 leak path. Hydration reuses the guarded applyPart, so no gap there either.
vivek7405
left a comment
There was a problem hiding this comment.
Eleventh pass. Four findings, one of which I disagree with on the facts, and I checked before rejecting it.
The drain was wrong a fourth time, and worse than that, it had restored the exact false green the previous commit claimed to remove: a fixed batch of 16 reads passes a leak sitting behind 20 flushed chunks. The mechanism I wrote was also wrong. Nothing is pending during any of this. Every chunk is enqueued synchronously inside the ReadableStream constructor and controller.error() runs a microtask after it returns, so what a consumer keeps is what its synchronously-issued reads DEQUEUED before that microtask, one chunk per read. Which means every fixed burst is a guess about how much a given render flushes first, and I had guessed four times.
The bound cannot be removed, since the burst is sized before anything is awaited. So exhausting it is now a hard failure that names itself and says to raise it, instead of a drain that looks clean. Leaks behind 3, 20 and 100 pad chunks all red. That is the fix I should have reached for two rounds ago: when a bound is unavoidable, make crossing it loud rather than picking a bigger number.
Where I disagree. The review says Bun's constant folding holds "only for a single-reference, non-exported module-scope const", and lists export const and a twice-referenced const as not folding. Measured on bun 1.3.14, both fold:
export const K = 'sk_live_VALUE' -> bun: { h: "Bearer sk_live_VALUE" }
const K, read by two functions -> bun: { h: "Bearer sk_live_VALUE" }
let is the one that does not. So folding is BROADER than the finding says, not narrower, and the unconditional prose was closer to right than the correction would have made it. I have taken the part that stands (the claim had no conditions at all, and one exists) and stated what I measured, with a warning not to build a habit on a transpiler's boundary.
The remaining two are accurate and fixed: the Bun proof cited the file header as evidence for module-scope folding when that sample shows a function-local const, so it carries its own measurement now.
…g claim Three corrections, and the folding one is the reason to stop making that claim at all. The drain's note said everything is enqueued synchronously in the ReadableStream constructor and nothing is ever pending. Instrumenting the controller shows otherwise: `streamTemplate` awaits at every text hole, so a three-hole template puts 2 of 6 chunks in the constructor and the other 4 after it, straight into pending read requests. Both paths exist and both matter; the recovery is per read either way, which is the part that actually justifies the burst. The exhaustion guard also miscounted. A stream holding exactly DRAIN_BURST chunks and then closing looks identical to one that ran over, so it threw on a drain that had missed nothing. It now settles the ambiguity with one more read: ended means clean, still producing means the burst really was short. On Bun's constant folding, the honest answer is to stop enumerating conditions. I claimed export status and read count were the axes. A reviewer claimed the opposite of both. Neither is right: on bun 1.3.14 the FIRST module-scope `const` folds and later ones do not, so moving a declaration changes whether a key reaches the HTML. Three wrong rules in three attempts is the signal that this is a transpiler's internal business and does not belong in guidance. The docs now say that plainly and keep the rule that survives it, which is to treat everything reachable from the action as exposed. That last point applies to this file too: the Bun proof's own reasoning depended on declaration order, since `const runtime` already holds the first slot, so the construction it warns about behaves identically on both runtimes here. Inlining is right for a reason that does not depend on any of it.
vivek7405
left a comment
There was a problem hiding this comment.
Twelfth pass. Six findings, and the Bun folding thread is the one worth drawing a conclusion from rather than just fixing.
Three attempts have now been made to state when Bun folds a module-scope const into a function body. I said export status and read count do not matter. Round eleven's reviewer said they do, and named the opposite of both. This round says neither: the axis is DECLARATION POSITION, only the first module-scope const in a file folds. I measured it and that is correct, which means every version of this claim so far has been wrong, including the one I rejected a finding to defend.
So the fix is not a fourth rule. It is to stop putting the rule in guidance at all. Three wrong answers in three attempts is the signal that this belongs to a transpiler and not in a doc someone will rely on, and the docs now say exactly that: the behaviour is real, the boundary is not worth learning, treat everything reachable from the action as exposed. That last sentence has been the correct advice the whole way through and is the only part that never needed revising.
The drain note was wrong about the mechanism too, in the direction that matters. I wrote that everything is enqueued synchronously in the ReadableStream constructor and nothing is ever pending. streamTemplate awaits at every text hole, so a three-hole template puts 2 of 6 chunks in the constructor and 4 after it, straight into pending reads. Both paths are real. What survives is the per-read recovery, which is what the burst was actually built on, so the design stands and only the explanation was fiction.
The exhaustion guard I added last round had an off-by-one that would have fired on an innocent stream: exactly DRAIN_BURST chunks followed by a close is indistinguishable from running over, and it threw on both. One extra read settles it.
Also fixed: a ladder row that contradicted the paragraph above it, a stale PR-body bullet describing the previous design, and the Bun proof's own rationale, which depended on declaration order in a file where const runtime already holds the first slot, so the construction it warns about behaves identically on both runtimes there.
The docstring explains the enqueue mechanism using a three-hole template,
which is accurate for that template but not for the one the test actually
renders. Instrumenting the controller for `<form action=${fn}></form>`
records a single event, the error: the guard refuses before anything is
enqueued, so `sink.text` is always empty and both assertions hold
trivially.
That is worth stating rather than leaving for the next reader to discover,
because it is the whole reason the drain had to be got right. On the
shipped path the test is inert by construction. Its value is entirely in
the counterfactual, where a regression that writes before refusing does
enqueue, and where a lazy drain reports clean on a real leak.
…table one Four rules have now been proposed for when Bun folds a module-scope const into a function body, each measured, each falsified by the next on the same bun version: export status, read count, declaration position, and whether the module has an import. The last of those looked decisive (no import folds everything, an import folds nothing) until an earlier measurement contradicted it, where a module with no import folded only its first const because each const was read by a different function. The honest conclusion is not a fifth rule. Whatever the optimizer keys on is finer than any of these and is nobody's business to encode. All three doc surfaces now say that four attempts were made and all four failed, which is more useful to a reader than a rule that will be wrong again, and they keep the advice that has survived every round: treat everything reachable from the action as exposed. The Bun proof's note gets the same treatment. Its argument for inlining the marker no longer depends on predicting the fold. A test whose marker is visible only under a transpiler heuristic can go quietly tautological when the heuristic shifts, and that is reason enough to remove the dependency rather than reason about it. Also narrows the drain docstring's account of the two enqueue paths. Both exist, but no template in this file has a text hole, so every chunk in every number quoted there is written during the constructor, and the second path plays no part in any of it.
vivek7405
left a comment
There was a problem hiding this comment.
Thirteenth pass. Four findings, one rejected on evidence, and the folding thread reached the point where the right move is to stop answering the question.
That thread has now produced FOUR rules for when Bun folds a module-scope const into a function body, each measured, each falsified by the next on the same Bun version: export status, then read count, then declaration position, now whether the module has an import. I verified the newest one and it does reproduce (no import folds everything, an import folds nothing), and it is still wrong, because a module with no import folded only its first const when each const was read by a different function. Five would be no better than four. Whatever the optimizer keys on is finer than any of these, and the docs now say plainly that four attempts were made and all four failed. A reader is better served knowing the boundary is unknowable than by a rule that will be wrong again. The advice underneath has never changed: treat everything reachable from the action as exposed.
Where I disagree, with evidence. The review says the streaming test's two assertions "cannot fail" and that "there is no reachable state in which either assertion reds". There is, and it is the state the test exists for. With the machine patched to write before refusing, the render still rejects, so assert.rejects is satisfied and execution reaches the assertions, which then red on the source in sink.text. Verified again at 20 pad chunks on this HEAD. The finding describes the GUARD-REMOVAL counterfactual, where there is no rejection at all and assert.rejects fails first, and that is a different scenario from the regression this test guards.
The part of it that is right, I had already committed before this round landed: on the shipped path nothing is ever enqueued, so sink.text is empty and both assertions hold trivially there. That is now stated in the file, along with the point that the whole drain apparatus buys nothing on the shipped path and exists entirely for the counterfactual.
Also narrowed the docstring's account of the two enqueue paths. Both exist, but no template in this file has a text hole, so every chunk in every number quoted there is written during the constructor and the second path plays no part in any of it.
A commit that threw (a refused binding, a value whose toString throws) escaped the renderError boundary that the fetch half already had. .then(onFulfil, onRejected) does not route onFulfil's own throw to its sibling handler, and _performRender's .then had no rejection handler, so the error surfaced as an unhandled rejection in production, renderError() never ran, __pendingAsyncCommits stayed >= 1 forever, and updateComplete never settled for that instance again. Reached by the #1154 guard, but not caused by it: any synchronous throw from a commit behaves the same, verified with an unrelated failing toString. Refusing to leak must not become a new way to hang a component.
Mutation-tested every branch of isFormActionAttr against the suite: seven
of eight mutants red it, and the survivor was .toLowerCase(). Dropping it
kept all 46 unit tests and the whole Bun table green while
<form ACTION=${fn}> and <button formAction=${fn}> rendered the action's
source in full, because every shape tested spelled the attribute
lowercase.
Behaviour at HEAD was already correct, so this is coverage only, but the
uncovered spelling is the Next one: formAction={serverAction} is React's
canonical submit binding. Pinned on both SSR machines, the client, and
the Bun table; the mutant now reds 6 unit tests and the parity file.
The components page states the three binding rules unqualified: an
attribute hole is stringified, a native property binding drops at SSR,
a truthy boolean hole emits the attribute. The form-action guard makes
all three false for action= and formaction=, so a reader following that
page writes .action=${handler} on a form and hits a hard render error
the page says cannot happen.
|
Fourteenth pass. Five findings, and after seven rounds spent on test docstrings and prose, this one went back into the guard and found two things there. The one branch nothing tested. Mutation testing every branch of Every shape tested up to now spelled the attribute lowercase, so nothing pinned the call that collapses case. Behaviour at HEAD was already correct, so this was coverage only, but the uncovered spelling is the one this guard exists for: Worth noting what this says about the previous rounds. The suite that missed this is the same suite that had been rewritten four times over one streaming drain. Coverage of the interesting path was never the thing those rounds were measuring. A refusal that hung the component instead of isolating it. The PR claims per-component error isolation. That is true for a sync render and false for an async one, measured:
Checked whether the guard caused it: it does not. An unrelated commit throw (a value whose Docs. The components page states the three binding rules unqualified (an attribute hole is stringified, a native property binding drops at SSR, a truthy boolean hole emits the attribute) and the guard falsifies all three for Two findings not fixed here, both real, both out of scope.
Rejected, with the reasoning kept. A commented-out Full suite: 3018 tests, and the 59 core failures reproduce identically with my changes reverted (855 tests / 59 fail with, 848 / 59 fail without), all of them the #954 fresh-worktree trap (no built |
Two problems on the same surfaces.
The carve-out said a custom element's .action is safe because the
property 'never reflects'. A prop declared reflect: true does reflect,
via a path outside the template commit sites the guard covers, so it
writes String(fn) into the attribute. Measured: a reflecting action prop
emits the function body at SSR, and on the client the same binding puts
it in a live attribute. The conclusion still holds for a plain property,
which is what the sentence now says.
The rest is narration of this change's own development ('four rules were
proposed while this guard was being written', 'it already drifted
once', 'twice drifted apart') sitting on a public docs page, in the
agent skill, in a source docblock, and in a CI comment. A reader six
months out cannot verify any of it and goes looking through git history
for regressions that never shipped. The durable reason survives without
the history.
Also corrects the blog dogfood conventions, which still taught
<form action=...> bound to a server action, the exact shape now refused.
AGENTS.md holds that a unit test is necessary but not sufficient for a
component change, and this path only ever runs in a browser. The linkedom
test substitutes a value with a throwing toString on a detached
container; the shipped combination is an async render returning
.action=${fn} on a native form, where action is a reflected IDL
attribute no DOM shim models.
Sits beside the existing rejected-fetch test, which covers the other half
of the same boundary. Counterfactual: reverting the try/catch reds this
test on Chromium, Firefox and WebKit.
|
Fifteenth pass. Five confirmed, four rejected, two left unverified when the agent budget ran out. Two of the rejected ones I checked myself and one of those rejections was wrong, which is the finding I would most have regretted taking on trust. The carve-out this PR documents is false for a reflecting prop. Three surfaces plus two code comments said a custom element's The jury refuted this one 1-1 and I filed it as rejected, then checked it because a doc sentence claiming something is safe is cheap to verify and expensive to get wrong. The conclusion holds for a plain property, which is what all five places now say, with the reflection path pointed at #1169. This is the second time on this PR that "your credential is safe" turned out to be the dangerous sentence, and both times it was written while correcting an overstatement in the other direction. Narrative debt on a public URL. The troubleshooting page told a reader that four candidate rules were proposed and falsified "while this guard was being written". A user hitting this error next year was never party to that review, cannot verify the count, and gains nothing over the sentence before it. The same artifact was in the agent skill, in The dogfood app still taught the refused shape. A browser test for the containment fix. AGENTS.md holds that a unit test is necessary but not sufficient for a component change, and the linkedom test substituted a throwing Three findings about renderer state, filed as #1172 rather than fixed here. The review found that the new throw destabilises directive bookkeeping, and it is right, but not about the cause. Reproduced every case with a throw that has nothing to do with this guard (a value whose The Also filed: #1170, a function in an Rejected and left alone. A commented-out Two findings went unverified for lack of agent budget. Worth another round on that basis alone, independent of what this one found. |
The prop guard was gated on 'is not a custom element', which is not the
same set as 'writes the value into the markup'. action reflects on
<form>, formAction on <button>/<input>; on any other native tag both are
plain expandos that stringify nothing. So <div .action=${fn}>,
<li .action=${fn}>, <button .action=${fn}> and <div .formAction=${fn}>
all rendered clean before this PR and hard-threw after it, breaking a
supported binding (the delegated-command shape
<button .action=${() => save(row)}>) to prevent a leak that cannot
happen there.
The suite passed either way: nothing covered a .prop binding on a native
element that is not a form. Both sides are pinned now, on both SSR
machines, the client and the Bun table. Over-firing reds 4 tests, dropping
the guard entirely reds 6.
The commit-throw fix guaranteed _commitAsync never rejects, which is not the same as handling a rejection where it is awaited. update() is a documented override point returning any thenable, so a rejecting one reproduced the original wedge verbatim: __pendingAsyncCommits stuck at 1, updateComplete never settling for the instance, and the escape for later non-committing cycles disabled with it. Handled at the awaiting site rather than only in the one implementation that happens not to reject.
Three corrections, all where the docs promised more containment than exists. 'Renders the component empty' understates it twice. The isolation replaces the element through its closing tag, so a shell or header component takes the page's whole authored body with it: every route returns 200 with a blank body, which reads like a routing failure. And on a route with a loading.ts the page renders inside a Suspense boundary after the 200 is flushed, where a throw is swallowed with no log and no onError, so the server-log line the docs tell you to check does not exist. 'The refusal covers the shape' has one exception worth naming: a hole inside an HTML comment is emitted raw, so commenting the form out does not disable the interpolation and still ships the body silently. That is the likeliest next move for someone who just hit the error. The carve-out table also gains the two rows the narrowed prop guard makes true.
|
Sixteenth pass, run with a raised agent cap since the last round left two findings unverified on budget. Five confirmed, two refuted. The first one is a defect this PR introduced, which no previous round found and no test on either side of it existed. The guard refused four shapes that never leaked. The So a delegated-command binding ( The whole 52-test suite passed before AND after the fix, because nothing covered a The containment fix was fixed at the wrong altitude. Round 14's fix guaranteed What "renders the component empty" was hiding. Two things, both of which send an operator to the wrong place:
The comment hole, refuted twice, documented anyway. A hole inside an HTML comment is emitted raw, so Also refuted: that the commit-throw recovery leaves a corrupted Full suite 861 tests with the same 59 pre-existing worktree failures as every round (verified identical with my changes reverted, two rounds ago). Browser green on Chromium, Firefox and WebKit. Bun table green at 13 refused shapes and 5 carve-outs. Worth stating plainly: three of the last three rounds found something in the shipped guard rather than in prose, and this one found a regression the previous fifteen missed. |
The rejection handler added last round wrote the error state without checking the render token, which both handlers inside _commitAsync do. So a discarded cycle rejecting late (a dropped fetch, or #492 aborting its own in-flight action) committed renderError() over the newer render that had already replaced it: the user saw an error for work that was correctly thrown away, and settle()'s own token check then returned early so nothing repaired it. The check gates the DOM write only. settle() still runs either way, since the decrement it owns has to happen or the counter wedges exactly as it did before the handler existed. Also fixes an escaped interpolation that would have published the literal string loading.${''}js on the troubleshooting page, and adds the test that distinguishes the _commitAsync try/catch from this handler: an update() override returning its own thenable discards the commit promise, so only the try/catch contains a throw on that path. Each of the three pieces now reds a different test when reverted alone.
String(fn) returns source text in every hole position, so a text child and any unclaimed attribute write the same body a comment does. Naming only the comment case implied the others were safe.
|
Seventeenth pass. Five confirmed, none refuted, and two of them are defects I introduced LAST round while fixing the round before. That is the part worth recording. A superseded cycle's rejection clobbered the live DOM. The rejection handler I added in round sixteen wrote the error state without the render-token check that both handlers inside So a cycle that was correctly thrown away could wipe the render that replaced it, and A published-docs typo, from the same round. The try/catch had no test that could fail. Round sixteen's rejection handler made round fourteen's test non-discriminating: reverting the try/catch alone leaves both tests green, because Three pieces, three distinct tests, no overlap. The PR body's claim is corrected rather than quietly dropped, since "the counterfactual proved it" was the evidence offered for that fix. The comment hole, filed at last. Raised in round fifteen, refuted; raised in sixteen, refuted; confirmed here on the narrow ground that it was the one residual leak path with no tracking issue. Checking it properly changed the shape of the answer: So the refuters were right that this is generic Full suite 863 tests with the same 59 pre-existing worktree failures. Browser green on three engines, Bun table green, website typecheck clean. Three rounds in a row have now found something in shipped behaviour, and this one found two regressions introduced by the previous round's fixes. That is an argument for the loop, and also an argument that fixes made late in a long review deserve the same counterfactual discipline as the original change, which these two did not get. |
String(error) was evaluated in the argument expression, outside _handleRenderError's own try/catch, so a rejection reason that cannot be coerced (a null-prototype object, a revoked Proxy) threw a TypeError out of the handler and skipped the decrement the handler exists to guarantee. Measured before: counter stuck at 1, updateComplete never settling, renderError never called. The release now sits in a finally, and the coercion cannot throw. Not a regression (main wedges on any rejecting update()), but an incomplete close of the hole this PR fixes. Also makes the three settlement assertions distinguish resolve from reject. All three mapped both outcomes to 'settled', so a change that rejected updateComplete instead of resolving it would have kept them green while every awaiting caller began throwing, which is precisely the unhandled rejection this contains. And carries the action= carve-out onto the ssr docs page, whose template-hole table states the attribute, boolean and native-prop semantics the guard overrides for those two names.
|
Eighteenth pass, and the last one. Three findings, all minor, all fixed here. The loop stops on this round by decision rather than because a round came back empty, and the reasoning for that is at the bottom. Error reporting could throw and skip the release it guarantees. That is exactly the wedge the handler's own comment claims it closes. Not a regression, since All three settlement assertions accepted a rejection as success. They measured with I wrote those races myself, three rounds apart, and copied the same shortcut each time. It is the identical failure this PR has now hit five separate ways: an assertion is only as good as its ability to observe the thing it claims. Discarded stream chunks, an IDL getter instead of the content attribute, a marker that never reached the string, a counterfactual made non-discriminating by a later commit, and now a race that maps both outcomes to one label. A doc surface missed. Full suite 863 tests with the established 59 pre-existing worktree failures. Browser green on Chromium, Firefox and WebKit. Bun table green. Website typecheck clean. All ten CI jobs were green on the previous head and are re-running now. Why this is the last round. The guard itself, which is what #1154 is about, has not produced a finding since round sixteen narrowed its The honest read on the loop: rounds five through ten earned their cost, rounds eleven through thirteen were spent re-deriving a transpiler's internals and were mostly waste, and rounds fourteen through eighteen each found something real, with two of those findings being regressions introduced by the previous round's own fixes. That last pattern is the argument both for the loop and against running it forever: late fixes are exactly where the discipline slips, and each new fix is a new thing to review. Stopping now is a judgment that the remaining risk is smaller than the risk of another round of repairs, not a claim that a nineteenth pass would find nothing. |
The module docblock claimed four commit sites; each renderer commits attribute, boolean and property holes on separate branches, so there are more, and the number was going to keep drifting. Replaced with what the reader needs: why the predicate lives in one module, and why the two entry points differ (an attribute stringifies on any tag, a property only where the DOM reflects it). The client test header still described the prop clause as firing on a native element, which stopped being true when it was narrowed to elements that reflect.
The referenced issue was closed as not planned, so the caveat should describe the behaviour on its own rather than point at a tracker. The warning itself still matters: a missing log line on a loading.ts route is not evidence the render succeeded.
Closes #1154
<form action=${importedAction}>wrote the action's source into the served 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 renderer stringified it:The body itself shipped to every visitor: the logic, the query shapes, the table names, and any literal written inline, a connection string included. Assume outer values go too. On Node an outer
constthe body reads appears only as its identifier, but Bun can fold a module-scope literal into the body before the engine sees it, so the same action reportsAuthorization: "Bearer sk_live_…". Do not go looking for the rule that decides when. Four separate rules were proposed and measured over this review (export status, read count, declaration position, whether the module has an import) and every one was falsified by the next on the same Bun version, so the boundary is finer than any of them and is not worth encoding. Nothing reachable from the action can be called safe. It is also the first thing a Next-trained author or agent writes, since<form action={serverAction}>is the canonical Next form, so it is reachable by accident rather than by misuse.Now refused at render time instead, in all three renderers.
What is refused, and what is not
The guard matches the SHAPE, not one spelling of it. Getting this boundary right took most of the work here, because the first cut matched on the raw attribute name and a single extra character walked around it.
action=/formaction=action=${[fn]}Array.prototype.toStringstringifies elements, so it leaks identically.action=on a native form (and.formAction=on a button/input).action=on any other native tag?action=action="".action=on a custom elementreflect: truewrites the source on a path outside these commit sites, see #1169)@action=unquotedEvery other attribute keeps today's stringify behaviour, and a string-valued action is byte-identical to before. Both carve-outs and the scope boundary have their own tests, so a guard that refused everything would fail the suite.
One rule, every commit site
SSR has two independent template state machines and the client renderer has four
applyPartclauses (attr,attr-mixed,prop,bool), each committing on its own branch. Every one of them routes throughpackages/core/src/form-action.js, because these renderers drifted apart three separate times during this change and duplicated logic is what allowed it.Two entry points there, and the distinction is load-bearing.
assertNotFunctionActionAttris name-based: an ATTRIBUTE is stringified into the markup on whatever tag it appears.assertNotFunctionReflectedActionPropis name-and-tag-based: a PROPERTY only reaches the markup where the DOM reflects it, which isactionon a<form>andformActionon a<button>/<input>. Using the first on the property path is what refused<div .action=${fn}>, a binding that never leaked.The client
proppath is the one linkedom cannot see: on a form,actionis a reflected IDL attribute, soel.action = fnwrites the source into the element's own markup in a real browser and not under a DOM shim. That is why it ships with a browser test rather than another linkedom one.Scope
Only the leak. The form-binding feature that gives
<form action=${action}>a working meaning is #1155 and ships separately.Consequence worth stating: until #1155 lands,
<form action=${fn}>is an error with no replacement. That is intended. It was never a working pattern, it was a silent data leak.Inside a component the error does not surface: per-component SSR error isolation contains it, so production renders the component empty and the page still returns 200. Nothing leaks either way, and every doc surface below now says so, since a form that silently vanished is a confusing way to learn you wrote a Next binding.
Two qualifications the docs now carry, because "renders the component empty" understates both. The isolation replaces the element through its matching closing tag, so anything slotted into it disappears too: put the bad form in a shared header and every route returns 200 with a blank body. And on a route with a
loading.{js,ts}the page renders inside a Suspense boundary after the 200 is flushed, where the throw is swallowed with no log and noonErrorat all, so the server-log line the docs tell you to check does not exist there. That silence is a known framework gap rather than intended behaviour.History
This supersedes #1156, which carried the same fix and was closed unmerged because the re-review of its final fix set never ran. That review has now run.
Test plan
propclause reds exactly the reflection test on all three enginesfor awaitor a sequential reader loop both call a real leak clean; running out of the burst is a hard failure rather than a clean-looking drain.propclause is pinned on BOTH sides: over-firing reds 4 tests, removing it reds 6test/bun/form-action-guard.mjsgreen on Node 26.1 and Bun 1.3.14, covering both SSR machines, the carve-outs and the cyclic-array casedist/,wsnamed exports through symlinked modules), verified identical with this branch's changes reverted. CI, which has realnode_modules, is green on all ten jobs/,/docs/troubleshooting,/docs/migrating-from-nextjs,/docs/server-actions,/ui,/ui/buttonin prod mode with no broken modulepreload hintsDocs
.agents/skills/webjs/references/muscle-memory-gotchas.md: the Next-habit entry, the full refused/allowed table, the isolation caveats, and the note thatString(fn)leaks from any hole rather than only a comment..agents/skills/webjs/references/components.md: the error boundary covers the COMMIT as well as the fetch.website/app/docs/troubleshooting/page.ts: the throw, keyed by symptom, including where it does and does not surface.website/app/docs/components/page.ts: a carve-out where the three binding rules are stated, since the guard falsifies all three for these two names.website/app/docs/ssr/page.ts: the same carve-out on the template-hole table, which carried the identical three claims.website/app/docs/migrating-from-nextjs/page.ts: corrects theaction=advice.examples/blog/CONVENTIONS.mdandexamples/blog/.agents/rules/workflow.md: the dogfood app's agent rules still told authors to bind a server action intoaction=, the exact shape now refused..github/workflows/ci.yml: a Bun job step for the parity proof.actionexport is untouched and<form action=${fn}>was never a documented pattern; the scaffold templates were checked and teach nothing that changes. Those surfaces move with Unify form submissions on <form action=${action}>, drop the page action export #1155.The async-commit fixes that came with it
_commitAsyncnow catches a throw from the DOM commit and routes it to_handleRenderError, the boundary the fetch half already used. Without it the refusal escaped as an unhandled rejection on theasync render()path,renderError()never ran, andupdateCompletenever settled again for that instance.Not caused by the guard: an unrelated commit throw (a value whose
toStringthrows) behaves identically, so this is a pre-existing hole the guard made reachable by an ordinary authoring mistake. Fixed here rather than filed, because this PR ships a doc claiming per-component containment and that claim was false on the async path. Covered by a linkedom test and a browser test on all three engines.Two follow-on fixes from later review rounds, both on the same path. The rejection had to be handled at the site that AWAITS the commit, not only inside
_commitAsync:update()is a documented override point returning any thenable, and a rejecting one reproduced the original wedge verbatim. And that handler then needed the render-token check both_commitAsynchandlers already had, or a superseded cycle's late rejection committed its error state over the newer render that had replaced it.A fourth followed:
String(error)was evaluated in the argument expression, outside_handleRenderError's own try/catch, so a rejection reason that cannot be coerced to a primitive threw out of the handler and skipped the release it exists to guarantee. The release now sits in afinally.Each piece reds a different test when reverted alone. Worth stating because an earlier version of this line claimed the try/catch alone reds both tests, and that was wrong: with the rejection handler in place, reverting the try/catch produces identical observable behaviour on the ordinary path. What distinguishes it is an
update()override that discards_commitAsync's promise, which is now its own test. The three settlement assertions were also rewritten to require a RESOLVEDupdateCompleterather than merely a settled one, since mapping both outcomes to one label made them blind to a change that rejected it.Out of scope here, and still open: #1168 (the scaffold gallery teaching
action=""), #1169 (areflect: trueprop leaks the same source on the reflection path, which is why the custom-element carve-out above is qualified), and #1172 (a throw mid-commit desyncsrepeat()/guard()directive state from the DOM, pre-existing and reproducible without this guard).Three further paths were found during review and deliberately closed as not planned rather than tracked, each with its reproduction preserved on the issue: a function in an
on*inline-handler attribute (#1170), the unlogged Suspense-boundary swallow (#1174), and the general case that a function in ANY hole stringifies its source (#1175). Where a reader could hit one of those, the docs warn about it directly instead.