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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,25 @@ Current truth lives in:
Historical changelog details remain available through git history and release
evidence.

## 0.42.0-alpha.3

- Revalidation continuity (ADR-0120): enhanced forms (`data-open-enhance`)
now morph the returned document into place instead of reloading —
submission returns the same HTML the no-JS path renders (303/422), the
client morphs it, and `history.pushState` follows the PRG target.
- Island survival: a hydrated island whose light-DOM surface is unchanged
in the incoming document keeps its shadow state (the DSD template child
is excluded from the comparison). Islands whose surface changed are
replaced; `data-open-preserve` exempts any subtree; the island client
script is never re-executed by a morph.
- Named regions: a `data-open-region` container limits the morph to the
matching region when present.
- The ActionResult JSON path remains for custom clients; the built-in
enhancement uses HTML morphing for continuity.
- Fixture proof: counter island at 3 survives a 422 morph and a PRG
morph; static/request-time mixed navigation — Chromium, Firefox and
WebKit, 36/36.

## 0.42.0-alpha.2

- The form/action loop (ADR-0120): plain HTML forms work without
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.2` (`v0.42.0-alpha.2`) — the stable five-package
Published package line: `0.42.0-alpha.3` (`v0.42.0-alpha.3`) — 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.2`(`v0.42.0-alpha.2`)——ADR-0119 范围化接口冻结下的
已发布包线为 `0.42.0-alpha.3`(`v0.42.0-alpha.3`)——ADR-0119 范围化接口冻结下的
stable 五包版本;已放弃的 beta 命名不再是当前版本线。

## 当前产品
Expand Down
15 changes: 10 additions & 5 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.2`\
> Current npm registry line: `v0.42.0-alpha.2`\
> Current source package line: `v0.42.0-alpha.3`\
> Current npm registry line: `v0.42.0-alpha.3`\
> 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 Expand Up @@ -152,10 +152,15 @@ coexistence.
3. Static/request-time mixed sites: navigation and hydration between the
two route kinds do not interfere; i18n canonical-route gates extended
to request-time routes.
4. www dogfood: one real loop scenario (a site form) ships on the
request-time path.
4. Mixed-mode dogfood: the request-time fixture ships a page combining a
dynamic form route, a static route and a hydrated island. (Amended
2026-07-27 from "www dogfood": www deploys to Cloudflare Pages as a
pure-static site and doubles as the byte-identical regression vehicle;
adding a dynamic route there would destroy both. The i18n gate
extension of step 3 defers to the first site that combines i18n with
request-time routes — none exists yet.)
- 准出: the island-survival matrix passes in three engines; the mixed-mode
site e2e is green; the www dogfood scenario is live and linked in the
site e2e is green; the dogfood fixture is green in CI and linked in the
alpha.3 release note; alpha.3 published.

### TP-5 — `0.42.0-alpha.4` hardening, recipes and starter
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.2`, completed
implementation anchor `v0.42.0-alpha.2`, and `0.42.0` WC Application Loop planning
Current execution anchor: published package line `v0.42.0-alpha.3`, completed
implementation anchor `v0.42.0-alpha.3`, 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
43 changes: 43 additions & 0 deletions docs/release/v0.42.0-alpha.2.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,48 @@
# v0.42.0-alpha.2

## The form/action loop

The second alpha of the 0.42 line (ADR-0120): plain HTML forms work
without JavaScript on `rendering: 'dynamic'` routes — the WC Application
Loop's action half is complete.

- **Protocol**: actions run before loaders (revalidation invariant);
`fail(4xx, data)` returns take the 422 re-render channel with submitted
values echoed; successful mutations answer **303 (PRG)** — never a 200
render — with 302s coerced up; POST to a route without an action is a
defined 404.
- **Named actions** dispatch via `formaction='?/name'`
(`export const actions = { name(ctx) { ... } }`); unknown names are a
defined 404.
- **JavaScript path**: fetch callers (`x-openelement-action` header)
receive the `ActionResult` discriminated union, and the island client
entry enhances `data-open-enhance` forms with the same protocol and no
DOM surgery — unhydrated islands are never touched; 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).

## Verification

- Full form loop with `javaScriptEnabled: false` (422 echo, PRG success)
plus the enhanced fetch path, named and unknown actions, ActionResult
shapes — Chromium, Firefox and WebKit, 33/33.
- Full release-tier gates green; npm `alpha`/`latest` at `0.42.0-alpha.2`;
post-publish consumer and third-party WC smoke.
- Interface snapshot re-baselined for the new unfrozen surface (`fail`,
`isActionFailure`, `ActionResult`, `ACTION_FETCH_HEADER`); no frozen
0.41 export changed.

## Migrating from alpha.1

Pure-static sites: nothing. Request-time routes: forms now follow the
303/422 rules — an action that previously returned data for a 200 render
should `return fail(422, data)` for validation errors and `redirect()`
(or nothing, for the default PRG) on success. The loader now runs after
the action on POST.

---

AutoFlow3 patch release evidence: `publish-existing-v0.42.0-alpha.2-2026-07-27T07-43-59-379Z`.

- Previous package line: `0.42.0-alpha.1`
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.2`.\
> Active execution target: `v0.42.0-alpha.2`.\
> Published package line: `v0.42.0-alpha.3`.\
> Active execution target: `v0.42.0-alpha.3`.\
> 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.2` is the published package line. npm beta.1 through beta.3 are
`0.42.0-alpha.3` 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.2`\
> npm registry line: `v0.42.0-alpha.2`\
> Active release target: `v0.42.0-alpha.2`\
> Repository package line: `v0.42.0-alpha.3`\
> npm registry line: `v0.42.0-alpha.3`\
> Active release target: `v0.42.0-alpha.3`\
> Next release line: `v0.42.0`\
> Product graph: five packages\
> Current maturity stage: stable (0.41.x)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
redirect,
useActionData,
} from '@openelement/app';
import '../islands/live-counter.tsx';

export const tagName = 'page-form';

Expand Down Expand Up @@ -55,6 +56,7 @@ const FormPage = definePage({
</form>
{actionData?.error ? <p id='error'>{actionData.error}</p> : null}
<p id='echo'>echo={echoed ?? ''}</p>
<live-counter></live-counter>
</main>
);
},
Expand Down
41 changes: 41 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 @@ -136,3 +136,44 @@ test.describe('action protocol (ADR-0120, 0.42.0-alpha.2)', () => {
await expect(page.locator('#echo')).toHaveText('echo=enhanced-path');
});
});

test.describe('revalidation continuity (0.42.0-alpha.3)', () => {
test('422 morph keeps a hydrated island alive and shows the failure echo', async ({ page }) => {
await page.goto('/form');
const button = page.locator('live-counter #increment');
const count = page.locator('live-counter #count');
await button.click();
await button.click();
await button.click();
await expect(count).toHaveText('3');

await page.click('#submit');
await expect(page.locator('#error')).toHaveText('message is required');
// The island survived the morph: its shadow state was not reset.
await expect(count).toHaveText('3');
});

test('PRG morph preserves island state and updates the URL', async ({ page }) => {
await page.goto('/form');
const button = page.locator('live-counter #increment');
const count = page.locator('live-counter #count');
await button.click();
await button.click();
await expect(count).toHaveText('2');

await page.fill('#message', 'morph-keeps-islands');
await page.click('#submit');
await page.waitForURL('**/form?echoed=morph-keeps-islands');
await expect(page.locator('#echo')).toHaveText('echo=morph-keeps-islands');
await expect(count).toHaveText('2');
});

test('mixed static/request-time navigation keeps both modes working', async ({ page }) => {
await page.goto('/');
await expect(page.locator('#home-marker')).toHaveText('request-time fixture home');
await page.goto('/live?x=mixed');
await expect(page.locator('#x-value')).toHaveText('x=mixed');
await page.goto('/');
await expect(page.locator('#home-marker')).toHaveText('request-time fixture home');
});
});
11 changes: 7 additions & 4 deletions packages/adapter-vite/__tests__/entry-generators.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,8 +245,11 @@ Deno.test('client entry includes the ADR-0120 form enhancement layer', () => {
]);
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'));
assert(code.includes("'x-openelement-enhance': 'true'"));
// Morph continuity: preserve escape hatch, intact-island survival and the
// no-re-execute rule for the client entry script.
assert(code.includes('data-open-preserve'));
assert(code.includes('__islandIntact'));
assert(code.includes("oldEl.tagName === 'SCRIPT'"));
assert(code.includes('history.pushState'));
});
2 changes: 1 addition & 1 deletion packages/adapter-vite/deno.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@openelement/adapter-vite",
"version": "0.42.0-alpha.2",
"version": "0.42.0-alpha.3",
"exports": {
".": "./src/index.ts",
"./nitro-mount": "./src/nitro-mount.ts",
Expand Down
129 changes: 103 additions & 26 deletions packages/adapter-vite/src/internal/ssg/entry-generators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,11 +239,103 @@ __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
// Form enhancement (ADR-0120, 0.42.0-alpha.2/alpha.3): forms marked
// data-open-enhance submit via fetch and the returned document is morphed
// into place — no full reload, and untouched subtrees (including hydrated
// islands whose light DOM did not change) keep their state. 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.
// degrades to the browser by construction.
function __syncAttrs(oldEl, newEl) {
for (var i = oldEl.attributes.length - 1; i >= 0; i--) {
var name = oldEl.attributes[i].name;
if (!newEl.hasAttribute(name)) oldEl.removeAttribute(name);
}
for (var j = 0; j < newEl.attributes.length; j++) {
var attr = newEl.attributes[j];
if (oldEl.getAttribute(attr.name) !== attr.value) {
oldEl.setAttribute(attr.name, attr.value);
}
}
}

function __islandIntact(oldEl, newEl) {
// A hydrated island (live shadow root) survives when its light-DOM
// surface serializes identically in the incoming document. The incoming
// <template shadowrootmode> child is skipped in the comparison: the
// browser already consumed it into the live shadow root. Replacing an
// intact island would reset its state.
if (!oldEl.shadowRoot) return false;
if (oldEl.attributes.length !== newEl.attributes.length) return false;
for (var i = 0; i < newEl.attributes.length; i++) {
var attr = newEl.attributes[i];
if (oldEl.getAttribute(attr.name) !== attr.value) return false;
}
var newKids = [];
for (var k = 0; k < newEl.childNodes.length; k++) {
var n = newEl.childNodes[k];
if (n.nodeType === 1 && n.tagName === 'TEMPLATE' && n.hasAttribute('shadowrootmode')) continue;
newKids.push(n);
}
if (oldEl.childNodes.length !== newKids.length) return false;
for (var m = 0; m < newKids.length; m++) {
var o = oldEl.childNodes[m];
var nn = newKids[m];
if (o.nodeType !== nn.nodeType) return false;
if (o.nodeType === 3 && o.data !== nn.data) return false;
if (o.nodeType === 1 && o.outerHTML !== nn.outerHTML) return false;
}
return true;
}

function __morphNode(oldEl, newEl) {
if (oldEl.nodeType !== newEl.nodeType || oldEl.tagName !== newEl.tagName) {
oldEl.replaceWith(newEl);
return;
}
if (oldEl.nodeType === 3) {
if (oldEl.data !== newEl.data) oldEl.data = newEl.data;
return;
}
if (oldEl.nodeType !== 1) return;
if (oldEl.tagName === 'SCRIPT') {
// Keep the live script node: replacing it would re-execute the island
// client entry and double every listener.
return;
}
if (oldEl.hasAttribute('data-open-preserve')) return;
if (__islandIntact(oldEl, newEl)) return;
if (oldEl.shadowRoot) {
// Hydrated island whose surface changed: replace (state resets by design).
oldEl.replaceWith(newEl);
return;
}
__syncAttrs(oldEl, newEl);
var oldChildren = Array.from(oldEl.childNodes);
var newChildren = Array.from(newEl.childNodes);
var shared = Math.max(oldChildren.length, newChildren.length);
for (var i = 0; i < shared; i++) {
var o = oldChildren[i];
var n = newChildren[i];
if (o && n) __morphNode(o, n);
else if (n) oldEl.appendChild(n);
else if (o) o.remove();
}
}

function __morphDocument(html) {
var doc = new DOMParser().parseFromString(html, 'text/html');
document.title = doc.title;
var region = document.querySelector('[data-open-region]');
if (region) {
var next = doc.querySelector('[data-open-region="' + region.getAttribute('data-open-region') + '"]');
if (next) {
__morphNode(region, next);
return;
}
}
__morphNode(document.body, doc.body);
}

document.addEventListener('submit', function (event) {
var form = event.target;
if (!(form instanceof HTMLFormElement)) return;
Expand All @@ -256,31 +348,16 @@ document.addEventListener('submit', function (event) {
fetch(actionUrl, {
method: method,
body: new FormData(form),
headers: { 'x-openelement-action': 'true' },
headers: { 'x-openelement-enhance': 'true' },
}).then(function (response) {
return response.json();
return response.text().then(function (html) {
return { html: html, url: response.url };
});
}).then(function (result) {
if (result && result.type === 'redirect' && result.location) {
window.location.assign(result.location);
return;
__morphDocument(result.html);
if (result.url && result.url !== window.location.href) {
history.pushState({}, '', result.url);
}
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();
});
Expand Down
Loading
Loading