Skip to content

feat(routing): add opt-in route-checked URL strings - #662

Merged
cpojer merged 7 commits into
voidzero-dev:mainfrom
yangshun:feat/typed-routes
Nov 2, 2026
Merged

cpojer merged 7 commits into
voidzero-dev:mainfrom
yangshun:feat/typed-routes

Conversation

@yangshun

@yangshun yangshun commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator

Follow-up to #646: add route checking for plain URL strings, alongside untyped strings and destination objects. It's opt-in for existing apps and on by default for new apps.

// void.config.ts
export default defineConfig({
  routing: { typedRoutes: true },
});

With routing.typedRoutes on, URL strings in Link's href, router.visit(), router.prefetch(), router.flush() and c.rewrite() must match one of the app's routes. Each dynamic segment accepts any text, and a query string or hash may follow. Fixed segments can be written as is (/café menu) or percent-encoded, as routeUrl() returns them (/caf%C3%A9%20menu). Links may also point to same-page queries and hashes and to http://, https://, mailto: and tel: URLs. Relative paths are rejected. With the flag off, the types are unchanged.

<Link href="/about">About</Link>
<Link href={`/users/${user.id}?tab=activity`}>Profile</Link>
<Link href={routeUrl('/users/:id', { params: { id } })}>Profile</Link>
<Link href="/abuot">About</Link> // Type error
<Link href={post.path as RouteUrl}>{post.title}</Link>

void/routes exports two new types. Both are string when the flag is off:

  • RouteUrl: the URL of one of the app's routes. routeUrl() returns it, and c.rewrite() accepts it.
  • Href: anything a link can point to. It's RouteUrl, a same-page query or hash, or an external URL. Link's href and RouteUrlInput use it.

New apps. void init (and create-void, which runs it) writes routing: { typedRoutes: true } when it scaffolds a new Pages app. Running void init in an existing project leaves the setting unchanged.

Also in this PR:

  • Runtime fix: query-only links such as ?page=2 and Link data merged into a #hash href now stay on the current page. Before, they went to /?page=2. Relative URLs now resolve against the current page instead of the origin, in the shared router, the React client and all four Link click handlers.
  • Route coverage:
    • Route files with only a default export count as routes.
    • A trailing /* also accepts its bare prefix (/docs/* accepts /docs), as Hono does.
  • Vue Link types: it now declares the anchor and button attributes and listeners it passes through (target, download, @click, …). They were rejected under strict template checking even with the flag off.

Notes for review:

  • How the switch works: the generated .void/routes.d.ts turns the check on through a RouteTypesConfig interface, the same augmentation pattern as RoutePathMap. Encoded spellings come from a generated EncodedRoutePathMap. Both work whether void/routes resolves to the generated file or to the package.
  • RewriteDestination is unchanged. routing.rewrites and routing.fallbacks destinations can contain :splat placeholders and static files. Only c.rewrite()'s parameter narrows.
  • Vue Link cast: the exported type is DefineSetupFnComponent<LinkProps>. The options component isn't assignable to it, so it keeps a cast. A compile-time check next to the props fails if the written-out props drift from the runtime ones. It can't live in a test, because isolatedDeclarations prevents exporting the runtime props.
  • Known type limits:
    • A dynamic segment also matches empty or /-leading text, so with a root dynamic page //example.com passes. Excluding that would need per-character unions. It's documented and pinned by a fixture.
    • Only uppercase percent-encoding, as routeUrl() writes it, is accepted.
  • void.config.ts isn't watched in dev, so changing the flag needs a dev restart or void prepare. The docs say so.
  • Svelte Link.svelte isn't template-checked, since the repo has no svelte-check. Its props type lives in link-props.ts, which the fixtures check.

Validation:

  • vp run check: 33/33 tasks passed.
  • Type fixtures with the flag on cover the generated declarations, the package declarations, and the package types alone, plus Link href in all four adapters. Every new case was confirmed to fail against the previous behaviour.
  • The four adapter integration suites pass (183 tests), including a new browser case for query-only Link navigation.
  • The packed-consumer smoke test (pnpm, all four frameworks) asserts that void init turns on the flag. Its build and typecheck then run with checked routes.
  • vp run test --base origin/main (before the last two commits): 15,501 passed. The 1 failure was a 5s timeout in plugin-cloudflare-v2.test.ts under load, and it passes on its own. The later commits were verified with their focused suites, vp run check and the packed checks.

Add `routing.typedRoutes` to `void.config.ts`. When it is on, URL strings
in `Link`'s `href`, `router.visit/prefetch/flush` and `c.rewrite()` must
match one of the app's routes. Dynamic segments accept any text, and a
query string or hash may follow. Links may also point to same-page
queries and hashes and to http(s), mailto and tel URLs. With the flag off,
the types are unchanged.

`void/routes` exports `RouteUrl` (a URL for one of the app's routes,
returned by `routeUrl()` and accepted by `c.rewrite()`) and `Href`
(anything a link can point to). The generated `.void/routes.d.ts` turns
the check on through a `RouteTypesConfig` interface, so it works whether
`void/routes` resolves to the generated file or to the package.

`RewriteDestination` stays unchanged because `routing.rewrites` and
`routing.fallbacks` destinations can contain placeholders and static
files. The Vue `Link` now exposes typed props instead of `Component`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cpojer

cpojer commented Nov 2, 2026

Copy link
Copy Markdown
Member

Awesome. imho this is clearly a better default. I don't really see any downsides to making this the default besides that it's a breaking change. What do you think about turning this on for all new projects created via void init?

One issue: Vue Link now rejects existing native attributes and events because the new props type for Vue omits them and it causes some issues with strictTemplates enabled in Vue (This usage now fails: <Link href="/" target="_blank" @click="handler">Home</Link>). Can you fix that?

@cpojer
cpojer self-requested a review November 2, 2026 07:24
@yangshun

yangshun commented Nov 2, 2026 via email

Copy link
Copy Markdown
Collaborator Author

yangshun and others added 3 commits November 2, 2026 15:48
- Resolve relative router and Link URLs such as `?page=2` against the
  current page instead of the origin, in the shared router facade, the
  React client and all four Link click handlers.
- Include default-export route files in RoutePathMap. They are served
  through `app.all()`, so links and rewrites to them are valid.
- Let a trailing `/*` pattern also accept its bare prefix, as Hono does.
- Document that dynamic segments can match empty or slash-leading text:
  template literal types cannot exclude it without per-character unions.
- Type the Vue Link's pass-through anchor/button attributes and listeners,
  and check its written-out props against the runtime prop options.
- Mention the `as RouteUrl` cast for runtime-built rewrite paths, and
  reuse the exported TypedRoutes helper in handler.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`void init` (and `create-void`, which runs it) now writes
`routing: { typedRoutes: true }` when it scaffolds a new Pages app, so
Link hrefs and router URLs are checked against the app's routes from the
start. Running `void init` in an existing project leaves the setting
unchanged.

The packed-consumer check asserts the scaffolded config turns it on, so its
build and typecheck run with checked routes. Docs and the app skill say new
apps have it on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`routeUrl('/café menu')` returns `/caf%C3%A9%20menu`, but typed routes only
accepted the raw spelling, so a written or copied encoded path failed to
typecheck. Codegen now emits an `EncodedRoutePathMap` with the encoded
spelling of each pattern whose fixed segments need encoding, serialized the
same way as `routeUrl()`, and `RouteUrl` accepts both spellings. Dynamic
segments are unchanged. Only the uppercase hex the URL serializer writes is
accepted.

A new fixture checks the package's own route URL types without a generated
declaration, since the generated copy otherwise takes precedence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@yangshun
yangshun marked this pull request as ready for review November 2, 2026 08:17
@yangshun
yangshun requested a review from a team as a code owner November 2, 2026 08:17
@yangshun
yangshun marked this pull request as draft November 2, 2026 08:21
The skill already routes Pages work to the type-safety guide, which covers
checked URL strings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cpojer
cpojer previously approved these changes Nov 2, 2026
The starter template now includes a generated void.config.ts with
routing.typedRoutes turned on, instead of a separate init step that read
and rewrote the config. Later init steps still merge database and worker
settings into it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The React, Vue, Svelte and Solid Pages playgrounds now check Link and router
URL strings against their routes. Also drop the packed-consumer assertion on
the scaffolded config; the init tests already cover it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@yangshun yangshun left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feel free to make any docs changes

Comment thread packages/void/src/runtime/route-url.ts Resolved
@cpojer

cpojer commented Nov 2, 2026

Copy link
Copy Markdown
Member

Dude you are killing me requesting a review and then moving it back to draft :P

Comment thread packages/void/src/cli/template.ts Resolved
Comment thread playground/react-pages/void.config.ts Resolved
@yangshun
yangshun marked this pull request as ready for review November 2, 2026 08:52
@yangshun

yangshun commented Nov 2, 2026

Copy link
Copy Markdown
Collaborator Author

Dude you are killing me requesting a review and then moving it back to draft :P

Yes I'm sorry for that, spotted some other things that I wanted changed. Take your time, don't need to review so fast haha.

@cpojer
cpojer merged commit 291d353 into voidzero-dev:main Nov 2, 2026
2 checks passed
@cpojer

cpojer commented Nov 2, 2026

Copy link
Copy Markdown
Member

We move fast here! This is awesome, thanks for making this change.

We should maybe consider adding a feature to void migrate to enable this, but it's probably way too much work having to parse the JS and insert it in the right spot etc. to be worth it. Most Void apps haven't even been created yet!

@yangshun
yangshun deleted the feat/typed-routes branch November 2, 2026 09:00
@yangshun

yangshun commented Nov 2, 2026

Copy link
Copy Markdown
Collaborator Author

Ya. I actually think it's ok to make it the default. It's a breaking change, but it flags a real error and people should fix it. WDYT?

@cpojer

cpojer commented Nov 2, 2026

Copy link
Copy Markdown
Member

We are trying to be careful about breaking changes as we want to provide a stable platform that people can depend on.

In a way what we just did is similar to Cloudflare's compatibility date - new projects get the new default automatically. I think that's fine for now.

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.

2 participants