Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 命名不再是当前版本线。

## 当前产品
Expand Down
4 changes: 2 additions & 2 deletions docs/current/VERSION_PLAN.md
Original file line number Diff line number Diff line change
@@ -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)\
Expand Down
4 changes: 2 additions & 2 deletions docs/governance/PROJECT_WORKFLOW.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions docs/release/v0.41.0-interface-snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
},
"declarations": {
".": {
"sha256": "57ecd6d850bbb9ed7f139e52f56dfbda3a07613a3570d149ffd3697473ddd273",
"sha256": "90188ae78f73f1e279897853ea77fca20ccffbddfe630937997a8d5537ffa3e6",
"publicDeclarations": [
"export type { ElementDefinition } from './types.ts';",
"export type {",
Expand All @@ -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 {"
Expand Down Expand Up @@ -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';",
Expand Down
44 changes: 44 additions & 0 deletions docs/release/v0.42.0-alpha.1.md
Original file line number Diff line number Diff line change
@@ -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`
Expand Down
6 changes: 3 additions & 3 deletions docs/roadmap/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).\
Expand Down Expand Up @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions docs/status/STATUS.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
50 changes: 38 additions & 12 deletions packages/adapter-vite/__fixtures__/request-time/app/routes/form.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown> }): FormActionData {
return { echoed: String(ctx.formData?.message ?? '') };
export function action(ctx: { formData: FormData }): OpenElementActionFailure<FormActionData> {
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 (
<main>
<h1>request-time form</h1>
<form method='post'>
<input id='message' name='message' type='text' />
<form method='post' data-open-enhance>
<input
id='message'
name='message'
type='text'
value={actionData?.message ?? ''}
/>
<button id='submit' type='submit'>Send</button>
<button id='shout' type='submit' formaction='?/shout'>Shout</button>
</form>
<p id='echo'>echo={actionData?.echoed ?? ''}</p>
{actionData?.error ? <p id='error'>{actionData.error}</p> : null}
<p id='echo'>echo={echoed ?? ''}</p>
</main>
);
},
Expand Down
77 changes: 77 additions & 0 deletions packages/adapter-vite/__fixtures__/request-time/e2e/live.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
12 changes: 12 additions & 0 deletions packages/adapter-vite/__tests__/entry-generators.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
});
Loading
Loading