diff --git a/.agents/skills/webjs/references/components.md b/.agents/skills/webjs/references/components.md index f7b4d5e6e..cfbcd5e4a 100644 --- a/.agents/skills/webjs/references/components.md +++ b/.agents/skills/webjs/references/components.md @@ -175,7 +175,7 @@ Three decoupled concerns, do not conflate them. 2. **The client re-fetch default is stale-while-revalidate.** When a prop or dependency change re-runs `async render()`, the previous content stays until the new render resolves. No blank, no flash, no user code. 3. **`renderFallback()` is the OPTIONAL re-fetch loading UI.** Shown ONLY during a client re-fetch, NEVER on first paint, and it does NOT create a server-streaming boundary. -Errors are isolated per component by default (no user code): a thrown `await` renders a component-scoped error state while siblings render, never bubbling to the route `error.ts`. Override `renderError(error)` only to customize it (dev shows the message, prod stays silent). +Errors are isolated per component by default (no user code): a thrown `await` renders a component-scoped error state while siblings render, never bubbling to the route `error.ts`. Override `renderError(error)` only to customize it (dev shows the message, prod stays silent). The boundary covers the COMMIT as well as the fetch, so a template that throws while being applied (a refused binding, a value whose `toString` throws) reaches `renderError()` too, and `updateComplete` still settles. Those two halves used to disagree: a fetch rejection was contained and a commit throw escaped as an unhandled rejection that also left `updateComplete` pending forever. Decision rules. Use `async render()` for request-time server data that should be in the first paint (the default). Add `renderFallback()` when a client re-fetch's stale content would mislead. Use `Task` / signals for genuinely client-only data (a click, viewport, live updates). For SLOW data where blocking the first byte hurts, wrap the region in `` to stream it (the only way to show a first-paint fallback; see `client-router-and-streaming.md`). Do NOT fetch in `connectedCallback` for data knowable server-side, and do NOT prop-drill what a leaf can fetch itself. diff --git a/.agents/skills/webjs/references/muscle-memory-gotchas.md b/.agents/skills/webjs/references/muscle-memory-gotchas.md index bb1cf6f2e..3adfb0b76 100644 --- a/.agents/skills/webjs/references/muscle-memory-gotchas.md +++ b/.agents/skills/webjs/references/muscle-memory-gotchas.md @@ -43,6 +43,67 @@ const users = await getUsers(); There is no React `cache()`, `use()`, or `unstable_cache`. Caching is the `cache()` query helper, `export const revalidate` on a page, or `export const cache` on a GET action. +### A function in `
` is refused, not stringified + +Next binds a Server Action with `` and React serializes the binding into hidden fields. WebJs does not read that shape. A function interpolated into `action=` is a hard render error. + +The reason is a source leak. During SSR a `.server.ts` import is the ACTUAL function (the RPC stub exists only in the browser), and `action=` is an ordinary attribute hole, so stringifying it would write the function's body into the HTML every visitor downloads, including any literal inside it. The renderer throws instead, on the server and on the client, for `action=` and `formaction=` alike. + +What escapes is the SOURCE the runtime reports, and how much that includes depends on the runtime. The body always goes: your query shapes, your table and column names, your internal paths, and any credential written inline. + +Whether an OUTER value goes with it is not something to rely on either way. `Function.prototype.toString` returns source text, so on Node a module-scope `const` the body reads appears as its identifier. Bun transpiles the module before the engine sees it and can fold that literal into the body, so the same action reports the VALUE: + +``` +// const VENDOR_API_KEY = 'sk_live_…'; then used as `Bearer ${VENDOR_API_KEY}` +node 26 Authorization: `Bearer ${VENDOR_API_KEY}` identifier only +bun 1.3 Authorization: "Bearer sk_live_…" the key itself +``` + +Do not go looking for the rule that decides when it folds. Export status, read count, declaration position, and whether the module has an import have each been measured as the deciding factor and each produced a counterexample on the same bun version, so whatever the optimizer keys on is finer than any of them. The two rows above are one measurement on two specific versions, not a per-runtime guarantee: read them as proof that the boundary moves, never as a promise that Node keeps an outer binding private. + +So treat everything reachable from the action as exposed. That is the assumption the refusal is built on, it is the only one that holds across runtimes, and it is the only one that stays true when the transpiler changes. + +The refusal covers the shape, not one spelling of it. Every hole form is refused (`action=${fn}`, `action="${fn}"`, the mixed `action="/x/${fn}"`), and so is a function wrapped in an array (`action=${[fn]}`), since an array stringifies each element through `String()` and leaks identically. Casing does not help either: `formAction=` and `ACTION=` fold to the same rule. + +**Commenting the form out does not disable the hole.** A comment is HTML, the interpolation is JavaScript, and the renderer emits a comment's holes raw, so `` still ships the whole action body with no throw and no log. Delete the binding instead of commenting around it. This is the one shape in this section that leaks silently, which is exactly why it is worth knowing. + +It is not special to comments. `String(fn)` returns source text wherever it runs, so a bare function in a text child (`
${fn}
`) or any unclaimed attribute (`title=${fn}`) writes the same body out. Only the two form-action attribute names are refused today; treat a function anywhere else in a template as a mistake that ships, and reach for `@event=${fn}` or a custom element's `.prop=${fn}`, neither of which stringifies. + +The refused and allowed shapes in full. Every "no" row is a binding that stringifies nothing, so refusing it would break working code rather than close a leak: + +| Written as | Refused? | Why | +|---|---|---| +| `action=` / `formaction=` | yes | ordinary attribute, stringified into the HTML | +| `.action=` on a native form | yes | the property reflects, so the source lands in the DOM on the client | +| `.formAction=` on a button or input | yes | same reason, that is where `formAction` reflects | +| `.action=` on any other native tag | **no** | a plain expando (`
`, ``, host), + /function was interpolated into formaction=/, + ); +}); + +test('client re-render swapping in an upper-case ACTION=${fn} throws, live DOM stays clean', () => { + const host = document.createElement('div'); + const tpl = (a) => html`
`; + render(tpl('/ok'), host); + assert.throws(() => render(tpl(fakeAction), host), /function was interpolated into action=/); + assert.ok(!host.innerHTML.includes('CLIENT_SECRET'), 'live DOM must not carry the source'); +}); + +// Re-render over an ALREADY-MOUNTED form. Here the host really does hold a live +// element, so if the guard let the value through, the source would be sitting +// in the DOM and this would catch it. +test('re-render swapping a string action for a function throws, live DOM stays clean', () => { + const host = document.createElement('div'); + const tpl = (a) => html`
`; + render(tpl('/ok'), host); + assert.equal(host.querySelector('form').getAttribute('action'), '/ok'); + assert.throws(() => render(tpl(fakeAction), host), /function was interpolated into action=/); + assert.ok(!host.innerHTML.includes('CLIENT_SECRET'), 'live DOM must not carry the source'); + assert.equal(host.querySelector('form').getAttribute('action'), '/ok', 'the prior value must survive'); +}); + +// `.action=${fn}` is a PROPERTY binding, a different commit site from the +// attribute one. `action` is a reflected IDL attribute, so assigning a function +// writes its source into the element's own `action` in a real browser. +test('client .action=${fn} property binding on a native form throws', () => { + const host = document.createElement('div'); + assert.throws( + () => render(html`
`, host), + /function was interpolated into action=/, + ); +}); + +// The reflection boundary on the client, where the property assignment is real +// rather than dropped. `.action` reflects on
, `.formAction` on +// `, host), + /function was interpolated into formaction=/, + ); +}); + +test('client .action=${fn} on a non-reflecting native element is left alone', () => { + const host = document.createElement('div'); + render(html`
hi
`, host); + const el = host.querySelector('div'); + assert.equal(typeof el.action, 'function', 'the expando is set, as it always was'); + assert.ok(!host.innerHTML.includes('CLIENT_SECRET'), 'and nothing reaches the markup'); +}); + +test('a custom element keeps accepting a function on .action', () => { + // Not a reflected IDL attribute, just an author-defined property, so passing + // a function is legitimate and must not be refused. + const host = document.createElement('div'); + render(html``, host); + assert.equal(typeof host.querySelector('my-widget').action, 'function'); +}); + +test('string action still renders on the client', () => { + const host = document.createElement('div'); + render(html`
`, host); + const form = host.querySelector('form'); + assert.equal(form.getAttribute('action'), '/submit'); +}); + +// --- Bypasses found reviewing the first cut of the guard ------------------- +// +// A quoted binding hole keeps its sigil in the part's name, so comparing the +// raw name let `.action="${fn}"` through on this side too. +// +// The file header's clause map is keyed by hole SHAPE, and every test below +// names its shape, so read the clause off that map. There is deliberately no +// per-section restatement of it here: an aggregate summary is a second copy +// that drifts from the first, and both times this section tried to carry one it +// ended up wrong (miscounting the quoted cases, and attributing 'attr' to a +// test that pins 'attr-mixed', since two tests here are array-wrapped and they +// land in different clauses). +// +// Each case renders the SAME template with a good value first and only then +// swaps in the bad one, per the note above. That matters twice over: on a fresh +// host a throwing part leaves the container empty, so a "no secret in the DOM" +// assertion would hold with or without the guard; and only a same-template +// re-render patches in place, so only then is there a live form whose surviving +// state is worth asserting. + +/** + * @param {(v: unknown) => unknown} tpl a template taking the value under test + * @param {unknown} bad the value that must be refused + * @param {RegExp} messagePattern + * @param {string} goodRendered what the good value leaves in the DOM + */ +function refusesOnRerender(tpl, bad, messagePattern, goodRendered = '/submit') { + const host = document.createElement('div'); + render(tpl('/submit'), host); + const before = host.innerHTML; + assert.ok(host.querySelector('form'), 'the good value must render a form'); + assert.ok(before.includes(goodRendered), `expected ${goodRendered} in ${before}`); + + assert.throws(() => render(tpl(bad), host), messagePattern); + + assert.ok(host.querySelector('form'), 'the previously rendered form must still be there'); + assert.equal(host.innerHTML, before, 'the refused render must leave the DOM untouched'); + assert.ok(!host.innerHTML.includes('CLIENT_SECRET'), 'live DOM must not carry the source'); +} + +test('client refuses a quoted property hole .action="${fn}"', () => { + refusesOnRerender((v) => html`
`, fakeAction, /function was interpolated into \.action=/); +}); + +test('client refuses a quoted event hole @action="${fn}"', () => { + refusesOnRerender((v) => html`
`, fakeAction, /function was interpolated into @action=/); +}); + +test('client refuses a quoted boolean hole ?action="${fn}"', () => { + // Quoting moves this into 'attr-mixed' like the other quoted holes, so the + // good value renders as a literal `?action="/submit"` and the helper's + // default check fits. (Only the UNQUOTED bool below needs an override, since + // a boolean binding drops the value and writes a bare `action=""`.) + refusesOnRerender((v) => html`
`, fakeAction, /function was interpolated into \?action=/); +}); + +test('client refuses an array-wrapped function', () => { + refusesOnRerender((v) => html`
`, [fakeAction], /function was interpolated into action=/); +}); + +test('client refuses an array-wrapped function inside a mixed hole', () => { + refusesOnRerender((v) => html`
`, [fakeAction], /function was interpolated into action=/, '/x//submit'); +}); + +test('client refuses an unquoted boolean hole ?action=${fn}', () => { + // A boolean binding renders the bare attribute for any truthy value, so the + // good render leaves `action=""` rather than the value itself. + refusesOnRerender((v) => html`
`, fakeAction, /function was interpolated into action=/, 'action=""'); +}); + +// The carve-outs. An event binding never stringifies its value and a function +// is the legitimate thing to pass one, so refusing it would be a false +// positive. + +test('an unquoted @action=${fn} event binding stays legal', () => { + const host = document.createElement('div'); + render(html`
`, host); + const form = host.querySelector('form'); + assert.ok(form, 'the form still renders'); + assert.ok(!form.hasAttribute('action'), 'an event binding writes no action attribute'); + assert.ok(!host.innerHTML.includes('CLIENT_SECRET'), 'and leaks nothing'); +}); + +test('client still renders an array of plain strings', () => { + const host = document.createElement('div'); + render(html`
`, host); + assert.equal(host.querySelector('form').getAttribute('action'), '/a,/b'); +}); + +test('a self-referential array does not crash the render', () => { + // `Array.prototype.join` has a cycle guard, so `String(cyclic)` is ''. The + // function check has to match that rather than recurse forever. + const cyclic = []; + cyclic.push(cyclic); + const host = document.createElement('div'); + render(html`
`, host); + assert.equal(host.querySelector('form').getAttribute('action'), ''); +}); + +test('the client keeps the same scope boundary for unclaimed attributes', () => { + // The boundary belongs on EVERY renderer. Pinning it on the buffered SSR path + // alone left the widening it guards against invisible here: dropping function + // values in every attribute in `applyPart` kept the whole suite green. + const host = document.createElement('div'); + render(html`
`, host); + const title = host.querySelector('div').getAttribute('title'); + assert.match(title, /CLIENT_SECRET/, 'an unclaimed attribute still stringifies the function'); + + // Same for the mixed path, which is a separate commit site. + const host2 = document.createElement('div'); + render(html`
`, host2); + assert.match(host2.querySelector('div').getAttribute('title'), /CLIENT_SECRET/, 'and on the mixed path'); +}); diff --git a/packages/core/test/rendering/form-action-attr-guard.test.js b/packages/core/test/rendering/form-action-attr-guard.test.js new file mode 100644 index 000000000..0c5a97008 --- /dev/null +++ b/packages/core/test/rendering/form-action-attr-guard.test.js @@ -0,0 +1,506 @@ +// #1154: a function interpolated into a form-action attribute must throw, +// never stringify. At SSR a `'use server'` import is the REAL function, so +// `String(fn)` would serialize the action's source (secrets included) into +// the served HTML. Covers every hole shape that used to leak (unquoted, +// quoted, mixed, and `formaction` on a submit button), plus the byte-identical +// passthrough for string-valued action attributes. +import { test, before } from 'node:test'; +import assert from 'node:assert/strict'; + +let html, renderToString, renderToStream; +before(async () => { + ({ html } = await import('../../src/html.js')); + ({ renderToString, renderToStream } = await import('../../src/render-server.js')); +}); + +async function drain(stream) { + let out = ''; + for await (const c of stream) out += typeof c === 'string' ? c : new TextDecoder().decode(c); + return out; +} + +/** + * Drain a stream into a CALLER-OWNED buffer, so what the consumer RECEIVED + * survives a throw. + * + * `drain()` accumulates into a local and loses it when the iteration throws, so + * a test that drains a refused render and then inspects the result is always + * inspecting an empty string, whatever the consumer actually got. "It threw" is + * not the same claim as "the client received nothing". + * + * Issues a large SYNCHRONOUS burst of reads, and fails loudly if the burst was + * not big enough. Four wrong answers preceded this, so the mechanism is worth + * stating exactly rather than by analogy. + * + * Chunks written while the `new ReadableStream` constructor is still running + * land in the queue that `controller.error()` later clears, and that is the + * path that matters here: for the template below, and for the patched machine + * the ladder was measured on, every chunk is written during the constructor. + * (A second path exists, since `streamTemplate` awaits at text holes and a + * chunk written after the constructor returns goes straight to a pending read. + * A three-hole template splits 2 in the constructor and 4 after. No template in + * this file has a text hole, so it plays no part in any of the numbers below, + * and it is mentioned only because two earlier versions of this note explained + * the behaviour with whichever path they happened to be wrong about.) + * + * Either way the recovery is per read: N reads issued in one synchronous run + * recover N chunks. So consumers form a ladder with no top, measured against a + * machine patched to flush its buffer, enqueue pad chunks, enqueue the source, + * then refuse: + * + * for await the prefix alone + * sequential getReader() loop the prefix and one more chunk + * burst of N N chunks in total + * + * Any FIXED burst is therefore guessable: a batch of 16 silently passed a leak + * sitting behind 20 pad chunks. The bound cannot be removed, since the burst is + * sized before anything is awaited, so instead running out of it is made a hard + * failure rather than a clean-looking drain. + * + * Worth being blunt about what this buys on the SHIPPED path: nothing. The + * template the caller below uses has a single attribute hole and no text hole, + * so the guard refuses before anything is enqueued at all, and instrumenting + * the controller records exactly one event, the error. `sink.text` is therefore + * always empty there, and both assertions hold trivially. All of this machinery + * exists for the counterfactual, where a regression that writes before refusing + * DOES enqueue, and where the difference between draining well and draining + * badly is the difference between catching that and waving it through. + * + * @param {any} stream + * @param {{ text: string }} sink written to as chunks arrive + */ +const DRAIN_BURST = 4096; + +async function drainInto(stream, sink) { + const reader = stream.getReader(); + const decode = (v) => (typeof v === 'string' ? v : new TextDecoder().decode(v)); + for (;;) { + const burst = Array.from({ length: DRAIN_BURST }, () => reader.read()); + let done = false; + let failure = null; + let chunks = 0; + for (const pending of burst) { + try { + const { done: d, value } = await pending; + if (d) done = true; + else if (value !== undefined) { sink.text += decode(value); chunks++; } + } catch (e) { + failure = failure || e; + } + } + if (failure) throw failure; + if (done) break; + if (chunks === DRAIN_BURST) { + // Every read came back with a chunk, so the burst may have stopped short + // of the end. May, not did: a stream holding EXACTLY this many chunks and + // then closing looks identical here, and throwing on that would be a + // false alarm. One more read settles it, and it is safe to await now + // because a stream still producing has nothing left to protect. + const { done: ended } = await reader.read(); + if (ended) break; + throw new Error( + `drainInto ran out of its ${DRAIN_BURST}-read burst with the stream still producing. ` + + 'Chunks beyond the burst were never dequeued, so this drain is no longer reading the ' + + 'worst case and a leak could hide behind them. Raise DRAIN_BURST.', + ); + } + } + return sink.text; +} + +// The secret sentinel must never appear in any output, thrown or not. +// The marker is INLINE in the body on purpose. `String(fn)` reproduces source, +// so a body that reads the marker from an outer `const` stringifies to the +// IDENTIFIER and never to the value. These assertions happened to survive that +// only because the identifier was itself called SECRET; renaming it would have +// silently turned every `/SECRET/` check into a tautology. The same shape was a +// live defect in `test/bun/form-action-guard.mjs`, where the marker and the +// identifier did not share a name and nothing matched. +async function leaky(input) { + const conn = 'postgres://user:SECRET_MARKER@host/db'; + return { success: true, conn, input }; +} + +test('unquoted action=${fn} throws instead of leaking source', async () => { + await assert.rejects( + () => renderToString(html`
`, { ssr: true }), + /function was interpolated into action=/, + ); +}); + +test('quoted action="${fn}" throws identically', async () => { + await assert.rejects( + () => renderToString(html`
`, { ssr: true }), + /function was interpolated into action=/, + ); +}); + +test('mixed hole action="/x/${fn}" throws', async () => { + await assert.rejects( + () => renderToString(html`
`, { ssr: true }), + /function was interpolated into action=/, + ); +}); + +test('formaction=${fn} on a submit button throws', async () => { + await assert.rejects( + () => renderToString(html`
`, { ssr: true }), + /function was interpolated into formaction=/, + ); +}); + +// Case normalization was the ONE branch of the guard with no test. Mutation +// testing every branch against this suite, seven of eight mutants red it and +// this was the survivor: dropping `.toLowerCase()` from isFormActionAttr kept +// all 46 unit tests and the whole Bun table green while `
` +// and `
`, { ssr: true }), + /function was interpolated into formaction=/, + ); +}); + +test('upper-case ACTION=${fn} throws', async () => { + await assert.rejects( + () => renderToString(html`
`, { ssr: true }), + /function was interpolated into action=/, + ); +}); + +test('a quoted mixed-case Action="${fn}" throws (sigil strip and case-fold compose)', async () => { + await assert.rejects( + () => renderToString(html`
`, { ssr: true }), + /function was interpolated into action=/, + ); +}); + +test('the streaming renderer folds case too', async () => { + await assert.rejects( + () => drain(renderToStream(html``, { ssr: false })), + /function was interpolated into formaction=/, + ); +}); + +test('no thrown message ever carries the function source', async () => { + for (const tpl of [ + html`
`, + html`
`, + ]) { + let msg = ''; + try { await renderToString(tpl, { ssr: true }); } catch (e) { msg = String(e && e.message); } + assert.ok(msg.length > 0, 'render must throw'); + assert.ok(!msg.includes('SECRET'), 'error message must not embed the source'); + } +}); + +test('string-valued action renders byte-identically to before', async () => { + assert.equal( + await renderToString(html`
`, { ssr: true }), + '
', + ); + assert.equal( + await renderToString(html`
`, { ssr: true }), + '
', + ); + assert.equal( + await renderToString(html`
`, { ssr: true }), + '
', + ); +}); + +// The STREAMING renderer is a second, independent state machine, missed by the +// first pass of this fix. It is reached only through +// `renderToStream(v, { ssr: false })`, which no page render uses (the server +// renders every page, Suspense included, via `renderToString`), so this was a +// public-API hole and not a live page leak. It gets its own coverage rather +// than being assumed to inherit the buffered renderer's guard. +test('the streaming renderer refuses the same function (ssr:false path)', async () => { + await assert.rejects( + () => drain(renderToStream(html`
`, { ssr: false })), + /function was interpolated into action=/, + ); +}); + +test('the streaming renderer never emits the source, not even before it refuses', async () => { + // Two things this has to get right, both learned the hard way. + // + // It reads into a caller-owned sink, because a helper that accumulates into a + // local loses everything when the iteration throws, and a test inspecting + // that local is inspecting an empty string no matter what the consumer got. + // + // And it reads in one large synchronous burst, because what a consumer keeps + // is exactly what its synchronously-issued reads dequeued before the clearing + // microtask: N reads recover N chunks. A `for await` drain, and even a + // sequential reader loop, both call a real leak clean, and any fixed burst is + // guessable, so `drainInto` treats exhausting its burst as a hard failure + // rather than a clean drain. + const sink = { text: '' }; + await assert.rejects( + () => drainInto(renderToStream(html`
`, { ssr: false }), sink), + /function was interpolated into action=/, + ); + assert.ok(!sink.text.includes('SECRET'), `received bytes must not carry the source, got: ${sink.text}`); + assert.ok(!sink.text.includes('async function'), 'no function source of any kind reached the client'); +}); + +// The unquoted shape lands in the streaming machine's `after-eq` branch; the +// quoted and mixed shapes land in its SEPARATE `attr-quoted`/`attr-unquoted` +// branch. Without these two, that second branch could be deleted outright and +// the suite would stay green, which is the same guard-asymmetry this whole +// change exists to prevent, just moved into the test layer. +test('the streaming renderer refuses a quoted hole', async () => { + await assert.rejects( + () => drain(renderToStream(html`
`, { ssr: false })), + /function was interpolated into action=/, + ); +}); + +test('the streaming renderer refuses a mixed hole', async () => { + await assert.rejects( + () => drain(renderToStream(html`
`, { ssr: false })), + /function was interpolated into action=/, + ); +}); + +test('the streaming renderer refuses formaction', async () => { + await assert.rejects( + () => drain(renderToStream(html``, { ssr: false })), + /function was interpolated into formaction=/, + ); +}); + +test('streaming keeps string-valued actions working', async () => { + const out = await drain(renderToStream(html`
`, { ssr: false })); + assert.match(out, /action="\/submit"/); +}); + +test('functions in OTHER attributes keep the existing stringify behaviour', async () => { + // Narrow claim: only action/formaction throw. Anything else is unchanged + // (arguably also a bug, but out of #1154's scope by design). + // + // This pins the SCOPE BOUNDARY, so it has to assert the source really is + // still written out. `out.startsWith('
`, { ssr: true }); + assert.match(out, /^
{ + const out = await renderToString(html`
`, { ssr: true }) + .then((html) => html, (e) => e); + assert.ok(out instanceof Error, 'must throw, not render'); + assert.match(out.message, /function was interpolated into \.action=/); + assert.doesNotMatch(out.message, /SECRET/, 'the message must not carry what it withholds'); +}); + +test('quoted boolean hole ?action="${fn}" throws', async () => { + await assert.rejects( + () => renderToString(html`
`, { ssr: true }), + /function was interpolated into \?action=/, + ); +}); + +test('quoted event hole @action="${fn}" throws', async () => { + await assert.rejects( + () => renderToString(html`
`, { ssr: true }), + /function was interpolated into @action=/, + ); +}); + +test('quoted .formaction="${fn}" on a submit button throws', async () => { + await assert.rejects( + () => renderToString(html``, { ssr: true }), + /function was interpolated into \.formaction=/, + ); +}); + +// `String(val)` is what every commit site does, and Array.prototype.toString +// runs each element through String() too, so wrapping the action in an array +// leaked exactly as passing it bare did. + +test('an array-wrapped function action=${[fn]} throws', async () => { + await assert.rejects( + () => renderToString(html`
`, { ssr: true }), + /function was interpolated into action=/, + ); +}); + +test('a nested array action=${[[fn]]} throws (Array toString joins recursively)', async () => { + await assert.rejects( + () => renderToString(html`
`, { ssr: true }), + /function was interpolated into action=/, + ); +}); + +test('an array of plain strings still renders, the array check is not a blanket refusal', async () => { + const out = await renderToString(html`
`, { ssr: true }); + assert.match(out, /action="\/a,\/b"/); +}); + +// `.action` on a NATIVE element is dropped at SSR, so it never leaked there. +// It still refuses, so a page cannot render clean on the server and then throw +// on hydration, where `action` reflects and the leak is real. + +test('.action=${fn} on a native form throws at SSR even though the prop would be dropped', async () => { + await assert.rejects( + () => renderToString(html`
`, { ssr: true }), + /function was interpolated into action=/, + ); +}); + +// The `.prop` guard fires on REFLECTION, not on "is a native element". Those +// are different sets, and gating on the second refused four shapes that never +// leaked. Both sides are pinned here, because the suite passed either way: +// nothing covered a `.prop` binding on a native element that is not a form. +// +// reflects (must throw) .action on
, .formAction on `, { ssr: true }), + /function was interpolated into formaction=/, + ); + await assert.rejects( + () => renderToString(html``, { ssr: true }), + /function was interpolated into formaction=/, + ); +}); + +test('.action=${fn} on a native element that does NOT reflect it still renders', async () => { + // A plain expando: nothing is stringified and nothing reaches the markup, so + // refusing it would break a supported binding (the delegated-command shape + // ``, + html`
  • hi
  • `, + html`
    hi
    `, + ]) { + const out = await renderToString(tpl, { ssr: true }); + assert.doesNotMatch(out, /SECRET/, 'a dropped prop must not carry the source'); + assert.match(out, /hi/, 'and the element must still render'); + } +}); + +test('the streaming renderer draws the same reflection boundary', async () => { + await assert.rejects( + () => drain(renderToStream(html``, { ssr: false })), + /function was interpolated into formaction=/, + ); + const out = await drain(renderToStream(html`
    hi
    `, { ssr: false })); + assert.doesNotMatch(out, /SECRET/); + assert.match(out, /hi/); +}); + +test('a custom element keeps accepting a function on an unclaimed prop', async () => { + const out = await renderToString(html``, { ssr: true }); + assert.doesNotMatch(out, /SECRET/, 'an unserializable prop is dropped, not serialized'); +}); + +test('a URL object action still renders, only functions are refused', async () => { + const out = await renderToString(html`
    `, { ssr: true }); + assert.match(out, /action="https:\/\/example\.com\/p"/); +}); + +// A component's render errors are isolated per component (#469), so a throw +// from inside one does NOT propagate: dev swaps in an error box, prod renders +// the component empty and the page still returns 200. That makes the refusal +// invisible on this path, so what has to be pinned is the security property +// rather than the throw. The leak must not reappear just because the error +// was swallowed. +test('a function action inside a component leaks nothing, even though the throw is isolated', async () => { + const { WebComponent } = await import('../../src/component.js'); + class Guarded extends WebComponent({}) { + render() { return html`
    `; } + } + Guarded.register('guarded-leak-form'); + + const out = await renderToString(html`
    `, { ssr: true }); + assert.doesNotMatch(out, /SECRET/, 'the isolated error path must not emit the source'); + assert.doesNotMatch(out, /async function/, 'no function source of any kind'); +}); + +// --- Carve-outs, and the machines agreeing on them ------------------------ +// +// A guard that refused everything would satisfy every assertion above, so what +// stays LEGAL has to be pinned just as hard. Both bindings below never +// stringify their value, so neither can leak, and refusing them would break +// ordinary code. + +test('an unquoted @action=${fn} event binding stays legal on both machines', async () => { + const buffered = await renderToString(html`
    `, { ssr: true }); + assert.doesNotMatch(buffered, /SECRET/); + const streamed = await drain(renderToStream(html`
    `, { ssr: false })); + assert.doesNotMatch(streamed, /SECRET/); +}); + +test('a custom element keeps a function on its own .action property', async () => { + const buffered = await renderToString(html``, { ssr: true }); + assert.doesNotMatch(buffered, /SECRET/, 'an unserializable prop is dropped, never serialized'); + const streamed = await drain(renderToStream(html``, { ssr: false })); + assert.doesNotMatch(streamed, /SECRET/); +}); + +test('an unquoted ?action=${fn} is refused rather than emitting a bare action=""', async () => { + // Never leaked, but a truthy function silently produced `action=""`, which is + // never what anyone meant. Refusing keeps the documented rule true for `?`. + await assert.rejects( + () => renderToString(html`
    `, { ssr: true }), + /function was interpolated into action=/, + ); + await assert.rejects( + () => drain(renderToStream(html`
    `, { ssr: false })), + /function was interpolated into action=/, + ); +}); + +test('a self-referential array renders instead of overflowing the stack', async () => { + // `Array.prototype.join` has a cycle guard, so `String(cyclic)` is ''. The + // function walk has to match that; a naive recursion turned a render that + // used to succeed into a RangeError. + const cyclic = []; + cyclic.push(cyclic); + const out = await renderToString(html`
    `, { ssr: true }); + assert.match(out, /action=""/); +}); + +test('the streaming renderer refuses .action=${fn} on a native form', async () => { + // The streaming machine's native-prop clause. Mapping every guard call site + // to a test that fails when it is reverted showed this one pinned ONLY by + // `test/bun/form-action-guard.mjs`, so the whole rendering suite stayed green + // with it deleted. Covered here too, since a clause guarded by a single + // cross-runtime script is one file away from being unguarded. + await assert.rejects( + () => drain(renderToStream(html`
    `, { ssr: false })), + /function was interpolated into action=/, + ); +}); diff --git a/packages/core/test/suspense/browser/async-render-client.test.js b/packages/core/test/suspense/browser/async-render-client.test.js index 0790648fc..333fb2b6a 100644 --- a/packages/core/test/suspense/browser/async-render-client.test.js +++ b/packages/core/test/suspense/browser/async-render-client.test.js @@ -161,6 +161,58 @@ suite('async render() on the client', () => { } }); + // Sibling of the test above, and a different failure: there the FETCH + // rejects, here the fetch resolves and the COMMIT throws. The two used to + // disagree, because `.then(onFulfil, onRejected)` does not route onFulfil's + // own throw to its sibling handler. + // + // Run in a real browser rather than only under linkedom because this is the + // shipped combination: an async render returning the refused `.action=${fn}` + // binding on a native form, where `action` is a reflected IDL attribute that + // a DOM shim does not model. The imported action is an RPC stub on the + // client, still a function, so the guard fires here exactly as it would in + // an app. + test('a throw from the COMMIT of an async render reaches renderError(), not the window', async () => { + const tag = uniq('async-commit-err'); + const origError = console.error; + console.error = () => {}; + const escaped = []; + const onRejection = (e) => { escaped.push(e); e.preventDefault(); }; + window.addEventListener('unhandledrejection', onRejection); + try { + const serverAction = async function saveTodo(input) { return { ok: true }; }; + class C extends WebComponent { + async render() { await tick(2); return html`
    `; } + renderError(e) { return html`

    ${e.message}

    `; } + } + C.register(tag); + const el = document.createElement(tag); + container().appendChild(el); + + // Bounded on purpose, and crossing the bound is a hard failure: an + // updateComplete that never settles is precisely the bug, so awaiting it + // unguarded would hang the run instead of reporting it. + const settled = await Promise.race([ + // resolve and reject are different outcomes: a rejecting updateComplete + // keeps renderError() firing while every awaiting caller starts throwing. + el.updateComplete.then(() => 'resolved', () => 'REJECTED'), + tick(1000).then(() => 'NEVER SETTLED'), + ]); + await tick(5); + + assert.equal(settled, 'resolved', 'updateComplete must RESOLVE, not reject, after a contained commit failure'); + assert.ok(el.querySelector('.err'), 'renderError committed'); + assert.match(el.querySelector('.err').textContent, /interpolated into action=/); + assert.equal(escaped.length, 0, 'the refusal must not reach window.unhandledrejection'); + assert.equal(el.__pendingAsyncCommits, 0, 'the in-flight count is released, not wedged'); + // The whole point of the guard: nothing carrying the source reaches the DOM. + assert.ok(!el.innerHTML.includes('saveTodo'), 'no function source in the live DOM'); + } finally { + window.removeEventListener('unhandledrejection', onRejection); + console.error = origError; + } + }); + test('race guard: the NEW render commits, a later-resolving STALE one is dropped', async () => { const tag = uniq('async-race'); const gates = []; diff --git a/test/bun/form-action-guard.mjs b/test/bun/form-action-guard.mjs new file mode 100644 index 000000000..8e92dfbb3 --- /dev/null +++ b/test/bun/form-action-guard.mjs @@ -0,0 +1,171 @@ +/** + * Cross-runtime proof that the form-action leak guard (#1154) refuses + * identically on Node and Bun. Run from the repo root: + * + * node test/bun/form-action-guard.mjs + * bun test/bun/form-action-guard.mjs + * + * WebJs runs on Node 24+ OR Bun, and the guard sits in the SSR template state + * machines, so a divergence here is a divergence in whether a server action's + * source reaches the served HTML. That is the one failure mode where "it works + * on Node" is not good enough: an app deployed on Bun would leak silently while + * the Node test suite stayed green. + * + * The runtime-sensitive part is not the string building, it is what each engine + * does with `String(fn)` and with the async render path around it. Assertions + * therefore check for the SECRET marker rather than for a stringification + * shape, and the reason is worth stating precisely, because the obvious one is + * wrong. `Function.prototype.toString` is spec'd to return the exact source + * text, so JSC and V8 do NOT format it differently. What differs is that Bun + * TRANSPILES the module before the engine ever sees it, so the source a + * function reports is the rewritten source: + * + * node 26.1.0 : async function leaky(input) { const conn = '…'; return { ok: true, conn, input }; } + * bun 1.3.14 : async function leaky(input) { return { ok: !0, conn: "…", input }; } + * + * Constant folded, `true` minified to `!0`, reindented. A string LITERAL + * survives that intact, which is exactly why the marker is the right thing to + * assert on and a formatting pattern is not. + */ +import assert from 'node:assert/strict'; + +import { html } from '../../packages/core/src/html.js'; +import { renderToString, renderToStream } from '../../packages/core/src/render-server.js'; + +const runtime = process.versions.bun ? `bun ${process.versions.bun}` : `node ${process.versions.node}`; + +// The marker is written INLINE, not read from a module-scope `const`. +// +// On Node that is load-bearing: `String(fn)` reproduces source, so a body +// saying `const conn = SECRET` stringifies to the identifier and never contains +// the marker, which would make every `!includes(BUN_PARITY_SECRET)` assertion +// below trivially true and this whole file proof of nothing. +// +// On Bun the same construction MIGHT instead expose the marker, because Bun can +// fold a module-scope `const` string into the body. Whether it does here is not +// worth deriving: four attempts to state the folding rule were measured while +// this guard was reviewed, and each was falsified by the next. +// +// That unpredictability IS the argument for inlining. A test whose marker is +// visible only under some transpiler heuristic is a test that can go quietly +// tautological when the heuristic shifts, which is exactly what it must never +// do. Inlining removes the dependency instead of reasoning about it. +async function leaky(input) { + const conn = 'postgres://user:BUN_PARITY_SECRET@host/db'; + return { ok: true, conn, input }; +} + +async function drain(stream) { + let out = ''; + for await (const c of stream) out += typeof c === 'string' ? c : new TextDecoder().decode(c); + return out; +} + +/** + * Every shape that must be refused, in both SSR machines. + * + * The UNQUOTED sigil forms are load-bearing here, not padding: the two machines + * route bindings through separate branches, and they have already drifted apart + * on exactly those (one refused `.action=${fn}` while the other dropped it, and + * a later fix over-corrected into refusing `@action=${fn}` on one side only). + * A parity file that enumerates only the quoted shapes cannot see either. + */ +const refused = { + 'action=${fn}': () => html`
    `, + 'action="${fn}"': () => html`
    `, + 'mixed action="/x/${fn}"': () => html`
    `, + 'formaction=${fn}': () => html``, + 'quoted prop .action="${fn}"': () => html`
    `, + 'quoted bool ?action="${fn}"': () => html`
    `, + 'quoted event @action="${fn}"': () => html`
    `, + 'native prop .action=${fn}': () => html`
    `, + 'unquoted bool ?action=${fn}': () => html`
    `, + 'array-wrapped action=${[fn]}': () => html`
    `, + // Case folding, on both runtimes: every other row here spells the attribute + // lowercase, and with those alone the `.toLowerCase()` in isFormActionAttr + // could be deleted with this whole table still green while `formAction=` + // leaked. camelCase is React's spelling, so it is the likeliest arrival. + 'camelCase formAction=${fn}': () => html``, + 'upper-case ACTION=${fn}': () => html`
    `, + 'reflecting prop .formAction=${fn} on a button': () => html``, +}; + +for (const [name, mk] of Object.entries(refused)) { + // Buffered renderer. + let threw = null; + try { await renderToString(mk(), { ssr: true }); } catch (e) { threw = e; } + assert.ok(threw, `[${runtime}] buffered SSR must refuse ${name}`); + assert.match(threw.message, /function was interpolated into/, `[${runtime}] ${name} message`); + assert.ok(!threw.message.includes('BUN_PARITY_SECRET'), + `[${runtime}] the refusal message must not carry the source it withholds (${name})`); + + // Streaming renderer, the second, independent state machine. Matches the + // message too: asserting only that SOMETHING threw would be satisfied by any + // unrelated error, which is how a machine that refuses for the wrong reason + // slips through a parity check. + let streamThrew = null; + try { await drain(renderToStream(mk(), { ssr: false })); } catch (e) { streamThrew = e; } + assert.ok(streamThrew, `[${runtime}] streaming SSR must refuse ${name}`); + assert.match(streamThrew.message, /function was interpolated into/, + `[${runtime}] streaming must refuse ${name} for the RIGHT reason`); + assert.ok(!streamThrew.message.includes('BUN_PARITY_SECRET'), + `[${runtime}] streaming refusal must not carry the source (${name})`); +} + +/** + * The carve-outs, which matter as much as the refusals: a guard that refused + * everything would satisfy every assertion above. Both machines must AGREE that + * these stay legal. + */ +const allowed = { + 'unquoted event @action=${fn}': () => html`
    `, + 'custom-element event @action=${fn}': () => html``, + 'custom-element prop .action=${fn}': () => html``, + // The `.prop` guard keys on REFLECTION, not on "is a native element", so + // these must stay legal on both runtimes: a plain expando writes no markup. + 'native prop .action=${fn} on a div': () => html`
    `, + 'native prop .action=${fn} on a button': () => html``, +}; + +for (const [name, mk] of Object.entries(allowed)) { + const buffered = await renderToString(mk(), { ssr: true }); + assert.ok(!buffered.includes('BUN_PARITY_SECRET'), `[${runtime}] ${name} must not leak (buffered)`); + + const streamed = await drain(renderToStream(mk(), { ssr: false })); + assert.ok(!streamed.includes('BUN_PARITY_SECRET'), `[${runtime}] ${name} must not leak (streaming)`); +} + +// A self-referential array stringifies to '' because `Array.prototype.join` has +// a cycle guard. The function check has to match that rather than recurse, on +// both engines. +const cyclic = []; +cyclic.push(cyclic); +const cyclicOut = await renderToString(html`
    `, { ssr: true }); +assert.match(cyclicOut, /action=""/, `[${runtime}] a cyclic array must render, not overflow the stack`); + +// The SCOPE boundary, on both machines. Every other passthrough above is +// `action`-valued, so none of them would notice a change that widened the claim +// to drop function values in every attribute. This is the one that would. +// Asserts on the SECRET marker rather than a stringification shape, for the +// reason given at the top of the file: Bun transpiles the module, so the source +// a function reports is rewritten, and a string literal is what survives that. +const otherBuffered = await renderToString(html`
    `, { ssr: true }); +assert.ok(otherBuffered.includes('BUN_PARITY_SECRET'), + `[${runtime}] an unclaimed attribute must still stringify (buffered)`); + +const otherStreamed = await drain(renderToStream(html`
    `, { ssr: false })); +assert.ok(otherStreamed.includes('BUN_PARITY_SECRET'), + `[${runtime}] an unclaimed attribute must still stringify (streaming)`); + +// The passthrough must stay byte-identical across runtimes: refusing everything +// would also pass the assertions above, so pin what still works. +const okBuffered = await renderToString(html`
    `, { ssr: true }); +assert.match(okBuffered, /action="\/submit"/, `[${runtime}] a string action must still render`); + +const okStream = await drain(renderToStream(html`
    `, { ssr: false })); +assert.match(okStream, /action="\/submit"/, `[${runtime}] a string action must still stream`); + +const okArray = await renderToString(html`
    `, { ssr: true }); +assert.match(okArray, /action="\/a,\/b"/, `[${runtime}] an array of strings is not a function`); + +console.log(`[${runtime}] form-action guard parity OK: ${Object.keys(refused).length} refused shapes, ${Object.keys(allowed).length} carve-outs, 5 passthroughs`); diff --git a/test/bun/form-action-guard.test.mjs b/test/bun/form-action-guard.test.mjs new file mode 100644 index 000000000..e311b3d41 --- /dev/null +++ b/test/bun/form-action-guard.test.mjs @@ -0,0 +1,15 @@ +/** + * Run the cross-runtime form-action guard proof (#1154) under WHICHEVER runtime + * executes the suite. Picked up by the root `node --test` runner (so `npm test` + * exercises the Node path); the Bun path runs twice in CI, as its own + * `bun test/bun/form-action-guard.mjs` step in the `bun` job and again through + * the `scripts/run-bun-tests.js` matrix. The proof is a plain assert script + * (`form-action-guard.mjs`, + * not `*.test.mjs`, so the runner does not double-run it); importing it runs it + * and throws on any failure. + */ +import { test } from 'node:test'; + +test('the form-action leak guard refuses identically on this runtime (#1154)', async () => { + await import('./form-action-guard.mjs'); +}); diff --git a/website/app/docs/components/page.ts b/website/app/docs/components/page.ts index ed413806d..983951db9 100644 --- a/website/app/docs/components/page.ts +++ b/website/app/docs/components/page.ts @@ -645,6 +645,8 @@ render() {

    Properties vs Attributes in Templates

    Templates support three binding prefixes for setting values on elements:

    +

    One carve-out applies to all three below: a function in action= or formaction= is refused, not stringified. Stringifying a function writes its source into the HTML, and during SSR an imported 'use server' action is the real function, so that source is the action's whole body. Every hole form is refused under those two names (plain, quoted, mixed, array-wrapped, and .action=\${fn} on a native form, which reflects into the live DOM). A function stays legal on an unquoted @action=\${fn} listener and on a custom element's own .action property, because neither stringifies it. So where the rules below say a value is stringified, that a native property binding drops at SSR, or that a boolean hole emits the attribute when truthy, read them as holding for every attribute except these two. See Troubleshooting.

    +

    Regular Attributes: attr=\${value}

    Sets an HTML attribute. The value is stringified. If the value is null, undefined, or false, the attribute is removed.

    diff --git a/website/app/docs/migrating-from-nextjs/page.ts b/website/app/docs/migrating-from-nextjs/page.ts index 005c648c8..6ab4f9573 100644 --- a/website/app/docs/migrating-from-nextjs/page.ts +++ b/website/app/docs/migrating-from-nextjs/page.ts @@ -103,7 +103,7 @@ Counter.register('my-counter');

    What ports cleanly, and what does not

    Ports directly: the app/ directory layout, dynamic segments ([id], [...rest], [[...rest]]), route groups ((group)), the metadata API, route handlers, middleware, and the loading / error / not-found conventions.

    -

    Needs rethinking: anything written as a Client Component becomes a web component; anything fetching server data moves into a .server action; React state becomes signals; next/link becomes a plain link. Write progressive-enhancement-first: a <form> plus a server action instead of a fetch in a click handler, since the form works without JavaScript and the client router upgrades it automatically.

    +

    Needs rethinking: anything written as a Client Component becomes a web component; anything fetching server data moves into a .server action; React state becomes signals; next/link becomes a plain link. Write progressive-enhancement-first: a <form> plus a server action instead of a fetch in a click handler, since the form works without JavaScript and the client router upgrades it automatically. Note the binding differs from Next's: <form action={serverAction}> has no WebJs equivalent and is refused at render (it would leak the action's source into the HTML), so omit action to post to the page's own URL and handle the submission in that page's action export.

    Not provided: image and font optimization, i18n, and a static export. WebJs is a no-build, standards-based framework, so these are libraries you layer on, not built-ins.

    Next steps: read Getting Started to scaffold an app, Architecture for the execution model in depth, and Progressive Enhancement for the design posture that replaces the Client Component habit.

    `; diff --git a/website/app/docs/ssr/page.ts b/website/app/docs/ssr/page.ts index d50971b82..a08176c4e 100644 --- a/website/app/docs/ssr/page.ts +++ b/website/app/docs/ssr/page.ts @@ -165,6 +165,8 @@ async function loadExpensiveItems() {

    The custom-element .prop path supports rich types out of the box: Array, Object, Date, Map, Set, BigInt, and reference cycles. Functions, class instances with private state, and DOM nodes are unserializable; they drop with a dev warning. See Components for the full property-binding semantics.

    +

    One exception to the table above: a function in action= or formaction= is refused, not serialized. Stringifying a function writes its source into the HTML, and during SSR an imported 'use server' action is the real function, so that source is the action's whole body. Under those two names the attribute row, the boolean row, and the native .prop row (on a <form>, or .formAction on a button or input, where the property reflects) all throw instead. Every other attribute behaves exactly as the table says, and a string-valued action is unchanged. See Troubleshooting.

    +

    Metadata in <head>

    The SSR pipeline collects metadata from the layout chain and the page, then injects it into the document <head>. You declare metadata via a named export:

    diff --git a/website/app/docs/troubleshooting/page.ts b/website/app/docs/troubleshooting/page.ts index 7c564cd20..6ca9f54c0 100644 --- a/website/app/docs/troubleshooting/page.ts +++ b/website/app/docs/troubleshooting/page.ts @@ -41,6 +41,14 @@ export default function Troubleshooting() {

    Cause: WebJs instruments the native DOM mutation methods on every light-DOM host to keep the slot API live, and it installs those interceptors as own properties ON THE ELEMENT INSTANCE. An own instance property shadows a method defined on the class prototype, so the framework's interceptor wins and your same-named class method is never reached. The instrumented names are append, prepend, before, after, replaceWith, replaceChildren, remove, appendChild, insertBefore, removeChild, and replaceChild. TypeScript does not catch it because a shorter override (zero or one argument) is assignable to the native signature.

    Fix: rename the member to a non-native name (appendRow(), removeItem()) and update its call sites. Only these native mutation members are affected; render() and the lifecycle hooks are meant to be overridden. The no-shadowed-native-member check rule catches this ahead of time (it flags only a real instance method, never a static member, a nested-object property, or a static shadow = true component, whose native shadow slots are not instrumented). See Components.

    +

    An error saying a function was interpolated into action=

    +

    Symptom: a render fails with a function was interpolated into action= on <form>, usually right after binding a server action to a form the way Next.js does it. Where that surfaces depends on which render threw. From a page or layout it propagates, so the nearest error boundary catches it and the message is visible. From inside a component it does not: per-component SSR error isolation contains it, so dev renders an error box in place of the component while production renders the component empty and the page still returns 200. A form that has silently vanished in production, with no error anywhere, is the same bug wearing a disguise; check the server log for the message. In neither case is the action's source emitted.

    +

    Two refinements on "renders the component empty", because both send people looking in the wrong place. The isolation replaces the element through its matching closing tag, so anything slotted into that component disappears with it: put the bad form in a shared header or shell and every page returns 200 with an empty body, which reads like a routing failure rather than one broken component. And on a route with a loading.ts, the page renders inside a Suspense boundary after the 200 and the shell are already flushed, where a throw is currently swallowed with no server log and no error report; the visitor gets chrome and a blank body, and with JS off the loading skeleton simply stays. That silence is why the log line you are looking for may not exist on that path; it is a known gap rather than intended behaviour.

    +

    Cause: during SSR an imported 'use server' action is the REAL function (the RPC stub exists only in the browser), and action= is an ordinary attribute hole, so stringifying it would write the action's whole body into the HTML every visitor downloads: its logic, the query shapes it builds, and any literal written inside it, a connection string or internal path included.

    +

    Assume outer values are exposed too. On Node, Function.prototype.toString returns source text, so a module-scope const the body reads appears only as its identifier. That is NOT a guarantee you can rely on: Bun transpiles a module before the engine sees it and can fold a module-scope string literal straight into the body, so the same action reports Authorization: "Bearer sk_live_…" where Node reports Bearer \${VENDOR_API_KEY}. Do not go looking for the rule that decides when it folds. It is a transpiler's internal choice rather than a documented boundary, and two modules on the same Bun version can differ. Whether a secret in an outer binding escapes therefore depends on the runtime and on how the module was transformed, and it is not a boundary worth building a habit on. Treat everything reachable from the action as exposed, which is what the refusal assumes. WebJs refuses rather than emitting it. The refusal covers the shape rather than one spelling: formaction= as well as action=, every hole form (action=\${fn}, action="\${fn}", the mixed action="/x/\${fn}"), and a function wrapped in an array (action=\${[fn]}), which stringifies its elements the same way. One shape it does not cover: a hole inside an HTML comment is emitted raw by the renderer, so commenting the form out does NOT disable the interpolation and still ships the source. Delete the binding rather than commenting around it. .action=\${fn} on a native form is refused at SSR too, even though the property is dropped there, so a page cannot render clean on the server and then throw on hydration, where the reflected IDL attribute would carry the source into the live DOM.

    +

    Two things stay legal, because neither stringifies its value: a custom element's .action property (an author-defined property, not the reflected IDL attribute a native <form> has, so <my-el .action=\${fn}> is fine), and an unquoted @action=\${fn} event listener, where a function is exactly what is wanted. One qualification on the first: that holds for a plain property, and NOT if the component declares it reflect: true. Reflection is a separate path that writes String(value) into the attribute without passing through the template commit sites this guard covers, so a reflecting action prop still emits the function's source. That path is a general problem with reflecting any function-valued prop rather than an action one, and it is tracked separately. Quoting a binding hole turns it back into a plain attribute, so @action="\${fn}" IS refused; that is the practical reason invariant 4 requires @, . and ? holes to be unquoted. ?action=\${fn} is refused as well: it never leaked, but a truthy function would silently emit a bare action="", which is never what anyone meant.

    +

    Fix: omit action so the form posts to the page's own URL, and handle the submission in that page's action export. A string action is unaffected. See Server Actions and Progressive Enhancement.

    +

    An error saying static properties is no longer supported

    Symptom: a component throws at construction with static properties is no longer supported. Declare reactive properties via the factory instead.

    Cause: the class body has a hand-written static properties = { ... } field. WebJs declares reactive properties only through the WebComponent({ ... }) base-class factory now, and the runtime throws on a direct static properties.