From 1e3740f1c071fe8014559af6fc8ccebf28818897 Mon Sep 17 00:00:00 2001 From: DevBot Date: Mon, 27 Jul 2026 14:10:12 +0800 Subject: [PATCH 1/8] docs(release): curate the 0.42.0-alpha.1 note --- docs/release/v0.42.0-alpha.1.md | 44 +++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) 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` From dcf09ad8ba631c4a580a301ba057871d360d0c68 Mon Sep 17 00:00:00 2001 From: DevBot Date: Mon, 27 Jul 2026 14:27:07 +0800 Subject: [PATCH 2/8] =?UTF-8?q?feat(app,adapter-vite,element):=20ADR-0120?= =?UTF-8?q?=20action=20protocol=20=E2=80=94=20303/422/named=20actions/Acti?= =?UTF-8?q?onResult=20(0.42.0-alpha.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server-side form/action loop on the request-time foundation: - actions run BEFORE loaders on POST (revalidation invariant; previously a mutation rendered stale loader data); - fail(status, data) return channel (4xx-enforced, duck-typed cross-bundle): 422 re-render with the failure echoed through useActionData; - successful non-GET actions never answer 200 with a page: 303 PRG back to the route (or the action's own Response escape hatch); - named actions: ?/name selects module.actions[name] (formaction-compatible); - fetch callers (x-openelement-action header) receive the ActionResult discriminated union instead of HTML/redirects — failure/redirect/error; - POST to a route without an action is a defined 404, not a render; - real FormData via request.formData() (was parseBody objects); - element exports ActionResult + ACTION_FETCH_HEADER; app exports fail(), isActionFailure, OpenElementActionFailure. Codegen tests cover ordering, 303/422/named/fetch paths and catch channels (44/44 entry-renderer, 509 suite green). Client enhancement layer and e2e follow in the next commits. --- .../__tests__/entry-renderer.test.ts | 40 ++++++++- .../src/internal/ssg/entry-render-helpers.ts | 84 +++++++++++++++++-- .../src/internal/ssg/entry-renderer.ts | 2 +- packages/app/src/authoring.ts | 38 +++++++++ packages/app/src/index.ts | 6 +- packages/element/src/index.ts | 3 +- .../element/src/internal/protocol/data.ts | 15 ++++ 7 files changed, 177 insertions(+), 11 deletions(-) diff --git a/packages/adapter-vite/__tests__/entry-renderer.test.ts b/packages/adapter-vite/__tests__/entry-renderer.test.ts index b82d5997d..3d51c5657 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,41 @@ 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); + + assertStringIncludes(code, "{ type: 'redirect', status: err.status, location: err.location }"); + assertStringIncludes(code, "{ type: 'error', status: 500, error: { message:"); +}); 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..79add73a3 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 __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 __actionData = typeof ${route.varName}.action === "function" ? await ${route.varName}.action(__actionCtx) : undefined`, + ` 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,10 +271,15 @@ export function renderRouteHandler( ) { lines.push(` ${optionLine}`); } - lines.push(` }))`); + lines.push(` })${isAction ? ', __actionStatus' : ''})`); lines.push(` } catch (err) {`); lines.push(` if (__isOpenElementRedirect(err)) {`); + if (isAction) { + lines.push( + ` if (__isFetch) return c.json({ type: 'redirect', status: err.status, location: err.location });`, + ); + } lines.push(` return c.redirect(err.location, err.status)`); lines.push(` }`); lines.push(` if (__isOpenElementNotFound(err)) {`); @@ -268,6 +331,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/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..8d85f3f4b 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,8 @@ 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/element/src/index.ts b/packages/element/src/index.ts index 1f65a4170..356049839 100644 --- a/packages/element/src/index.ts +++ b/packages/element/src/index.ts @@ -83,7 +83,8 @@ 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'; From 429ccd7d180d7eaafb60d25b972a8b69671ddd5e Mon Sep 17 00:00:00 2001 From: DevBot Date: Mon, 27 Jul 2026 14:36:09 +0800 Subject: [PATCH 3/8] feat(adapter-vite): form enhancement layer + 303 coercion for action redirects (0.42.0-alpha.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The island client entry now carries the ADR-0120 form enhancement: data-open-enhance forms submit via fetch with the ActionResult protocol — redirect follows client-side, failure fires a cancelable open:action-failure event and otherwise falls back to the native 422 render. No DOM surgery: unhydrated islands are never touched. - Redirects thrown from POST actions coerce 302 to 303 (PRG); GET keeps the author's status. - Fixture: /form exercises fail(422) echo, PRG success, named ?/shout action; e2e proves the full loop with javaScriptEnabled: false, the ActionResult JSON shapes, the named/unknown-action paths and the enhanced submit — 33/33 across Chromium, Firefox and WebKit. --- .../request-time/app/routes/form.tsx | 50 +++++++++--- .../request-time/e2e/live.spec.ts | 77 +++++++++++++++++++ .../__tests__/entry-generators.test.ts | 12 +++ .../__tests__/entry-renderer.test.ts | 8 +- .../src/internal/ssg/entry-generators.ts | 47 +++++++++++ .../src/internal/ssg/entry-render-helpers.ts | 9 ++- 6 files changed, 188 insertions(+), 15 deletions(-) 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 3d51c5657..a3e3ebdd0 100644 --- a/packages/adapter-vite/__tests__/entry-renderer.test.ts +++ b/packages/adapter-vite/__tests__/entry-renderer.test.ts @@ -820,6 +820,12 @@ Deno.test('renderEntry: action catch paths speak ActionResult to fetch callers', const desc = buildEntryDescriptor(basicRoutes, {}); const code = renderEntry(desc); - assertStringIncludes(code, "{ type: 'redirect', status: err.status, location: err.location }"); + // 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/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 79add73a3..7314a7ac6 100644 --- a/packages/adapter-vite/src/internal/ssg/entry-render-helpers.ts +++ b/packages/adapter-vite/src/internal/ssg/entry-render-helpers.ts @@ -277,10 +277,15 @@ export function renderRouteHandler( lines.push(` if (__isOpenElementRedirect(err)) {`); if (isAction) { lines.push( - ` if (__isFetch) return c.json({ type: 'redirect', status: err.status, location: err.location });`, + ` 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(` return c.redirect(err.location, err.status)`); lines.push(` }`); lines.push(` if (__isOpenElementNotFound(err)) {`); lines.push( From 3a02152f3e0eff52e90f65c0abb89e5a818569b8 Mon Sep 17 00:00:00 2001 From: DevBot Date: Mon, 27 Jul 2026 14:37:58 +0800 Subject: [PATCH 4/8] docs(www,changelog): form/action loop guide cards and the alpha.2 entry --- CHANGELOG.md | 22 ++++++++++++++++++++++ www/app/routes/guide/routing-and-data.tsx | 14 ++++++++++++++ 2 files changed, 36 insertions(+) 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/www/app/routes/guide/routing-and-data.tsx b/www/app/routes/guide/routing-and-data.tsx index 6fa1375ce..52ee75fc9 100644 --- a/www/app/routes/guide/routing-and-data.tsx +++ b/www/app/routes/guide/routing-and-data.tsx @@ -26,6 +26,7 @@ const content: Record<'en' | 'zh', GuideContent> = { { 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 版本线,未冻结)。", + }, ], }, }; From 95159086caae42c2f68d8f8cb2b24feaa068178d Mon Sep 17 00:00:00 2001 From: DevBot Date: Mon, 27 Jul 2026 14:47:07 +0800 Subject: [PATCH 5/8] chore(release): re-baseline the interface snapshot for the 0.42 line (ADR-0120) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The snapshot gate flagged the alpha.2 additions: element gains the ActionResult type and ACTION_FETCH_HEADER, app gains fail()/isActionFailure/ OpenElementActionFailure — all new request-time surface authorized by ADR-0119 (request-time explicitly unfrozen) and specified by ADR-0120. No frozen 0.41 export changed. Also deno fmt. --- docs/release/v0.41.0-interface-snapshot.json | 8 ++++---- packages/app/src/index.ts | 8 +++++++- packages/element/src/index.ts | 8 +++++++- 3 files changed, 18 insertions(+), 6 deletions(-) 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/packages/app/src/index.ts b/packages/app/src/index.ts index 8d85f3f4b..11de27c09 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -32,7 +32,13 @@ export type { } from './authoring.ts'; // Re-export route data types from protocol for convenience -export type { Action, ActionContext, ActionResult, 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 diff --git a/packages/element/src/index.ts b/packages/element/src/index.ts index 356049839..54ffa4bcb 100644 --- a/packages/element/src/index.ts +++ b/packages/element/src/index.ts @@ -83,7 +83,13 @@ 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, ActionResult, 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, From 17fa4e34f91d9e886df875007a9bcfa5b6b22e5c Mon Sep 17 00:00:00 2001 From: DevBot Date: Mon, 27 Jul 2026 14:54:24 +0800 Subject: [PATCH 6/8] chore(release): v0.42.0-alpha.2 (ADR-0120/v0.42.0) --- README.md | 2 +- README.zh.md | 2 +- docs/current/VERSION_PLAN.md | 4 ++-- docs/governance/PROJECT_WORKFLOW.md | 4 ++-- docs/roadmap/ROADMAP.md | 6 +++--- docs/status/STATUS.md | 6 +++--- packages/adapter-vite/deno.json | 2 +- packages/app/deno.json | 2 +- packages/create/deno.json | 2 +- packages/create/src/version.ts | 2 +- packages/element/deno.json | 2 +- packages/ui/deno.json | 2 +- packages/ui/src/generated-manifest.json | 2 +- tools/project-constants.ts | 8 ++++---- www/app/data/version.ts | 2 +- www/app/routes/roadmap.tsx | 4 ++-- 16 files changed, 26 insertions(+), 26 deletions(-) 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/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/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/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/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/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/project-constants.ts b/tools/project-constants.ts index eb167d6b0..881768913 100644 --- a/tools/project-constants.ts +++ b/tools/project-constants.ts @@ -1,6 +1,6 @@ -export const PACKAGE_VERSION = '0.42.0-alpha.1'; +export const PACKAGE_VERSION = '0.42.0-alpha.2'; export const PACKAGE_VERSION_TAG = `v${PACKAGE_VERSION}`; -export const ACTIVE_EXECUTION_VERSION = 'v0.42.0-alpha.1'; +export const ACTIVE_EXECUTION_VERSION = 'v0.42.0-alpha.2'; export const RETAINED_PACKAGE_NAMES = Object.freeze([ '@openelement/adapter-vite', '@openelement/app', @@ -49,7 +49,7 @@ export const NITRO_COMPATIBILITY_DATE = '2026-06-12'; // single source of truth for the "from" side of version-anchor replacements // (see buildVersionAnchorReplacements in tools/autoflow/release.ts). It is // kept in sync automatically by updateProjectConstants() during a bump. -export const PREVIOUS_PACKAGE_VERSION = '0.41.2'; +export const PREVIOUS_PACKAGE_VERSION = '0.42.0-alpha.1'; export const PREVIOUS_PACKAGE_VERSION_TAG = `v${PREVIOUS_PACKAGE_VERSION}`; // The theme the www roadmap current-line timeline entry carried immediately @@ -60,7 +60,7 @@ export const PREVIOUS_PACKAGE_VERSION_TAG = `v${PREVIOUS_PACKAGE_VERSION}`; // sweep' describing alpha.19 under the v0.41.1 entry). The bump side // re-records this constant from the pre-bump entry; bootstrap value // documents the incident. -export const PREVIOUS_RELEASE_THEME = 'release tooling self-repair'; +export const PREVIOUS_RELEASE_THEME = 'request-time rendering foundation'; /** * Version strings that must never reappear in the head anchor zone of the diff --git a/www/app/data/version.ts b/www/app/data/version.ts index 1065672a7..d243afe39 100644 --- a/www/app/data/version.ts +++ b/www/app/data/version.ts @@ -1,2 +1,2 @@ // Current published package line. Release tooling owns the next bump. -export const OPENELEMENT_VERSION = 'v0.42.0-alpha.1'; +export const OPENELEMENT_VERSION = 'v0.42.0-alpha.2'; diff --git a/www/app/routes/roadmap.tsx b/www/app/routes/roadmap.tsx index 66c494a70..32815c553 100644 --- a/www/app/routes/roadmap.tsx +++ b/www/app/routes/roadmap.tsx @@ -332,7 +332,7 @@ type TimelineEntry = { const entries: TimelineEntry[] = [ { - version: 'v0.42.0-alpha.1', + version: 'v0.42.0-alpha.2', theme: 'request-time rendering foundation', 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).", @@ -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 (
From f76675a57241d2923962679a17d9c455145eae12 Mon Sep 17 00:00:00 2001 From: DevBot Date: Mon, 27 Jul 2026 15:02:52 +0800 Subject: [PATCH 7/8] docs(www): write the 0.42.0-alpha.2 roadmap theme (form/action loop) --- www/app/routes/roadmap.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/www/app/routes/roadmap.tsx b/www/app/routes/roadmap.tsx index 32815c553..118844027 100644 --- a/www/app/routes/roadmap.tsx +++ b/www/app/routes/roadmap.tsx @@ -333,9 +333,9 @@ type TimelineEntry = { const entries: TimelineEntry[] = [ { version: 'v0.42.0-alpha.2', - theme: 'request-time rendering foundation', + 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', }, From 9b1ad78ccb5531940f0d6576adf20e03326374b6 Mon Sep 17 00:00:00 2001 From: DevBot Date: Mon, 27 Jul 2026 15:12:41 +0800 Subject: [PATCH 8/8] test(tools): assert the boundary invariant, not a version-shape-dependent prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stale-prefix assertion kept breaking at every version-shape change (stable→alpha.1, alpha.1→alpha.2): string-prefix relations between stale claims and the current line are incidental. The real invariant is that the stale regex with numeric boundaries never matches the current line. --- tools/check-version-anchors.test.ts | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) 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(`(?