Skip to content

Unify form submissions on <form action=${action}>, drop the page action export #1155

Description

@vivek7405

Problem

WebJs has two ways to handle a form submission, and they don't compose with the modules architecture:

  1. Page action export (Add no-JS form-to-action error re-render with preserved input #244): the no-JS write path. Works, but the action must live in (or be re-exported through) the page module, so every module action needs a hand-written per-page adapter that parses FormData and delegates. The canonical gallery example (packages/cli/templates/gallery/app/features/forms/page.ts:70) shows the glue.
  2. 'use server' RPC action from a component via @submit + preventDefault + a programmatic call. Composes with modules/<feature>/actions/, but does nothing with JS off, and the @submit binding is an interactivity signal that forces the whole component to ship (defeats elision).

Two mechanisms doing one job is exactly what makes an AI agent guess. There are no users yet (no-backward-compat posture, see the "no backward-compat burden" project stance), so collapse to ONE way.

Design / approach

Adopt the Next.js dispatch model (verified against the local Next clone, packages/next/src/server/app-render/action-handler.ts): the form posts to the current URL; the action's identity rides in a hidden field; the server resolves identity → function and runs it. Next needs a build manifest for the ID; WebJs already has a buildless equivalent: hashFile(file) + the /__webjs/action/<hash>/<fn> naming (packages/server/src/actions.js:193, actions.js:294).

Authoring surface (the one way):

// modules/feedback/actions/submit-feedback.server.ts
'use server';
export async function submitFeedback(formData: FormData) { ... }

// app/feedback/page.ts (or any component render())
import { submitFeedback } from '#modules/feedback/actions/submit-feedback.server.ts';
html`<form method="post" action=${submitFeedback}> ... </form>`

SSR emits action="" plus <input type="hidden" name="__webjs_action" value="<hash>/<fn>">. The page action export is deleted (breaking change, wanted).

Decisions locked in design review (do not re-litigate):

  1. Argument shape: a form-bound action always receives FormData, on the JS and no-JS path alike. The serializer already round-trips FormData/File losslessly (packages/core/src/serialize.js:20 _$wj:"FD", :241 File with bytes+name+mime+lastModified, incl. the Bun fresh-Blob-identity workaround at :145), so nothing is lost. validate is the typing seam: it receives the FormData, and its transform-return becomes the action's typed input (existing runValidate semantics in actions.js).
  2. One wire path for forms. With JS ON the router does NOT call the RPC stub: it intercepts the submit (existing onSubmit, packages/core/src/router-client.js:531) and POSTs the same FormData + hidden field to the same page URL, applying the 422/303 response in place, exactly as it does for page actions today. Identical semantics on both paths by construction. The RPC endpoint remains for programmatic calls only.
  3. Hash skew (form held open across a deploy submits an unknown hash): respond 422 re-rendering the page with a generic "this page was updated, please resubmit" on actionData. Never a silent no-op. (Next throws its "older or newer deployment" error for the same case.)
  4. Action identity is an always-on hook, NOT the seed facade: action-seed.js's facade is opt-out-able via webjs.seed: false / WEBJS_SEED=0, and disabling seeding must not kill every no-JS form. Attach identity on both sides: the generated browser stub gets hash/fn properties (actions.js:424, currently a bare arrow), and the server-side real function gets a WeakMap (or property) registration via its own always-on load-hook (Node module.registerHooks / Bun.plugin, same split as action-seed.js + action-seed-bun.js).
  5. The SSR renderer throws on an unidentifiable function in action= (that is the companion security issue, same PR: function-valued action/formaction must never stringify).

Forced correctness (renderer-emitted, not author-trusted):

  • When action=${fn}, force/emit method="post" if absent (React does the same); a GET form would never run the action.
  • Files: the no-JS path needs enctype="multipart/form-data"; emit it when absent (harmless otherwise, and the 422 re-render keeps working).
  • New webjs check rule: a form-bound action declaring export const method = 'GET' is an error (a GET action is CSRF-exempt + URL-args; contradictory as a form target).
  • A streaming return (feat: streaming RPC results (return a ReadableStream/AsyncIterable from an action) #489) from a form submission: respond with the normal buffered path error (no client to consume frames on no-JS); reject loudly.

Security tightening that rides along: verifyOrigin is currently called ONLY on the RPC endpoint (actions.js:471); the page-action POST path (dev.js:2206) has no origin check (today shielded only by SameSite=Lax cookies, session.js:287/auth.js:83). The unified form dispatcher gets the same verifyOrigin + webjs.allowedOrigins check as RPC.

Implementation notes (for the implementing agent)

Where to edit:

  • packages/core/src/render-server.js L318-331 (plain-attribute commit, after-eq state): claim action=/formaction= function holes; emit action="" + hidden field + forced attrs. Note the hidden input must be emitted INSIDE the form element; the state machine is mid-tag at that point, so buffer until the tag closes (the in-tag → text transition) or handle via a per-form pending-emit. Also render-client.js L674 for the client-side commit of the same template.
  • packages/server/src/actions.js: stub gen L313-424 (attach hash+fn to each exported stub fn); hashFile L193 is the shared identity scheme.
  • New server-side identity hook: model on packages/server/src/action-seed.js (Node registerHooks) + action-seed-bun.js (Bun.plugin), but ALWAYS-ON (no webjs.seed gate). Consider folding into the existing facade files with the identity part unconditional and only the seed-collection part gated; that avoids a third hook installation.
  • packages/server/src/dev.js L2202-2210: replace the loadPageAction-gated dispatch with: non-GET page request + __webjs_action field present → resolve hash/fn via the action index (buildActionIndex in actions.js) → run through the reshaped runPageAction.
  • packages/server/src/page-action.js: keep runPageAction's response mapping (bounded parseFormBody, 303 PRG w/ same-site redirect guard, 422 re-render with actionData, thrown redirect() 307 / notFound() / forbidden() / unauthorized() handling, direct Response passthrough for streamResponse Add a stream-action protocol with HTTP and live-channel delivery #248) but swap "the page module's action export" for "the resolved module action". Delete loadPageAction. Run the action's declared middleware + validate config exports here (same seam as invokeAction).
  • packages/core/src/router-client.js onSubmit L531 + performSubmission L1293: detect the hidden field (or just: form posts to own URL), keep the existing in-place 422 swap / 303 follow. Should need little change; verify the buildSubmitFormData path (L607) preserves the hidden field and the submitter button's name/value.
  • CSRF: import verifyOrigin from csrf.js into the form dispatch path; allowlist via readAllowedOrigins (already read at dev.js:579).

Migration (delete the old way everywhere):

  • examples/blog/app/feedback/page.ts
  • packages/cli/templates/gallery/app/examples/todo/page.ts, gallery/app/features/{file-storage,forms}/page.ts, gallery/app/features/auth/signup/page.ts
  • website/app/docs/{file-storage,progressive-enhancement,server-actions}/page.ts
  • Remove the page-action surface from: root AGENTS.md (Pages section + ActionResult section), .agents/skills/webjs/references/{data-and-actions,routing-and-pages,muscle-memory-gotchas}.md, scaffold skill copy packages/cli/templates/.agents/skills/webjs/, docs site website/app/docs/, and the webjs-doc-sync + webjs-scaffold-sync skill maps if they name it.

Landmines:

  • The seed lookup key (hashFile(file)/fn/stringify(args), action-seed.js) and SSR seeding (feat: seed SSR action results into hydration so async render does not re-fetch (follow-up to #469) #472) apply to the RPC path; a form dispatch result must NOT be seeded (it is a mutation; also the 303 discards the body).
  • runPageAction currently falls through to 404 when the page has no action export; the new dispatch must 404 (or 405) a non-GET to a page WITHOUT the hidden field, and 422-skew a field whose hash doesn't resolve. Distinguish the two.
  • The action index is built lazily in ensureReady; the form dispatch runs after ensureReady in handle() so the index is warm (same as RPC).
  • no-server-import-in-browser-module (check.js): a page importing a 'use server' action is exempt (RPC stub) and stays exempt; forms don't change the elision verdict of the page, but REMOVING @submit handlers during migration may flip components to display-only. That is the point (elision win), but watch the differential-elision e2e stays green.
  • Per-action middleware (feat: composable per-action middleware with typed context #490) + validate must run on the form path too, or an action is protected over RPC and unprotected over forms (privilege gap).
  • formaction on a submit button (multiple actions per form) is legal HTML; either support it (hidden field per submitter, resolved at submit time from the submitter) or reject it in the renderer for v1. Decide and document; do not leave it half-working.
  • Bun parity is MANDATORY (runtime-sensitive: load hook, serializer, dispatch): new test/bun/form-action-dispatch.mjs + run node scripts/run-bun-tests.js.

Invariants: PE is the headline invariant: the no-JS path must fully work (AGENTS.md "Progressive enhancement is the default architecture"). Boundary comments (#1015) untouched. packages/ stays plain JS + JSDoc. Buildless: no manifest, identity is content-hash at runtime.

Tests (every applicable layer):

  • Unit: renderer emission (hidden field, forced method/enctype, string passthrough); identity attachment on stubs; dispatch resolution incl. skew + missing-field 404/405; validate/middleware on the form path; CSRF 403 cross-origin.
  • Browser (npm run test:browser): JS-on submit intercepted, 422 applied in place, 303 followed; elided form component still submits.
  • E2E (WEBJS_E2E=1, test/e2e/): JS-OFF submit → 303 → data persisted (model on test/e2e/form-submission-and-race.test.mjs); file upload no-JS.
  • Bun: test/bun/form-action-dispatch.mjs.
  • Counterfactuals: revert the dispatch → e2e fails; revert identity hook → renderer throws (proves no silent no-op form is possible).
  • Smoke: regenerate a scaffold app (generate + boot + webjs check), since the gallery templates change (generators emit strings; escaping bugs only show in a generated app).

Ships in ONE PR together with the companion renderer-throw security issue (user's explicit call). Conventional PR title (feat:), Closes #<both> in the body.

Acceptance criteria

  • <form method="post" action=${importedAction}> works identically with JS on and off (e2e proves both; DB row + 303 asserted with JS disabled)
  • Form-bound actions receive FormData; validate transform-return typing works on the form path
  • Page action export support is REMOVED (code + all docs/templates/examples migrated; grep for export const action/export async function action in app code returns nothing)
  • 422 re-render with actionData + field repopulation works as before; success 303 honors the same-site redirect guard
  • Hash skew responds 422 with a resubmit message, never a silent no-op
  • Identity survives webjs.seed: false
  • verifyOrigin protects the form dispatch; cross-origin POST is 403
  • Per-action middleware + validate run on the form path (test proves the privilege gap is closed)
  • GET-method action bound to a form is a webjs check violation
  • Elision: a form-only component with no other signal is elided and its form still works (differential e2e green)
  • Bun parity test added and green; browser + e2e + smoke layers green
  • All doc surfaces synced (AGENTS.md, skill references, scaffold templates + scaffold skill copy, docs site, website)

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Projects

Status
In progress

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions