diff --git a/CHANGELOG.md b/CHANGELOG.md index f39ce1040..d25ab956b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,28 @@ Current truth lives in: Historical changelog details remain available through git history and release evidence. +## 0.42.0-alpha.2 + +- The form/action loop (ADR-0120): plain HTML forms work without + JavaScript on `rendering: 'dynamic'` routes. +- Protocol: actions run before loaders (revalidation invariant); + `fail(4xx, data)` returns take the 422 re-render channel with the echo; + successful mutations answer 303 (PRG) — never a 200 render; redirects + thrown from actions coerce to 303; POST without an action is a defined + 404. +- Named actions dispatch via `formaction='?/name'` + (`export const actions = { name(ctx) {...} }`); unknown names are a + defined 404. +- Fetch callers (`x-openelement-action` header) receive the `ActionResult` + discriminated union (`failure`/`redirect`/`error`); the island client + entry enhances `data-open-enhance` forms with the same protocol and no + DOM surgery — unhydrated islands are untouched, failure falls back to + the native 422 render unless the page handles `open:action-failure`. +- Contract: an action must be safe to re-run after a failed validation + (validate first, mutate after). +- e2e: the full loop passes with `javaScriptEnabled: false` and on the JS + enhancement path — Chromium, Firefox and WebKit, 33/33. + ## 0.42.0-alpha.1 - First alpha of the 0.42 line (ADR-0120): request-time rendering gains diff --git a/README.md b/README.md index 040719cb1..1286a1e25 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Elements are the durable application contract; JSX and Basic Element are the authoring layer; Declarative Shadow DOM is the default server representation; interactive regions upgrade selectively. -Published package line: `0.42.0-alpha.1` (`v0.42.0-alpha.1`) — the stable five-package +Published package line: `0.42.0-alpha.2` (`v0.42.0-alpha.2`) — the stable five-package release under ADR-0119's scoped interface freeze; the abandoned beta naming is not an active line. diff --git a/README.zh.md b/README.zh.md index 5f95ceb2a..4f5c955f7 100644 --- a/README.zh.md +++ b/README.zh.md @@ -6,7 +6,7 @@ Elements 是可长期保存的应用组件模型;JSX 与 Basic Element 是作者层; Declarative Shadow DOM 是默认服务端表示;交互区域按需升级。 -已发布包线为 `0.42.0-alpha.1`(`v0.42.0-alpha.1`)——ADR-0119 范围化接口冻结下的 +已发布包线为 `0.42.0-alpha.2`(`v0.42.0-alpha.2`)——ADR-0119 范围化接口冻结下的 stable 五包版本;已放弃的 beta 命名不再是当前版本线。 ## 当前产品 diff --git a/docs/current/VERSION_PLAN.md b/docs/current/VERSION_PLAN.md index 2de49c621..14d59682b 100644 --- a/docs/current/VERSION_PLAN.md +++ b/docs/current/VERSION_PLAN.md @@ -1,7 +1,7 @@ # v0.42.0 — WC Application Loop release plan -> Current source package line: `v0.42.0-alpha.1`\ -> Current npm registry line: `v0.42.0-alpha.1`\ +> Current source package line: `v0.42.0-alpha.2`\ +> Current npm registry line: `v0.42.0-alpha.2`\ > Active release target: `v0.41.1`\ > Planning release target: `v0.42.0` (WC Application Loop)\ > Next release line: `v0.43.0` (Universal WC SSR)\ diff --git a/docs/governance/PROJECT_WORKFLOW.md b/docs/governance/PROJECT_WORKFLOW.md index eadcdd894..f6c29a9ac 100644 --- a/docs/governance/PROJECT_WORKFLOW.md +++ b/docs/governance/PROJECT_WORKFLOW.md @@ -11,8 +11,8 @@ complete because an issue, chat message, or SOP says it is complete. It is complete only when the repository contains the decision, the execution package, the implementation, and the gates that prove the claim. -Current execution anchor: published package line `v0.42.0-alpha.1`, completed -implementation anchor `v0.42.0-alpha.1`, and `0.42.0` WC Application Loop planning +Current execution anchor: published package line `v0.42.0-alpha.2`, completed +implementation anchor `v0.42.0-alpha.2`, and `0.42.0` WC Application Loop planning under ADR-0120 and `docs/current/VERSION_PLAN.md`. OpenElement is one Web Components-native, static-first application framework: Basic Element is an authoring mode, not a diff --git a/docs/release/v0.41.0-interface-snapshot.json b/docs/release/v0.41.0-interface-snapshot.json index bdc62dde9..9895a7d8b 100644 --- a/docs/release/v0.41.0-interface-snapshot.json +++ b/docs/release/v0.41.0-interface-snapshot.json @@ -11,7 +11,7 @@ }, "declarations": { ".": { - "sha256": "57ecd6d850bbb9ed7f139e52f56dfbda3a07613a3570d149ffd3697473ddd273", + "sha256": "90188ae78f73f1e279897853ea77fca20ccffbddfe630937997a8d5537ffa3e6", "publicDeclarations": [ "export type { ElementDefinition } from './types.ts';", "export type {", @@ -22,7 +22,7 @@ "export type { Signal } from './internal/protocol/signal.ts';", "export type { IslandOptions } from './internal/protocol/island.ts';", "export type { StyleSheetLike } from './internal/protocol/style-sheet.ts';", - "export type { Action, ActionContext, Loader, LoaderContext } from './internal/protocol/data.ts';", + "export type {", "export type {", "export type { OpenElementRouteKind, OpenElementRouteNode } from './internal/protocol/app-model.ts';", "export type {" @@ -56,10 +56,10 @@ }, "declarations": { ".": { - "sha256": "95cc58ef49406811e4e9fd7a6897f03e237772b1657cb65d5b686c8b0536d067", + "sha256": "6c416f644599a98ae619395e656ff9b046da0dd89eff6cf4e7cf229bb30dd794", "publicDeclarations": [ "export type {", - "export type { Action, ActionContext, Loader, LoaderContext } from '@openelement/element';", + "export type {", "export type { ElementDefinition } from '@openelement/element';", "export type { SpaAppInstance, SpaAppOptions } from './spa.ts';", "export type { CreateRequestContextOptions, OpenElementRequestContext } from './model.ts';", diff --git a/docs/release/v0.42.0-alpha.1.md b/docs/release/v0.42.0-alpha.1.md index e781e3a17..775a65814 100644 --- a/docs/release/v0.42.0-alpha.1.md +++ b/docs/release/v0.42.0-alpha.1.md @@ -1,5 +1,49 @@ # v0.42.0-alpha.1 +## Request-time rendering foundation + +The first alpha of the 0.42 line (ADR-0120). `renderIntent.mode` was inert +metadata before this release; now it decides where a page renders: + +- **`rendering: 'dynamic'`** routes skip prerendering (build hook, dynamic + expansion and i18n locale prerendering) and are served per request by the + generated `dist/server/index.js` — a nitro-mount handler over the same + SSR bundle used for prerendering — with + `dist/server/server-manifest.json` recording the partition and BuildPlan + evidence carrying `requestTimeRoutes`. +- **Hard rule**: pages with actions cannot be prerendered; a route module + exporting an action without `mode: 'dynamic'` fails the build. +- **Hydration parity**: request-time HTML receives the island client entry + from the server entry (the static pipeline's post-build injection never + touched request-time pages — found and fixed via the new e2e fixture). +- **Freeze regression proof**: a pure-static project's public output is + byte-identical to the 0.41.2 build (zero HTML/JS/CSS differences). + +The loader/action contract types shipped earlier and are unchanged; this +alpha wires the rendering-mode semantics around them. Everything here is +explicitly **unfrozen** until the 0.42.0 stable decision. + +## Verification + +- Request-time e2e fixture + (`packages/adapter-vite/__fixtures__/request-time/`): loader data varies + per request (query + nonce), islands hydrate identically to static pages + — Chromium, Firefox and WebKit, 12/12. +- Full release-tier gates green; npm `alpha` and `latest` dist-tags at + `0.42.0-alpha.1`; post-publish consumer smoke and third-party WC smoke. +- Release tooling hardened en route: stable→new-alpha-line retired-version + guards, line-prose gate idempotency, stale-prefix test shape. + +## Migrating + +Nothing to do for pure-static sites — output is unchanged. To try +request-time rendering, set `renderIntent: { mode: 'dynamic' }` on a page +with a `loader` export and serve `dist/server/index.js` (Nitro or plain +Node) alongside the static files; see the guide's Routing and Data and +Deployment pages. + +--- + AutoFlow3 patch release evidence: `publish-existing-v0.42.0-alpha.1-2026-07-27T06-06-18-512Z`. - Previous package line: `0.41.2` diff --git a/docs/roadmap/ROADMAP.md b/docs/roadmap/ROADMAP.md index fcde5d848..ca6fc52cf 100644 --- a/docs/roadmap/ROADMAP.md +++ b/docs/roadmap/ROADMAP.md @@ -4,8 +4,8 @@ Execution and release state follow the [`Project Workflow`](../governance/PROJECT_WORKFLOW.md). > Source of truth for forward product planning.\ -> Published package line: `v0.42.0-alpha.1`.\ -> Active execution target: `v0.42.0-alpha.1`.\ +> Published package line: `v0.42.0-alpha.2`.\ +> Active execution target: `v0.42.0-alpha.2`.\ > Current implementation state: five-package convergence is published; > alpha.17 closed the first audit remediation, alpha.18 completed the > second audit sweep (ADR-0117), and alpha.19 completed the third (ADR-0118).\ @@ -116,7 +116,7 @@ making the standard Custom Element contract span both layers. See the official ## Current release state -`0.42.0-alpha.1` is the published package line. npm beta.1 through beta.3 are +`0.42.0-alpha.2` is the published package line. npm beta.1 through beta.3 are immutable partial artifacts and remain withdrawn from the active release story. The planned beta name was cancelled so the version label honestly reflects that breaking architecture and interface changes are still allowed. diff --git a/docs/status/STATUS.md b/docs/status/STATUS.md index 86958c9fb..20b24c447 100644 --- a/docs/status/STATUS.md +++ b/docs/status/STATUS.md @@ -1,9 +1,9 @@ # OpenElement Status > Updated: 2026-07-27\ -> Repository package line: `v0.42.0-alpha.1`\ -> npm registry line: `v0.42.0-alpha.1`\ -> Active release target: `v0.42.0-alpha.1`\ +> Repository package line: `v0.42.0-alpha.2`\ +> npm registry line: `v0.42.0-alpha.2`\ +> Active release target: `v0.42.0-alpha.2`\ > Next release line: `v0.42.0`\ > Product graph: five packages\ > Current maturity stage: stable (0.41.x) diff --git a/packages/adapter-vite/__fixtures__/request-time/app/routes/form.tsx b/packages/adapter-vite/__fixtures__/request-time/app/routes/form.tsx index 69fc9abcf..9740257ff 100644 --- a/packages/adapter-vite/__fixtures__/request-time/app/routes/form.tsx +++ b/packages/adapter-vite/__fixtures__/request-time/app/routes/form.tsx @@ -1,34 +1,60 @@ /** - * /form — request-time page with an action export. - * - * Exercises the ADR-0120 hard rule from its valid side: a route with an - * action must declare renderIntent mode 'dynamic', and then builds fine. + * /form — request-time page exercising the ADR-0120 action protocol: + * - empty submission -> fail(422, data) -> 422 re-render with the echo; + * - valid submission -> redirect (PRG) with the value in the URL; + * - named action 'shout' via formaction='?/shout'. */ -import { definePage, useActionData } from '@openelement/app'; +import { + definePage, + fail, + type OpenElementActionFailure, + redirect, + useActionData, +} from '@openelement/app'; export const tagName = 'page-form'; interface FormActionData { - echoed: string; + error?: string; + message?: string; } -export function action(ctx: { formData: Record }): FormActionData { - return { echoed: String(ctx.formData?.message ?? '') }; +export function action(ctx: { formData: FormData }): OpenElementActionFailure { + const message = String(ctx.formData.get('message') ?? '').trim(); + if (!message) { + return fail(422, { error: 'message is required', message } satisfies FormActionData); + } + redirect(`/form?echoed=${encodeURIComponent(message)}`); } +export const actions = { + shout(ctx: { formData: FormData }): never { + const message = String(ctx.formData.get('message') ?? '').trim() || 'silence'; + redirect(`/live?x=${encodeURIComponent(message.toUpperCase())}`); + }, +}; + const FormPage = definePage({ renderIntent: { mode: 'dynamic' }, head: { title: 'request-time fixture — form' }, - render() { + render({ request }) { const actionData = useActionData() as FormActionData | undefined; + const echoed = request ? new URL(request.url).searchParams.get('echoed') : undefined; return (

request-time form

-
- + + +
-

echo={actionData?.echoed ?? ''}

+ {actionData?.error ?

{actionData.error}

: null} +

echo={echoed ?? ''}

); }, diff --git a/packages/adapter-vite/__fixtures__/request-time/e2e/live.spec.ts b/packages/adapter-vite/__fixtures__/request-time/e2e/live.spec.ts index 67fce3e52..e454a5dac 100644 --- a/packages/adapter-vite/__fixtures__/request-time/e2e/live.spec.ts +++ b/packages/adapter-vite/__fixtures__/request-time/e2e/live.spec.ts @@ -59,3 +59,80 @@ test.describe('request-time rendering', () => { expect(html).toContain('echo=hello-action'); }); }); + +test.describe('action protocol (ADR-0120, 0.42.0-alpha.2)', () => { + test('validation failure returns 422 with the echo (no JS needed)', async ({ request }) => { + const response = await request.post('/form', { form: { message: ' ' } }); + expect(response.status()).toBe(422); + const html = await response.text(); + expect(html).toContain('message is required'); + }); + + test('valid submission is a 303 PRG redirect, never a 200 render', async ({ request }) => { + const response = await request.post('/form', { + form: { message: 'hello-action' }, + maxRedirects: 0, + }); + expect(response.status()).toBe(303); + expect(response.headers()['location']).toBe('/form?echoed=hello-action'); + }); + + test('named action via formaction dispatches to ?/shout', async ({ request }) => { + const response = await request.post('/form?/shout', { + form: { message: 'hi there' }, + maxRedirects: 0, + }); + expect(response.status()).toBe(303); + expect(response.headers()['location']).toBe('/live?x=HI%20THERE'); + }); + + test('unknown named action is a defined 404', async ({ request }) => { + const response = await request.post('/form?/nope', { form: { message: 'x' } }); + expect(response.status()).toBe(404); + }); + + test('fetch callers receive the ActionResult union', async ({ request }) => { + const failure = await request.post('/form', { + form: { message: '' }, + headers: { 'x-openelement-action': 'true' }, + }); + expect(failure.status()).toBe(422); + expect(await failure.json()).toEqual({ + type: 'failure', + status: 422, + data: { error: 'message is required', message: '' }, + }); + + const success = await request.post('/form', { + form: { message: 'hello' }, + headers: { 'x-openelement-action': 'true' }, + maxRedirects: 0, + }); + const body = await success.json(); + expect(body.type).toBe('redirect'); + expect(body.location).toBe('/form?echoed=hello'); + }); + + test('full form loop works with JavaScript disabled', async ({ browser }) => { + const context = await browser.newContext({ javaScriptEnabled: false }); + const page = await context.newPage(); + await page.goto('/form'); + await page.fill('#message', 'no-js-works'); + await page.click('#submit'); + await page.waitForURL('**/form?echoed=no-js-works'); + await expect(page.locator('#echo')).toHaveText('echo=no-js-works'); + + await page.goto('/form'); + await page.click('#submit'); + await expect(page.locator('#error')).toHaveText('message is required'); + await context.close(); + }); + + test('enhanced submit follows the ActionResult redirect without a native POST', async ({ page }) => { + await page.goto('/form'); + await page.fill('#message', 'enhanced-path'); + await page.click('#submit'); + await page.waitForURL('**/form?echoed=enhanced-path'); + await expect(page.locator('#echo')).toHaveText('echo=enhanced-path'); + }); +}); diff --git a/packages/adapter-vite/__tests__/entry-generators.test.ts b/packages/adapter-vite/__tests__/entry-generators.test.ts index 53ee561e9..b9f543b4e 100644 --- a/packages/adapter-vite/__tests__/entry-generators.test.ts +++ b/packages/adapter-vite/__tests__/entry-generators.test.ts @@ -238,3 +238,15 @@ Deno.test('client entry rejects legacy eager/lazy strategy values', () => { 'Invalid island strategy', ); }); + +Deno.test('client entry includes the ADR-0120 form enhancement layer', () => { + const code = generateClientEntry([ + { tagName: 'x-counter', modulePath: './counter.ts', strategy: 'load' }, + ]); + assert(code.includes("document.addEventListener('submit'")); + assert(code.includes('data-open-enhance')); + assert(code.includes("'x-openelement-action': 'true'")); + assert(code.includes("result.type === 'redirect'")); + assert(code.includes("result.type === 'failure'")); + assert(code.includes('open:action-failure')); +}); diff --git a/packages/adapter-vite/__tests__/entry-renderer.test.ts b/packages/adapter-vite/__tests__/entry-renderer.test.ts index b82d5997d..a3e3ebdd0 100644 --- a/packages/adapter-vite/__tests__/entry-renderer.test.ts +++ b/packages/adapter-vite/__tests__/entry-renderer.test.ts @@ -393,7 +393,7 @@ Deno.test('renderEntry: definePage descriptor feeds load, metadata, and revalida assertStringIncludes(code, 'function __pageDefinition(module) {'); assertStringIncludes( code, - "import { isOpenElementRedirect as __isOpenElementRedirect, isOpenElementNotFound as __isOpenElementNotFound } from '@openelement/app';", + "import { isOpenElementRedirect as __isOpenElementRedirect, isOpenElementNotFound as __isOpenElementNotFound, isActionFailure as __isActionFailure } from '@openelement/app';", ); assertFalse(code.includes('function __isOpenElementRedirect(error) {')); assertFalse(code.includes('function __isOpenElementNotFound(error) {')); @@ -785,3 +785,47 @@ Deno.test('buildEntryDescriptor: ssr field is extracted from manifest declaratio assertEquals(clientOnly?.ssr, false); assertEquals(defaultComp?.ssr, undefined); // no ssr field in manifest -> undefined }); + +// ─── 0.42.0-alpha.2 (ADR-0120): action protocol codegen ─────────────────── + +Deno.test('renderEntry: action POST follows the ADR-0120 protocol', () => { + const desc = buildEntryDescriptor(basicRoutes, {}); + const code = renderEntry(desc); + + // The action runs before the loader (revalidation invariant): a mutation + // never renders stale loader data. + const actionIndex = code.indexOf('const __actionResult ='); + const loaderIndex = code.indexOf('const __data =', actionIndex); + assertEquals(actionIndex > 0, true, 'action execution must be emitted'); + assertEquals(loaderIndex > actionIndex, true, 'loader must run after the action on POST'); + + // Real FormData (not parseBody objects), fail() 422 channel, PRG 303 on + // success, named actions via ?/name, fetch-path ActionResult JSON. + assertStringIncludes(code, 'await c.req.raw.formData()'); + assertStringIncludes(code, '__isActionFailure(__actionResult)'); + assertStringIncludes(code, 'return c.redirect(__url.pathname + __url.search, 303)'); + assertStringIncludes(code, "key.startsWith('/')"); + assertStringIncludes(code, '__namedActions[__actionName]'); + assertStringIncludes(code, "c.req.header('x-openelement-action')"); + assertStringIncludes( + code, + "{ type: 'failure', status: __actionResult.status, data: __actionResult.data }", + ); + assertStringIncludes(code, ', __actionStatus)'); + // No action export on a route: POST is a defined 404, not a render. + assertStringIncludes(code, 'This route does not accept submissions.'); +}); + +Deno.test('renderEntry: action catch paths speak ActionResult to fetch callers', () => { + const desc = buildEntryDescriptor(basicRoutes, {}); + const code = renderEntry(desc); + + // Redirects out of a POST action are coerced to 303 (PRG), including the + // ActionResult redirect shape; GET handlers keep the author's status. + assertStringIncludes(code, 'err.status === 302 ? 303 : err.status'); + assertStringIncludes( + code, + "{ type: 'redirect', status: __redirectStatus, location: err.location }", + ); + assertStringIncludes(code, "{ type: 'error', status: 500, error: { message:"); +}); diff --git a/packages/adapter-vite/deno.json b/packages/adapter-vite/deno.json index d908f7568..4385e51d1 100644 --- a/packages/adapter-vite/deno.json +++ b/packages/adapter-vite/deno.json @@ -1,6 +1,6 @@ { "name": "@openelement/adapter-vite", - "version": "0.42.0-alpha.1", + "version": "0.42.0-alpha.2", "exports": { ".": "./src/index.ts", "./nitro-mount": "./src/nitro-mount.ts", diff --git a/packages/adapter-vite/src/internal/ssg/entry-generators.ts b/packages/adapter-vite/src/internal/ssg/entry-generators.ts index fe17b5b8a..5ddaba053 100644 --- a/packages/adapter-vite/src/internal/ssg/entry-generators.ts +++ b/packages/adapter-vite/src/internal/ssg/entry-generators.ts @@ -238,5 +238,52 @@ var __schedule = window.requestIdleCallback || window.requestAnimationFrame || f __schedule(__deferred);` : '// No client:idle islands' } + +// Form enhancement (ADR-0120, 0.42.0-alpha.2): forms marked +// data-open-enhance submit through the ActionResult protocol. Without +// JavaScript the same form is a native POST (303/422 HTML), so behavior +// degrades to the browser by construction. No DOM surgery happens here, so +// islands that have not hydrated yet are never touched. +document.addEventListener('submit', function (event) { + var form = event.target; + if (!(form instanceof HTMLFormElement)) return; + if (!form.hasAttribute('data-open-enhance')) return; + var method = (form.getAttribute('method') || 'get').toUpperCase(); + if (method === 'GET') return; + event.preventDefault(); + var submitter = event.submitter; + var actionUrl = (submitter && submitter.formAction) || form.action || window.location.href; + fetch(actionUrl, { + method: method, + body: new FormData(form), + headers: { 'x-openelement-action': 'true' }, + }).then(function (response) { + return response.json(); + }).then(function (result) { + if (result && result.type === 'redirect' && result.location) { + window.location.assign(result.location); + return; + } + if (result && result.type === 'failure') { + // Page code (islands) may handle the failure inline; without a + // listener the native path takes over and the browser renders the + // 422 page with the echo. A failed action must be safe to re-run: + // validate first, mutate after validation passes. + var failureEvent = new CustomEvent('open:action-failure', { + cancelable: true, + detail: { status: result.status, data: result.data, form: form }, + }); + if (!form.dispatchEvent(failureEvent)) { + form.removeAttribute('data-open-enhance'); + form.requestSubmit(submitter instanceof HTMLElement ? submitter : undefined); + } + return; + } + // Unexpected shape: fall back to a full reload of the current state. + window.location.reload(); + }).catch(function () { + window.location.reload(); + }); +}); `; } diff --git a/packages/adapter-vite/src/internal/ssg/entry-render-helpers.ts b/packages/adapter-vite/src/internal/ssg/entry-render-helpers.ts index 27a5e35f0..7314a7ac6 100644 --- a/packages/adapter-vite/src/internal/ssg/entry-render-helpers.ts +++ b/packages/adapter-vite/src/internal/ssg/entry-render-helpers.ts @@ -155,6 +155,11 @@ export function renderRouteHandler( lines.push(` let __params = {}`); lines.push(` let __routeMetaValue = ${routeMeta}`); lines.push(` const __routeContext = ${routeContext}`); + if (isAction) { + // Declared outside try so the catch can branch on the fetch path without + // hitting a TDZ error when the action block never ran. + lines.push(` let __isFetch = false;`); + } lines.push(` try {`); lines.push(` __params = c.req.param() || {}`); lines.push(` const __loadContext = {`); @@ -166,16 +171,69 @@ export function renderRouteHandler( ); lines.push(` route: __routeContext,`); lines.push(` }`); - lines.push( - ` const __data = typeof ${route.varName}.loader === "function" ? await ${route.varName}.loader(__loadContext) : undefined`, - ); + if (!isAction) { + lines.push( + ` const __data = typeof ${route.varName}.loader === "function" ? await ${route.varName}.loader(__loadContext) : undefined`, + ); + } if (isAction) { - lines.push(` const __formData = await c.req.parseBody()`); - lines.push(` const __actionCtx = { ...__loadContext, formData: __formData }`); + // ADR-0120 action protocol (0.42.0-alpha.2): + // - named actions: ?/name selects module.actions[name] (SvelteKit shape); + // - the action runs BEFORE the loader so a mutation never renders stale + // data (revalidation invariant); + // - fail() returns take the 422 re-render channel with the failure data + // echoed; everything else succeeds; + // - a successful non-GET action never answers 200 with a rendered page: + // 303 back to the route (PRG), or an ActionResult for fetch callers. + lines.push(` const __url = new URL(c.req.url);`); + lines.push( + ` const __actionName = (() => { for (const key of __url.searchParams.keys()) { if (key.startsWith('/')) return key.slice(1); } return undefined; })();`, + ); + lines.push( + ` const __namedActions = (typeof ${route.varName}.actions === 'object' && ${route.varName}.actions !== null) ? ${route.varName}.actions : {};`, + ); + lines.push( + ` const __actionFn = __actionName !== undefined ? __namedActions[__actionName] : (typeof ${route.varName}.action === 'function' ? ${route.varName}.action : undefined);`, + ); + lines.push(` __isFetch = c.req.header('x-openelement-action') === 'true';`); + lines.push(` if (typeof __actionFn !== 'function') {`); + lines.push( + ` return c.html(wrapInDocument(__statusHtml('404 Not Found', 'This route does not accept submissions.'), { title: '404 Not Found', lang: ${ + jsStringLiteral(docConfig.lang) + }, headExtras: ${headExtrasExpr}, allowHeadExtrasScripts: ${ + JSON.stringify(docConfig.allowHeadExtrasScripts) + }, cspNonce: c.get('cspNonce') }), 404);`, + ); + lines.push(` }`); + lines.push(` if (__actionName !== undefined && typeof __actionFn !== 'function') {`); + lines.push( + ` return c.json({ type: 'error', status: 404, error: { message: 'No action named "' + __actionName + '" on this route.' } }, 404);`, + ); + lines.push(` }`); + lines.push(` const __formData = await c.req.raw.formData();`); lines.push( - ` const __actionData = typeof ${route.varName}.action === "function" ? await ${route.varName}.action(__actionCtx) : undefined`, + ` const __actionResult = typeof __actionFn === 'function' ? await __actionFn({ ...__loadContext, formData: __formData }) : undefined;`, ); + lines.push(` if (__isFetch) {`); + lines.push(` if (__isActionFailure(__actionResult)) {`); + lines.push( + ` return c.json({ type: 'failure', status: __actionResult.status, data: __actionResult.data }, __actionResult.status);`, + ); + lines.push(` }`); + lines.push( + ` return c.json({ type: 'redirect', status: 303, location: __url.pathname + __url.search });`, + ); + lines.push(` }`); + lines.push(` if (!__isActionFailure(__actionResult)) {`); + lines.push(` if (__actionResult instanceof Response) return __actionResult;`); + lines.push(` return c.redirect(__url.pathname + __url.search, 303);`); + lines.push(` }`); + lines.push( + ` const __data = typeof ${route.varName}.loader === "function" ? await ${route.varName}.loader(__loadContext) : undefined;`, + ); + lines.push(` const __actionData = __actionResult.data;`); + lines.push(` const __actionStatus = __actionResult.status;`); } lines.push( ` let node = jsx(__tag, ${ @@ -213,11 +271,21 @@ export function renderRouteHandler( ) { lines.push(` ${optionLine}`); } - lines.push(` }))`); + lines.push(` })${isAction ? ', __actionStatus' : ''})`); lines.push(` } catch (err) {`); lines.push(` if (__isOpenElementRedirect(err)) {`); - lines.push(` return c.redirect(err.location, err.status)`); + if (isAction) { + lines.push( + ` const __redirectStatus = err.status === 302 ? 303 : err.status;`, + ); + lines.push( + ` if (__isFetch) return c.json({ type: 'redirect', status: __redirectStatus, location: err.location });`, + ); + lines.push(` return c.redirect(err.location, __redirectStatus)`); + } else { + lines.push(` return c.redirect(err.location, err.status)`); + } lines.push(` }`); lines.push(` if (__isOpenElementNotFound(err)) {`); lines.push( @@ -268,6 +336,13 @@ export function renderRouteHandler( lines.push( ` console.error('[openElement] ${failureLabel} for ' + ${pathLiteral} + ':', err)`, ); + if (isAction) { + lines.push(` if (__isFetch) {`); + lines.push( + ` return c.json({ type: 'error', status: 500, error: { message: String(err && err.message ? err.message : err) } }, 500);`, + ); + lines.push(` }`); + } lines.push(` if (import.meta.env.PROD) {`); lines.push(` return c.html('

500 Internal Server Error

', 500)`); lines.push(` } else {`); diff --git a/packages/adapter-vite/src/internal/ssg/entry-renderer.ts b/packages/adapter-vite/src/internal/ssg/entry-renderer.ts index 494cceff8..340663731 100644 --- a/packages/adapter-vite/src/internal/ssg/entry-renderer.ts +++ b/packages/adapter-vite/src/internal/ssg/entry-renderer.ts @@ -90,7 +90,7 @@ export function renderEntry(desc: EntryDescriptor): string { lines.push(`import { createLogger } from '@openelement/element';`); lines.push(`import { createRuntimeAdapter } from '@openelement/element/build-utils';`); lines.push( - `import { isOpenElementRedirect as __isOpenElementRedirect, isOpenElementNotFound as __isOpenElementNotFound } from '@openelement/app';`, + `import { isOpenElementRedirect as __isOpenElementRedirect, isOpenElementNotFound as __isOpenElementNotFound, isActionFailure as __isActionFailure } from '@openelement/app';`, ); lines.push( `import { headerNav as __headerNav, navSections as __navSections } from '@openelement/generated/nav';`, diff --git a/packages/app/deno.json b/packages/app/deno.json index b1bd8f7c4..f981314d2 100644 --- a/packages/app/deno.json +++ b/packages/app/deno.json @@ -1,6 +1,6 @@ { "name": "@openelement/app", - "version": "0.42.0-alpha.1", + "version": "0.42.0-alpha.2", "exports": { ".": "./src/index.ts", "./hono": "./src/hono.ts", diff --git a/packages/app/src/authoring.ts b/packages/app/src/authoring.ts index 9fbb249cd..da6c6076d 100644 --- a/packages/app/src/authoring.ts +++ b/packages/app/src/authoring.ts @@ -98,6 +98,44 @@ export function isOpenElementNotFound(error: unknown): error is OpenElementNotFo ); } +/** + * Expected-failure channel for actions (0.42.0-alpha.2, ADR-0120): validation + * failures RETURN `fail(status, data)` — never throw — so the server can + * answer 422 with the form re-rendered and the submitted values echoed back. + * Thrown values keep the exception channel (redirect/notFound/error page). + */ +export class OpenElementActionFailure { + readonly name = 'OpenElementActionFailure'; + readonly status: number; + readonly data: Data; + + constructor(status: number, data: Data) { + if (status < 400 || status > 499) { + throw new Error( + `${ERROR_PREFIX} fail() status must be a 4xx code (got ${status}); ` + + 'validation failures are client errors, use 400/422.', + ); + } + this.status = status; + this.data = data; + } +} + +export function fail(status: number, data: Data): OpenElementActionFailure { + return new OpenElementActionFailure(status, data); +} + +export function isActionFailure(error: unknown): error is OpenElementActionFailure { + return error instanceof OpenElementActionFailure || + ( + typeof error === 'object' && + error !== null && + (error as { name?: unknown }).name === 'OpenElementActionFailure' && + typeof (error as { status?: unknown }).status === 'number' && + 'data' in (error as Record) + ); +} + export interface PageHead { title?: string; description?: string; diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index b42f95d63..11de27c09 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -2,9 +2,12 @@ export { defineIsland, defineIslandConfig, definePage, + fail, + isActionFailure, isOpenElementNotFound, isOpenElementRedirect, notFound, + OpenElementActionFailure, OpenElementNotFound, OpenElementRedirect, redirect, @@ -29,7 +32,14 @@ export type { } from './authoring.ts'; // Re-export route data types from protocol for convenience -export type { Action, ActionContext, Loader, LoaderContext } from '@openelement/element'; +export type { + Action, + ActionContext, + ActionResult, + Loader, + LoaderContext, +} from '@openelement/element'; +export { ACTION_FETCH_HEADER } from '@openelement/element'; // Re-export from @openelement/element for convenience export { defineElement } from '@openelement/element'; diff --git a/packages/create/deno.json b/packages/create/deno.json index e273e6cf9..29b345ca9 100644 --- a/packages/create/deno.json +++ b/packages/create/deno.json @@ -1,6 +1,6 @@ { "name": "@openelement/create", - "version": "0.42.0-alpha.1", + "version": "0.42.0-alpha.2", "exports": "./src/cli.ts", "imports": {}, "tasks": { diff --git a/packages/create/src/version.ts b/packages/create/src/version.ts index 1f24da96d..ff9e55753 100644 --- a/packages/create/src/version.ts +++ b/packages/create/src/version.ts @@ -1,2 +1,2 @@ /** The published CLI version, embedded so packed npm installs are self-contained. */ -export const CREATE_VERSION = '0.42.0-alpha.1'; +export const CREATE_VERSION = '0.42.0-alpha.2'; diff --git a/packages/element/deno.json b/packages/element/deno.json index 184426970..70e67b919 100644 --- a/packages/element/deno.json +++ b/packages/element/deno.json @@ -1,6 +1,6 @@ { "name": "@openelement/element", - "version": "0.42.0-alpha.1", + "version": "0.42.0-alpha.2", "exports": { ".": "./src/index.ts", "./jsx-runtime": "./src/jsx-runtime.ts", diff --git a/packages/element/src/index.ts b/packages/element/src/index.ts index 1f65a4170..54ffa4bcb 100644 --- a/packages/element/src/index.ts +++ b/packages/element/src/index.ts @@ -83,7 +83,14 @@ export { isValidTagName } from './internal/core/tag-utils.ts'; export type { StyleSheetLike } from './internal/protocol/style-sheet.ts'; // App-owned contracts use these types without reopening the retired protocol package. -export type { Action, ActionContext, Loader, LoaderContext } from './internal/protocol/data.ts'; +export type { + Action, + ActionContext, + ActionResult, + Loader, + LoaderContext, +} from './internal/protocol/data.ts'; +export { ACTION_FETCH_HEADER } from './internal/protocol/data.ts'; export type { AppShellConfig, CompatibilityClassification, diff --git a/packages/element/src/internal/protocol/data.ts b/packages/element/src/internal/protocol/data.ts index 95e631f56..38242e75c 100644 --- a/packages/element/src/internal/protocol/data.ts +++ b/packages/element/src/internal/protocol/data.ts @@ -36,3 +36,18 @@ export type Loader = (ctx: LoaderContext) => T | Promise; /** Route action: handles form submissions for a page route. */ export type Action = (ctx: ActionContext) => T | Promise; + +/** + * Wire shape returned to the JavaScript form-enhancement path (0.42.0-alpha.2, + * ADR-0120). The no-JS path never sees this: it gets the equivalent semantics + * as plain HTTP (303 on success, 422 with the re-rendered form on validation + * failure, redirect/error as status codes). + */ +export type ActionResult = + | { type: 'success'; status: number; data?: Success } + | { type: 'failure'; status: number; data?: Failure } + | { type: 'redirect'; status: number; location: string } + | { type: 'error'; status: number; error: { message: string } }; + +/** Request header marking a fetch from the JS form-enhancement layer. */ +export const ACTION_FETCH_HEADER = 'x-openelement-action'; diff --git a/packages/ui/deno.json b/packages/ui/deno.json index 2337138c1..bb0f51118 100644 --- a/packages/ui/deno.json +++ b/packages/ui/deno.json @@ -1,6 +1,6 @@ { "name": "@openelement/ui", - "version": "0.42.0-alpha.1", + "version": "0.42.0-alpha.2", "exports": { ".": "./src/index.ts", "./open-button": "./src/open-button.tsx", diff --git a/packages/ui/src/generated-manifest.json b/packages/ui/src/generated-manifest.json index 2844ff23c..ce605b763 100644 --- a/packages/ui/src/generated-manifest.json +++ b/packages/ui/src/generated-manifest.json @@ -1,7 +1,7 @@ { "schemaVersion": "1.0.0", "packageName": "@openelement/ui", - "version": "0.42.0-alpha.1", + "version": "0.42.0-alpha.2", "description": "Open Props Web Component library for openElement", "author": "openElement", "license": "MIT", diff --git a/tools/check-version-anchors.test.ts b/tools/check-version-anchors.test.ts index d5b91b9af..5a5afe2ca 100644 --- a/tools/check-version-anchors.test.ts +++ b/tools/check-version-anchors.test.ts @@ -3,6 +3,7 @@ import { findStaleAnchorFailures, findVersionAnchorFailures, headAnchorZone, + staleClaimsAlternation, versionAnchors, } from './check-version-anchors.ts'; import { @@ -11,7 +12,6 @@ import { PACKAGE_VERSION_TAG, PREVIOUS_PACKAGE_VERSION, PREVIOUS_PACKAGE_VERSION_TAG, - stalePackageVersionClaims, } from './project-constants.ts'; function readerFrom(files: Record): (path: string) => string { @@ -90,20 +90,15 @@ Deno.test('stale claims: a stale tag before the first head anchor is still in th assert(failures.some((f) => f.startsWith('docs/current/VERSION_PLAN.md:'))); }); -Deno.test('stale claims: the current line never matches its own stale prefix', () => { - // `0.41.0-alpha.1` (stale) is a prefix of a current prerelease like - // `0.41.0-alpha.17`; the numeric boundary must keep the current line from - // failing. That prefix relation holds only for same-base bumps: a new-line - // prerelease whose previous line is a different-base stable (0.41.2 → - // 0.42.0-alpha.1) has no stale prefix at all, and a stable current line - // never has one either. - const hasStalePrefix = stalePackageVersionClaims().some((claim) => - PACKAGE_VERSION.startsWith(claim) - ); - const sameBasePrereleaseBump = PACKAGE_VERSION.includes('-') && - PREVIOUS_PACKAGE_VERSION.includes('-') && - PACKAGE_VERSION.split('-')[0] === PREVIOUS_PACKAGE_VERSION.split('-')[0]; - assertEquals(hasStalePrefix, sameBasePrereleaseBump); +Deno.test('stale claims: the stale regex never flags the current line', () => { + // A stale claim can be a string prefix of the current version + // (`0.41.0-alpha.1` inside `0.41.0-alpha.17`) — but only sometimes + // (alpha.1 inside alpha.2 is not one). The invariant that must hold for + // every version shape is the boundary: the stale regex with numeric + // boundaries must never match the current line, bare or v-tagged. + const pattern = new RegExp(`(? = { { id: 'metadata', label: 'Metadata', level: 3 }, { id: 'data-boundary', label: 'Data boundary', level: 3 }, { id: 'rendering-modes', label: 'Rendering modes', level: 3 }, + { id: 'form-actions', label: 'Form actions', level: 3 }, ], previous: { href: '/guide/comparison', label: 'Comparison' }, next: { href: '/guide/mdx', label: 'MDX' }, @@ -51,6 +52,12 @@ const content: Record<'en' | 'zh', GuideContent> = { body: "renderIntent.mode selects where a page renders: 'auto' (default) and 'static' prerender at build; 'dynamic' skips prerendering and renders per request through the generated dist/server entry, running the route loader on every request. Pages that export an action must declare 'dynamic' — the build rejects prerendered action pages (0.42 line, unfrozen).", }, + { + id: 'form-actions', + title: 'Form actions', + body: + "A dynamic route may export an action ({ formData }) — plain HTML forms work without JavaScript: validation failures return fail(4xx, data) and re-render with the echo (HTTP 422), successes answer 303 (PRG). Named actions dispatch via formaction='?/name'. Forms marked data-open-enhance use the same protocol through fetch (ActionResult JSON); an action must be safe to re-run after a failed validation (0.42 line, unfrozen).", + }, ], }, zh: { @@ -62,6 +69,7 @@ const content: Record<'en' | 'zh', GuideContent> = { { id: 'metadata', label: '元数据', level: 3 }, { id: 'data-boundary', label: '数据边界', level: 3 }, { id: 'rendering-modes', label: '渲染模式', level: 3 }, + { id: 'form-actions', label: '表单 action', level: 3 }, ], previous: { href: '/guide/comparison', label: '对比' }, next: { href: '/guide/mdx', label: 'MDX' }, @@ -87,6 +95,12 @@ const content: Record<'en' | 'zh', GuideContent> = { body: "renderIntent.mode 决定页面在哪里渲染:'auto'(默认)与 'static' 在构建时预渲染;'dynamic' 跳过预渲染,通过生成的 dist/server 入口按请求渲染,每次请求都会运行路由 loader。导出 action 的页面必须声明 'dynamic'——构建会拒绝预渲染的 action 页面(0.42 版本线,未冻结)。", }, + { + id: 'form-actions', + title: '表单 action', + body: + "dynamic 路由可导出 action({ formData })——纯 HTML 表单无需 JavaScript 即可工作:校验失败返回 fail(4xx, data),以 422 重渲染并回显;成功则以 303 应答(PRG)。命名 action 通过 formaction='?/name' 分派。标记 data-open-enhance 的表单走同一协议的 fetch 路径(ActionResult JSON);action 在校验失败后必须可安全重跑(0.42 版本线,未冻结)。", + }, ], }, }; diff --git a/www/app/routes/roadmap.tsx b/www/app/routes/roadmap.tsx index 66c494a70..118844027 100644 --- a/www/app/routes/roadmap.tsx +++ b/www/app/routes/roadmap.tsx @@ -332,10 +332,10 @@ type TimelineEntry = { const entries: TimelineEntry[] = [ { - version: 'v0.42.0-alpha.1', - theme: 'request-time rendering foundation', + version: 'v0.42.0-alpha.2', + theme: 'form/action loop', copy: - "renderIntent mode 'dynamic' skips prerendering; a generated nitro-mount server serves those routes per request with loaders, and the build rejects prerendered action pages (ADR-0120).", + 'Plain HTML forms work without JavaScript: fail() validation echoes at 422, successes answer 303 (PRG), named actions dispatch via formaction, and data-open-enhance forms use the same ActionResult protocol.', state: 'stable', stamp: 'CURRENT', }, @@ -420,7 +420,7 @@ export class RoadmapPage extends OpenElement { {entries.map((phase) => { // The current-line stamp follows the bump-maintained anchor so a // release bump re-marks the timeline without manual edits. - const stamp = phase.version === 'v0.42.0-alpha.1' ? 'CURRENT' : phase.stamp; + const stamp = phase.version === 'v0.42.0-alpha.2' ? 'CURRENT' : phase.stamp; return (