From bc44e050776954912d8e5cec8908ed86fc515e90 Mon Sep 17 00:00:00 2001 From: DevBot Date: Mon, 27 Jul 2026 16:46:35 +0800 Subject: [PATCH 1/4] docs(release): curate the 0.42.0-alpha.3 note --- docs/release/v0.42.0-alpha.3.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/docs/release/v0.42.0-alpha.3.md b/docs/release/v0.42.0-alpha.3.md index 826c02ff9..a04785139 100644 --- a/docs/release/v0.42.0-alpha.3.md +++ b/docs/release/v0.42.0-alpha.3.md @@ -1,5 +1,38 @@ # v0.42.0-alpha.3 +## Revalidation continuity + +The third alpha of the 0.42 line (ADR-0120): the enhanced submit no +longer reloads the world. `data-open-enhance` forms fetch the same HTML +the no-JavaScript path renders (303/422), morph it into place, and follow +the PRG target with `history.pushState`. + +- **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. + +## Verification + +- Survival matrix: a counter island at 3 survives both a 422 validation + morph and a PRG success morph; static/request-time mixed navigation — + Chromium, Firefox and WebKit, 36/36. +- Full release-tier gates green; npm `alpha`/`latest` at `0.42.0-alpha.3`; + post-publish consumer and third-party WC smoke. + +## Migrating from alpha.2 + +Nothing required. Forms already marked `data-open-enhance` gain morphing +automatically; if you relied on the JSON `ActionResult` responses, they +are unchanged for `x-openelement-action` callers. + +--- + AutoFlow3 patch release evidence: `publish-existing-v0.42.0-alpha.3-2026-07-27T08-43-31-362Z`. - Previous package line: `0.42.0-alpha.2` From 4b593e33ab207d364b9f56d1ef666cb44113a97b Mon Sep 17 00:00:00 2001 From: DevBot Date: Mon, 27 Jul 2026 17:13:33 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat(create,docs,tools):=200.42.0-alpha.4?= =?UTF-8?q?=20hardening=20=E2=80=94=20recipes,=20starter=20loop,=20perf=20?= =?UTF-8?q?baseline=20(TP-5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Validation recipes verified in CI: zod (/register) and valibot (/subscribe) fixture routes with 422/303 asserted in three engines; docs/integrations/validation.md. better-auth and Drizzle published as doc-level recipes, honestly marked unverified. - create starter gains /contact, a request-time route exercising the loop; consumer smoke (local build + Nitro node) passes. - PACKAGE_SURFACE records the 0.42 additions as unfrozen with targets. - tools/perf-request-time.ts records the 0.44 latency baseline: cold 28.6ms, warm p50 1.58ms / p95 2.97ms. - fix(morph): whitespace-only text nodes no longer break island intactness (alpha.3 latent island reset), survival matrix 42/42. --- CHANGELOG.md | 22 ++++ deno.json | 19 +++- deno.lock | 20 +++- docs/current/PACKAGE_SURFACE.md | 12 ++ docs/integrations/better-auth.md | 30 +++++ docs/integrations/drizzle.md | 35 ++++++ docs/integrations/validation.md | 39 +++++++ docs/release/v0.42.0-alpha.4-performance.json | 15 +++ .../request-time/app/routes/register.tsx | 57 ++++++++++ .../request-time/app/routes/subscribe.tsx | 58 ++++++++++ .../request-time/e2e/live.spec.ts | 28 +++++ .../src/internal/ssg/entry-generators.ts | 13 ++- .../create/templates/app/routes/contact.tsx | 57 ++++++++++ tools/perf-request-time.ts | 103 ++++++++++++++++++ 14 files changed, 500 insertions(+), 8 deletions(-) create mode 100644 docs/integrations/better-auth.md create mode 100644 docs/integrations/drizzle.md create mode 100644 docs/integrations/validation.md create mode 100644 docs/release/v0.42.0-alpha.4-performance.json create mode 100644 packages/adapter-vite/__fixtures__/request-time/app/routes/register.tsx create mode 100644 packages/adapter-vite/__fixtures__/request-time/app/routes/subscribe.tsx create mode 100644 packages/create/templates/app/routes/contact.tsx create mode 100644 tools/perf-request-time.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7adb6bc8e..0213edcd3 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.4 + +- Hardening and recipes (TP-5, ADR-0120): the 0.42 line closes with + integration proof instead of new semantics. +- Validation recipes verified in CI: zod (`/register`) and valibot + (`/subscribe`) run inside fixture actions with 422/303 asserted in three + engines — `docs/integrations/validation.md`. better-auth and Drizzle + recipes are published as doc-level (honestly marked unverified): + `docs/integrations/better-auth.md`, `docs/integrations/drizzle.md`. +- The `create` starter gains a request-time `/contact` route exercising + the full loop (`rendering: 'dynamic'` + action + `data-open-enhance`), + so starter consumers get the loop out of the box. +- `PACKAGE_SURFACE.md` records the 0.42 line additions as unfrozen with + their freeze target. +- Performance baseline for 0.44: request-time render on the fixture is + cold 28.6ms, warm p50 1.58ms / p95 2.97ms + (`docs/release/v0.42.0-alpha.4-performance.json`). +- Fix (from alpha.3): the morph could replace an island whose light DOM + carried whitespace-only text around the DSD template, resetting its + state; the comparison now ignores whitespace-only text nodes, covered + by the survival matrix in three engines (42/42). + ## 0.42.0-alpha.3 - Revalidation continuity (ADR-0120): enhanced forms (`data-open-enhance`) diff --git a/deno.json b/deno.json index d756d72d4..96b67458e 100644 --- a/deno.json +++ b/deno.json @@ -29,7 +29,9 @@ "@shoelace-style/shoelace": "npm:@shoelace-style/shoelace@^2.19.0", "@material/web/checkbox/checkbox.js": "npm:@material/web@^2.0.0/checkbox/checkbox.js", "nitro": "npm:nitro@3.0.0", - "@rollup/plugin-terser": "npm:@rollup/plugin-terser@1.0.0" + "@rollup/plugin-terser": "npm:@rollup/plugin-terser@1.0.0", + "zod": "npm:zod@^3.25", + "valibot": "npm:valibot@^1.1" }, "vendor": true, "nodeModulesDir": "manual", @@ -131,8 +133,12 @@ }, "lint": { "rules": { - "tags": ["recommended"], - "exclude": ["no-sloppy-imports"] + "tags": [ + "recommended" + ], + "exclude": [ + "no-sloppy-imports" + ] }, "exclude": [ "www/content/blog/", @@ -145,7 +151,12 @@ "compilerOptions": { "module": "ESNext", "moduleResolution": "bundler", - "lib": ["ES2022", "DOM", "DOM.Iterable", "deno.ns"], + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable", + "deno.ns" + ], "strict": true, "skipLibCheck": true, "jsx": "react-jsx", diff --git a/deno.lock b/deno.lock index 05a84d47b..e1ac42701 100644 --- a/deno.lock +++ b/deno.lock @@ -33,8 +33,10 @@ "npm:preact@^10.28.0": "10.29.2", "npm:sanitize-html@^2.17.4": "2.17.5", "npm:typescript@^5.9.0": "5.9.3", + "npm:valibot@^1.1.0": "1.4.2_typescript@5.9.3", "npm:vite@8.0.16": "8.0.16_yaml@2.9.0", - "npm:yaml@^2.8.1": "2.9.0" + "npm:yaml@^2.8.1": "2.9.0", + "npm:zod@^3.25.0": "3.25.76" }, "jsr": { "@std/assert@1.0.19": { @@ -2378,6 +2380,15 @@ "util-deprecate@1.0.2": { "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, + "valibot@1.4.2_typescript@5.9.3": { + "integrity": "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==", + "dependencies": [ + "typescript" + ], + "optionalPeers": [ + "typescript" + ] + }, "vfile-message@4.0.3": { "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", "dependencies": [ @@ -2441,6 +2452,9 @@ "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", "bin": true }, + "zod@3.25.76": { + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==" + }, "zwitch@2.0.4": { "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==" } @@ -2464,8 +2478,10 @@ "npm:preact-render-to-string@^6.5.0", "npm:preact@^10.28.0", "npm:typescript@^5.9.0", + "npm:valibot@^1.1.0", "npm:vite@8.0.16", - "npm:yaml@^2.8.1" + "npm:yaml@^2.8.1", + "npm:zod@^3.25.0" ], "members": { "examples/deno-desktop-mastodon": { diff --git a/docs/current/PACKAGE_SURFACE.md b/docs/current/PACKAGE_SURFACE.md index 6ca398de0..6eb76189a 100644 --- a/docs/current/PACKAGE_SURFACE.md +++ b/docs/current/PACKAGE_SURFACE.md @@ -114,6 +114,18 @@ semantics (0.42 WC Application Loop and 0.44 Production Runtime scope), the internal in the map above. Post-freeze changes to the frozen surface require a major-version ADR. +### 0.42 line additions (unfrozen until the 0.42.0 stable decision) + +- `definePage({ renderIntent: { mode } })`: `'auto'`/`'static'`/`'dynamic'` + rendering modes; `'dynamic'` routes render per request through the + generated `dist/server/index.js` (0.42.0-alpha.1). +- Route-module `loader`/`action`/`actions` exports with the ADR-0120 + protocol: `fail(status, data)` 422 re-render, 303 PRG, named actions via + `formaction='?/name'` (0.42.0-alpha.2). +- `ActionResult` / `ACTION_FETCH_HEADER` wire types and the + `data-open-enhance` / `data-open-preserve` / `data-open-region` + enhancement attributes with morph-based continuity (0.42.0-alpha.3). + - `@openelement/ui` supported subpaths: `open-badge`, `open-button`, `open-callout`, `open-card`, `open-code-block`, `open-dialog`, `open-dropdown`, `open-input`, `open-props-tokens`, `open-tabs`, diff --git a/docs/integrations/better-auth.md b/docs/integrations/better-auth.md new file mode 100644 index 000000000..0b6b13aa2 --- /dev/null +++ b/docs/integrations/better-auth.md @@ -0,0 +1,30 @@ +# Auth recipe (better-auth) + +> Status: **doc-level, not CI-verified** — integration shape, not a tested +> artifact. It will move to verified status when a consumer reproduces it +> (0.42.x recipe follow-up). + +openElement does not ship auth. The loop gives better-auth three honest +attachment points, all Web-standard: + +1. **Session read in loaders**: a dynamic route's `loader({ request })` + gets the standard `Request`; pass its headers to better-auth's + `auth.api.getSession({ headers: request.headers })` and return the + session as loader data. Render signed-in vs signed-out in DSD. +2. **Auth endpoints**: mount better-auth's handler as an API route (or a + Nitro route) on the same deployment — it owns its URLs; the framework + does not proxy them. +3. **Actions**: in an action, use the session to authorize before + mutating; on failure `redirect('/login')` (303 by protocol) or + `fail(403, { error })` when the form should re-render with an error. + +```ts +export async function loader({ request }: LoaderContext) { + const session = await auth.api.getSession({ headers: request.headers }); + if (!session) redirect('/login'); + return { user: session.user }; +} +``` + +Sessions remain a 0.44 framework topic; until then the cookie/session +wiring is entirely better-auth's, which is the point of the recipe. diff --git a/docs/integrations/drizzle.md b/docs/integrations/drizzle.md new file mode 100644 index 000000000..6d24637a7 --- /dev/null +++ b/docs/integrations/drizzle.md @@ -0,0 +1,35 @@ +# Data recipe (Drizzle) + +> Status: **doc-level, not CI-verified** — integration shape, not a tested +> artifact. It will move to verified status when a consumer reproduces it +> (0.42.x recipe follow-up). + +openElement does not ship an ORM or a data layer. Drizzle (or any query +builder) runs inside loaders and actions, which are ordinary server +functions on a `rendering: 'dynamic'` route: + +1. **Reads**: run queries in `loader`; return plain serializable data. The + page renders it in DSD at request time; pure-static routes keep using + build-time data instead. +2. **Writes**: run mutations in `action` after validation (see the + validation recipe). On conflict or constraint failure, + `return fail(422, { error })` to re-render with the echo; on success, + `redirect()` to the changed resource (303 PRG, revalidation re-runs the + loader for you). +3. **Connection**: create the client once at module scope of a small + `db.ts` and import it from routes. Connection secrets belong to the + deployment environment (`ctx.env`), never to the client bundle — + loaders/actions never ship to the browser. + +```ts +// app/db.ts +export const db = drizzle(process.env.DATABASE_URL!); + +// app/routes/posts.tsx +export async function loader() { + return { posts: await db.select().from(postsTable).limit(20) }; +} +``` + +Transactions, pooling and migrations stay with Drizzle; the framework +owns only the route-to-interaction loop around them. diff --git a/docs/integrations/validation.md b/docs/integrations/validation.md new file mode 100644 index 000000000..cf54084bb --- /dev/null +++ b/docs/integrations/validation.md @@ -0,0 +1,39 @@ +# Validation recipe (zod / valibot) + +> Status: **verified in CI** — both shapes are exercised end-to-end by the +> request-time fixture (`packages/adapter-vite/__fixtures__/request-time/`: +> `/register` with zod, `/subscribe` with valibot, 422/303 asserted in three +> engines). + +The framework is deliberately validation-agnostic: an action receives the +standard `FormData`, and any schema library runs inside it. The contract is +the ADR-0120 protocol, not the library: + +1. Parse `ctx.formData` with your schema. +2. On failure `return fail(422, { error, ...submittedValues })` — the page + re-renders with the message and the echo (no JavaScript required). +3. On success `redirect(...)` (or return data for the default 303 PRG). +4. Because a failed action may be re-run, validate first and mutate only + after validation passes. + +```ts +import { definePage, fail, redirect, useActionData } from '@openelement/app'; +import { z } from 'zod'; + +const schema = z.object({ email: z.string().email('a valid email is required') }); + +export function action(ctx: { formData: FormData }) { + const parsed = schema.safeParse({ email: String(ctx.formData.get('email') ?? '') }); + if (!parsed.success) { + return fail(422, { + error: parsed.error.issues[0]?.message ?? 'invalid input', + email: String(ctx.formData.get('email') ?? ''), + }); + } + redirect(`/register?welcome=${encodeURIComponent(parsed.data.email)}`); +} +``` + +valibot is interchangeable (`v.safeParse(schema, input)`); see the fixture +for both. The page reads the failure via `useActionData()`; mark the form +`data-open-enhance` to get the morph-based enhanced path for free. diff --git a/docs/release/v0.42.0-alpha.4-performance.json b/docs/release/v0.42.0-alpha.4-performance.json new file mode 100644 index 000000000..6be784ef4 --- /dev/null +++ b/docs/release/v0.42.0-alpha.4-performance.json @@ -0,0 +1,15 @@ +{ + "version": "0.42.0-alpha.4", + "date": "2026-07-27T09:09:32.598Z", + "route": "/live (request-time, loader + DSD render)", + "cold": { + "ms": 28.64 + }, + "warm": { + "samples": 50, + "p50": 1.58, + "p95": 2.97, + "mean": 1.62 + }, + "note": "Buffered per-request DSD render via the generated dist/server entry on Deno.serve; the 0.44 stream/abort/timeout work compares against this." +} diff --git a/packages/adapter-vite/__fixtures__/request-time/app/routes/register.tsx b/packages/adapter-vite/__fixtures__/request-time/app/routes/register.tsx new file mode 100644 index 000000000..1c24abaf2 --- /dev/null +++ b/packages/adapter-vite/__fixtures__/request-time/app/routes/register.tsx @@ -0,0 +1,57 @@ +/** + * /register — zod validation recipe (0.42.0-alpha.4): the action validates + * with zod inside the route action; framework stays validation-agnostic. + */ +import { + definePage, + fail, + type OpenElementActionFailure, + redirect, + useActionData, +} from '@openelement/app'; +import { z } from 'zod'; + +export const tagName = 'page-register'; + +const registerSchema = z.object({ + email: z.string().email('a valid email is required'), +}); + +interface RegisterActionData { + error?: string; + email?: string; +} + +export function action(ctx: { formData: FormData }): OpenElementActionFailure { + const parsed = registerSchema.safeParse({ email: String(ctx.formData.get('email') ?? '') }); + if (!parsed.success) { + return fail(422, { + error: parsed.error.issues[0]?.message ?? 'invalid input', + email: String(ctx.formData.get('email') ?? ''), + }); + } + redirect(`/register?welcome=${encodeURIComponent(parsed.data.email)}`); +} + +const RegisterPage = definePage({ + renderIntent: { mode: 'dynamic' }, + head: { title: 'request-time fixture — register (zod)' }, + render({ request }) { + const actionData = useActionData() as RegisterActionData | undefined; + const welcome = request ? new URL(request.url).searchParams.get('welcome') : undefined; + return ( +
+

register with zod

+
+ + +
+ {actionData?.error ?

{actionData.error}

: null} +

welcome={welcome ?? ''}

+
+ ); + }, +}); + +customElements.define(tagName, RegisterPage); +export default RegisterPage; diff --git a/packages/adapter-vite/__fixtures__/request-time/app/routes/subscribe.tsx b/packages/adapter-vite/__fixtures__/request-time/app/routes/subscribe.tsx new file mode 100644 index 000000000..6267a37fd --- /dev/null +++ b/packages/adapter-vite/__fixtures__/request-time/app/routes/subscribe.tsx @@ -0,0 +1,58 @@ +/** + * /subscribe — valibot validation recipe (0.42.0-alpha.4): same contract as + * the zod recipe, different library, to prove the loop is library-agnostic. + */ +import { + definePage, + fail, + type OpenElementActionFailure, + redirect, + useActionData, +} from '@openelement/app'; +import * as v from 'valibot'; + +export const tagName = 'page-subscribe'; + +const subscribeSchema = v.object({ + email: v.pipe(v.string(), v.email('a valid email is required')), +}); + +interface SubscribeActionData { + error?: string; + email?: string; +} + +export function action(ctx: { formData: FormData }): OpenElementActionFailure { + const email = String(ctx.formData.get('email') ?? ''); + const parsed = v.safeParse(subscribeSchema, { email }); + if (!parsed.success) { + return fail(422, { + error: parsed.issues[0]?.message ?? 'invalid input', + email, + }); + } + redirect(`/subscribe?welcome=${encodeURIComponent(parsed.output.email)}`); +} + +const SubscribePage = definePage({ + renderIntent: { mode: 'dynamic' }, + head: { title: 'request-time fixture — subscribe (valibot)' }, + render({ request }) { + const actionData = useActionData() as SubscribeActionData | undefined; + const welcome = request ? new URL(request.url).searchParams.get('welcome') : undefined; + return ( +
+

subscribe with valibot

+
+ + +
+ {actionData?.error ?

{actionData.error}

: null} +

welcome={welcome ?? ''}

+
+ ); + }, +}); + +customElements.define(tagName, SubscribePage); +export default SubscribePage; 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 0344ed149..1163f3ea3 100644 --- a/packages/adapter-vite/__fixtures__/request-time/e2e/live.spec.ts +++ b/packages/adapter-vite/__fixtures__/request-time/e2e/live.spec.ts @@ -177,3 +177,31 @@ test.describe('revalidation continuity (0.42.0-alpha.3)', () => { await expect(page.locator('#home-marker')).toHaveText('request-time fixture home'); }); }); + +test.describe('validation recipes (0.42.0-alpha.4)', () => { + test('zod recipe: 422 with the library message, then PRG success', async ({ request }) => { + const failure = await request.post('/register', { form: { email: 'not-an-email' } }); + expect(failure.status()).toBe(422); + expect(await failure.text()).toContain('a valid email is required'); + + const success = await request.post('/register', { + form: { email: 'ada@example.com' }, + maxRedirects: 0, + }); + expect(success.status()).toBe(303); + expect(success.headers()['location']).toBe('/register?welcome=ada%40example.com'); + }); + + test('valibot recipe: 422 with the library message, then PRG success', async ({ request }) => { + const failure = await request.post('/subscribe', { form: { email: 'nope' } }); + expect(failure.status()).toBe(422); + expect(await failure.text()).toContain('a valid email is required'); + + const success = await request.post('/subscribe', { + form: { email: 'grace@example.com' }, + maxRedirects: 0, + }); + expect(success.status()).toBe(303); + expect(success.headers()['location']).toBe('/subscribe?welcome=grace%40example.com'); + }); +}); diff --git a/packages/adapter-vite/src/internal/ssg/entry-generators.ts b/packages/adapter-vite/src/internal/ssg/entry-generators.ts index 76976de60..ad194e76e 100644 --- a/packages/adapter-vite/src/internal/ssg/entry-generators.ts +++ b/packages/adapter-vite/src/internal/ssg/entry-generators.ts @@ -274,11 +274,20 @@ function __islandIntact(oldEl, newEl) { 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; + // Whitespace-only text nodes carry no meaning: hydration normalizes the + // live tree (merged text), the fresh parse keeps them split. + if (n.nodeType === 3 && n.data.trim() === '') continue; newKids.push(n); } - if (oldEl.childNodes.length !== newKids.length) return false; + var oldKids = []; + for (var k0 = 0; k0 < oldEl.childNodes.length; k0++) { + var o0 = oldEl.childNodes[k0]; + if (o0.nodeType === 3 && o0.data.trim() === '') continue; + oldKids.push(o0); + } + if (oldKids.length !== newKids.length) return false; for (var m = 0; m < newKids.length; m++) { - var o = oldEl.childNodes[m]; + var o = oldKids[m]; var nn = newKids[m]; if (o.nodeType !== nn.nodeType) return false; if (o.nodeType === 3 && o.data !== nn.data) return false; diff --git a/packages/create/templates/app/routes/contact.tsx b/packages/create/templates/app/routes/contact.tsx new file mode 100644 index 000000000..cb317f2a1 --- /dev/null +++ b/packages/create/templates/app/routes/contact.tsx @@ -0,0 +1,57 @@ +/** @jsxImportSource @openelement/element */ +import { + definePage, + fail, + type OpenElementActionFailure, + redirect, + useActionData, +} from '@openelement/app'; + +export const tagName = 'contact-page'; + +interface ContactActionData { + error?: string; + email?: string; +} + +/** + * A request-time route exercising the 0.42 WC Application Loop: plain HTML + * form works without JavaScript (422 echo / 303 PRG), data-open-enhance + * morphs the page when JS is present. + */ +export function action(ctx: { formData: FormData }): OpenElementActionFailure { + const email = String(ctx.formData.get('email') ?? '').trim(); + if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) { + return fail(422, { error: 'a valid email is required', email }); + } + redirect(`/contact?subscribed=${encodeURIComponent(email)}`); +} + +const ContactPage = definePage({ + renderIntent: { mode: 'dynamic' }, + head: { title: 'Contact — openElement' }, + render({ request }) { + const actionData = useActionData() as ContactActionData | undefined; + const subscribed = request ? new URL(request.url).searchParams.get('subscribed') : undefined; + return ( +
+

Stay in the loop

+
+ + +
+ {actionData?.error ?

{actionData.error}

: null} + {subscribed ?

subscribed={subscribed}

: null} +
+ ); + }, +}); + +customElements.define(tagName, ContactPage); +export default ContactPage; diff --git a/tools/perf-request-time.ts b/tools/perf-request-time.ts new file mode 100644 index 000000000..868e94d2e --- /dev/null +++ b/tools/perf-request-time.ts @@ -0,0 +1,103 @@ +/** + * Request-time render latency baseline (0.42.0-alpha.4, TP-5). + * + * Boots the request-time fixture server and measures /live latency: the + * first request after boot (cold) and a warm series. Writes the result to + * docs/release/v0.42.0-alpha.4-performance.json as the 0.44 stream/abort/ + * timeout baseline. + * + * Usage: deno run -A tools/perf-request-time.ts + */ + +import { join } from 'node:path'; + +const fixture = 'packages/adapter-vite/__fixtures__/request-time'; +const port = 4387; + +async function build(): Promise { + const out = await new Deno.Command('deno', { + args: ['task', 'fixture:request-time:build'], + stdout: 'piped', + stderr: 'piped', + }).output(); + if (out.code !== 0) { + console.error(new TextDecoder().decode(out.stderr)); + Deno.exit(out.code); + } +} + +await build(); + +const server = new Deno.Command('deno', { + args: [ + 'run', + '-A', + join(fixture, 'e2e/server.ts'), + '--port', + String(port), + '--dir', + join(fixture, 'dist'), + ], + stdout: 'piped', + stderr: 'piped', +}).spawn(); + +async function waitForServer(): Promise { + for (let i = 0; i < 50; i++) { + try { + const res = await fetch(`http://127.0.0.1:${port}/`); + if (res.ok) return; + } catch { /* not up yet */ } + await new Promise((r) => setTimeout(r, 100)); + } + throw new Error('fixture server did not start'); +} + +function stats(samples: number[]): { p50: number; p95: number; mean: number } { + const sorted = [...samples].sort((a, b) => a - b); + const pick = (q: number) => sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))]; + return { + p50: pick(0.5), + p95: pick(0.95), + mean: samples.reduce((a, b) => a + b, 0) / samples.length, + }; +} + +try { + await waitForServer(); + + const coldStart = performance.now(); + const coldRes = await fetch(`http://127.0.0.1:${port}/live?x=perf`); + await coldRes.text(); + const coldMs = performance.now() - coldStart; + + const warm: number[] = []; + for (let i = 0; i < 50; i++) { + const start = performance.now(); + const res = await fetch(`http://127.0.0.1:${port}/live?x=perf-${i}`); + await res.text(); + warm.push(performance.now() - start); + } + + const round = (n: number) => Math.round(n * 100) / 100; + const result = { + version: '0.42.0-alpha.4', + date: new Date().toISOString(), + route: '/live (request-time, loader + DSD render)', + cold: { ms: round(coldMs) }, + warm: { + samples: warm.length, + p50: round(stats(warm).p50), + p95: round(stats(warm).p95), + mean: round(stats(warm).mean), + }, + note: + 'Buffered per-request DSD render via the generated dist/server entry on Deno.serve; the 0.44 stream/abort/timeout work compares against this.', + }; + const path = 'docs/release/v0.42.0-alpha.4-performance.json'; + await Deno.writeTextFile(path, JSON.stringify(result, null, 2) + '\n'); + console.log(JSON.stringify(result, null, 2)); + console.log(`written -> ${path}`); +} finally { + server.kill('SIGTERM'); +} From 2895abfbbcc50ba4bd55036f95742b363196f7a3 Mon Sep 17 00:00:00 2001 From: DevBot Date: Mon, 27 Jul 2026 17:24:59 +0800 Subject: [PATCH 3/4] chore(release): v0.42.0-alpha.4 (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 323c371f5..dcd1d92d4 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.3` (`v0.42.0-alpha.3`) — the stable five-package +Published package line: `0.42.0-alpha.4` (`v0.42.0-alpha.4`) — 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 38cc3267a..7f4e9636e 100644 --- a/README.zh.md +++ b/README.zh.md @@ -6,7 +6,7 @@ Elements 是可长期保存的应用组件模型;JSX 与 Basic Element 是作者层; Declarative Shadow DOM 是默认服务端表示;交互区域按需升级。 -已发布包线为 `0.42.0-alpha.3`(`v0.42.0-alpha.3`)——ADR-0119 范围化接口冻结下的 +已发布包线为 `0.42.0-alpha.4`(`v0.42.0-alpha.4`)——ADR-0119 范围化接口冻结下的 stable 五包版本;已放弃的 beta 命名不再是当前版本线。 ## 当前产品 diff --git a/docs/current/VERSION_PLAN.md b/docs/current/VERSION_PLAN.md index eaeab4e6a..6ee0c4b60 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.3`\ -> Current npm registry line: `v0.42.0-alpha.3`\ +> Current source package line: `v0.42.0-alpha.4`\ +> Current npm registry line: `v0.42.0-alpha.4`\ > 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 b12f635c3..d306ca16d 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.3`, completed -implementation anchor `v0.42.0-alpha.3`, and `0.42.0` WC Application Loop planning +Current execution anchor: published package line `v0.42.0-alpha.4`, completed +implementation anchor `v0.42.0-alpha.4`, 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 3d6841dfe..40cf94972 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.3`.\ -> Active execution target: `v0.42.0-alpha.3`.\ +> Published package line: `v0.42.0-alpha.4`.\ +> Active execution target: `v0.42.0-alpha.4`.\ > 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.3` is the published package line. npm beta.1 through beta.3 are +`0.42.0-alpha.4` 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 073774557..950fe80b5 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.3`\ -> npm registry line: `v0.42.0-alpha.3`\ -> Active release target: `v0.42.0-alpha.3`\ +> Repository package line: `v0.42.0-alpha.4`\ +> npm registry line: `v0.42.0-alpha.4`\ +> Active release target: `v0.42.0-alpha.4`\ > 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 c81b41829..025441ef9 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.3", + "version": "0.42.0-alpha.4", "exports": { ".": "./src/index.ts", "./nitro-mount": "./src/nitro-mount.ts", diff --git a/packages/app/deno.json b/packages/app/deno.json index 8ae5702a6..c50777723 100644 --- a/packages/app/deno.json +++ b/packages/app/deno.json @@ -1,6 +1,6 @@ { "name": "@openelement/app", - "version": "0.42.0-alpha.3", + "version": "0.42.0-alpha.4", "exports": { ".": "./src/index.ts", "./hono": "./src/hono.ts", diff --git a/packages/create/deno.json b/packages/create/deno.json index 32c49186f..d8d8bbcc4 100644 --- a/packages/create/deno.json +++ b/packages/create/deno.json @@ -1,6 +1,6 @@ { "name": "@openelement/create", - "version": "0.42.0-alpha.3", + "version": "0.42.0-alpha.4", "exports": "./src/cli.ts", "imports": {}, "tasks": { diff --git a/packages/create/src/version.ts b/packages/create/src/version.ts index 873cf86de..6c78da291 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.3'; +export const CREATE_VERSION = '0.42.0-alpha.4'; diff --git a/packages/element/deno.json b/packages/element/deno.json index fd44b95f1..46f891a1a 100644 --- a/packages/element/deno.json +++ b/packages/element/deno.json @@ -1,6 +1,6 @@ { "name": "@openelement/element", - "version": "0.42.0-alpha.3", + "version": "0.42.0-alpha.4", "exports": { ".": "./src/index.ts", "./jsx-runtime": "./src/jsx-runtime.ts", diff --git a/packages/ui/deno.json b/packages/ui/deno.json index acea661a0..c5adace3e 100644 --- a/packages/ui/deno.json +++ b/packages/ui/deno.json @@ -1,6 +1,6 @@ { "name": "@openelement/ui", - "version": "0.42.0-alpha.3", + "version": "0.42.0-alpha.4", "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 9c430c113..9bd3d9af6 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.3", + "version": "0.42.0-alpha.4", "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 f61d8c6bb..5d4a44af6 100644 --- a/tools/project-constants.ts +++ b/tools/project-constants.ts @@ -1,6 +1,6 @@ -export const PACKAGE_VERSION = '0.42.0-alpha.3'; +export const PACKAGE_VERSION = '0.42.0-alpha.4'; export const PACKAGE_VERSION_TAG = `v${PACKAGE_VERSION}`; -export const ACTIVE_EXECUTION_VERSION = 'v0.42.0-alpha.3'; +export const ACTIVE_EXECUTION_VERSION = 'v0.42.0-alpha.4'; 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.42.0-alpha.2'; +export const PREVIOUS_PACKAGE_VERSION = '0.42.0-alpha.3'; 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 = 'form/action loop'; +export const PREVIOUS_RELEASE_THEME = 'revalidation continuity'; /** * 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 505064be0..e2ee50a18 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.3'; +export const OPENELEMENT_VERSION = 'v0.42.0-alpha.4'; diff --git a/www/app/routes/roadmap.tsx b/www/app/routes/roadmap.tsx index 60f6d6753..38b621cd0 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.3', + version: 'v0.42.0-alpha.4', theme: 'revalidation continuity', copy: 'Enhanced forms morph the returned document: intact hydrated islands keep their state, data-open-preserve exempts subtrees, and the URL follows the PRG target.', @@ -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.3' ? 'CURRENT' : phase.stamp; + const stamp = phase.version === 'v0.42.0-alpha.4' ? 'CURRENT' : phase.stamp; return (
From 7a2dcb0260833a6adbcedd9e5b9fcf84d00be713 Mon Sep 17 00:00:00 2001 From: DevBot Date: Mon, 27 Jul 2026 17:32:11 +0800 Subject: [PATCH 4/4] docs(www): write the 0.42.0-alpha.4 roadmap theme (hardening and recipes) --- 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 38b621cd0..1e6493521 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.4', - theme: 'revalidation continuity', + theme: 'hardening and recipes', copy: - 'Enhanced forms morph the returned document: intact hydrated islands keep their state, data-open-preserve exempts subtrees, and the URL follows the PRG target.', + 'Validation recipes verified in CI (zod, valibot), the starter ships a request-time contact page, and the 0.44 latency baseline is recorded.', state: 'stable', stamp: 'CURRENT', },