You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
WebJs has two ways to handle a form submission, and they don't compose with the modules architecture:
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.
'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).
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):
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).
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.
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.)
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).
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).
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 streamResponseAdd 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.jsonSubmit 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).
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.
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.
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
Problem
WebJs has two ways to handle a form submission, and they don't compose with the modules architecture:
actionexport (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 parsesFormDataand delegates. The canonical gallery example (packages/cli/templates/gallery/app/features/forms/page.ts:70) shows the glue.'use server'RPC action from a component via@submit+preventDefault+ a programmatic call. Composes withmodules/<feature>/actions/, but does nothing with JS off, and the@submitbinding 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):
SSR emits
action=""plus<input type="hidden" name="__webjs_action" value="<hash>/<fn>">. The pageactionexport is deleted (breaking change, wanted).Decisions locked in design review (do not re-litigate):
FormData, on the JS and no-JS path alike. The serializer already round-tripsFormData/Filelosslessly (packages/core/src/serialize.js:20_$wj:"FD",:241File with bytes+name+mime+lastModified, incl. the Bun fresh-Blob-identity workaround at :145), so nothing is lost.validateis the typing seam: it receives theFormData, and its transform-return becomes the action's typed input (existingrunValidatesemantics inactions.js).onSubmit,packages/core/src/router-client.js:531) and POSTs the sameFormData+ 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.actionData. Never a silent no-op. (Next throws its "older or newer deployment" error for the same case.)action-seed.js's facade is opt-out-able viawebjs.seed: false/WEBJS_SEED=0, and disabling seeding must not kill every no-JS form. Attach identity on both sides: the generated browser stub getshash/fnproperties (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 (Nodemodule.registerHooks/Bun.plugin, same split asaction-seed.js+action-seed-bun.js).action=(that is the companion security issue, same PR: function-valuedaction/formactionmust never stringify).Forced correctness (renderer-emitted, not author-trusted):
action=${fn}, force/emitmethod="post"if absent (React does the same); a GET form would never run the action.enctype="multipart/form-data"; emit it when absent (harmless otherwise, and the 422 re-render keeps working).webjs checkrule: a form-bound action declaringexport const method = 'GET'is an error (a GET action is CSRF-exempt + URL-args; contradictory as a form target).Security tightening that rides along:
verifyOriginis 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 bySameSite=Laxcookies,session.js:287/auth.js:83). The unified form dispatcher gets the sameverifyOrigin+webjs.allowedOriginscheck as RPC.Implementation notes (for the implementing agent)
Where to edit:
packages/core/src/render-server.jsL318-331 (plain-attribute commit,after-eqstate): claimaction=/formaction=function holes; emitaction=""+ 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 (thein-tag→ text transition) or handle via a per-form pending-emit. Alsorender-client.jsL674 for the client-side commit of the same template.packages/server/src/actions.js: stub gen L313-424 (attachhash+fnto each exported stub fn);hashFileL193 is the shared identity scheme.packages/server/src/action-seed.js(NoderegisterHooks) +action-seed-bun.js(Bun.plugin), but ALWAYS-ON (nowebjs.seedgate). 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.jsL2202-2210: replace theloadPageAction-gated dispatch with: non-GET page request +__webjs_actionfield present → resolve hash/fn via the action index (buildActionIndexinactions.js) → run through the reshapedrunPageAction.packages/server/src/page-action.js: keeprunPageAction's response mapping (boundedparseFormBody, 303 PRG w/ same-siteredirectguard, 422 re-render withactionData, thrownredirect()307 /notFound()/forbidden()/unauthorized()handling, directResponsepassthrough forstreamResponseAdd a stream-action protocol with HTTP and live-channel delivery #248) but swap "the page module'sactionexport" for "the resolved module action". DeleteloadPageAction. Run the action's declaredmiddleware+validateconfig exports here (same seam asinvokeAction).packages/core/src/router-client.jsonSubmitL531 +performSubmissionL1293: 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 thebuildSubmitFormDatapath (L607) preserves the hidden field and the submitter button's name/value.verifyOriginfromcsrf.jsinto the form dispatch path; allowlist viareadAllowedOrigins(already read atdev.js:579).Migration (delete the old way everywhere):
examples/blog/app/feedback/page.tspackages/cli/templates/gallery/app/examples/todo/page.ts,gallery/app/features/{file-storage,forms}/page.ts,gallery/app/features/auth/signup/page.tswebsite/app/docs/{file-storage,progressive-enhancement,server-actions}/page.tsactionsurface from: rootAGENTS.md(Pages section +ActionResultsection),.agents/skills/webjs/references/{data-and-actions,routing-and-pages,muscle-memory-gotchas}.md, scaffold skill copypackages/cli/templates/.agents/skills/webjs/, docs sitewebsite/app/docs/, and thewebjs-doc-sync+webjs-scaffold-syncskill maps if they name it.Landmines:
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).runPageActioncurrently falls through to 404 when the page has noactionexport; 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.ensureReady; the form dispatch runs afterensureReadyinhandle()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@submithandlers during migration may flip components to display-only. That is the point (elision win), but watch the differential-elision e2e stays green.middleware(feat: composable per-action middleware with typed context #490) +validatemust run on the form path too, or an action is protected over RPC and unprotected over forms (privilege gap).formactionon 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.test/bun/form-action-dispatch.mjs+ runnode 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):
method/enctype, string passthrough); identity attachment on stubs; dispatch resolution incl. skew + missing-field 404/405;validate/middlewareon the form path; CSRF 403 cross-origin.npm run test:browser): JS-on submit intercepted, 422 applied in place, 303 followed; elided form component still submits.WEBJS_E2E=1,test/e2e/): JS-OFF submit → 303 → data persisted (model ontest/e2e/form-submission-and-race.test.mjs); file upload no-JS.test/bun/form-action-dispatch.mjs.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)FormData;validatetransform-return typing works on the form pathactionexport support is REMOVED (code + all docs/templates/examples migrated; grep forexport const action/export async function actionin app code returns nothing)actionData+ field repopulation works as before; success 303 honors the same-siteredirectguardwebjs.seed: falseverifyOriginprotects the form dispatch; cross-origin POST is 403middleware+validaterun on the form path (test proves the privilege gap is closed)webjs checkviolation