From e5086c29d3bb5291a8c950ddb6531b614b917003 Mon Sep 17 00:00:00 2001 From: Vivek Date: Tue, 11 Aug 2026 01:41:11 +0530 Subject: [PATCH 1/3] fix: drop submitter-needs-bound-form, which contradicts #1307 The rule and its renderer differential were written against behaviour the renderer does not have. #1307 made a bound submitter self-sufficient: the renderer stamps formmethod="post" and formenctype on the button itself, and a submitter's formmethod overrides the form's method per HTML. So a bound button posts correctly inside an unbound, method-less, or method="get" form, and the rule's premise that it "submits as a GET with the identity in the query string" is false for every shape it flagged. It was flagging #1307's own demonstration fixtures in examples/blog, and the differential asserted an error string that exists nowhere in core or server. Both have reddened main since 7683c1ba, on webjs check, the unit suite, and the Bun matrix, blocking every open PR. The runtime diagnostics in form-dispatch.js are untouched: they detect a request shape server-side and stay reachable however the request was made. --- .../webjs/references/data-and-actions.md | 2 +- .../webjs/references/muscle-memory-gotchas.md | 11 +- packages/server/src/check.js | 455 +---------- .../check/submitter-needs-bound-form.test.js | 716 ------------------ .../test/scanner/html-form-scopes.test.js | 30 +- .../app/docs/progressive-enhancement/page.ts | 2 +- website/app/docs/troubleshooting/page.ts | 4 +- 7 files changed, 23 insertions(+), 1197 deletions(-) delete mode 100644 packages/server/test/check/submitter-needs-bound-form.test.js diff --git a/.agents/skills/webjs/references/data-and-actions.md b/.agents/skills/webjs/references/data-and-actions.md index 7ec2dcfd5..b79f12e08 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, `\`; - } -} -RowBtn.register('row-btn'); -${extra}`; - -const TODO_LIST = `import { html, WebComponent } from '@webjsdev/core'; -class TodoList extends WebComponent({}) { - render() { return html\`\`; } -} -TodoList.register('todo-list'); -`; -const TODO_ROW = `import { html, WebComponent } from '@webjsdev/core'; -import { publishDraft } from '#modules/feedback/actions/publish.server.ts'; -class TodoRow extends WebComponent({}) { - render() { return html\`
  • \`; } -} -TodoRow.register('todo-row'); -`; - -test('the rule is registered', () => { - assert.ok(RULES.some((r) => r.name === RULE), 'RULES lists submitter-needs-bound-form'); -}); - -test('flags a component submitter whose only call site is an unbound form', async () => { - const dir = await makeApp({ - 'components/row-btn.ts': rowBtn(), - 'app/page.ts': `import { html } from '@webjsdev/core'; -import '#components/row-btn.ts'; -export default () => html\`\`; -`, - }); - const v = hits(await checkConventions(dir)); - assert.equal(v.length, 1, 'exactly one violation'); - assert.match(v[0].file, /row-btn\.ts/); - assert.match(v[0].message, / is rendered/); - assert.match(v[0].fix, /
    { - const dir = await makeApp({ - 'components/row-btn.ts': rowBtn(), - 'app/page.ts': `import { html } from '@webjsdev/core'; -import { saveAll } from '#modules/feedback/actions/save.server.ts'; -export default () => html\`
    \`; -`, - }); - assert.deepEqual(hits(await checkConventions(dir)), []); - await rm(dir, { recursive: true, force: true }); -}); - -test('silent when one call site is bound and another is not', async () => { - const dir = await makeApp({ - 'components/row-btn.ts': rowBtn(), - 'app/a/page.ts': `import { html } from '@webjsdev/core'; -import { saveAll } from '#modules/feedback/actions/save.server.ts'; -export default () => html\`
    \`; -`, - 'app/b/page.ts': `import { html } from '@webjsdev/core'; -export default () => html\`
    \`; -`, - }); - assert.deepEqual(hits(await checkConventions(dir)), [], 'a mixed tag is indefinite'); - await rm(dir, { recursive: true, force: true }); -}); - -test('silent when the unbound host form still DELIVERS the identity (method=post)', async () => { - // The distinction the rule turns on, and the one that is easy to get wrong. - // An unbound `
    ` WORKS across a module boundary: the - // cannot-tell fallback binds the submitter, the submitter's own name/value - // pair carries `__webjs_action` into the POST body, and the dispatcher takes - // the last entry it finds. Flagging it would be a false positive on working - // code, with a diagnosis (a GET, a query string) that never happens. - const dir = await makeApp({ - 'components/row-btn.ts': rowBtn(), - 'app/page.ts': `import { html } from '@webjsdev/core'; -export default () => html\`
    \`; -`, - }); - assert.deepEqual(hits(await checkConventions(dir)), [], 'an unbound POST form delivers, so nothing is broken'); - await rm(dir, { recursive: true, force: true }); -}); - -test('fires when the unbound host form cannot deliver (method=get, or a bad enctype)', async () => { - // The other side of the same coin: these really do lose the identity. - for (const form of ['
    ', '', '']) { - const dir = await makeApp({ - 'components/row-btn.ts': rowBtn(), - 'app/page.ts': `import { html } from '@webjsdev/core'; -export default () => html\`${form}
    \`; -`, - }); - const v = hits(await checkConventions(dir)); - assert.equal(v.length, 1, `${form} cannot carry the identity`); - assert.match(v[0].message, /cannot carry the identity to the server/); - await rm(dir, { recursive: true, force: true }); - } -}); - -test('silent when the host form method is a dynamic hole', async () => { - const dir = await makeApp({ - 'components/row-btn.ts': rowBtn(), - 'app/page.ts': `import { html } from '@webjsdev/core'; -export default ({ m }) => html\`
    \`; -`, - }); - assert.deepEqual(hits(await checkConventions(dir)), [], 'a dynamic method is unknowable'); - await rm(dir, { recursive: true, force: true }); -}); - -test('silent when the component could splice a fragment into a form it opens itself', async () => { - // A fragment built into a local and spliced into a form the SAME file opens - // inherits the splice point's scope, not this component's call-site scope, - // and the two templates are separate scans. Same reasoning the submitter half - // already applies to a bare helper. - const dir = await makeApp({ - 'components/todo-list.ts': `import { html, WebComponent } from '@webjsdev/core'; -class TodoList extends WebComponent({}) { - render() { - const rows = html\`\`; - return html\`
    \${rows}
    \`; - } -} -TodoList.register('todo-list'); -`, - 'components/todo-row.ts': TODO_ROW, - 'app/page.ts': `import { html } from '@webjsdev/core'; -export default () => html\`
    \`; -`, - }); - assert.deepEqual(hits(await checkConventions(dir)), [], 'a file that opens its own form cannot attribute a none-scope use'); - await rm(dir, { recursive: true, force: true }); -}); - -test('a regex literal in a component file does not silently disable the rule', async () => { - // The class body is located in the MASK and sliced out of the raw source at - // the same offsets, so the brace matcher never lexes raw source. Feeding it - // raw source made `static re = /[{]/` yield zero class bodies, which dropped - // the cross-module half for that file with no signal at all. - const dir = await makeApp({ - 'components/row-btn.ts': `import { html, WebComponent } from '@webjsdev/core'; -import { publishDraft } from '#modules/feedback/actions/publish.server.ts'; -class RowBtn extends WebComponent({}) { - static re = /[{]/; - render() { return html\`\`; } -} -RowBtn.register('row-btn'); -`, - 'app/page.ts': `import { html } from '@webjsdev/core'; -export default () => html\`
    \`; -`, - }); - const v = hits(await checkConventions(dir)); - assert.equal(v.length, 1, 'the rule still sees the class body'); - await rm(dir, { recursive: true, force: true }); -}); - -test('silent when a module-scope helper in the component file opens a form', async () => { - // The guard is the WHOLE-FILE scan, not the class body: a helper outside the - // class can open a form the body never sees, and splicing into that is the - // same hole the class-body case has. - const dir = await makeApp({ - 'components/row-btn.ts': `import { html, WebComponent } from '@webjsdev/core'; -import { publishDraft } from '#modules/feedback/actions/publish.server.ts'; -const shell = (inner) => html\`
    \${inner}
    \`; -class RowBtn extends WebComponent({}) { - render() { return shell(html\`\`); } -} -RowBtn.register('row-btn'); -`, - 'app/page.ts': `import { html } from '@webjsdev/core'; -export default () => html\`
    \`; -`, - }); - assert.deepEqual(hits(await checkConventions(dir)), [], 'the submitter may be spliced into the helper form'); - await rm(dir, { recursive: true, force: true }); -}); - -test('silent on an unrecognised enctype, which falls back to a parseable body', async () => { - // `enctype` defaults to application/x-www-form-urlencoded for a missing AND an - // invalid value, so this form really does deliver. - const dir = await makeApp({ - 'components/row-btn.ts': rowBtn(), - 'app/page.ts': `import { html } from '@webjsdev/core'; -export default () => html\`
    \`; -`, - }); - assert.deepEqual(hits(await checkConventions(dir)), []); - await rm(dir, { recursive: true, force: true }); -}); - -test('silent when the submitter template is passed as a component PROPERTY', async () => { - // Lexically inside the form, but the scan cannot say where it lands. SSR - // renders nothing for it at all: the binding carries a function, so it fails - // to serialize and is dropped with a warning, and decides where the - // button goes at hydration. A conclusive verdict here was never this scan's - // to give. - const dir = await makeApp({ - 'app/page.ts': `import { html } from '@webjsdev/core'; -import { publishDraft } from '#modules/feedback/actions/publish.server.ts'; -export default () => html\`
    P\`}>
    \`; -`, - }); - assert.deepEqual(hits(await checkConventions(dir)), []); - await rm(dir, { recursive: true, force: true }); -}); - -test('silent when a COMPONENT hands its submitter template to another element', async () => { - // The variant that matters most, and the one a page-only test misses. Marking - // the handed-off template 'none' silences it on a page and recreates the false - // positive here, because 'none' is exactly the value the cross-module half - // attributes to this file's own tag and resolves against ITS call sites. - // is what decides where the button lands. - const dir = await makeApp({ - 'components/row-btn.ts': `import { html, WebComponent } from '@webjsdev/core'; -import { publishDraft } from '#modules/feedback/actions/publish.server.ts'; -class RowBtn extends WebComponent({}) { - render() { return html\`P\`}>\`; } -} -RowBtn.register('row-btn'); -`, - 'app/page.ts': `import { html } from '@webjsdev/core'; -export default () => html\`
    \`; -`, - }); - assert.deepEqual(hits(await checkConventions(dir)), [], 'the receiving element places the button'); - await rm(dir, { recursive: true, force: true }); -}); - -test('silent when a component hands a TAG to another element', async () => { - // The same root cause on the tag half: places the row, so - // 's call sites say nothing about where renders. - const dir = await makeApp({ - 'components/todo-list.ts': `import { html, WebComponent } from '@webjsdev/core'; -class TodoList extends WebComponent({}) { - render() { return html\`\`}>\`; } -} -TodoList.register('todo-list'); -`, - 'components/todo-row.ts': TODO_ROW, - 'app/page.ts': `import { html } from '@webjsdev/core'; -export default () => html\`
    \`; -`, - }); - assert.deepEqual(hits(await checkConventions(dir)), [], 'a handed-off tag has no attributable call site'); - await rm(dir, { recursive: true, force: true }); -}); - -test('a tag inside a suspense FALLBACK is a real call site, unlike an ordinary prop', async () => { - // The suspense carve-out changes the tag half too, and that half can create - // violations rather than only silence them, so it needs its own coverage. The - // fallback is rendered inline inside the enclosing form, so really - // does land there and the verdict is truthful. - const page = (host) => `import { html } from '@webjsdev/core'; -export default () => html\`
    ${host}
    \`; -`; - const fallback = await makeApp({ - 'components/todo-row.ts': TODO_ROW, - 'app/page.ts': page('
    \`}>'), - }); - assert.equal(hits(await checkConventions(fallback)).length, 1, 'the fallback renders inline, so it is a call site'); - await rm(fallback, { recursive: true, force: true }); - - // The same shape through an ordinary property is handed off and stays silent. - const handed = await makeApp({ - 'components/todo-row.ts': TODO_ROW, - 'app/page.ts': page('\`}>'), - }); - assert.deepEqual(hits(await checkConventions(handed)), [], 'my-shell decides where the row lands'); - await rm(handed, { recursive: true, force: true }); -}); - -test('silent on a formaction hole that is a URL, not an action', async () => { - // The scan is lexical; the RENDERER binds only when the hole's value is a - // FUNCTION (`isBoundFormAction`). So an ordinary progressive-enhancement form - // whose buttons post to different `route.ts` endpoints is not a binding at - // all, and reporting it was a false positive on working code. - for (const hole of ["\\${'/api/items/' + id + '/archive'}", "\\${'/api/x'}", '\\${localUrl}']) { - const dir = await makeApp({ - 'app/items/page.ts': `import { html } from '@webjsdev/core'; -const localUrl = '/api/x'; -export default ({ id }) => html\`
    \`; -`, - }); - assert.deepEqual(hits(await checkConventions(dir)), [], `${hole} is a url, not an action`); - await rm(dir, { recursive: true, force: true }); - } -}); - -test('the callable matrix: every url shape silent, every function shape flagged', async () => { - // The whole table in one place, because two review rounds found defects here - // and both were a single spelling. The rule fires only on a PROVABLY callable - // export: an earlier version accepted a bare `(` after the `=`, which proves a - // parenthesized EXPRESSION, so the ordinary env-fallback url constant read as - // an action. A single-parameter arrow was the mirror error, silently dropping - // a real binding. - const cases = [ - // [export source, should the rule fire] - ["export const publishDraft = '/api/x';", false], - ["export const publishDraft = ('/api/x');", false], - ["export const publishDraft = (process.env.X || '/api/x');", false], - ["export const publishDraft = (process.env.X ? '/a' : '/b');", false], - ['export const publishDraft = cache(async () => 1);', false], - ["const publishDraft = '/api/x';\nexport { publishDraft };", false], - ['export async function publishDraft(fd) { return 1; }', true], - ['export function publishDraft(fd) { return 1; }', true], - ['export const publishDraft = async (fd) => 1;', true], - ['export const publishDraft = async fd => 1;', true], - ['export const publishDraft = () => 1;', true], - ['export const publishDraft = async function (fd) { return 1; };', true], - ['async function publishDraft(fd) { return 1; }\nexport { publishDraft };', true], - // A TS annotation can contain its own `=>`, and this is the spelling the - // derive-the-type rule pushes authors toward. Skipping the annotation with a - // lazy regex stopped at the arrow's `=` and silently dropped the binding. - ['export const publishDraft: (fd: FormData) => Promise = async (fd) => 1;', true], - ['export const publishDraft: (fd: FormData) => Promise = async function (fd) { return 1; };', true], - ['export const publishDraft: ActionFn = async (fd) => 1;', true], - // A RETURN-type annotation on the arrow, which is at least as common as the - // declaration-side one and was silent until it had its own rows. - ['export const publishDraft = async (fd: FormData): Promise => 1;', true], - ['export const publishDraft = async (fd): Promise => 1;', true], - ['export const publishDraft = (fd: FormData): number => 1;', true], - ['export const publishDraft = async (fd: FormData): Promise> => 1;', true], - // ...and the annotated NON-callable must still be silent. - ["export const publishDraft: string = (process.env.X || '/api/x');", false], - ["export const publishDraft: string = '/api/x';", false], - // An inline object type carries TypeScript's canonical `;` member - // separator INSIDE the annotation's brackets, and a generic can close - // immediately before the `=`. Both were read as end-of-declaration. - ['export const publishDraft: ActionFn<{ id: string; title: string }> = async (input) => 1;', true], - ['export const publishDraft: { (fd: FormData): Promise; } = async (fd) => 1;', true], - ['export const publishDraft: Promise= async () => 1;', true], - ["export const publishDraft: { a: string; b: string }['a'] = '/api/x';", false], - ]; - for (const [body, shouldFire] of cases) { - const dir = await makeApp({ - 'modules/feedback/actions/publish.server.ts': `'use server';\n${body}\n`, - 'app/page.ts': `import { html } from '@webjsdev/core'; -import { publishDraft } from '#modules/feedback/actions/publish.server.ts'; -export default () => html\`
    \`; -`, - }); - assert.equal(hits(await checkConventions(dir)).length, shouldFire ? 1 : 0, - `${shouldFire ? 'should fire' : 'should be silent'}: ${body.split('\n')[0]}`); - await rm(dir, { recursive: true, force: true }); - } -}); - -test('a re-export clause is unknowable, even beside a same-named local function', async () => { - // The guard has to reject on the text AFTER the clause: a `\\s*(?!from)` - // lookahead can never reject, because `\\s*` backtracks to zero width and the - // lookahead then reads the whitespace. Without it, a module that re-exports a - // name AND declares a same-named local is read as exporting that local, which - // contradicts the barrel-re-export silence the docs promise. - const dir = await makeApp({ - 'modules/feedback/actions/other.server.ts': `'use server'; -export async function publishDraft() { return 1; } -`, - 'modules/feedback/actions/publish.server.ts': `'use server'; -function publishDraft() { return 1; } -export { publishDraft } from './other.server.ts'; -`, - 'app/page.ts': `import { html } from '@webjsdev/core'; -import { publishDraft } from '#modules/feedback/actions/publish.server.ts'; -export default () => html\`
    \`; -`, - }); - assert.deepEqual(hits(await checkConventions(dir)), [], 'a re-export needs another hop, so it stays silent'); - await rm(dir, { recursive: true, force: true }); -}); - -test('the binding resolver across a broad sweep of TypeScript spellings', async () => { - // Five review rounds each found one more spelling this resolver got wrong, so - // this sweeps the space instead of waiting to be told the next one. Every row - // is a way a real app might export an action, or a url constant. - const cases = [ - // Callables that must FIRE. - ['export async function publishDraft(fd) {}', true], - ['export function publishDraft(fd) {}', true], - ['export async function publishDraft(fd: T): Promise {}', true], - ['export const publishDraft = async ({ id, title }: Input): Promise => {};', true], - ['export const publishDraft = async (...args: unknown[]) => {};', true], - ['export const publishDraft = async (\n fd: FormData,\n): Promise => {};', true], - ['export const publishDraft = function publishDraft(fd) {};', true], - ['export let publishDraft = async (fd) => {};', true], - ['const publishDraft = async (fd) => {};\nexport { publishDraft };', true], - ['async function impl(fd) {}\nexport { impl as publishDraft };', true], - // BOTH separators, inside BOTH annotation positions. The previous sweep - // had neither, so it stayed green through a walk that broke on a comma or - // a `;` at any depth. `Promise>` is the fieldErrors - // shape the ActionResult envelope pushes authors toward. - ['export const publishDraft = async (fd): Promise> => ({});', true], - ['export const publishDraft = async (fd): Promise> => ({});', true], - ['export const publishDraft = async (fd): { ok: boolean; id: string } => ({});', true], - ['export const publishDraft = async (fd: Map) => 1;', true], - // These pin the depth guard on `;` / `,` inside a return type. They do NOT - // pin the arrow step beside it: a mutant that breaks at any arrow answers - // true on these by accident, and no input distinguishes it, because - // reaching that walk needs a `(…)` group followed by `:`, which in - // expression position only ever precedes an arrow's return type. - ['export const publishDraft = async (fd): { a: () => void, b: string } => ({});', true], - ['export const publishDraft = async (fd): [() => void, string] => ({});', true], - // A GENERIC arrow, which never reached the parameter-list branch at all. - ['export const publishDraft = async (fd: T): Promise => {};', true], - ['export const publishDraft = (fd: T) => 1;', true], - // A generic CONSTRAINT containing its own arrow: without the arrow step in - // the type-parameter walk, that `>` closes the list early and the parameter - // branch is never reached. Nothing pinned this guard until this row. - ['export const publishDraft = void>(fd: T) => 1;', true], - // `async` with no space before the type parameters, which is valid and was - // unreachable because the async skip required whitespace or a paren. - ['export const publishDraft = async(fd: T) => 1;', true], - // A constraint carrying its own type ARGUMENTS. Without the depth condition - // on the type-parameter walk's `>`, the inner `>` closes the list early and - // the parameter branch is never reached. `Record` is the shape - // the ActionResult envelope pushes authors toward. - ['export const publishDraft = >(fd: T) => 1;', true], - ['export const publishDraft = async >(fd: T) => 1;', true], - ['export const publishDraft = >(fd: A) => 1;', true], - // A DEFAULT type parameter puts a bare `=` inside the annotation's - // brackets, which is the only thing keeping the depth condition on the - // assignment scan honest. - ['export const publishDraft: (fd: T) => Promise = async (fd) => 1;', true], - // Whitespace between the type-parameter list and the parameter list, which - // is what the trailing skip after that walk exists for. - ['export const publishDraft = (fd: T) => 1;', true], - // Non-callables that must stay SILENT. - ["export const publishDraft = `/api/${'x'}`;", false], - ['export const publishDraft = 42;', false], - ["export const publishDraft = ['/a', '/b'].join('/');", false], - ]; - for (const [body, shouldFire] of cases) { - const dir = await makeApp({ - 'modules/feedback/actions/publish.server.ts': `'use server';\n${body}\n`, - 'app/page.ts': `import { html } from '@webjsdev/core'; -import { publishDraft } from '#modules/feedback/actions/publish.server.ts'; -export default () => html\`
    \`; -`, - }); - assert.equal(hits(await checkConventions(dir)).length, shouldFire ? 1 : 0, - `${shouldFire ? 'should fire' : 'should be silent'}: ${body.replace(/\n/g, ' ')}`); - await rm(dir, { recursive: true, force: true }); - } -}); - -test('silent on a url CONSTANT exported from a .server module', async () => { - // "Imported from a `.server` module" is not enough: a server module exports - // non-functions too, and the renderer binds only a FUNCTION. A url constant - // renders as an ordinary attribute, so reporting it is the same false positive - // wearing a different hat. - const dir = await makeApp({ - 'lib/urls.server.ts': "export const ARCHIVE_URL = '/api/items/archive';\n", - 'app/items/page.ts': `import { html } from '@webjsdev/core'; -import { ARCHIVE_URL } from '#lib/urls.server.ts'; -export default () => html\`
    \`; -`, - }); - assert.deepEqual(hits(await checkConventions(dir)), [], 'a url constant is not an action binding'); - await rm(dir, { recursive: true, force: true }); -}); - -test('silent on an ambiguous factory-produced export', async () => { - // `export const go = cache(fn)` may well be callable, but it is not PROVABLY - // a function from the source, and an ambiguous export firing the rule is the - // false-positive direction. - const dir = await makeApp({ - 'modules/feedback/actions/factory.server.ts': `'use server'; -import { cache } from '@webjsdev/server'; -export const publishCached = cache(async () => ({ success: true })); -`, - 'app/items/page.ts': `import { html } from '@webjsdev/core'; -import { publishCached } from '#modules/feedback/actions/factory.server.ts'; -export default () => html\`
    \`; -`, - }); - assert.deepEqual(hits(await checkConventions(dir)), []); - await rm(dir, { recursive: true, force: true }); -}); - -test('silent on binding shapes the resolver cannot follow', async () => { - // Real bindings the rule now MISSES rather than misreports: a namespace - // import, a default import, and a non-identifier expression. Silence is the - // safe direction, and these are named in the rule's silence list. - const shapes = { - 'namespace import': `import * as acts from '#modules/feedback/actions/publish.server.ts'; -export default () => html\`
    \`;`, - 'member expression': `import { publishDraft } from '#modules/feedback/actions/publish.server.ts'; -const bag = { publishDraft }; -export default () => html\`
    \`;`, - }; - for (const [label, body] of Object.entries(shapes)) { - const dir = await makeApp({ - 'app/items/page.ts': `import { html } from '@webjsdev/core';\n${body}\n`, - }); - assert.deepEqual(hits(await checkConventions(dir)), [], `${label} is unknowable, so silent`); - await rm(dir, { recursive: true, force: true }); - } -}); - -test('still fires when the hole IS an imported server action', async () => { - // The counterfactual for the filter above: narrowing to real bindings must not - // silence the shape the rule exists for. - const dir = await makeApp({ - 'app/items/page.ts': `import { html } from '@webjsdev/core'; -import { publishDraft } from '#modules/feedback/actions/publish.server.ts'; -export default () => html\`
    \`; -`, - }); - assert.equal(hits(await checkConventions(dir)).length, 1); - await rm(dir, { recursive: true, force: true }); -}); - -test('silent when the tag has no call site anywhere in the app', async () => { - const dir = await makeApp({ - 'components/row-btn.ts': rowBtn(), - 'app/page.ts': `import { html } from '@webjsdev/core'; -export default () => html\`

    hi

    \`; -`, - }); - assert.deepEqual(hits(await checkConventions(dir)), []); - await rm(dir, { recursive: true, force: true }); -}); - -test('silent when the submitter lives in a bare html helper, not the class body', async () => { - const dir = await makeApp({ - 'components/row-btn.ts': `import { html, WebComponent } from '@webjsdev/core'; -import { publishDraft } from '#modules/feedback/actions/publish.server.ts'; -export const publishBtn = () => html\`\`; -class RowBtn extends WebComponent({}) { - render() { return html\`row\`; } -} -RowBtn.register('row-btn'); -`, - 'app/page.ts': `import { html } from '@webjsdev/core'; -export default () => html\`
    \`; -`, - }); - assert.deepEqual(hits(await checkConventions(dir)), [], 'a fragment inherits the caller scope'); - await rm(dir, { recursive: true, force: true }); -}); - -test('silent when the file registers two tags (ambiguous attribution)', async () => { - const dir = await makeApp({ - 'components/pair.ts': `import { html, WebComponent } from '@webjsdev/core'; -import { publishDraft } from '#modules/feedback/actions/publish.server.ts'; -class RowBtn extends WebComponent({}) { - render() { return html\`\`; } -} -RowBtn.register('row-btn'); -class RowLabel extends WebComponent({}) { - render() { return html\`label\`; } -} -RowLabel.register('row-label'); -`, - 'app/page.ts': `import { html } from '@webjsdev/core'; -export default () => html\`
    \`; -`, - }); - assert.deepEqual(hits(await checkConventions(dir)), []); - await rm(dir, { recursive: true, force: true }); -}); - -test('flags the same-scan case with no cross-module step', async () => { - const dir = await makeApp({ - 'app/page.ts': `import { html } from '@webjsdev/core'; -import { publishDraft } from '#modules/feedback/actions/publish.server.ts'; -export default () => html\`
    \`; -`, - }); - const v = hits(await checkConventions(dir)); - assert.equal(v.length, 1); - assert.match(v[0].file, /page\.ts/); - // The same-scan case is a RENDER error, not the silent one, so it says so. - assert.match(v[0].message, /SAME template/); - assert.match(v[0].message, /refuses this shape outright/); - await rm(dir, { recursive: true, force: true }); -}); - -test('the same-scan case is flagged even with method="post", because the renderer refuses it', async () => { - // Unlike the cross-module case, delivery is irrelevant here: the renderer - // sees both halves in one scan and throws, whatever the method. - const dir = await makeApp({ - 'app/page.ts': `import { html } from '@webjsdev/core'; -import { publishDraft } from '#modules/feedback/actions/publish.server.ts'; -export default () => html\`
    \`; -`, - }); - const v = hits(await checkConventions(dir)); - assert.equal(v.length, 1); - assert.match(v[0].message, /refuses this shape outright/); - await rm(dir, { recursive: true, force: true }); -}); - -test('transitive: silent when the outer page binds the form', async () => { - const dir = await makeApp({ - 'components/todo-list.ts': TODO_LIST, - 'components/todo-row.ts': TODO_ROW, - 'app/page.ts': `import { html } from '@webjsdev/core'; -import { saveAll } from '#modules/feedback/actions/save.server.ts'; -export default () => html\`
    \`; -`, - }); - assert.deepEqual(hits(await checkConventions(dir)), []); - await rm(dir, { recursive: true, force: true }); -}); - -test('transitive: fires through an intermediate component when the outer form is unbound', async () => { - const dir = await makeApp({ - 'components/todo-list.ts': TODO_LIST, - 'components/todo-row.ts': TODO_ROW, - 'app/page.ts': `import { html } from '@webjsdev/core'; -export default () => html\`
    \`; -`, - }); - const v = hits(await checkConventions(dir)); - assert.equal(v.length, 1, 'a one-level rule would go silent here'); - assert.match(v[0].file, /todo-row\.ts/); - await rm(dir, { recursive: true, force: true }); -}); - -test('a reference cycle is silent and does not hang', async () => { - const dir = await makeApp({ - 'components/a-one.ts': `import { html, WebComponent } from '@webjsdev/core'; -import { publishDraft } from '#modules/feedback/actions/publish.server.ts'; -class AOne extends WebComponent({}) { - render() { return html\`\`; } -} -AOne.register('a-one'); -`, - 'components/b-two.ts': `import { html, WebComponent } from '@webjsdev/core'; -class BTwo extends WebComponent({}) { - render() { return html\`\`; } -} -BTwo.register('b-two'); -`, - 'app/page.ts': `import { html } from '@webjsdev/core'; -export default () => html\`
    \`; -`, - }); - assert.deepEqual(hits(await checkConventions(dir)), [], 'a cycle can never be a verdict'); - await rm(dir, { recursive: true, force: true }); -}); - -test('a docs page showing the shape as a code sample stays clean', async () => { - const dir = await makeApp({ - 'app/docs/page.ts': `import { html } from '@webjsdev/core'; -const sample = '
    '; -export default () => html\`
    \${sample}
    -

    Write <form action=\${'$'}{save}> around it.

    \`; -`, - }); - assert.deepEqual(hits(await checkConventions(dir)), [], 'a string is never read as markup'); - await rm(dir, { recursive: true, force: true }); -}); diff --git a/packages/server/test/scanner/html-form-scopes.test.js b/packages/server/test/scanner/html-form-scopes.test.js index 95b77d3c6..8448665c5 100644 --- a/packages/server/test/scanner/html-form-scopes.test.js +++ b/packages/server/test/scanner/html-form-scopes.test.js @@ -12,10 +12,12 @@ import { PARSEABLE_ENCTYPES } from '../../../core/src/form-action.js'; import { readFile } from 'node:fs/promises'; /** - * Unit tests for the lexical half of `submitter-needs-bound-form` (#1307): - * the whole-app rule in `check.js` is only as good as the scan under it, and - * the shapes it must NOT read as markup (a plain string, a `css` template, an - * HTML comment) are what keep the rule from firing on a docs page. + * Unit tests for the lexical form-scope scan in `js-scan.js`, plus the shared + * template primitives around it (`classifyActionHole`, `matchClosingBrace`, + * `extractWebComponentClassBodies`) and the enctype constant the renderer and + * the client guard both answer to. The shapes the scan must NOT read as markup + * (a plain string, a `css` template, an HTML comment) are what keep any + * consumer of it off a docs page. */ test('a submitter reports the scope of its enclosing form', () => { @@ -230,33 +232,27 @@ test('the unparseable-enctype constant tracks the renderer\'s own set', async () 'and does not reach for the renderer allowlist again'); }); -test('the start-tag-hole rule matches what the RENDERER actually does', async () => { +test('a start-tag hole is handed off or inherited, and SSR agrees', 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. + // placeholder that must be in the first flushed bytes), so the scanner reads + // it against that form rather than treating it as handed off. 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. + // Read against the enclosing form, not handed off. Nothing is asserted about + // a render-time refusal here: a bound submitter is SELF-SUFFICIENT (#1307), so + // the renderer stamps it with its own `formmethod="post"` and refuses only a + // same-element contradiction, never a cross-element one. 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 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.

    From ec59af0c59753c9bb0321d56d33b927257728177 Mon Sep 17 00:00:00 2001 From: Vivek Date: Tue, 11 Aug 2026 01:50:30 +0530 Subject: [PATCH 2/3] fix: clear the removed rule's premise from the surfaces it outlived Review of the removal turned up five places still asserting, or still built for, the enclosing-form question #1307 deleted. The gallery's submit-todo comment contradicted itself four lines apart, saying the enclosing form does NOT have to be bound and then that it does, and pointed readers at the rule this branch removes. That file is scaffold payload, so it ships into every generated app. The production onError message for WEBJS_FORM_SUBMITTED_AS_GET named the enclosing form as the cause; the dev logger four lines above it was already correct, so the two disagreed on one event. render-server.js still carried the four-state form-scope comment and passed a vestigial third argument to a two-parameter render(), both left over from the same removal. check.js kept two imports whose only uses were inside the deleted function, and the now-callerless scanner's JSDoc still described the consumer it lost. --- .../todo/actions/submit-todo.server.ts | 17 ++++------ packages/core/src/render-server.js | 31 ++++--------------- packages/server/src/check.js | 3 +- packages/server/src/form-dispatch.js | 2 +- packages/server/src/js-scan.js | 19 ++++++++---- 5 files changed, 27 insertions(+), 45 deletions(-) diff --git a/gallery/modules/todo/actions/submit-todo.server.ts b/gallery/modules/todo/actions/submit-todo.server.ts index cf9d24bfa..3ba7bba1c 100644 --- a/gallery/modules/todo/actions/submit-todo.server.ts +++ b/gallery/modules/todo/actions/submit-todo.server.ts @@ -21,17 +21,12 @@ import { deleteTodo } from './delete-todo.server.ts'; // control's visible label), and a bound submitter cannot carry its own // `name`/`value`, which is exactly the channel `name="intent"` uses below. // -// Third thing to know, and the one that fails silently: the enclosing -// has to be bound too, because `method="post"` and the enctype are supplied on -// the form's start tag and a per-button action cannot retrofit them. The -// renderer refuses an unbound form it can see, 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 binds anyway. What happens then depends on that -// form: one still declaring `method="post"` works (the identity rides the -// button's own name/value pair into the body), but one with no method submits as -// a GET, so the identity rides the query string and the action never runs while -// the page returns 200. Run `webjs check`: `submitter-needs-bound-form` finds -// these across modules. +// Third thing to know: no `formaction` url is emitted, because the identity +// travels in the body instead. So the submission targets whatever the FORM +// targets, and a form declaring `action="/x"` sends its buttons there. The +// action still runs when `/x` is a PAGE route; against a `route.ts` or another +// origin the identity is ignored and nothing runs, which the dev-time client +// guard reports at submit time. // // With JS the component intercepts the submit and calls the underlying action // directly for the optimistic path, so this runs only with JS off. diff --git a/packages/core/src/render-server.js b/packages/core/src/render-server.js index e5e1d28cc..9e9683759 100644 --- a/packages/core/src/render-server.js +++ b/packages/core/src/render-server.js @@ -182,23 +182,6 @@ async function renderTemplate(tr, ctx) { let pendingPropAttrs = []; /** @type {string[]} */ let pendingSubmitterProps = []; - // Whether the tag stream is currently inside a form that BOUND an action - // (#1207), as THIS scan can see it. Three states, and the third is the point: - // 'bound' an enclosing opened here and bound an action - // 'unbound' an enclosing opened here and bound nothing - // 'none' there is conclusively no enclosing - // 'unknown' there may be one, but this scan cannot see it - // - // The last two look alike and must not be merged, which is what a boolean did. - // A component's template is rendered by a SEPARATE pass (`injectDSD` calls - // `render` on it), so a ``'; - 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('a start-tag hole is handed off or inherited, and SSR agrees', 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), so the scanner reads - // it against that form rather than treating it as handed off. - 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 }); - - // Read against the enclosing form, not handed off. Nothing is asserted about - // a render-time refusal here: a bound submitter is SELF-SUFFICIENT (#1307), so - // the renderer stamps it with its own `formmethod="post"` and refuses only a - // same-element contradiction, never a cross-element one. - const fallbackSrc = 'html`
    x`}>
    `'; - assert.deepEqual(scanHtmlFormScopes(fallbackSrc).submitters, - [{ tag: 'button', scope: 'unbound', delivers: false, expr: 'p' }]); - - // 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, - ); -});