diff --git a/.agents/skills/webjs/SKILL.md b/.agents/skills/webjs/SKILL.md index fb5a1e10e..5f069802f 100644 --- a/.agents/skills/webjs/SKILL.md +++ b/.agents/skills/webjs/SKILL.md @@ -240,7 +240,7 @@ Success is a 303 (PRG); failure re-renders the page at 422 with the result on `a - Writing `formmethod="get"` or `formenctype="text/plain"` on a button that BINDS an action. Neither can carry that action's body, so the pair contradicts itself and throws. On a button that binds nothing it is a legal native override and is honoured. - Binding an action whose file declares `export const method = 'GET'`. Form-bound actions strictly require POST (default). Binding a GET action to a form is a 405 at runtime and a `webjs check` error (`form-action-not-a-get-action`). - Leaving read-only RPC server query actions as default `POST`. Always export `export const method = 'GET'` for RPC data queries so arguments ride URL params, ETags/304 caching work, and CSRF is safely bypassed. -- Writing `method="get"` on a bound `
`. WebJs supplies `method="post"` and `formenctype` automatically; manually setting `method="get"` triggers a `WEBJS_FORM_SUBMITTED_AS_GET` diagnostic warning. +- Writing `method="get"` on a bound ``. WebJs supplies `method="post"` and `formenctype` automatically, and a bound form declaring `method="get"` is REFUSED at render (a thrown error, not a warning), because a GET sends no body for the action to read. - Throwing `redirect()` / `notFound()` inside a `route.ts` handler (uncaught 500). Return a `Response` there. - A placeholder first paint that fetches in `connectedCallback`. SSR does not call `connectedCallback`; put first-paint data in the constructor (server-known inputs) or use `async render()`. - A browser global (`window`, `document`, `localStorage`) in the constructor or `render()`. It throws at SSR; do browser-only work in `connectedCallback`. diff --git a/.agents/skills/webjs/references/data-and-actions.md b/.agents/skills/webjs/references/data-and-actions.md index 7ec2dcfd5..5eb4c60c6 100644 --- a/.agents/skills/webjs/references/data-and-actions.md +++ b/.agents/skills/webjs/references/data-and-actions.md @@ -146,7 +146,7 @@ Everything the action declares applies here too, or an action would be protected - `invalidates` is evicted when the action actually RAN (a middleware short-circuit does not evict), and the evicted tags are reported on the response so the browser's tag coordinator bypasses a stale cached GET. One reach limit: `fetch` follows the success `303` transparently, so JS cannot read a redirect's headers; the tags are on the wire and the `422` re-render carries them, and the redirect's own render is server-side and seeds fresh data. - `invalidates` and `tags` receive the SAME first argument the action does, so on a form boundary they receive the `FormData`. `invalidates: (input) => ['post:' + input.id]` returns `post:undefined` for a submission and evicts nothing. Either read the field (`(fd) => ['post:' + fd.get('id')]`), declare a `validate` that transforms the `FormData` into the typed input first (the transform result is what the config functions then see), or use an argument-independent tag. - `method = 'GET'` cannot be bound to a form: a GET action rides its args in the url and is CSRF-exempt, so it cannot answer a form POST. That is a `405` at runtime and the `form-action-not-a-get-action` error in `webjs check`. -- A form whose buttons run DIFFERENT actions binds each on its submitter, `
`'; - assert.deepEqual(scanHtmlFormScopes(src).submitters, [{ tag: 'button', scope: 'none', delivers: null, expr: 'del' }]); -}); - -test('classifyActionHole matches the tag and the attribute as a pair', () => { - assert.equal(classifyActionHole('
'), null, 'the tag already closed'); - assert.equal(classifyActionHole('plain text'), null); -}); - -test('matchClosingBrace walks past a template hole (#1307)', () => { - // A hole is a CODE context nested in a template, not a brace in the block - // being matched. Counting it toward the outer depth (the earlier behaviour) - // meant depth could never return to zero. - const s = '{ return html`${x}`; }'; - assert.equal(matchClosingBrace(s, 1), s.length - 1); - const nested = '{ a(html`${ b(html`${c}`) }`); }'; - assert.equal(matchClosingBrace(nested, 1), nested.length - 1); - // Still returns -1 when there really is no match. - assert.equal(matchClosingBrace('{ a(', 1), -1); -}); - -test('a class body holding a template hole is extractable from RAW source', () => { - // Every other caller passes a masked source in which holes are blanked, so - // this path was the one that exposed the brace bug. - const src = [ - 'class RowBtn extends WebComponent({}) {', - ' render() { return html``; }', - '}', - ].join('\n'); - const bodies = extractWebComponentClassBodies(src); - assert.equal(bodies.length, 1); - assert.match(bodies[0].body, /formaction=\$\{del\}/); - assert.deepEqual(scanHtmlFormScopes(bodies[0].body).submitters, [{ tag: 'button', scope: 'none', delivers: null, expr: 'del' }]); -}); - -test('an unbound form reports whether it would still DELIVER the identity', () => { - // This is the bit that decides broken from working, and it is not boundness. - // A submitter's identity rides its OWN name/value pair, so an unbound form - // that still sends a parseable POST body delivers it and the action runs. - const sub = (formTag) => scanHtmlFormScopes( - 'html`' + formTag + '`', - ).submitters[0]; - - assert.equal(sub('
').delivers, true, 'a POST body carries the pair'); - assert.equal(sub('').delivers, true, 'the keyword folds case'); - assert.equal(sub('').delivers, true); - assert.equal(sub('').delivers, false, 'no method is a GET'); - assert.equal(sub('').delivers, false); - // `method` is an enumerated attribute matched against exact keywords with no - // whitespace stripping, so a padded value falls to the GET default. - assert.equal(sub('').delivers, false); - assert.equal(sub('').delivers, false, 'the server cannot parse it'); - // `enctype` is an enumerated attribute whose missing AND invalid value default - // are both application/x-www-form-urlencoded, so an unrecognised value falls - // back to a parseable body. Treating it as unparseable reported a working form - // as broken, which is the one thing this rule must never do. - assert.equal(sub('').delivers, true, 'an invalid enctype falls back to urlencoded'); - assert.equal(sub('').delivers, true, 'padded, so invalid, so urlencoded'); - assert.equal(sub('').delivers, false, 'the keyword folds case'); - // A hole anywhere else in the start tag makes the answer dynamic. - assert.equal(sub('').delivers, null); - assert.equal(sub('').delivers, null); -}); - -test('opensForm reports whether the source opens any form at all', () => { - assert.equal(scanHtmlFormScopes('html``').opensForm, false); - assert.equal(scanHtmlFormScopes('html`
`').opensForm, true); - // A form written only inside a plain string is not a form. - assert.equal(scanHtmlFormScopes("const s = '
';").opensForm, false); -}); - -test('class-body offsets index the RAW source identically to the mask', () => { - // How the rule gets a body with its templates intact without asking the brace - // matcher to lex raw source: locate in the position-preserving mask, slice - // from `content`. A regex literal holding a brace is the case that broke when - // raw source was passed directly. - const src = [ - 'class RowBtn extends WebComponent({}) {', - ' static re = /[{]/;', - ' render() { return html``; }', - '}', - ].join('\n'); - const masked = redactStringsAndTemplates(src); - assert.equal(masked.length, src.length, 'the mask is position-preserving'); - const bodies = extractWebComponentClassBodies(masked); - assert.equal(bodies.length, 1, 'the masked view blanks the regex body, so the braces balance'); - const body = src.slice(bodies[0].bodyStart, bodies[0].bodyEnd); - assert.match(body, /formaction=\$\{del\}/, 'the raw slice keeps the template intact'); - assert.deepEqual(scanHtmlFormScopes(body).submitters, [{ tag: 'button', scope: 'none', delivers: null, expr: 'del' }]); -}); - -test('a template in an ordinary START-TAG hole is handed off, not inherited', () => { - // A hole inside a start tag is an attribute or property VALUE whose placement - // this scan cannot speak for: SSR never renders it in place (a serializable - // value applies at hydration, a function-carrying one is dropped outright), so - // the receiving element decides where it lands in the browser. Scoring it by - // lexical nesting reported a shape the renderer treats as cannot-tell (and - // therefore binds) as a conclusive 'unbound'. `` is - // the one exception and has its own test below. - const passed = 'html`x`}>`'; - // 'handed', NOT 'none'. Both mean "no enclosing form in this scan", but only - // 'none' is a cannot-tell the caller may attribute to this file's own - // component. A handed-off template belongs to whichever element received it, - // so collapsing the two resolves it through the wrong call sites. - assert.deepEqual(scanHtmlFormScopes(passed).submitters, [{ tag: 'button', scope: 'handed', delivers: null, expr: 'del' }]); - // A hyphenated tag inside a property hole is placed by the receiving element - // too, so it is not this file's call site either. - const tagPassed = 'html``}>`'; - assert.deepEqual(scanHtmlFormScopes(tagPassed).tagUses.filter((u) => u.tag === 'todo-row'), - [{ tag: 'todo-row', scope: 'handed', delivers: null, expr: null }]); - // A form the handed-off template opens ITSELF is still its own scope. - const ownForm = 'html``}>`'; - assert.deepEqual(scanHtmlFormScopes(ownForm).submitters, [{ tag: 'button', scope: 'bound', delivers: null, expr: 'del' }]); - // A hole in CHILD position IS rendered inline by this scan, so it still - // inherits. This is the pair that keeps the fix from being a blanket opt-out. - const child = 'html`
${html``}
`'; - assert.deepEqual(scanHtmlFormScopes(child).submitters, [{ tag: 'button', scope: 'unbound', delivers: true, expr: 'del' }]); -}); - -test('the unparseable-enctype constant tracks the renderer\'s own set', async () => { - // The scanner states its enctype rule as a DENYLIST of one because of the - // invalid-value default, while the renderer refuses the wider allowlist. This - // pins the relationship rather than asserting the two are equal, so a change - // to core's set surfaces here instead of drifting silently. - assert.ok(!PARSEABLE_ENCTYPES.has('text/plain'), 'the renderer cannot parse text/plain either'); - for (const e of PARSEABLE_ENCTYPES) { - const src = `html\`
\``; - assert.equal(scanHtmlFormScopes(src).submitters[0].delivers, true, `${e} delivers`); - } - assert.deepEqual([...PARSEABLE_ENCTYPES].sort(), - ['application/x-www-form-urlencoded', 'multipart/form-data'], - 'if core gains an enctype, revisit the scanner denylist'); - - // There are now THREE hardcoded copies of the denylist keyword: the scanner's - // `UNPARSEABLE_FORM_ENCTYPE`, the client guard in `router-client.js`, and this - // test. Pin the client one too, so the two halves of the feature cannot drift - // into disagreeing on the same input (which is what the allowlist did). - const clientSrc = await readFile(new URL('../../../core/src/router-client.js', import.meta.url), 'utf8'); - assert.match(clientSrc, /enctype\.toLowerCase\(\) === 'text\/plain'/, - 'the client guard uses the same one-keyword denylist, not the renderer allowlist'); - assert.doesNotMatch(clientSrc, /PARSEABLE_ENCTYPES/, - 'and does not reach for the renderer allowlist again'); -}); - -test('the start-tag-hole rule matches what the RENDERER actually does', async () => { - // A differential rather than an assertion about the scanner alone, because the - // whole 'handed' state is a claim about the renderer's behaviour. Most - // start-tag holes are values the receiving element places, but - // `` is rendered INLINE with the enclosing form - // scope (#471: a custom-element property applies at hydration, too late for a - // placeholder that must be in the first flushed bytes). Treating that one as - // handed off would blind the same-scan half to a real render-time throw. - const { html } = await import('../../../core/src/html.js'); - const { renderToString } = await import('../../../core/src/render-server.js'); - const { setFormActionResolver } = await import('../../../core/src/form-action.js'); - setFormActionResolver(async () => 'abc1234567/publish'); - const publish = async () => ({ success: true }); - - // Rendered inline, so the renderer judges it against the unbound form and - // throws. The scanner must see the same thing. - const fallbackSrc = 'html`
x`}>
`'; - assert.deepEqual(scanHtmlFormScopes(fallbackSrc).submitters, - [{ tag: 'button', scope: 'unbound', delivers: false, expr: 'p' }]); - await assert.rejects( - () => renderToString( - html`
x`}>
`, - { ssr: true }, - ), - /requires the enclosing
to also be bound/, - 'the renderer really does refuse it, so the scanner must not be silent', - ); - - // The negative half, and it has to assert the MECHANISM rather than "it did - // not throw". A template carrying a function cannot serialize, so SSR drops - // the whole property binding and emits nothing for it: the submitter is never - // rendered and never judged here at all. Asserting only that the render - // succeeded would stay green for ANY scanner verdict, which is precisely the - // non-discriminating test this differential exists to avoid. - const propSrc = 'html`x`}>
`'; - assert.deepEqual(scanHtmlFormScopes(propSrc).submitters, - [{ tag: 'button', scope: 'handed', delivers: null, expr: 'p' }]); - const warns = []; - const quiet = console.warn; - console.warn = (...a) => warns.push(a.join(' ')); - let out; - try { - out = await renderToString( - html`
x`}>
`, - { ssr: true }, - ); - } finally { console.warn = quiet; } - assert.doesNotMatch(out, /`', - ).submitters[0]; - - // The scanner: an invalid enctype still delivers, so it is not a defect. - assert.equal(sub('
').delivers, true); - // The renderer: the same typo throws, because it would cost the author their - // file upload with no other signal. - await assert.rejects( - () => renderToString(html`
`, { ssr: true }), - /cannot work|enctype/i, - ); - // And both agree that text/plain is broken. - assert.equal(sub('
').delivers, false); - await assert.rejects( - () => renderToString(html`
`, { ssr: true }), - /cannot work|enctype/i, - ); -}); diff --git a/website/app/docs/progressive-enhancement/page.ts b/website/app/docs/progressive-enhancement/page.ts index 57e606c40..b1c0201b4 100644 --- a/website/app/docs/progressive-enhancement/page.ts +++ b/website/app/docs/progressive-enhancement/page.ts @@ -177,7 +177,7 @@ export default function NewPost({ actionData }: {

- A per-button action needs its enclosing form bound. method="post" and the enctype are supplied on the form's start tag, which is already emitted by the time the renderer reaches the button, so a submitter cannot retrofit them. An unbound host form the renderer can see is refused at render. One it CANNOT see is not: a submitter inside a component is a cannot-tell, because the component renders its own template in a separate pass with no view of the host page, and cannot-tell has to bind (refusing there would drop the component from a page that still returned 200). So a formaction=\${action} button in a component inside an unbound form ships. If that form still declares method="post" it works, because the identity rides the button's own name/value pair into the body; if it declares no method it submits as a GET, puts the reserved identity in the query string, and the action never runs. Run webjs check: the submitter-needs-bound-form rule reads every template in the app at once, so it resolves the enclosing form across modules and flags this at edit time. + A per-button action is self-sufficient. The renderer supplies the submission attributes at the level where the action is bound, so a formaction=\${action} button gains formmethod="post" and formenctype ON THE BUTTON, alongside the identity riding its own name/value pair. A submitter's formmethod overrides the form's method per HTML, so the button posts correctly inside a bound form, an unbound form, a form with no method, or a method="get" form. That is what makes a submitter inside a component safe: the component renders its own template in a separate pass with no view of the host page, and it no longer needs one. Bind the enclosing form when the FORM itself should run an action on a plain submit.

diff --git a/website/app/docs/troubleshooting/page.ts b/website/app/docs/troubleshooting/page.ts index 476462e1c..6044369a7 100644 --- a/website/app/docs/troubleshooting/page.ts +++ b/website/app/docs/troubleshooting/page.ts @@ -56,8 +56,8 @@ export default function Troubleshooting() {

A button submits and the page just re-renders

Symptom: clicking a formaction=\${action} button reloads the same page with a 200, nothing was written, no error appears anywhere, and the address bar has grown a ?__webjs_action= parameter.

-

Cause: the enclosing <form> binds no action AND declares no method="post", so the browser submits it as a GET. (An unbound form that DOES declare method="post" still works, because the identity rides the button's own name/value pair and reaches the server in the body.) A GET sends no body, so the identity the button carries rides the query string instead, and a GET at a page url just renders the page. That is why there is no 405 and no log line. The renderer refuses this shape when it can see both halves in one template, but a submitter inside a COMPONENT is a cannot-tell (the component renders in its own pass with no view of the host page) and cannot-tell has to bind, so a split-across-modules form reaches production.

-

Fix: bind the enclosing form too (<form action=\${saveAll}>), which is what supplies method="post" and the enctype. Run webjs check to find it: the submitter-needs-bound-form rule reads every template in the app at once, so it resolves the enclosing form across module boundaries and transitively through intermediate components. It is conservative and stays silent when a tag is rendered in a bound form somewhere and an unbound one elsewhere, so a clean check is not by itself proof the form is bound. Two runtime signals back it up: in dev the browser console carries one [webjs] error at submit time naming the fix, and in production the onError hook receives the fingerprint with err.code === 'WEBJS_FORM_SUBMITTED_AS_GET'. The 405 case has its own code, WEBJS_FORM_ACTION_MISSING. Neither changes the response.

+

Cause: the submission went out as a GET, so it carried no body and the identity rode the query string instead, and a GET at a page url just renders the page. That is why there is no 405 and no log line. A submitter you bound with formaction=\${action} does NOT reach this on its own: the renderer gives it formmethod="post", which overrides the form's method per HTML, so it posts correctly inside a form that is unbound, method-less, or even method="get". What reaches this is a formmethod="get" or method="get" written by hand and deliberately honoured, or a hand-authored form carrying the reserved field.

+

Fix: remove the explicit formmethod="get" or method="get" from the submission path you meant to write. Two runtime signals find it: in dev the browser console carries one [webjs] error at submit time naming the fix, and in production the onError hook receives the fingerprint with err.code === 'WEBJS_FORM_SUBMITTED_AS_GET'. The 405 case has its own code, WEBJS_FORM_ACTION_MISSING. Neither changes the response.

A render fails on a button's formmethod or formenctype

Symptom: a page fails at render with <button formenctype="text/plain" formaction=\${action}> cannot work, or the same message naming formmethod.