diff --git a/.agents/skills/webjs/SKILL.md b/.agents/skills/webjs/SKILL.md index c9bf72124..3973d8d52 100644 --- a/.agents/skills/webjs/SKILL.md +++ b/.agents/skills/webjs/SKILL.md @@ -105,7 +105,7 @@ App-internal imports use the `#` root alias (`import { db } from '#db/connection 9. No backtick characters inside an `html\`...\`` body, even in comments (it closes the literal and 500s). 10. TypeScript must be erasable (`erasableSyntaxOnly: true`): no `enum`, no value `namespace`, no constructor parameter properties, no legacy decorators. 11. Reactive properties are declared ONLY through the base-class factory `extends WebComponent({ count: Number })`. Never a `static properties` block, never a class-field initializer (it clobbers the reactive accessor). -12. A form that writes binds its action: `
`, or a per-button ``; } +} +PublishButton.register('publish-button'); + +// app/triage/page.ts <- the form lives here +// WRONG: the form binds nothing, and NOTHING throws. +html`
`; +// RIGHT: bind the enclosing form too. +html`
`; +``` + +The component renders its own template in a separate pass with no view of the host page, so the renderer sees a cannot-tell and binds anyway (refusing would drop an isolated component from a page that still returned 200, which is worse). What ships is a button carrying the reserved `__webjs_action` identity inside whatever form the page wrote. Whether that is broken depends on the form, and the distinction is easy to miss: one that still sends a parseable POST body WORKS, because the identity rides the button's own `name`/`value` pair into the body and the dispatcher runs the action. One with no `method` (or `method="get"`) submits a GET, so the identity rides the QUERY STRING, the action never runs, the page re-renders, the status is 200, and there is no throw, no log, and no 405. A silent write path is the whole failure mode, so treat the address bar growing a `?__webjs_action=` as the fingerprint. + +**Two runtime signals back the check up.** In dev, submitting a form that carries an action identity it cannot deliver logs one `console.error` naming the fix, once per shape; it never throws, so the submission behaves exactly as it does in production. In production, both server-visible fingerprints reach the `onError` hook (the programmatic `createRequestHandler({ onError })` option and any sink an `instrumentation.{js,ts}` installed) with a code to group on: `WEBJS_FORM_SUBMITTED_AS_GET` for a page GET carrying the reserved field in its query string, and `WEBJS_FORM_ACTION_MISSING` for a form body carrying no identity at all. Both are detect-only, so no status changes, and both carry the submitted field NAMES and never the values. + +**Run `webjs check` and it catches this for you.** The `submitter-needs-bound-form` rule reads every template in the app at once, which neither renderer can do, so it resolves the enclosing form across module boundaries and transitively through intermediate components (a page's form around `` around `` around the button). It is conservative by design and says nothing when it cannot be sure: a tag rendered in a bound form somewhere and an unbound one elsewhere, a tag whose host form is unbound but still DELIVERS (that shape works), a form whose `method` or `enctype` comes from a hole, a tag with no call site in the app, a submitter in a bare `html` helper rather than a component class body, a file registering more than one tag, a file that opens a form of its own, a submitter or tag handed to another element through a start-tag hole (``), a `formaction` hole that is not a proven action binding (a url string or CONSTANT, a factory-produced export, a namespace or default import, a barrel re-export, or a non-identifier expression like `acts.publishDraft`), or a reference cycle. The one start-tag hole it DOES judge is ``, because the renderer renders a fallback inline in the enclosing form rather than handing it off. Silence from the rule is therefore not proof the form is bound; a green check plus the shape above still deserves a look. + **Inside a component you may never see the error.** Per-component SSR error isolation contains the throw, so development shows an error box in place of the component and production renders it empty with the page still returning 200. A form that has silently vanished in production is this bug wearing a disguise; the message is in the server log. Nothing leaks either way. Two things that "renders it empty" understates, both worth knowing before you go looking: diff --git a/AGENTS.md b/AGENTS.md index 154012550..d8d793386 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -483,7 +483,7 @@ const result = await optimistic(liked, true, () => likePost(postId)); 11. **No em-dashes (U+2014), no hyphen or semicolon used as pause-punctuation in prose, and no colon attached to a code-shaped LHS.** Banned as a pause: U+2014, a space-surrounded hyphen between words, a space-surrounded semicolon between words. Banned colon attachments: a colon-then-prose after `xyz()`, a ``, an `[expr]` subscript, or a `foo()` definition list (rephrase verb-led). Prefer a period, comma, a colon on a plain-noun LHS, parentheses, or a restructure. Plain hyphens stay fine in compound words, flags, filenames, ranges; semicolons and colons stay fine inside code / TS / JSON / CSS. The same hook also enforces brand casing with one simple rule: `WebJs` is a proper noun, so write it capitalized wherever it NAMES the project in prose, at a sentence start AND mid-sentence (`WebJs ships`, `Most WebJs apps`, `the WebJs serializer`). It stays lowercase `webjs` ONLY as a literal code token: a `webjs ` CLI command (`webjs dev`, `webjs db migrate`), a `webjs.dev` domain, an `@webjsdev` package, a `"webjs"` config key, a `WEBJS_*` env var, the `webjsdev/webjs` org path, or anything inside a `` `code` `` span or fenced block. If you mean the literal config key or command in prose, wrap it in backticks. Enforced via `.claude/hooks/block-prose-punctuation.sh`, which scans only NEW content (you can still edit an existing line to fix a glyph or casing). -12. **A form that writes binds its action: `
`, and a form whose buttons run different actions binds each on its submitter, ` + + +
+ + `; +} diff --git a/examples/blog/modules/feedback/components/publish-button.ts b/examples/blog/modules/feedback/components/publish-button.ts new file mode 100644 index 000000000..9ca39df26 --- /dev/null +++ b/examples/blog/modules/feedback/components/publish-button.ts @@ -0,0 +1,29 @@ +import { html, WebComponent } from '@webjsdev/core'; +import { publishDraft } from '#modules/feedback/actions/publish-draft.server.ts'; + +/** + * The submitter half of the CANNOT-TELL shape (#1307), split out of its form on + * purpose. + * + * A component renders its own template in a separate pass with no view of the + * host page, so when the renderer reaches this `formaction=${publishDraft}` it + * cannot know whether the enclosing `
` binds an action. That is the + * cannot-tell answer, and cannot-tell BINDS: refusing there would reject a + * per-row button in a list and a button inside a component, both ordinary + * shapes, and an SSR refusal is isolated per component, so production would + * return 200 with this button silently gone. + * + * `/feedback/triage-split` renders this inside a form that IS bound, which is + * the fallback working correctly. It is also the counterfactual for the whole + * change: make cannot-tell refuse and this component renders empty, so the e2e + * that submits this button with JavaScript off goes red. + * + * The mirror image, this button inside an UNBOUND form, is what + * `webjs check`'s `submitter-needs-bound-form` rule exists to catch. + */ +class PublishButton extends WebComponent({}) { + render() { + return html``; + } +} +PublishButton.register('publish-button'); diff --git a/packages/cli/templates/gallery/modules/todo/actions/submit-todo.server.ts b/packages/cli/templates/gallery/modules/todo/actions/submit-todo.server.ts index a0f83144c..708fa2dc5 100644 --- a/packages/cli/templates/gallery/modules/todo/actions/submit-todo.server.ts +++ b/packages/cli/templates/gallery/modules/todo/actions/submit-todo.server.ts @@ -19,6 +19,18 @@ 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. +// // With JS the component intercepts the submit and calls the underlying action // directly for the optimistic path, so this runs only with JS off. export async function submitTodo(formData: FormData) { diff --git a/packages/core/AGENTS.md b/packages/core/AGENTS.md index 6998ef61c..87e3a9ada 100644 --- a/packages/core/AGENTS.md +++ b/packages/core/AGENTS.md @@ -35,10 +35,10 @@ the same output in all three. | `directives.js` | the lit-html-parity directive set (`unsafeHTML`, `live`, `keyed`, `guard`, `templateContent`, `ref` / `createRef`, `cache`, `until`, `asyncAppend` / `asyncReplace`, `watch`, plus each `is*` guard). `repeat` lives in `repeat.js`. All are re-exported from `index.js` / `index-browser.js` so the bare specifier and the `/directives` subpath (which collapses onto the dist browser bundle) expose the full set | | `repeat.js` | `repeat(items, keyFn, templateFn)` for keyed list reconciliation | | `suspense.js` | `Suspense()` page/region-level boundary primitive | -| `webjs-suspense.js` | The `` component-level streaming boundary element (#471). SSR (`render-server.js`) does the work: `injectDSD`'s `processSuspenseElements` pre-pass reads `.fallback` (carried as `data-webjs-fallback` by `renderTemplate`, since a TemplateResult is not serializer-safe) and, in a streaming context, flushes the fallback as `` while pushing the children to `ctx.pending` for out-of-order streaming (concurrent across boundaries via `Promise.all`); without a streaming context the children render inline (blocking). This client element is layout-neutral (`display:contents`) and the registration home for the soft-nav apply; first-load streaming needs no client runtime (the inline swap script `replaceWith`s the boundary element with the resolved children, which then upgrade). Every swap path (the inline script, the boot `__webjsResolve`, and the soft-nav `applyStreamedResolve`) removes the transient wrapper, so a boundary settles to the same DOM however the page was reached. SSR-inert (defined client-side only) | +| `webjs-suspense.js` | The `` component-level streaming boundary element (#471). SSR (`render-server.js`) does the work: `injectDSD`'s `processSuspenseElements` pre-pass reads `.fallback` (carried as `data-webjs-fallback` by `renderTemplate`, because the normal `data-webjs-prop-*` path applies the property at hydration, far too late for a placeholder that must be in the first flushed bytes) and, in a streaming context, flushes the fallback as `` while pushing the children to `ctx.pending` for out-of-order streaming (concurrent across boundaries via `Promise.all`); without a streaming context the children render inline (blocking). This client element is layout-neutral (`display:contents`) and the registration home for the soft-nav apply; first-load streaming needs no client runtime (the inline swap script `replaceWith`s the boundary element with the resolved children, which then upgrade). Every swap path (the inline script, the boot `__webjsResolve`, and the soft-nav `applyStreamedResolve`) removes the transient wrapper, so a boundary settles to the same DOM however the page was reached. SSR-inert (defined client-side only) | | `context.js` | Context Protocol: `createContext`, `ContextProvider`, `ContextConsumer`, `ContextRequestEvent` | | `task.js` | `Task` / `TaskStatus` controller for async data in components | -| `router-client.js` | Turbo Drive–style client router; entry: `enableClientRouter` / `navigate`. Also exports `loadFrame(frameEl, url)` (#253), the reusable frame self-load `webjs-frame.js` calls: it fetches `url` as a frame nav (the `x-webjs-frame` header) and applies the matched subtree through the SAME `fetchAndApply` frame-swap path a click uses (no history push / snapshot / optimistic skeleton, since it swaps one region). Post-swap activation of a boundary range goes through `activateSwappedRange` (#1102), the ONE place both tiers (`replaceBoundaryRange`, `swapMarkerRange`) reactivate scripts and upgrade custom elements. Two things it owns and a new call site must keep: it SNAPSHOTS the range before iterating, because `reactivateScripts` replaces a top-level script and a detached node cuts a live `nextSibling` walk (every later node in the range is then silently skipped); and `reactivateScripts` handles container-IS-a-script itself, since `querySelectorAll` never matches the node it is called on. A top-level script therefore re-executes on every swap of its range, INCLUDING one the keyed differ reused by `id`, matching what a descendant script in a reused container has always done. `data-webjs-permanent` splits into two cases and they must NOT be unified (#1252). The marked element IS a script: NEVER exempt, whether the walk reaches it as the container or as a descendant of one (the regraft selector has no tag filter, so a marked script IS preserved by identity and does land in the WeakSet, which is why the exemption is STRICT containment and never reflexive). The regraft also has a both-exist guard, so on the swap that first mounts a route there is no live node to preserve and exempting the inert parsed copy would leave a script that runs on a cold load and never on a soft nav, which is #1102 itself. Script INSIDE a preserved marked element: exempt, because the attribute is subtree-scoped (`diffElementInPlace` already returns early rather than recursing into one) and re-emitting an init script against an instance the author kept alive is a double-initialization. The filter keys on the `regraftedPermanents` WeakSet, which the two regrafts populate on every successful path, so it means ACTUALLY preserved by identity rather than merely carrying the attribute; an attribute-only filter would leave a first-mount permanent element's scripts never running at all | +| `router-client.js` | Turbo Drive–style client router; entry: `enableClientRouter` / `navigate`. Carries the dev-only submit-time guard (#1307): in `onSubmit`, after the body is built and BEFORE `preventDefault`, a submission carrying a bound action's identity that cannot deliver it logs one `console.error` per shape: a non-POST method (which loses the identity to the query string on both paths), or the literal `enctype="text/plain"` (which breaks only the no-JS path, since with JS the router posts `FormData` and ignores the attribute). `text/plain` alone, NOT the renderer's wider `PARSEABLE_ENCTYPES` allowlist: `enctype` falls back to urlencoded for a missing AND an invalid value, so testing the allowlist would warn about a form that works. It LOGS and never throws, because this is a delegated document-level listener where a throw would escape uncaught AND abort before `performSubmission`, so dev would submit differently from production; `warnOnce` takes an optional level for it. Also exports `loadFrame(frameEl, url)` (#253), the reusable frame self-load `webjs-frame.js` calls: it fetches `url` as a frame nav (the `x-webjs-frame` header) and applies the matched subtree through the SAME `fetchAndApply` frame-swap path a click uses (no history push / snapshot / optimistic skeleton, since it swaps one region). Post-swap activation of a boundary range goes through `activateSwappedRange` (#1102), the ONE place both tiers (`replaceBoundaryRange`, `swapMarkerRange`) reactivate scripts and upgrade custom elements. Two things it owns and a new call site must keep: it SNAPSHOTS the range before iterating, because `reactivateScripts` replaces a top-level script and a detached node cuts a live `nextSibling` walk (every later node in the range is then silently skipped); and `reactivateScripts` handles container-IS-a-script itself, since `querySelectorAll` never matches the node it is called on. A top-level script therefore re-executes on every swap of its range, INCLUDING one the keyed differ reused by `id`, matching what a descendant script in a reused container has always done. `data-webjs-permanent` splits into two cases and they must NOT be unified (#1252). The marked element IS a script: NEVER exempt, whether the walk reaches it as the container or as a descendant of one (the regraft selector has no tag filter, so a marked script IS preserved by identity and does land in the WeakSet, which is why the exemption is STRICT containment and never reflexive). The regraft also has a both-exist guard, so on the swap that first mounts a route there is no live node to preserve and exempting the inert parsed copy would leave a script that runs on a cold load and never on a soft nav, which is #1102 itself. Script INSIDE a preserved marked element: exempt, because the attribute is subtree-scoped (`diffElementInPlace` already returns early rather than recursing into one) and re-emitting an init script against an instance the author kept alive is a double-initialization. The filter keys on the `regraftedPermanents` WeakSet, which the two regrafts populate on every successful path, so it means ACTUALLY preserved by identity rather than merely carrying the attribute; an attribute-only filter would leave a first-mount permanent element's scripts never running at all | | `webjs-frame.js` | The `` custom element (a swap anchor; the router does the swap). Adds the `src` + `loading` self-load (#253): an eager (`connectedCallback`) or lazy (viewport, via `lazy-loader.js`'s `observeViewportOnce`) self-fetch through `router-client.js`'s `loadFrame`, with a per-element loaded-URL guard so eager connect / the lazy observer / a `src` mutation never double-fetch. SSR-inert (defined client-side only) | | `webjs-stream.js` | The `` surgical-update element + `renderStream(payload)` (#248). The element self-applies its action on connect via native DOM (append / prepend / before / after / replace / update / remove against a `target` id or `targets` selector), cloning its single `