Skip to content

feat(pages): add useForm setError and clear field errors on change - #4

Draft
0froq wants to merge 55 commits into
mainfrom
cursor/feat-useform-seterror-dbee
Draft

0froq wants to merge 55 commits into
mainfrom
cursor/feat-useform-seterror-dbee

Conversation

@0froq

@0froq 0froq commented Oct 25, 2026

Copy link
Copy Markdown
Owner

Fixes voidzero-dev#525

Summary

Adds setError(field, message) to useForm across React, Vue, Svelte, and Solid so apps can surface client-side validation through the same typed errors object as server withValidator() responses.

Changing a field clears that field's validation error automatically:

  • React and Solid: setData(field, value) clears errors[field]
  • Vue and Svelte: assigning to data clears errors[field] (snapshot-based watch / $effect)

API

Method Purpose
setError(field, message) Set a client-side field validation error
(existing) clearErrors(...fields) Clear validation errors manually

Tests

  • packages/vue/test/unit/use-form.test.ts — setError and clearing on data change

Typecheck

@void/react, @void/vue, @void/svelte, @void/solid

Fit to voidzero-dev#525

Implements proposal (1) setError(field, message) and proposal (2) clear-on-change via setData / data assignment, unblocking client-side schema validation in userland without a second error state.

Related: voidzero-dev#523 (form line; reset behavior is separate in PR voidzero-dev#568 on upstream).

Open in Web Open in Cursor 

Brooooooklyn and others added 30 commits October 21, 2026 12:47
…oidzero-dev#518)

Fixes voidzero-dev#509

The email domains guide said Void "preserves foreign MX records and
enabled catch-alls". That reads as if you can register an apex whose
mail stays with another provider. The platform refuses that case on
purpose:

```
void email domain add acme.com
  apex has MX records (any, on a new registration)  → 409 "already has MX records"
  catch-all routes to something else                → refused
```

Void never replaces another provider's mail setup. The guide now says
that, in the same terms as the CLI reference (`platform-email.md`) and
the own-account section. It points to the two options: an apex with no
mail yet, or the platform's shared mail address.

Docs only. Outbound-only custom domains, the middle path the reporter
mentioned, is a separate feature request.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…v#526)

Fixes voidzero-dev#472

## Problem

`db/schema.ts` re-exports a table file from outside `db/`, and that file
uses `sql` from `void/db` at the top level:

```
route ──▶ void/db ──▶ db/schema.ts ──▶ src/.../projects.schema.ts ──▶ void/db (still loading)
                                                                         sql = undefined ✗
```

In dev this throws `__vite_ssr_import_0__.sql is not a function`. voidzero-dev#504
fixed the `db/schema/` folder. Files anywhere else still hit the error.

## Fix

Vite SSR runs imports in the order they are written. `void/db` now
re-exports the drizzle-orm helpers (`sql`, `eq`, …) **first**, before it
imports the schema. This applies to all three dialects (SQLite,
PostgreSQL, MySQL).

```
void/db
  1. export { sql, eq, ... } from drizzle-orm   ← ready now
  2. driver, env
  3. import * as schema ──▶ projects.schema.ts ──▶ void/db.sql ✓
```

- The fix works wherever the schema file lives. It needs no import-graph
tracking.
- `resolveId`, `virtual:void-db-schema` and `db/schema/**` are
unchanged.
- Build output already linked `sql` directly. It still works, and it
ships the same modules in a different order.
- Still not supported: reading `db` at the top level of a schema file.
It needs a request anyway.

## Tests

| Test | main | this PR |
|---|---|---|
| `plugin-drizzle.test.ts`: helper export comes first
(sqlite/postgresql/mysql) | ✗ | ✓ |
| `plugin-vite-environment.test.ts`: real dev server, voidzero-dev#472 layout, route
imports `db` before `@schema` | ✗ `sql is not a function` | ✓ |

Both files pass (103/103). Lint, format and the `void` typecheck are
clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ng (voidzero-dev#522)

Fixes voidzero-dev#520

In a named action, drop the action key before `c.redirect()` merges the
request query. `POST /books?delete&page=2` → `c.redirect('/books')` now
goes to `/books?page=2`. Other handlers keep the current behavior.

`createPageMutationHandler` marks requests that picked a named action (a
`WeakSet`). The `c.redirect` wrapper then removes only the first
non-empty query segment (the one that picked the action) and keeps the
rest exactly as sent. Also adds one sentence to
`reference/api/rewrites.md`.

```
POST /books?delete&delete=x&q=a%20b   →  c.redirect('/books')  →  /books?delete=x&q=a%20b
             └─ action ─┘ └──── kept as sent ────┘
```

Checked:
- `routing-runtime.test.ts`: an `it.each` table (7 rows: valued key,
leading `&`, a page param with the same name, encoding kept, single
action unchanged). 6 rows fail without the fix.
- typecheck for `void`, lint, fmt

22 unit tests fail locally on main too. This PR adds no new failures.

---------

Co-authored-by: LongYinan <github@lyn.one>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…writes and the page URL (voidzero-dev#530)

Follow-ups to voidzero-dev#522. That PR dropped the action key (`?delete`) only from
`c.redirect()` inside the action. This covers the other places it
leaked.

## What changes

| Case: `POST /books?delete&page=2` | Before | After |
|---|---|---|
| Auth / user middleware `c.redirect('/login')` | `/login?delete&page=2`
| `/login?page=2` |
| Action `c.rewrite('/api/x')`, then `c.redirect('/done')` |
`/done?delete&page=2` | `/done?page=2` |
| `c.redirect('/books-v2', 307)` (or 308) | `/books-v2?page=2` (since
voidzero-dev#522) | `/books-v2?delete&page=2` |
| `PUT /books?update` + `c.redirect('/books')` (302) | `/books` (since
voidzero-dev#522) | `/books?update` |
| API route under a catch-all named-action page: `POST /api/x?return=/a`
+ redirect | `?return` dropped (earlier commit here) | `?return=/a` kept
|
| Action re-renders the page: `page.url` | `/books?delete&page=2` |
`/books?page=2` |
| …address bar after `useForm` / `action()` | `/books?delete&page=2` |
`/books?page=2` |

The key goes only when the browser follows with a GET (303, or 301/302
after a POST). Other redirects send the same method again, and the
destination needs the key to pick the action.

Redirect and rewrite **rules** (`void.json` routing) are unchanged: they
keep the query as sent. On Void Cloud, platform dispatch applies them
before the Worker and knows nothing about pages, so every target leaves
them alone.

## How

```
compile.ts      app.use(createNamedActionMarker(matchedRoutes))   ← after routing rules, before auth and user middleware
                matchedRoutes comes from the app's own resolved hono/route (Hono keys it by a per-copy Symbol)
                marks only if the first matched Void route handler (tagged: API, page GET, page mutation)
                is a named-action page; user middleware of any arity is skipped
routing.ts      c.redirect: drop the first query segment if marked and the browser follows with a GET
                c.rewrite: no new query → mark the new request too
protocol.ts     mutation page.url: drop the key if marked
client          after a POST that renders: if page.url's query == our URL minus its first segment,
                write that to history
pages/action-key.ts   shared WeakSet + string helper (no regex). Rolldown drops the WeakSet from client bundles.
```

Docs: the actions guide now says what happens to the query. The rewrites
reference mentions middleware and 307/308.

## Flaky tests (second commit)

Each of these failed in a local full run under load:

| Test | Cause | Fix |
|---|---|---|
| `plugin-class-a-wrapper` `… during build` | macOS reports
`server.mjs`, written just before startup, as changed (2 of 6 runs in a
probe). The reload gives the second import a new `Counter`. | `watch:
null` (12 of 12 runs stable) |
| `plugin-vite-environment` voidzero-dev#472 test | Real dev server: about 1 s
alone, >5 s under load | `watch: null`, lighter schema file, 30 s limit
|
| `plugin-entry-imports` | Imports the whole plugin entry: about 4.2 s
alone | 30 s limit |

## Tests

- `routing-runtime.test.ts`: middleware, 307, rewrite, rewrite with its
own query, and page URL. 5 rows fail on main.
- `pages-client.test.ts`: history URL after a POST, 4 rows. 2 fail on
main; the other 2 check that single-action URLs stay unchanged.
- `pages-compile.test.ts`: the marker is emitted after
`routingMiddleware` and before user middleware, only for `actions`.
- `vp run test --unit`: all pass, except one `cf-version-runner` test
that timed out under load and passes alone (not touched here). Lint,
format and typecheck are clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
)

React Pages currently pre-includes both React DOM entry points in
browser and Worker dependency optimization. Include `react-dom/client`
in the browser and `react-dom/server` in the Worker, with regression
coverage for those scopes and the existing SSR includes.

An installed-package comparison on a two-page React app reduced
generated prebundle JavaScript by **1,580,422 bytes** (source maps and
metadata excluded):

| Environment | Before | After |
| --- | ---: | ---: |
| Client | 1,660,390 B | 992,097 B |
| Worker | 1,669,081 B | 756,952 B |
| SSR | 2,705,925 B | 2,705,925 B |

The 80-sample benchmark used 20 randomized baseline/branch pairs for
each cold/warm cache state, with fresh dev processes and browser
contexts. Every sample passed hydration, interaction, and
client-navigation checks. Browser transfer was effectively unchanged,
and neither cold nor warm process-to-hydration time showed a
statistically significant improvement (adjusted p = 0.751 and 0.800).
This supports a smaller generated cache; it does not establish a startup
speedup. Timings came from a small React fixture with background host
activity.

Validation:

- Focused React plugin tests: 4 passed.
- `vp run test --base origin/main`: 10,750 passed, 31 skipped.
- `vp run check`: all 33 tasks passed.
- Formatting and lint passed for both changed files.
- `vp test run --pool=forks --maxWorkers=4`: 14,048 passed, 31 skipped.
…he request (voidzero-dev#533)

Follow-up to voidzero-dev#530 (Codex finding on that PR, which got merged before
this fix landed).

## Problem

A redirect that sends the same request again keeps the action key
(307/308, or 301/302 after PUT/PATCH/DELETE). When the destination has
its own query, the merge put the key **after** the destination's params:

```
PUT /books?delete&page=2 → c.redirect('/books-v2?tab=details', 302)
→ /books-v2?tab=details&delete=&page=2      repeated PUT picks "tab" ✗
```

## Fix (only in the `c.redirect` wrapper)

| Destination | Result |
|---|---|
| `/books-v2?tab=details` (another page) |
`/books-v2?delete&tab=details&page=2` (key first, as sent) |
| `/books-v2?tab=1&delete=x` (sets the key too) |
`/books-v2?delete=x&tab=1&page=2` (its value wins, moves first) |
| `/books?archive` (same page, own first key) | `/books?archive=&page=2`
(that key picks the action) |
| `/books-v2?` (bare reset) | `/books-v2?delete` (rest dropped, key
kept) |
| `/books-v2` (no query) | `/books-v2?delete&page=2` (unchanged, byte
for byte) |
| `#fragment` | stays last |

Unchanged: GET follow-ups (key dropped), unmarked requests,
`mergeQueryIntoLocation`, redirect rules and platform dispatch.

Known limit: a redirect to a **different** page whose first query key is
one of that page's actions (`c.redirect('/other?archive', 307)`) still
puts the request's key first. The redirect code can't tell an action key
from a normal param on another page.

## Tests

`routing-runtime.test.ts`: 10 new rows in the named-action table (307
and PUT 302 with a destination query, a valued key, a fragment, a bare
`?`, the same key in the destination, a same-page action key). 9 fail on
main. The POST 302 row passes on main too: it checks the GET follow-up
stays unchanged. routing-runtime, pages-client, pages-compile and
compile tests: 205/205. Lint, format and typecheck are clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…-dev#528)

## Issue

`createManifestLoader()` caches the client manifest after fetching and
parsing it. Concurrent HTML renders on a fresh instance can all miss
that cache and load separate copies.

This duplicates work during overlapping cold loads. Warm requests
already reuse the cached manifest.

## Fix

Share one pending promise across concurrent manifest loads. Cache
successful results and clear the pending promise when it settles,
allowing missing manifests and failed loads to be retried.

## Verification

Added unit coverage for concurrent loads sharing one fetch and manifest
object, completed-cache reuse, and retries after a missing response or
rejected fetch.

- `vp test run packages/void/test/unit/routing-runtime.test.ts`: 40
tests passed on Node 24.21.0.

Benchmarked eight simultaneous cold SSR requests using real generated
manifests, with no artificial asset delay. Median burst completion times
across nine shuffled runs on Node 24.21.0:

| Generated pages | Before | After | Manifest reads/parses |
|---|---:|---:|---:|
| 1,000 | 28.52 ms | 21.27 ms | 8 → 1 |
| 10,000 | 120.55 ms | 49.43 ms | 8 → 1 |

Single-request controls performed one load on both revisions; warm
bursts performed zero additional loads. HTML output matched, and browser
hydration and navigation checks passed.

These results describe concurrent cold bursts. Production frequency and
full SSG build impact have not been measured.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ero-dev#541)

The lowercase check added in voidzero-dev#513 still accepts names such as `my_app`,
`my.app`, `my app`, paths, and names longer than a workers.dev DNS
label. The scaffolder writes the supplied name directly into
package.json, so these inputs should receive actionable validation
before files are created or dependencies installed.

Extend the validator already shared by the interactive prompt and
positional CLI argument to require lowercase ASCII letters, digits, and
dashes, with an alphanumeric first/last character and a maximum of 63
characters. Preserve lowercase diagnostics and the interactive default.
Add regression coverage for invalid characters, paths, Unicode/control
characters, dash boundaries, single-character names, and the
63/64-character boundary; document the accepted project-name format.

Cloudflare references:
- [Worker name
configuration](https://developers.cloudflare.com/workers/wrangler/configuration/#inheritable-keys):
alphanumeric characters and dashes; underscores are explicitly
disallowed.
- [workers.dev
limitations](https://developers.cloudflare.com/workers/configuration/routing/workers-dev/#limitations):
at most 63 characters, with no leading/trailing dash. Workers without
workers.dev can have names up to 255 characters; the scaffolder
intentionally uses the workers.dev-compatible limit. Cloudflare itself
permits uppercase; this change preserves Void's existing lowercase
policy.

This narrows scaffold inputs: paths (including `.`) and scoped names are
rejected because the input is also used as the package name. Existing
project deployment naming and the separately validated Void platform
slug remain unchanged.

Validation: five dependency-free Node test groups against the production
validator/parser passed, covering valid/invalid input tables,
boundaries, defaults, and existing argument forwarding. The repository
Vite+ tests, affected-test checks, typecheck, and full suite could not
run because `vp` is unavailable and the shell proxy is unreachable,
preventing dependency installation.

---------

Signed-off-by: Christoph Nakazawa <christoph.pojer@gmail.com>
Co-authored-by: Christoph Nakazawa <christoph.pojer@gmail.com>
…idzero-dev#521)

Fixes voidzero-dev#519

Add `"postinstall": "void prepare"` to the starter scripts. Init
installs after writing the starter, so a new scaffold gets the types at
that point, and a fresh clone gets them on install. This is the same
approach as `nuxt prepare`.

`EMPTY_PROJECT_ALLOWED_SCRIPTS` also allows `postinstall: void prepare`,
so running init again on a starter still sees it as an empty project.

Checked:
- `init.test.ts` (`postinstall` in the React + D1 starter, and the
deleted-starter check)
- a 1.1.12 starter cloned fresh with `postinstall` added: `pnpm install`
creates `.void/db.d.ts` and `routes.d.ts`, and `tsc` passes (it fails
without it)

16 tests in `init.test.ts` fail locally on main too. This PR adds no new
failures.
Adds Vite+ to the "Create an app" setup commands and the "Binary
Invocation" section of the CLI docs.

Also converts the "Run locally" code on the quickstart page to a code
group to match the "Create an app" code group

## Notes

The icon is a _tiny_ hack. The string `vite+` renders the `vite` icon
since non-alphabetical characters are ignored when determining the icon.
I have [a PR with
`vscode-icons`](vscode-icons/vscode-icons#4425)
to add the Vite+ icon there, which will bubble up into Vitepress via
`@iconify-json/simple-icons`. I'm happy to submit a follow-up PR once
that's live.

## Demo

<img width="3024" height="1718" alt="image"
src="https://github.com/user-attachments/assets/42df2704-d3c1-4fee-a702-657cbf06cc38"
/>
…tate (voidzero-dev#531)

Fixes voidzero-dev#506

## Summary

`router.visit(url, { preserveState: true })` updated the page but
skipped the history write, so the address bar kept the old URL.
`makeCommit()` in `packages/void/src/pages/navigation-engine.ts` gated
the history write on `!options.preserveState`; it now gates on
`!options._popstate`.

- Every normal visit writes history; only back/forward traversal
(`_popstate`) skips it.
- `preserveState` now only affects component state; `replace` still
chooses `replaceState` vs `pushState`.
- `router.refresh()` defaults to `replace: true`, so it updates the
current entry instead of pushing a new one.

voidzero-dev#507 is a separate bug and is not addressed here.

## Tests

Added 4 cases to `packages/void/test/unit/pages-client.test.ts`:

- `preserveState` + push writes the destination URL with `pushState`
- `preserveState` + `replace` writes it with `replaceState`
- `refresh()` does not push a new history entry
- popstate navigation does not write history

The push/replace cases fail on `main` and pass with this change. `vp
test run test/unit/pages-client.test.ts` passes (53/53); typecheck, fmt
and lint pass.

## Follow-up changes (085e16b)

- `router.refresh()` now goes through one shared `refreshPage()` helper,
used by both the core router and React's own router. React's copy was
missed and would have pushed a new entry.
- `refresh()` keeps the URL hash. Replacing the entry with `pathname +
search` dropped `#hash`. An internal `_refresh` flag makes sure the page
is still fetched and not treated as a hash-only visit.
- Same-page hash visits (`visit('#x', { preserveState: true })`) now
write history too, by the same rule. The voidzero-dev#130 test that asserted
otherwise is updated.
- A replaced history entry keeps its scroll key, so repeated `refresh()`
calls (e.g. polling) don't add one saved position per call to
sessionStorage.
- Docs: the `router.refresh()` example notes it keeps the same history
entry.

---------

Co-authored-by: LongYinan <github@lyn.one>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Void's shipped default Sandbox still uses Node 26.10.0, and the GitHub
build-agent image uses the floating `node:26-slim` tag. Upgrade the
maintained runtime pins to the fixed versions listed in the [October
2026 Node security
release](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases)
for CVE-2026-111497.

- Set `.node-version` to **24.22.1**, which is already consumed by the
CI setup and release workflows.
- Upgrade the default Sandbox to **node:26.12.1-trixie-slim** and pin
the build agent to **node:26.12.1-slim**. Docker Hub manifest inspection
confirmed both fixed tags exist, support amd64/arm64, and resolve to the
same Debian Trixie image index; the distro is preserved.
- Sync the Sandbox Dockerfile example and build-agent README, including
the example's existing SDK helper version with the installed 1.0.2
helper.

Package compatibility ranges, dependency versions and the lockfile are
unchanged. Platform CI already uses `docker build --pull`; there are no
additional static cached Node image references to update.

## Validation

- `vp install --frozen-lockfile`: passed, including the core package
build.
- Focused Sandbox configuration, packaging, RPC/runtime and
build-container tests: **5 files / 315 tests passed** under Node
24.22.1.
- `vp run test --list`: selected all 731 files because `.node-version`
changed; that selection was covered by the full verification below.
- `vp run test:all`: formatting, lint, all package/playground builds,
artifact checks, platform type-aware lint and `vp run check` passed.
- Both updated Dockerfiles built successfully using `--pull --platform
linux/amd64`. Inspection containers reported **Node v26.12.1** and
**Debian 13 (trixie)**. The Sandbox helper was executable and a harmless
Brotli round trip passed. The build agent retained Corepack 0.35.0, Bun
1.3.14, builder uid 1000 and its packaged agent entry; pnpm 11.8.0 and
Yarn 1.22.22 ran offline as the unprivileged builder.
- `git diff --check`: passed.

**The full test suite did not pass:** 728 files passed, 2 failed and 1
skipped; **14,262 tests passed, 2 failed and 31 skipped**. The overall
`vp run test:all` exit code was 1.

1. `platform/packages/api/test/integration/admin-dashboard.test.ts:491`:
the ResizeObserver sizes array remained empty until `expect.poll` timed
out. The entire file passed on a focused rerun.
2. `packages/void/test/integration/astro.test.ts`: the initial SSR fetch
lost its loopback connection. On the focused rerun, the dev cases passed
but the PostgreSQL suite failed in `beforeAll` at line 297: Astro
restarted after dependency optimization/tsconfig reload, and
Miniflare/Cloudflare Vite plugin type discovery lost a loopback socket
(`Server stopped while waiting`, `other side closed`). The same
PostgreSQL startup failure was reproduced with the original **Node
24.21.0**, confirmed with both `node --version` and `vp exec node
--version`. The fixed 24.22.1 pin was restored afterward. No application
or test-source changes were made to hide these failures.

This PR prepares the source fix. Existing published packages and running
containers still require their normal release/rebuild/rollout before
their runtime exposure is remediated.
## Summary

Pages clients eagerly load a manifest that repeats page IDs and layout
chains. Emit compact rows and share identical layout chains to reduce
this payload.

Initialization expands the rows into the existing component and layout
lookup objects. Static routes before the first dynamic route use a path
Map; remaining routes retain ordered regex matching to preserve
precedence.

## Generated structure

Simplified example with two static pages sharing a layout. Loader names
represent lazy imports; dynamic routes are omitted.

Before:

```js
const components = {
  about: loadAbout,
  contact: loadContact,
  layout: loadLayout,
};

const layoutTree = {
  about: ["layout"],
  contact: ["layout"],
};

const routes = [
  { pattern: /^\/about$/, id: "about" },
  { pattern: /^\/contact$/, id: "contact" },
];
```

After:

```js
const layoutChains = [["layout"]];
const rows = [
  ["about", loadAbout, 0],
  ["contact", loadContact, 0],
];

const components = {};
const layoutTree = {};
const staticRouteIds = new Map();

for (const [id, load, chain, path = "/" + id] of rows) {
  components[id] = load;
  if (chain >= 0) layoutTree[id] = layoutChains[chain];
  if (!staticRouteIds.has(path)) staticRouteIds.set(path, id);
}

components.layout = loadLayout;
```

Component and layout selection continue to use object lookups. Static
route matching uses `Map.get(path)`.

> [!CAUTION]
> Yes this format is less readable (by a bit), but it's definitely more
space and runtime efficient for large number of pages. I'd argue this is
a better change.

## Bundle size

React production builds with generated static pages, a homepage, and one
shared layout. Only the `void` package changes between variants; the
adapter and external dependencies are identical. Sizes measure the
emitted Pages client entry.

| Generated pages | Gzip level 6, before → after | Brotli quality 11,
before → after |
|---:|---:|---:|
| 1 | 68,726 → 68,776 B (+0.07%) | 59,489 → 59,516 B (+0.05%) |
| 100 | 72,725 → 72,021 B (−0.97%) | 61,356 → 61,038 B (−0.52%) |
| 1,000 | 107,695 → 98,216 B (−8.80%) | 76,396 → 72,989 B (−4.46%) |
| 5,000 | 249,580 → 206,988 B (−17.07%) | 142,147 → 124,254 B (−12.59%)
|

The complete JavaScript dependency set required to hydrate page 1 also
shrinks; bytes were not moved into another eagerly required chunk.

## Runtime measurements

At 5,000 generated pages:

| Metric | Before | After | Change |
|---|---:|---:|---:|
| Cold manifest parse + initialization | 15.784 ms | 9.659 ms |
**−38.8%** |
| Module initialization alone | 2.529 ms | 4.510 ms | **+78.3%** |
| Retained heap after initialization | 3.03 MiB | 2.66 MiB | **−12.4%**
|
| Warmed last-page route lookup | 153.734 µs | 0.291 µs | **−99.8%** |

The compact representation adds about **2 ms of initialization work**,
while combined cold parse + initialization improves by **6.12 ms**
(paired 95% interval: 5.12–7.15 ms faster; adjusted p = 0.00012). The
one-page cold case increases **29.3%**, equivalent to **0.019 ms**.

Measurements made using: 

- Chrome 155 on Apple M5 with 70% idle CPU host
- 16 randomized pairs per page count
- Cold timing uses an isolated function wrapper; initialization and heap
measurements use real ES modules, with heap sampled after forced garbage
collection.
 
End-to-end loading and hydration speed remain unmeasured.

> [!NOTE]
>  **Why keep objects for components and layoutTree?**
>  
> <details><summary>Click to see comparison</summary>
> A separate 16-pair experiment replaced both lookup objects with Maps
while keeping compact rows, layout chains, and route matching identical.
>
> 
> | Metric at 5,000 generated pages | Objects | Maps | Change |
> |---|---:|---:|---:|
> | Module initialization | 4.590 ms | 4.364 ms | **−4.9%** |
> | Cold parse + initialization | 10.325 ms | 10.267 ms | **−0.6%** |
> | Retained heap after initialization | 2.66 MiB | 2.69 MiB | **+1.2%**
|
> | Component + layout lookup | 10.83 ns | 9.44 ns | **−12.9%** |
> 
> Maps improved initialization by **0.226 ms**, but combined cold timing
showed no statistically significant improvement (p = 0.90). Reads saved
approximately **1.4 ns**, while retained heap increased by **32.5 KiB**.
> 
> We retained objects because these gains did not establish an overall
startup improvement and would require changing shared runtime and
adapter interfaces. The static-route Map replaces a linear regex scan,
where the benefit is much larger.
> </details>

## Validation

- 15 focused tests across the core and four framework adapters passed.
- Core and installed fixture typechecks passed.
- Ten production SSR builds passed, including identical repeated build
fingerprints.
- Browser hydration, counter interaction, and client navigation passed.
- All 128 samples in each runtime experiment passed their route and
lookup checks.
- Benchmark tests, checks, formatting, and lint passed.

Measurements compare baseline `6ab6559ccec2f905c693bedeedd5734287946f1b`
with candidate `1b5ba4384f06a5df0d0c0092692d61fe13e5f882` and cover
static React Pages output with a shared layout. Dynamic-route-heavy
applications and other engines may produce different results.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: LongYinan <github@lyn.one>
…oidzero-dev#545)

Follow-up to voidzero-dev#534. Fixes page routing bugs that were already on main.

## What was broken

| Case | Before |
|---|---|
| `about.tsx` + `about/index.tsx`, `x.tsx` + `x.vue`, `(a)/team.tsx` +
`(b)/team.tsx` | Two files for one route, no warning. The server and the
client could each pick a different file (one file's loader fed the other
file's component). Route groups changed winner between runs. With two
loaders, dev failed with `Identifier '_pload_about' has already been
declared`. |
| `index.tsx` + `index/index.tsx` | Different routes, same page id
`index`, so one route rendered the other page. |
| `layout/index.tsx` next to `layout.tsx` | The page had the layout's
id, so `/layout` rendered the layout as the page. |
| `café.tsx`, `a b.tsx` | Never matched during client navigation: the
client compared the encoded `/caf%C3%A9` with the raw pattern. |
| A clashing page added while `void dev` runs | The dev server process
exited. |

## What changes

```
scanPages
  same route / same page id   → one error, every clash listed, sorted
  [id] + [slug] (same URLs)   → one warning; the first page always wins today
  framework app (Nuxt …)      → no check: users.vue + users/index.vue is Nuxt nesting

manifest
  layout id == page id        → that layout is keyed "_layout" (only then)

client navigation
  URL → pathname → decodePagePath() → matchRoute(path)
  decodePagePath copies Hono's getPath decoding (no regex)

void dev
  scan error → logged once, last good route table kept
```

Example error:

```
pages: Multiple page files resolve to the same route:
  "/about": "about.tsx", "about/index.tsx"
  "/team": "(a)/team.tsx", "(b)/team.tsx"
Keep one page file per route and rename or remove the others.
```

Notes:
- Apps without a clash get the same ids and manifest rows. The generated
`matchRoute` now takes a decoded pathname. The engine and
`matchAdminRoute` follow that.
- The dev guard keeps the last good route table for any scan error, not
only page clashes.
- No repo playground has a clash: all 17 pages dirs were scanned.

## Tests

- `pages-scan.test.ts`: the route clash error (exact text), the page id
clash, the warning printed once across rescans, and no false positive
for `/users/[id]/edit` next to `/users/[userId]/posts` or for
`layout.tsx` + `layout/index.tsx`.
- `scan.test.ts`: a Void app with `users.vue` + `users/index.vue`
throws, and a Nuxt app does not.
- `pages-manifest.test.ts`: the layout folder case. Encoded paths are
checked against a real Hono app for 19 URLs (`%C3%A9`, `%c3%a9`, `%20`,
`%25`, `%2525`, `%2F`, `%zz`, over-encoded ASCII, query/hash), with and
without a root `[slug]`.
- `plugin-routing.test.ts`: moving `about.tsx` to `about/index.tsx` in
dev logs once, keeps the old table, and recovers on unlink. Nothing is
logged when an adapter pages plugin is present.
- Each new test fails on main. Unit suites (7,564), the browser tests
for all four adapters and the smart runner's affected integration files
pass locally.

## Not in this PR (already on main)

- `routes/` has the same silent first-file-wins clash. `.dev`/`.prod`
pairs are intended there, so it needs its own rule.
- `a-b.tsx` and `a_b.tsx`, each with a `.server.ts`, both become
`_pload_a_b` (dev SyntaxError).
- ISR page TTLs compare the encoded pathname with the raw pattern
(`@void/edge` `matchSegmentPattern`).
- An unnamed catch-all `[...].tsx` matches the bare `/files` on the
server but not on the client.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…d Worker conditions (voidzero-dev#540)

Fixes voidzero-dev#539

`prepareBundledBuildOutput` bundles framework Worker output again with a
plain Vite lib build. It now matches `@cloudflare/vite-plugin` in two
ways:

- **Additional modules.** A plugin applies the Cloudflare Vite plugin's
module rules: `.wasm` and `.wasm?module`, `.bin`, `.txt`, `.html` and
`.sql`, including `#` subpath imports that resolve to them. Matching
imports stay external, and each file is emitted beside the bundle with a
path relative to the chunk that imports it. `copyBundle` already uploads
these files as `wasm`, `data` and `text` modules. The Cloudflare plugin
does not export its rules, so the plugin repeats them.
- **Resolve conditions.** The build uses `WORKER_CONDITIONS`, which
Void's other Worker bundles already use. The constant moves from
`wrapper.ts` to its own module so that `cf-build-output.ts` still loads
Vite lazily.

Both changes apply to every bundle that `prepareBundledBuildOutput`
builds, including the static Worker.

## Tests

`packages/void/test/unit/cf-build-output.test.ts`:

- A framework Worker with wasm-bindgen-style and plain Wasm, text and
data imports, a `#` subpath import and a dynamic import produces `wasm`,
`text` and `data` modules and passes the `cf` dry run.
- A dependency with `workerd` and `browser` exports resolves to its
`workerd` build.

Each test fails when its change is removed. `vp run test`, `vp run
--filter ./packages/void typecheck`, `vp lint` and `vp fmt --check`
pass.

I also prepared the output of a SvelteKit app that uses
`@resvg/resvg-wasm` and micromark: `index_bg.wasm` is uploaded as a
`wasm` module, and the bundle contains the Worker build of
`decode-named-character-reference`.


## Follow-up changes (21e826b, 1cb2d9f)

```
import './a.txt'  ─┐                          ┌─> a.txt   (text)
import './a.bin'  ─┤  same bytes, before: ──> │   one file, one type ✗
                   │  same bytes, now:   ──>  └─> a.txt (text) + a.bin (data) ✓
import './sa"me.bin'   before: ENOENT ✗   now: sa_me.bin ✓
esm-env DEV            before: not folded  now: folded to false ✓
```

- Each module file is emitted once per source path, with its own name
and extension. Files with the same bytes are no longer merged, so each
import keeps its module type.
- The external id holds a counter key instead of the path, and emitted
names keep only letters, digits, `.`, `_` and `-`. A quote in a path no
longer breaks the import.
- The build adds the `production` condition, like
`@cloudflare/vite-plugin` does for production builds. It is fixed rather
than taken from `NODE_ENV`, because a deploy bundle is always a
production build. Without it, the SvelteKit playground bundle lost the
production build of `esm-env`.
- The reference regex is replaced by a small `indexOf` loop. Each build
uses a random marker, so app strings that look like a module id stay as
they are.

Tests: the Wasm test adds same-bytes `.txt`/`.bin` files, a quoted file
name (`'` on Windows) and an app string that looks like the old marker;
the conditions test adds a package with `development`/`production`
exports.

Left out, since they behave the same before this PR (and in
`@cloudflare/vite-plugin` for the second):

- Packages without `exports` still prefer the `browser` main field.
- Wasm reached through an export subpath without a `.wasm` suffix is not
kept as a module.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: LongYinan <github@lyn.one>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…voidzero-dev#548)

Fixes voidzero-dev#507

## Summary

An older visit whose page update was still running could commit after a
newer visit had already committed. It then wrote its URL, head and
scroll over the newer page, and disposed the newer page's Deferred
props.

```
before:  visit('/a') ── applyUpdate(A) ……… slow ……… commit(A) ✗ URL /a, title A, B's Deferred rejected
                          visit('/b') ── applyUpdate(B) ── commit(B)   page /b

after:   commit(A) sees that a newer visit already set the URL → skipped, commit() returns false
```

### Rule: the address bar only moves forward

`makeCommit()` in `packages/void/src/pages/navigation-engine.ts` tracks
the newest visit whose URL is in the address bar:

| sets it | why |
| --- | --- |
| a successful commit | its URL was just written |
| a back/forward visit, when it starts | the browser has already moved |
| ~~a same-page hash visit~~ | it renders nothing, so a page already on
screen still commits its URL |

An older visit commits only if no newer visit has set the URL.
`resource.commit()` now resolves `true`/`false`.

### Cases

| case | result |
| --- | --- |
| A finishes after B committed (the issue) | A skipped; page, URL, head,
scroll and B's Deferred props stay on B |
| A finishes while B is still loading | A commits `/a` (its page is on
screen), then B commits `/b` |
| the new page's mount code calls `visit('/b', { replace: true })` | A
pushes `/a`, B replaces it; the previous entry is kept |
| Back pressed while A is being applied | A never pushes, so forward
history is kept |
| a hash link (or Back to a same-page hash entry) during A's update | A
still commits `/a`, as before, so page and URL match |

I first tried "skip A if any newer visit started" (what Inertia and
SvelteKit do). Review showed it breaks redirects made from a page's
mount code: the redirect's `replace` then overwrote the previous page's
entry. So the rule only looks at visits that have already set the URL.

### Callers

- **`client.ts`** (Vue, Svelte, Solid): a skipped visit throws an
AbortError inside `applyNavigationResource`. The existing catch settles
it like any abandoned visit: GET resolves `undefined`, other methods
reject with AbortError (`useForm` already ignores that). A
view-transition callback that runs after a newer visit started now skips
`applyUpdate`, so the older page cannot paint over the newer one.
- **React** (`pages-client.ts`): `finishNavigation` uses the result. A
skipped visit settles the same way and does not dispatch `finish`.

No docs change: nothing changes for users except the bug.

## Tests

New `describe('superseded navigation commits (voidzero-dev#507)')` in
`packages/void/test/unit/pages-client.test.ts` (10 cases):

- normal visits in order (control)
- the issue's repro: URL, `router.url`, title, scroll and B's Deferred
prop stay on B
- A finishing before B commits
- a redirect started from the new page's mount
- a hash visit during A's update, and Back to a same-page hash entry
during it
- Back to another page during A's update
- a superseded form submission rejects with AbortError
- a late view-transition callback
- the engine called the way the React adapter calls it

Each guard has a test that fails when the guard is removed. 5 of them
fail on `main`. `vp test run` passed except one platform browser test
that timed out at 5s under full load. That test runs with JavaScript
turned off, so this change doesn't reach it; it passes alone and in its
project run twice. `vp run test`, `vp run check`, `vp lint` and `vp fmt`
pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…dzero-dev#551)

Fixes voidzero-dev#510

## What it does

An admin can now register an apex whose mail lives with Microsoft 365 or
Google Workspace, for **sending only**:

```sh
void platform email domain-add brand.example --project hr-portal --outbound-only
```

```
                 managed (today)                 outbound-only (new)
                 ───────────────                 ───────────────────
 apex MX         → Cloudflare Email Routing      untouched (M365 / Workspace)
 catch-all       → Void ingress Worker           untouched
 ingress Worker  deployed                        never deployed
 steps           routing_enable … publish        sending_onboard → publish
 receive mail    yes                             no  (inbound_status = disabled)
 send mail       yes                             yes
 Sending refused → verified_only fallback        → blocked, with the reason
```

## Answers to the 4 questions

1. **Shape.** A column, `email_domain_routes.inbound_mode` (`managed` |
`none`, default `managed`), plus a check that `none` is only allowed
with `addressing = 'custom'`. `addressing` already plays the "route
kind" role, so a second kind would duplicate it.
2. **Who.** Admin only. Owners get a 403 from the service. The owner CLI
points them at the admin command when their apex already has foreign MX.
3. **DMARC/SPF.** They live together; Void never edits DMARC or SPF.
Before onboarding, the step calls Cloudflare's read-only `POST
/email/sending/subdomains/preview`. If it reports any real conflict (not
just `*.missing`), the operation is **blocked** with a list
(`dmarc.multiple at _dmarc.brand.example; …`). Once the admin fixes
those records, `domain-sync` resumes. A domain that is already onboarded
skips the preview.
4. **Subdomains.** Not in this PR; apex only. Subdomain senders need
several routes per connection, so that is a follow-up.

```
domain-add --outbound-only
   │
   ├─ admin? apex? not the shared zone? ── no ──▶ 4xx, no Cloudflare writes
   │
   ▼
sending_onboard
   ├─ already onboarded? ─ yes ─▶ onboard (idempotent)
   ├─ preview conflicts? ─ yes ─▶ blocked + checklist
   ├─ onboard 4xx       ─────────▶ blocked ("needs Workers Paid")
   ├─ onboard 5xx/408   ─────────▶ unknown (operator recovery, as today)
   ▼
publish ─▶ outbound ready, inbound disabled
```

## Safety details

- **Removal** keeps the usual step list, but `catchall_detach` and
`ingress_delete` finish locally for outbound-only routes, so no Email
Routing or Workers calls are made. The stored credential is still
cleared, so a Sending-only token can finish removal.
- **Mode switch** on an active route returns 409 ("remove it first").
The add dry-run says so (`plan: 'refuse'`).
- **Race guard.** If the MX preflight was skipped because the route was
active, and the route is no longer active by the time the transaction
runs, the add returns 409 "retry" instead of reactivating it without an
MX check.
- **Status refresh** no longer overwrites a newer Sending result
recorded by a setup step. Otherwise, on an outbound-only route, a stale
`blocked` would stop all sending.
- **Older servers.** The CLI aborts unless the dry-run echoes
`target.inboundMode === 'none'`, so an old platform that ignores the
flag cannot run full managed setup.
- **Rollback.** `compatibility.json` lists 1.1.11 through 1.2.1 as
rollback-safe (the column is additive and defaults to `managed`).
`platform/meta/operations.md` warns not to run `domain-add` or
`domain-sync` on outbound-only domains after a rollback below this
release, because an older runtime would treat them as managed and take
over the MX.
- `domain-rotate-secret` returns 409 for these domains, in both the
preview and the real request.

## Not verified live

- That Cloudflare onboarding keeps a single existing `_dmarc` record
instead of replacing it. The preview is Cloudflare's own check, but this
still needs one run against a real test zone.
- Removal still does not offboard Email Sending in Cloudflare. That was
already true for managed domains and is unchanged here.

## Tests

- `admin-upgrade-compatibility.test.ts`: 0061 upgrade from the 0060
schema, defaults, and the check constraint.
- `email-connections.test.ts`: foreign-MX apex runs 2 steps with no
inbound calls; preview conflict blocks, then resumes; 403 blocks (not
`verified_only`), then resumes; managed still falls back; 504 stays
unknown; refusals; removal makes no inbound calls.
- `operator-email-domains.test.ts`: HTTP flag validation, dry-run text,
blocked → sync → ready, status, previews, rotate refused, audit, remove;
status-refresh race (confirmed to fail without the fix).
- `email-admission.test.ts`: sending admitted, receipt refused.
- `operator-cli.test.ts`, `email-cli-domain.test.ts`: flag parsing,
old-server guard, owner hint.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…p the Worker for hashed chunks (voidzero-dev#552)

Signed-in users never cached JS or CSS, and every static file request
started the Worker. A Codiff app on Void ran into this and worked around
it with a post-build script. This PR fixes it in Void.

## What was wrong

```
Signed-in GET /assets/<hash>.js   (pages / SSR / SPA with edge rules)
  auth middleware ─► next() ─► file served: "public, max-age=31536000, immutable"
        ◄── appendAuthHeaders copies getSession's "no-store" + "pragma: no-cache" over it
  result: no-store   (also for anonymous requests that carry any cookie, e.g. _ga=1)
```

The Worker also ran for every file, and for signed-in users each file
cost one session read (D1).

## Changes

**1. Static files keep their own cache policy** (all targets,
`runtime/`)

| request | before | after |
|---|---|---|
| no cookie | `public, …, immutable` | same |
| any cookie / signed in | `no-store` | `private, max-age=31536000,
immutable` |
| session cookie refreshed on this request | `no-store` | `private,
no-store` |
| page / API response for a signed-in user | `no-store` | same |

- The fallback marks responses that come from `ASSETS`
(`static-asset-marker.ts`).
- The auth middleware treats a response as a file only if user
middleware left its Cache-Control, Set-Cookie and **body stream**
unchanged. If middleware swapped the body after `next()`, the response
is handled like a route again (`no-store`).
- `private`, not `public`: user middleware may gate files on the
session, so a shared cache must never keep one.

**2. Hashed chunks skip the Worker** (native `void deploy --platform
cloudflare` only)

```
eligible build?  ── no ──► output unchanged
      │ yes
      ├─ dist/ssr/wrangler.json  run_worker_first: ['/**', '!/assets/a-123.js', '!/assets/b-456.js', …]
      └─ dist/client/_headers    one immutable rule per hashed chunk (Void marker on line 1)
```

- Eligible means: no global middleware; no header, redirect, rewrite or
fallback rules; no public `_headers`/`_redirects`; no route, page,
WebSocket, prerender or revalidate pattern that can reach the assets
folder; base `/`; 1–99 hashed chunks; not a managed (dispatch) build.
- Rules are per file, not `/assets/*`. Misses, client routes like
`/assets/42`, and custom 404 pages still reach the Worker.
- The version-API upload sends the generated `_headers` as a
`text/plain` module, as cf does. User-written `_headers` handling is
unchanged.
- Watch rebuilds keep `_headers` and `run_worker_first` in step. A stale
generated `_headers` is removed.
- Workers Free supports all of this (static requests are free).

## Not in this PR

- An opt-in that would let apps with middleware also skip the Worker
(new API, and a security call for gated apps)
- Turning on better-auth `cookieCache` by default (changes how quickly a
revoked session stops working)
- Carrying a D1 read-replica bookmark across requests
- Managed (dispatch) bypass

## Verification

- New tests: `asset-worker-bypass`, `client-asset-manifest`,
`plugin-routing` bypass wiring, `cf-version-runner` `_headers` module,
`auth-middleware` (incl. swapped body), `auth-isr-cache-policy` with
real better-auth against each fallback (incl. 304), `routing-runtime`
marker, Miniflare bypass test (Worker does not run), `ssr-playground`
build output.
- `vp run check`: 33/33
- Full `vp test run`: 732 files, 14403 tests passed, 0 failed
- Repro wiring the real runtime: signed-in / cookie requests now get
`private, …` and never `no-store` on files

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…o-dev#553)

Fixes voidzero-dev#511

## What this adds

A project owner can give the project one short, permanent name on the
platform's shared mail domain:

```sh
void email alias acme          # asks to confirm; --yes skips the prompt
void email alias               # shows the address and inbound status
```

```
before                               after (slug acme-store, alias acme)
──────                               ─────
acme-store@mail.example.com          acme-store@mail.example.com   ← still works
acme-store+support@…                 acme-store+support@…          ← still works
                                     acme@mail.example.com         ← new
                                     acme+support@…                ← new
```

Both names send and receive. Handlers route the same way either way:
`acme+support@…` and `acme-store+support@…` both reach
`email/support.ts`.

## Answers to the three questions

### 1. Alias field, or another way?

A field: `projects.email_alias` (nullable, migration 0062).

```
projects
  slug         acme-store   (unique, as before)
  email_alias  acme         (unique when set)

one namespace: a trigger refuses
  alias = any slug     slug = any alias     alias = own slug
```

- The DB trigger (`void_project_mailbox_name_guard`, SQLSTATE 23505)
keeps slugs and aliases apart in both directions, including on older
runtimes after a rollback.
- The alias follows the slug rules: shape, 56 characters, reserved
names, no `preview-`. Names of five characters or fewer need the same
plan permission as short slugs.
- Only the owner can set it (`PUT /projects/:id/email/alias`).

### 2. Permanent, or changeable?

Permanent. Set once, never changed or removed, and freed only when the
project is deleted.

- Reply addresses people have saved (`acme+ticket-42@…`) keep working.
- No swap logic, so no window where mail bounces.
- Because it is permanent, the route checks Cloudflare **before**
saving: it refuses a name that an existing rule already routes
(including plus addresses), and refuses when the domain has no room for
the rules this project will need.

### 3. Does the old slug keep working?

Yes, for sending and receiving. An alias project uses two Cloudflare
literal rules:

```
                         Cloudflare Email Routing (shared domain)
mail to acme-store+x@ ─▶ rule: acme-store@  ─┐
mail to acme+x@       ─▶ rule: acme@        ─┴─▶ email gateway ─▶ resolve-ingress
                                                     │  finds project by slug OR alias
                                                     │  checks readiness of THAT name
                                                     ▼
                                                  proxy router ─▶ project worker
                                                  (prefix must equal the name used)
```

- Default `from` stays `<slug>+noreply@…`. Apps that want the alias as
sender set `from`.
- Admission accepts `From` with either name. Replies and forwards can
use either name, whatever address the mail came in on.
- Deploys only wait for the slug rule. A failing alias rule shows in
`void email alias` but never blocks a deploy.

## Rollback

```
upgrade ──▶ alias rules created
rollback ─▶ older runtime ignores email_alias; its reconcile removes alias rules;
            slug mail keeps working
upgrade ──▶ next reconcile restores the alias rows and rules
            (including a removal that stopped part way)
```

- `compatibility.json` needs no change: no release contains 0061, and
the existing 1.1.11–1.2.1 entries now cover 0062 (covered by a test).
- Known rollback gap, written down in `platform/meta/operations.md`: on
an older runtime, creating a project whose slug equals an existing alias
returns 500 (the trigger refuses it), not `slug already taken`.

## Rule limit

Each receiving alias project uses one more rule out of Cloudflare's
default 200 per domain. The admin docs say so.

## Tests

- Migration: upgrade from the 1.1.11–1.2.1 schema, trigger refusals,
older-runtime inserts.
- Shared recipients: second rule, removed / half-removed row restore,
per-name readiness, cron pickup, room and conflict checks, deploy not
blocked by the alias.
- Route: owner only, validation, plan check, taken, Cloudflare conflict,
settings.
- Ingress, admission, proxy router (mailbox compare, reply pins),
gateway validation, generated `handleEmail` prefix strip.
- CLI: args, help, the command.

Local: typecheck, lint, fmt, `vp run test` (affected) pass. Browser
tests can't run on this machine (TLS proxy blocks the Playwright
download).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
yangshun and others added 6 commits October 22, 2026 23:21
…ero-dev#543)

## Issue

Saving a page currently re-reads and analyzes unchanged source files,
then regenerates routing output even when only the page body changed.
This adds unnecessary work as the app grows.

## Fix

This change caches file-derived analysis, invalidates edited files
through the watcher, and skips route-entry regeneration and route-module
invalidation when routing inputs remain unchanged. Recent writes are
conservatively re-analyzed to preserve freshness. Layout inventories are
deterministic, and returned Markdown metadata cannot mutate the cached
analysis. Startup and file changes serialize regeneration so the
generated files and successful-input checkpoint stay consistent. Watcher
failures are logged and retried on a later save. Auth provider/dialect
settings participate in change detection, and missing generated routing
files are repaired on the next source save.

The implementation is shared across React, Vue, Svelte, and Solid Pages.
**The benchmarks below measure React only.**

## Results

Measured from file write to the updated heading appearing in Chrome,
using Cloudflare dev with local workerd on an Apple M5:

| Generated pages | Update | Before | After | Time saved | Change |
|---:|---|---:|---:|---:|---:|
| 1 | Component HMR | 97.0 ms | 96.4 ms | 0.6 ms | −0.7% |
| 1 | Server-page reload | 132.9 ms | 142.9 ms | −10.0 ms | +7.5% |
| 100 | Component HMR | 95.2 ms | 96.8 ms | −1.6 ms | +1.7% |
| 100 | Server-page reload | 119.5 ms | 141.6 ms | −22.2 ms | +18.5% |
| 1,000 | Component HMR | 350.1 ms | 98.6 ms | **251.5 ms** | **−71.8%**
|
| 1,000 | Server-page reload | 329.8 ms | 167.2 ms | **162.6 ms** |
**−49.3%** |

Each fixture includes two additional fixed pages. Results average
session medians across ten paired sessions per size, with 360 timed
saves overall and seeded, sequential variant ordering after warmup.

Both 1,000-page improvements are statistically significant after Holm
correction across six comparisons (`p = 0.0117`). The 95% savings
intervals are **235–268 ms** for HMR and **123–197 ms** for reloads.
Smaller fixtures show no statistically clear improvement or regression.

Validation:

- Regression coverage includes overlapping startup/saves; retry after
write, auth-generation, and invalidation failures; changed auth
settings; deleted outputs; stable layout ordering; and metadata
isolation. Body-only saves return to the fast path after recovery.
- Latest-base verification (`36a3697f`): core build, 465
routing/cache/navigation and email unit/integration tests, and all 33
workspace typecheck tasks passed. The targeted run skipped 13 tests.
- With preceding base `cd480dde`: affected suite passed 10,936 tests;
full suite passed 14,314 tests, with 31 skipped.
- Formatting and lint passed.
- Real Cloudflare dev with Chrome: loader and middleware body edits ran
fresh code without restarting the isolate. A forced generated-file write
failure was logged without terminating the server; the next save
regenerated the POST route and its types. A body-only save also repaired
deleted route types. No browser runtime errors.
- Node production Pages coverage is required. Optional Bun/Deno
availability probes retain the 10-second timeout.

The performance figures above are from the original benchmark run; this
follow-up was verified for correctness.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: LongYinan <github@lyn.one>
…o-dev#556)

## Issue

OAuth upload validation currently scans every approved asset for each
requested asset while holding the project lock. At the allowed
5,000-asset batch size, this can require millions of comparisons.

## Fix

This reduces validation from O(approved × requested) to expected
O(approved + requested).

Build a `Set` of approved `(blake3, md5, size)` tuples, using a local
`assetApprovalKey()` helper with `JSON.stringify` for both indexing and
lookup. Project matching and exact matching of all three fields are
preserved.

## Benchmark results

12 paired runs on Apple M2 Pro, Node v24.21.0. Validation timings
include Set construction and JSON encoding; database, presigning, and
network I/O are excluded.

| Approved assets | Requested assets | Before | After | % reduction |
| --- | --- | --- | --- | --- |
| 100 | 100 | 0.084 ms | 0.051 ms | 39.1% |
| 5,000 | 100 | 4.721 ms | 2.082 ms | 55.9% |
| 5,000 | 1,000 | 40.969 ms | 2.317 ms | 94.3% |
| 5,000 | 5,000 | **227.016 ms** | **3.340 ms** | **98.5%** |
| 20,000 | 5,000 | 808.418 ms | 10.352 ms | 98.7% |

The 5,000/5,000 case is about **68× faster**.

## Why the tests changed

Expanded coverage verifies multiple approved assets, reordered batches,
duplicates, mismatched fields, mixed valid/invalid batches, and strict
field types (`"3"` versus `3`). **All 19 tests pass against both the
original and optimized implementations.**

## Verification

Typechecking passed; 4,447 affected tests and 14,496 full-suite tests
passed.
## Issue

Trunk CI is broken now.

Staging smoke tests still require `no-store` for Access-protected hashed
assets, but voidzero-dev#552 intentionally preserves `private, max-age=31536000,
immutable`. Require `private` and `immutable` for protected assets,
continue rejecting conflicting cache directives, and align the manual
smoke-test instructions.

## Fix

Fixes the asset-cache assertion in [the staging smoke
run](https://github.com/voidzero-dev/void/actions/runs/39804112659/job/119918024323).

## Verification

Validation: all 89 existing focused auth/cache/smoke tests passed, all
33 workspace typecheck tasks passed, and formatting and lint checks
passed.

Unfortunately the failing workflow doesn't run on PRs so this can't be
verified on CI until merge.
)

GitHub check logs accumulated horizontal indentation after every Unix
newline, turning dependency lists and deploy output into a staircase
([reported
check](https://github.com/voidzero-dev/voidzero.dev/runs/120314852404)).
Build children write to pipes, so their LF delimiters need
carriage-return/newline semantics when replayed into the check renderer.

Render captured LF delimiters as CRLF and keep plain LF-delimited logs
on the text fast path. Preserve raw captured delimiters, intentional
indentation, ANSI parser state, and bare-CR progress rewrites.
Regressions exercise the real build-agent capture → Markdown path with
LF/CRLF, split chunks, colors, progress updates, and deployment boxes,
plus a 100-line plain tail exceeding the terminal input budget.

Validation:
- Reproduced the staircase with failing regression tests before the fix.
- 197 focused renderer/capture/oracle tests passed.
- `vp run check`: all 33 workspace tasks passed; changed-file formatting
and lint passed.
- `vp run test --list` and `vp run test`: 4,425 tests passed.
Socket/browser tests were blocked by sandbox permissions; one unrelated
Zero Trust timeout passed when rerun alone.
- `vp test run --bail 1`: full-suite selection attempted once, stopped
on Chromium launch/socket permission failures. `vp run build:all` also
encountered sandbox-denied loopback sockets during prerendering.
…dzero-dev#582)

## Summary

Google sign-in through configured login methods fails at the token
exchange. `oauth4webapi`'s `ClientSecretBasic` form-encodes the client
ID and secret before Base64 (RFC 6749 §2.3.1), turning every `-`, `.`
and `_` into `%2D`, `%2E`, `%5F`. Google compares the raw values without
decoding, so `761060634857-….apps.googleusercontent.com` / `GOCSPX-…`
never match.

Google advertises both methods, and Basic was picked first. Prefer the
form body, which providers always decode:

```diff
 exchange()
   methods = token_endpoint_auth_methods_supported ?? ['client_secret_basic']
-  if methods has client_secret_basic → ClientSecretBasic
-  elif methods has client_secret_post → ClientSecretPost
+  if methods has client_secret_post → ClientSecretPost
+  elif methods has client_secret_basic → ClientSecretBasic
   else → reject
```

Basic-only providers keep the existing behavior. The legacy
`auth/google.ts` path already posts credentials in the body and is
unaffected.

## Evidence

New unit test: Google connection, client ID
`761060634857-abc_def.apps.googleusercontent.com`, discovery listing
`['client_secret_post', 'client_secret_basic']`. Expects the raw
`client_id` in the token request body and no `Authorization` header.

- **Before:** `platform-oidc.test.ts`: 1 failed | 24 passed
- **After:** `platform-oidc.test.ts`: 25 passed

`platform-login` and `platform-bootstrap` integration tests: 42 passed.
The 2 browser cases didn't run locally because Playwright's Chromium
isn't installed. `@voidcloud/api` typecheck passes.

## Merge Danger

**Door:** two-way

**Blast Radius:** OIDC login

Providers that advertise both methods now receive `client_secret_post`
instead of Basic. Both are standard and advertised by the provider
itself.
Fixes voidzero-dev#577

## Summary

`logger.error('save failed', { error })` logged `"error":{}`. `name`,
`message`, `stack` and `cause` are non-enumerable on `Error`, so
`JSON.stringify` drops them.

`format()` in `packages/void/src/runtime/log.ts` now passes a replacer
to `JSON.stringify`. The replacer writes each `Error` as its own
enumerable fields plus `name`, `message`, `stack` and `cause`. This
covers nested errors and `cause` chains. The `{ msg, ...fields }` shape
is unchanged. An error that appears a second time (for example
`error.cause = error`) is written as `{ name, message }`, so
serialization terminates and the other fields are kept.

An error with its own `toJSON` still uses it, because `JSON.stringify`
calls `toJSON` before the replacer.

## Tests

New cases in `packages/void/test/unit/runtime-log.test.ts`:

- `Error` fields keep `name`, `message`, `stack` and `cause`.
- `Error` subclasses keep their own enumerable fields (`status`).
- A cyclic `cause` does not drop the other fields.

`vp test run packages/void/test/unit/runtime-log.test.ts`: 3 failed, 12
passed without the fix; 15 passed with it. `vp lint` and `vp fmt
--check` are clean on the changed files.

## Docs

`docs/reference/api/resources.md` notes that you can pass a caught
`Error` in `fields`.

---------

Signed-off-by: Christoph Nakazawa <xnakazawa@pm.me>
Co-authored-by: Christoph Nakazawa <xnakazawa@pm.me>
@cursor
cursor Bot force-pushed the cursor/feat-useform-seterror-dbee branch from 97b07ae to ed7548d Compare October 26, 2026 03:10
Brooooooklyn and others added 19 commits October 26, 2026 11:13
…ero-dev#554)

Fixes voidzero-dev#550

## Problem

`loadVoidAuthConfig()` used a native `import()`. Node keeps the first
evaluation of a file, and of every file it imports, until the process
exits. So while the dev server ran, the generated auth schema kept using
the first `auth.ts` it saw.

```
save auth.ts ──► watcher ──► import(auth.ts) ──► cached copy ✗
save helper  ──► (not watched)                                ✗
fix syntax   ──► import(auth.ts) ──► old copy                 ✗
```

## Fix

In dev, Void declares a small **Node-only Vite environment**,
`void_auth_config` (only for `serve`; no HMR client), and loads
`auth.ts` through its module runner:

```
                         void_auth_config (Vite env, dev only)
save a file ──► in its module graph? ──yes──► mark stale ──► regenerate
                                                                     │
               next load: stale? ──yes──► moduleGraph.invalidateAll()  │
                              │                                       ▼
                              └──────────────► runner.import(auth.ts) ──► .void/better-auth-schema.ts
```

One flag (`authConfigStale`) drives it. It's set by a save of a file in
the graph, or by a failed load.

- **Dependencies come from Vite's module graph.** Void keeps no list of
its own.
- **Packages** stay external, so they load once.
- **Builds and CLI commands** (`void prepare`, `db generate`, `void
build`) keep the plain `import()` from `main`. They run once per
process, so caching doesn't matter there.
- **Start-up auth migrations** load through the same runner. A restarted
dev server (`r`, or Void's own restart) creates the new tables and
columns.

### Edge cases

| Case | Behavior |
|---|---|
| `auth.ts` edit / local helper edit | schema regenerates ✓ |
| syntax error in a helper, then fix | error logged, then regenerates ✓
|
| import of a file that doesn't exist yet, then created | the new file
triggers a clean load ✓ |
| file saved while a load is running | loads run in order, so the next
load picks it up ✓ |
| other saves while the config is broken | error logged; the
regeneration waits for the fix, as `main` does when the auth config
fails to load |
| route or unrelated save | config not reloaded ✓ |

Vite's own update pass skips this environment (`hotUpdate` returns
`[]`), so saves don't log a `page reload` that nothing receives.

### Same pattern as Vite and React Router

```
                      own env, consumer 'server'   runner.import   invalidateAll on change
Vite runnerImport()   ✓ (inline)                   ✓               n/a (one-shot)
React Router routes   ✓ (__config_loader)          ✓               ✓
this PR               ✓ (void_auth_config)         ✓               ✓
```

All APIs used are public exports of `vite` 8 (Environment API, "release
candidate": stable within a major). A plain `server.restart()` would not
fix voidzero-dev#550: Vite restarts in the same process, so Node's `import()` cache
would survive it.

### History

The first push used a process-wide `module.registerHooks` resolve hook.
That needed its own dependency list, content hashes and CommonJS special
cases. After review it's replaced by this Vite-based version, which is
smaller and only exists in dev.

## Restart still needed for

New auth tables and columns in the **local database**. They're created
when the dev server starts or restarts, as before. The docs line says
so.

## Checks

- `plugin-routing.test.ts` drives a **real Vite dev server** (no
watcher, events sent by hand, plus a slow `watchChange` plugin so the
test can't depend on Vite's handler order). It covers:
  - edits to the config and to a helper;
  - a syntax error, then the fix;
  - a missing file created later;
  - a route save that doesn't reload the config.

Other tests cover `hotUpdate`, and that the environment is declared only
for `serve` (not preview or build).
- `plugin-migration.test.ts`: start-up migrations get the dev server's
importer.
- `auth-config-loader.test.ts`: the exports, the importer, and
`devAuthConfigImporter` on a real server.
- Each guard was removed once (mutation check) and a test failed each
time: stale on save, stale on failure, `invalidateAll`, the created-file
reload, and the runner import.
- On a built CLI in a scratch app, `vp dev`:
- helper edit, config edit, syntax error then fix, and a missing file
created later all updated the schema;
  - routes still served;
  - an in-process restart added the new column to the local D1 table;
  - no extra log lines.
- `void` package typecheck, lint and fmt pass. Affected tests (`vp run
test --base origin/main`, 736 files) pass.

The second commit gives the platform status browser test the same 30s
timeout as its sibling browser tests. It timed out once under a full
parallel run.

### Not changed here (older behavior)

- If `auth.ts` itself has a syntax error, auth config discovery
(`findVoidAuthConfig`) can't parse it and treats it as "no config",
without saying so.
- If `auth.ts` imports a file that doesn't exist yet, the **Worker**
(`void_worker`) keeps returning 500 even after the file is created;
`main` does the same. The generated schema does recover.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
## Summary

Replace the plain `void init` welcome pages with a shared Vite-style
layout for React, Vue, Svelte, and Solid, using Void colors, system
fonts, and automatic light/dark mode.

Preserve the original Pages-mode guidance and the database starters'
message list, form, API example, and seed instructions. Bundle a small
stack SVG and a theme-aware Void logo with `void`, with Vite attribution
embedded in the icon sprite.

Keep the shared starter styles in a CSS source file and inline the raw
CSS into the CLI bundle, with matching type declarations and build/test
configuration.

> [!IMPORTANT]
> This ships the SVG files with the core `void` package. I'm not a fan
of this but the starter code is already implemented within `void` and
since the SVG files are just a few KB + won't be bundled into a build,
it's not terrible.
>
> My preference would be to shift the starter code and assets into
`create-void`, but then `void init` will have to call `create-vite`,
creating a circular dep. Happy to do this in a follow-up PR.
> 
> Also, I'm not sure why `void init` needs to exist when there's
`create-void`. Maybe it can be removed?

## Examples

Screenshots from the four generated apps. React and Vue include
database-backed message forms; Svelte and Solid use Static Pages.

| React | Vue |
| --- | --- |
| ![React
starter](https://raw.githubusercontent.com/yangshun/void/0c6a519b9b0253785409f7fc7bd3fd1a6a555fe6/starters/react.jpg)
| ![Vue
starter](https://raw.githubusercontent.com/yangshun/void/0c6a519b9b0253785409f7fc7bd3fd1a6a555fe6/starters/vue.jpg)
|

| Svelte | Solid |
| --- | --- |
| ![Svelte
starter](https://raw.githubusercontent.com/yangshun/void/0c6a519b9b0253785409f7fc7bd3fd1a6a555fe6/starters/svelte.jpg)
| ![Solid
starter](https://raw.githubusercontent.com/yangshun/void/0c6a519b9b0253785409f7fc7bd3fd1a6a555fe6/starters/solid.jpg)
|

## Validation:

- `CI=true vp run test`: 734 test files and 14,499 tests passed; 31
tests skipped.
- `vp run check`: all 33 typecheck tasks passed.
- Targeted lint and formatting checks passed.
- Rebuilt CLI smoke test confirms generated `pages/style.css` matches
`starter-ui.css` byte-for-byte.
- Browser-verified the stack illustration and Void logo in light and
dark mode.
…-dev#566)

Fixes voidzero-dev#527

## Summary

Named actions no longer pollute history when they re-render the current
page. voidzero-dev#530 already removed `?actionName` from the committed URL; voidzero-dev#531
still used `pushState` for every mutation, so each
`useForm('/db?create')` / `action()` submit added another back-stack
entry at `/db`.

- Same-document mutations use `replaceState` instead of `pushState`.
- `mutationCommitUrl()` prefers the server `pageData.url` when the
action updates query params on the same path (e.g. `POST /books?delete`
→ `/books?page=2` still pushes once).

## Tests

Updated POST history expectations to `replaceState` when the browser URL
already matches the committed page URL. Added coverage for URL-changing
POSTs and repeated same-page named-action submits.

`vp test run packages/void/test/unit/pages-client.test.ts` — 69/69
passed. 🐸

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
voidzero-dev#588)

Fixes voidzero-dev#580

## Summary

Static `void deploy --platform cloudflare` always wrote
`assets.html_handling: auto-trailing-slash` into the generated
`wrangler-static.json`, ignoring `cloudflare.assets.html_handling` from
`void.config.ts` / the materialized root Wrangler config. That caused
extra 307 redirects for prerendered sites using `about/index.html` with
links to `/about`.

## Changes

- Read `assets.html_handling` from the root Wrangler config at static
deploy time.
- Pass it through `buildStaticManifest` into `manifest.assetConfig` so
`writeCloudflareStaticDeployConfig` persists the authored value.
- Type `cloudflare.assets.html_handling` in Void config as
`AssetConfig['html_handling']` from `@void/deploy-core` (no duplicated
union).

## Testing

- `packages/void/test/unit/package.test.ts` (manifest + wrangler reader)
- `packages/deploy-cloudflare/test/unit/static.test.ts` (config writer)
- `packages/void/test/unit/cloudflare-deploy.test.ts` (end-to-end static
deploy)

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
…idzero-dev#589)

Fixes voidzero-dev#557

## Summary

Vue Pages generated `patchProps` assigned `comp.props =
Object.keys(props)`, which replaced components that already declare
runtime props and dropped defaults, required flags, validators, and
types. The `__voidPatched` guard then froze that stripped declaration.

## Changes

- Add `hasRuntimePropsDeclaration()` in generated SSR and client bundles
before patching.
- Skip overwriting `comp.props` when the component already has a
non-empty runtime declaration.
- Keep registering prop keys from page data only for type-only
`defineProps<T>()` components that lack runtime props metadata.
- Deduplicate the injected helper via `PATCH_PROPS_RUNTIME` in
`plugin-pages.ts`.
- Add unit tests that execute the generated `patchProps` from renderer
and client modules.

## Testing

- `pnpm exec vp test run packages/vue/test/unit/plugin-pages.test.ts`
- `pnpm exec vp run --filter ./packages/vue typecheck`

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
…-dev#587)

Fixes voidzero-dev#565

## Problem

With auth on, `vp dev` aliases five better-auth subpaths to files in
Void's own copy of better-auth. Each alias used a **string** `find`, and
in Vite a string `find` also matches every deeper path:

```
alias find 'better-auth/client'  →  …/better-auth/dist/client/index.mjs

import 'better-auth/client'          →  …/dist/client/index.mjs          ✓
import 'better-auth/client/plugins'  →  …/dist/client/index.mjs/plugins  ✗ (no such file)
```

So any app that passes a Better Auth client plugin (`adminClient()`,
`organizationClient()`, …) to `createAuthClient` broke in dev:

- the client failed with `Failed to resolve import
"better-auth/client/plugins"`;
- SSR returned 500 (`No such module …`).

`vp build` adds no alias, so it was fine.

## Fix

Each `find` is now an anchored RegExp, `^better-auth/<sub>$`. Vite's
alias API has only two kinds of `find`: a string, which also matches
deeper paths, or a RegExp. So a RegExp is the only way to get an exact
match. The repo already does the same (`{ find: /^void$/ }` in
`cli/wrapper.ts`).

```
                                 before (string)        after (exact)
better-auth/client               Void's copy ✓          Void's copy ✓   (unchanged)
better-auth/react|vue|svelte|solid  Void's copy ✓       Void's copy ✓   (unchanged)
better-auth/client/plugins       broken path ✗          normal resolution ✓
```

The five exact ids get the same replacement in every Vite step that
reads `resolve.alias`:

- the alias plugin;
- `vite:pre-alias`;
- SSR externalization;
- the dependency optimizer.

So the cold-cache reload protection from voidzero-dev#24 (the reason the aliases
exist) is kept.

## Checks

- **New test** in `plugin-routing.test.ts`. It starts a real Vite dev
server with auth on and resolves:
- `better-auth/client` and `/react` from an app file, which has no
better-auth of its own. They resolve only through the aliases, to Void's
copy.
- `better-auth/client/plugins` from inside the package. It resolves to
`dist/client/plugins/index.mjs`.
- **Mutation check:** the test fails with the old string `find` and with
no aliases at all.
- **Scratch app from the issue:** a React pages app, auth on,
`adminClient()` from `better-auth/client/plugins`, cold cache, `vp dev`.

  ```
                   before                     after
SSR GET / 500 No such module 200, authClient.admin.listUsers is a
function
  client module    500 Failed to resolve      200
  ```

- `void` typecheck, lint and fmt pass. Affected tests (`vp run test`,
508 files) pass.

## Not changed here

- **`client/plugins` is not pinned to Void's copy.** The issue mentions
this as optional. Without it, the user's own better-auth provides
`client/plugins`, as in `vp build`, and Vite discovers it like any other
dependency. That costs one optimizer reload on the first cold-cache page
that imports it. Pinning would point the import at Void's better-auth
version instead of the one the app installed. That's a product decision,
so I left it out.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…idzero-dev#568)

Fixes voidzero-dev#523

## Summary

After a successful submit, `useForm` still promotes submitted values to
defaults (edit forms / `hasChanges` unchanged). You can now get back to
mount-time values:

- `reset({ initial: true }, ...fields)` — reset to the values passed to
`useForm` on mount
- `resetOnSuccess: true` on `useForm` options — clear create forms after
success
- `reset()` without options still targets current defaults (last
submitted values after success)

Shared via `parseFormResetArgs` in `void/pages-client`; React / Vue /
Svelte / Solid + docs / codegen.

## Tests

- `packages/void/test/unit/form-reset.test.ts`
- `packages/vue/test/unit/use-form.test.ts` (reset / `reset({ initial:
true })` / `resetOnSuccess`)

Typecheck: `void`, `@void/react`, `@void/vue`, `@void/svelte`,
`@void/solid`.

🐸

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
…idzero-dev#559)

## Summary

Depends on [voidzero-dev#561](voidzero-dev#561), which
adds JSX server companion support. **Merge after voidzero-dev#561, or together with
it.** On `main` without voidzero-dev#561, the page scanner still lists
`index.server.tsx` as a page of its own.

React and Solid decide whether to build the `pages-client` bundle with
`hasNonIslandPages()`. That check counted server companions
(`index.server.tsx`, `index.server.jsx`) as ordinary pages. So an
island-only app still emitted a `pages-client` bundle it never used.

```
pages/
  index.island.tsx    island page      → islands-client only
  index.server.tsx    server companion → not a page (skipped now)
  status.server.v2.tsx  ordinary page  → still needs pages-client ✓
```

The skip matches voidzero-dev#561's page scanner exactly: a name that **ends in**
`.server.tsx` or `.server.jsx` is a companion. A name that only contains
`.server.` (`status.server.v2.tsx`) is still a page.

## Tests

- `packages/{react,solid}/test/unit/plugin-pages.test.ts`:
- island-only pages plus a `.server.tsx` or `.server.jsx` file → no
`pages-client`;
- an ordinary page next to them (`about.tsx`, `about.jsx`, `blog/…`,
`status.server.v2.tsx`) → `pages-client` kept.
- **Mutation check:** without the skip, 4 tests fail. With a plain
"contains `.server.`" rule, the 2 `status.server.v2.tsx` cases fail.
- **With voidzero-dev#561 merged** (no conflicts):
  - these tests and voidzero-dev#561's own tests pass;
  - a real island-only React build has no `pages-client` chunk;
- `vp preview` serves only the islands script, and the island hydrates
(a button click updates it).
- Affected tests (`vp run test`, 508 files), lint and typecheck pass.

## Not changed here (older behavior)

- `hasNonIslandPages()` checks only the file name. So a file in a
`_`-prefixed folder (e.g. `_components/Button.tsx`) still counts as a
page, while the scanner skips it. Switching the adapters to the
scanner's own result would fix that; it's a separate change.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: LongYinan <github@lyn.one>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…unch Chromium (voidzero-dev#591)

## Problem

Two platform browser tests launch Chromium under Vitest's 5s default:

- `platform-bootstrap.test.ts` › accepts native browser submissions…
- `platform-login.test.ts` › confirms a real native linking form…

The bootstrap test takes about 4.4s on its own, so under a full parallel
run (`vp run test`) it timed out:

```
FAIL  platform-bootstrap.test.ts > … native browser submissions …
Error: Test timed out in 5000ms.
```

## Fix

Use the same 30s timeout as every other platform browser test:

```
admin-dashboard  (2 browser tests)  30_000 ✓
admin-plans                         30_000 ✓
platform-status                     30_000 ✓
platform-bootstrap                  5s → 30_000
platform-login                      5s → 30_000
```

The diff is mostly the formatter re-indenting the two test bodies. With
whitespace hidden (`?w=1`) it's +17/−5.

## Checks

- Both files pass: 44 tests.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…oidzero-dev#561)

## Summary

This adds support for `.tsx` and `.jsx` in HTTP/WebSocket route files
and recognizes `.server.tsx`/`.server.jsx` page companions.

It also fixes existing `.server.tsx` companions being discovered as
standalone pages such as `/foo.server` and included in generated client
component manifests.

The extension enables JSX syntax; the file's location and naming
determine its role.

This PR ended up larger than I imagined, mainly because there are many
cases to handle. Half of the changes are tests and docs updates. There's
also a fair bit of consolidation going on.

P.S. TBH I didn't start out wanting to support JSX for WebSockets, but
the route conventions + extensions logic was quite entangled and it was
hard to untangle them. Adding support for JSX WebSockets was the easiest
way forward while not making the code messy. One use case I convinced
myself to add support for JSX in WebSockets is chat apps that sends
rendered HTML over WebSockets (hopefully that's not too much of a
stretch to imagine!).

> [!WARNING]
> This PR updates the docs with a new feature, and I think docs are
published at merge time, which can result in unreleased features
appearing in the docs.

## Motivations

It's only a matter of time before someone requests for this, given the
precedents for JSX-capable HTTP route files.

- **[Vercel OG image
generation](https://vercel.com/docs/og-image-generation):** the official
examples use `api/og.tsx` and `api/og.jsx`, as well as Next.js
`app/api/og/route.tsx`, to generate images from JSX inside HTTP
handlers.
- **[TanStack Start server
routes](https://tanstack.com/start/latest/docs/framework/react/guide/server-routes):**
the documentation defines HTTP `server.handlers` in `routes/hello.tsx`,
alongside a JSX page component.

Christoph also mentioned that he [prefers
`.tsx`](https://github.com/nkzw-tech/server-template#why-does-this-template-use-tsx-as-a-file-extension).

## Scope

| File | Behavior |
| --- | --- |
| `routes/api.tsx` / `routes/api.jsx` | HTTP handlers at `/api` |
| `routes/api.dev.tsx` / `routes/api.prod.jsx` | HTTP handlers
restricted to development / production |
| `routes/chat.ws.tsx` / `routes/chat.ws.jsx` | WebSocket handlers at
`/chat` |
| `routes/chat.dev.ws.tsx` / `routes/chat.prod.ws.jsx` | WebSocket
handlers restricted to development / production |
| `pages/foo.server.tsx` / `pages/foo.server.jsx` | Loaders, actions,
and page configuration for the matching page, excluded from standalone
page discovery and generated client component manifests |
| `pages/foo.tsx` / `pages/foo.jsx` | Existing page component support;
no new component formats |

Both JSX extensions support `.dev` and `.prod` restrictions. For
WebSocket routes, the environment suffix goes before `.ws`.

HTTP and WebSocket discovery, filename parsing, page companion lookup,
and route binding inference share `ROUTE_EXTENSIONS` (`ts`, `tsx`, `js`,
`jsx`, `mjs`, `mts`) from `shared/route-extensions.ts`. The page scanner
uses the derived `ROUTE_EXTENSIONS_SET` for membership checks. Both
route scanners use the fixed `listRouteFiles()` helper and the existing
WebSocket filename matcher to distinguish their roles.
`listConventionFiles()` retains its existing script extensions for crons
and queues.

Discovery and binding inference share the `.well-known` glob policy,
including configured source directories. Environment filtering applies
only to route files, so `_`-prefixed helpers retain their bindings in
both environments. Companion exclusion applies only to supported script
extensions, preserving page components such as `web.server.md`.

WebSocket resource-name inference strips JSX extensions so changing
`chat.ws.ts` to `chat.ws.tsx` or `chat.ws.jsx` preserves the inferred
Durable Object identity. Void sets no JSX options: route JSX compiles
with the app's own JSX settings (framework plugins, tsconfig for TS
files, or `oxc.jsx`). The docs show Hono's JSX via `oxc.jsx` for apps
without a JSX framework.

The documentation covers supported HTTP, WebSocket, and companion
extensions. General enforcement against manually importing `.server.*`
modules from client code remains a separate feature.

## Validation

- `vp run check`: all 33 workspaces passed.
- `vp run test`: 11,214 passed, 31 skipped.
- `vp test run --maxWorkers=4`: 14,594 passed, 31 skipped.
- Formatting and lint passed.

Regression coverage executes JSX HTTP handlers and WebSocket messages
using React and Hono runtimes, checks environment filtering and
generated types/bindings (including `.well-known` routes and underscored
helpers), and executes companion loaders/actions. It also verifies that
Vue, Svelte, and Markdown pages with a `.server` suffix remain
discoverable. Production-build assertions confirm companion code is
present on the server and absent from the generated client graph and
assets.

## Follow-up

[voidzero-dev#559](voidzero-dev#559) (merged) excludes
server companions from React and Solid's client bundle detection, so
island-only apps do not emit the full `pages-client` entry. Until this
PR lands, `main` still lists a companion such as `index.server.tsx` as
its own page (`/index.server`).

## Review and polish (rebased on `main`)

Rebased onto `main` (the merge commit is dropped; the four commits are
unchanged). A deep review found the code correct; one docs commit was
added:

```
"uses your app's configured JSX runtime"  →  Void sets no JSX runtime
  React / Solid app        framework JSX → render with its server renderer
  API only / Vue / Svelte  no runtime (Vue's jsx: preserve leaves JSX raw → build fails)
                           → docs now show oxc.jsx with hono/jsx (needs hono in the app's deps)
```

Checked in real apps with that setup, `.tsx` and `.jsx` routes, dev and
`vp preview`:

| App | `/hello` (.tsx) | `/hello2` (.jsx) | existing pages |
|---|---|---|---|
| Vue pages | 200 | 200 | 200 |
| Svelte pages | 200 | 200 | 200 |
| API only (with `hono` dep) | 200 | 200 | 200 |

Also in that commit: the WebSocket and companion docs link to the JSX
section; the companion table drops the `routes/` row and the repeated
pair; `reference/structure.md` lists companion extensions;
`websockets.md` says `chat.ws.dev.ts` is not a WebSocket route (it is
served as an HTTP route at `/chat.ws`).

Mutation checks: reverting `ROUTE_EXTENSIONS` to the old list fails 93
tests; removing the `.server` page skip fails 20. Affected tests (`vp
run test --base origin/main`, 518 files) pass except one unrelated
platform browser test that times out at 5s under full load (passes
alone); that is fixed separately.

### Left for the maintainer (product calls)

- Docs go live at merge, before the feature ships (as noted above).
- `.tsx`/`.jsx` files already in `routes/` that were ignored before now
become routes; a release note could tell users to prefix non-route files
with `_`.
- Two companions for one page (`foo.server.ts` + `foo.server.tsx`)
silently use the first by extension order, as `.ts` + `.tsx` already did
on `main`.
- Older, not from this PR: `a.ts` + `a.tsx` (like `a.ts` + `a.js` today)
register two handlers for `/a` without an error.

---------

Co-authored-by: LongYinan <github@lyn.one>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ro-dev#569)

Live Cloudflare acceptance exposed failures in interrupted platform
updates, slow application requests, usage reporting, page revalidation
and account prerequisite guidance. This change fixes those paths and
preserves the provider resources used by repeatable live tests.

## Changes

- Explain R2 activation, the paid Workers for Platforms subscription,
and Analytics Engine activation with direct account dashboard links and
recovery instructions. Report both known R2/WfP blockers from the
existing read-only planning checks without adding probes. Preserve
genuine permission failures and provider diagnostic causes. Replace
misleading token-permission advice for Analytics Engine code 10089 and
document its dashboard Enable action and Workers Free availability.
- Resume interrupted credential updates without trying to reactivate
immutable Worker versions bound to old secrets. Keep traffic closed,
retain update authority across checkpoints, and reject concurrent Worker
replacement. Retry transient health-check transport errors only.
- Await foreground application responses by default. Keep explicit
operator deadlines and bounded storage/background work. A real Sandbox
request completes after 44.9 seconds instead of hitting the previous
ten-second timeout.
- Use the current AI Gateway analytics query and scope the optional
managed-build gate to GitHub connection routes, allowing ordinary
project usage requests when managed builds are disabled.
- Store managed ISR entries in the existing R2 bucket so an eventually
consistent KV read cannot restore purged HTML for its full TTL. Keep
legacy KV discovery/cleanup; log legacy deletion failures without
blocking the HTML/JSON edge purge after successful R2 deletion.
Authoritative R2 errors still propagate. Native deployment behavior and
bindings are unchanged.
- Retain native live-test resources under an explicit namespace, verify
data/resource reuse, and serialize the shared CI target. Document
connection-pool budgeting for platforms sharing a PostgreSQL server.

## Validation

Final local verification on `21bb87ef0`: `vp test run` passed 736 files
and 14,547 tests (31 tests skipped, zero unhandled errors), and all 33
package typechecks passed. The prerequisite-message tests pass 184
focused cases; the legacy-KV follow-up passes 39 focused cases. Both
path-specific and full-purge endpoint regressions failed before that fix
and pass afterward. Affected verification passed 10,974 tests for the
CLI changes and 4,907 for the cache follow-up. Core runtime/package and
documentation builds, formatting and lint passed. Independent reviews
found no remaining actionable issues in these changes.

A rebuilt CLI was tested against the unpaid account: the real
installation plan reports R2 code 10042 and Workers for Platforms code
10121 together, with account-specific links and retry instructions. It
exits before creating installation files, and the existing platform
registry is unchanged. The captured real Analytics Engine code 10089
failure was replayed through the updated formatter and now directs the
operator to Enable and the appropriate recovery command.

The free-account comparison also verified a native static app and stock
Void AI Gateway creation. Analytics Engine binding upload failed with
10089 before activation and passed after zero-price dashboard activation
while Workers Free remained selected. R2 was not activated and WfP was
not purchased; a fresh managed platform cannot complete installation on
that account until those prerequisites are satisfied. Airwallex’s
generic AI Gateway 500/code 7000 was not reproduced.

Both paid-account Cloudflare installations were deployed and
independently verified on `852341fc6` with packaged manifest
`34f1cea97be8251055b5002ec9eb2cdaa83393be73b81cd6f23532daafb00a25`.
Provider readback verified all five active Worker versions at 100%
traffic, all 19/15 retained resource IDs, settings and migration
history. Three 600-second page-action purges and one all-project purge
returned fresh first-load MISS content in the same Cloudflare location,
with independent R2 before/deleted/replacement readback. Project usage
returns HTTP 200; AI usage matches the provider aggregate after
whole-neuron rounding. All 24 managed apps passed final reachability;
the kitchen smoke passed 14/14.

That live acceptance covered an upgraded and a fresh installation, 24
managed apps (239 functional checks; 81 browser checks across 16 apps),
four native app classes, two PostgreSQL variants, two genuine users,
teams and ownership, admin/user settings, quota/suspension controls,
protected apps, connected-app publishing/private preview,
inbound/outbound email, queues, cron, AI, Live SSE, WebSockets and
Sandbox. Earlier local acceptance also covered 243 real PostgreSQL
checks, playground builds and packed npm/pnpm consumers for four
adapters.

Remote
[CI](https://github.com/voidzero-dev/void/actions/runs/39996202354),
[Platform
CI](https://github.com/voidzero-dev/void/actions/runs/39996202494),
[Prepublish
Test](https://github.com/voidzero-dev/void/actions/runs/39996202534),
and [Security
Analysis](https://github.com/voidzero-dev/void/actions/runs/39996202533)
completed successfully on `21bb87ef0`: 19 successful checks, one
skipped, none failing or pending. The skipped credentialed `live-e2e`
job is separate from the provider-backed acceptance above. Automated
review completed on this head with no findings; the legacy-KV review
thread is resolved.

## Known limitations

The legacy-KV cleanup follow-up has local endpoint regression coverage
and has not been redeployed to the acceptance installations; their
runtime remains `852341fc6`. No existing MySQL service was available, so
MySQL has local wire-protocol coverage only. Verified-email invitation
acceptance, direct Google/GitHub account linking, optional GitHub build
services and third-party AI BYOK were not configured. Successful project
custom-domain attachment remains unverified: the test installation
disables that capability, and its denial path preserves DNS and the
existing app. Destructive remote teardown is excluded by the
retained-resource test policy. An ambiguous pre-fix email receipt
remains preserved; recovery and a duplicate send have not been
attempted. No additional paid plan was purchased.
…oidzero-dev#592)

Fixes voidzero-dev#422

`bundleEntry` bundles the SvelteKit Worker again for `--platform void`
with a plain Vite lib build. That build used Vite's default client
conditions, so dependencies resolved to their `browser` exports:
`@sentry/sveltekit` failed to build, and `postgres` built but picked its
Node build.

voidzero-dev#540 fixed the same problem for `--platform cloudflare` in
`prepareBundledBuildOutput`. `bundleEntry` now uses the same conditions:
`WORKER_CONDITIONS` plus `production`. `production` is fixed rather than
taken from `NODE_ENV`, because a deploy bundle is always a production
build.

The module rules part of voidzero-dev#540 (`.wasm`, `.txt`, `.bin`, …) is not
covered here (see voidzero-dev#535).
Fixes voidzero-dev#525

## Summary

Adds `setError(field, message)` to `useForm` across React, Vue, Svelte, and Solid so apps can surface client-side validation through the same typed `errors` object as server `withValidator()` responses. Changing a field clears that field's validation error automatically (`setData` / `data` assignment).

## Changes

- Add public `setError(field, message)` on `useForm` for all four frameworks.
- Clear `errors[field]` on field change via `setData` (React/Solid) or `data` assignment (Vue/Svelte).
- Update docs for actions/forms and the pages API reference.
- Add Vue unit coverage for `setError` and clear-on-change.

## Testing

- `packages/vue/test/unit/use-form.test.ts`
- Typecheck `@void/react`, `@void/vue`, `@void/svelte`, `@void/solid`

Co-authored-by: froQ <sayhola@froq.me>
@cpojer
cpojer force-pushed the cursor/feat-useform-seterror-dbee branch from ed7548d to e019196 Compare October 26, 2026 07:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

useForm: support client-side validation (or at least setError)