Skip to content

feat(routing): support typed navigation destinations - #646

Merged
cpojer merged 1 commit into
voidzero-dev:mainfrom
yangshun:codex/typed-navigation-apis
Nov 2, 2026
Merged

cpojer merged 1 commit into
voidzero-dev:mainfrom
yangshun:codex/typed-navigation-apis

Conversation

@yangshun

Copy link
Copy Markdown
Contributor

Allow typed route destination objects in router.visit(), router.prefetch(), router.flush(), and c.rewrite(). Known route patterns autocomplete and dynamic routes require matching path parameters, while existing string and supported URL inputs continue to work.

router.visit({
  pathname: '/users/:id',
  params: { id: 42 },
  query: { tab: 'activity' },
  hash: 'details',
});

Export RouteDestination and RouteUrlInput from void/routes and serialize destinations through the same builder as routeUrl(). c.redirect() retains Hono's string/URL signature; typed redirects use c.redirect(routeUrl(...)).

Coverage includes generated and package declarations, redirect/rewrite behavior, and client navigation/cache behavior across the four adapters.

Validation with the dependent Link commit applied:

  • vp run check: 33/33 tasks passed.
  • vp test run: 15,379 passed, 31 skipped.
  • Scoped formatting and lint passed.

@cpojer

cpojer commented Oct 31, 2026

Copy link
Copy Markdown
Member

In hindsight, I feel like we should have shipped with typed routes from the start.

I'm not actually sure I love this implementation of passing objects around – it's probably fine because AI writes it, but since Void manages the route map, I'm wondering if we could type these as "Route" which is a set of strings like type Route = '/login' | '/feed' | \/profile?user=${string}`;` – but probably too late to add this to Void.

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

yangshun commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor Author

In hindsight, I feel like we should have shipped with typed routes from the start.

Possibly, but never too late! FWIW Next.js took more than 5 years to add typed routes.

I'm not actually sure I love this implementation of passing objects around – it's probably fine because AI writes it, but since Void manages the route map, I'm wondering if we could type these as "Route" which is a set of strings like type Route = '/login' | '/feed' | \/profile?user=${string}; – but probably too late to add this to Void.

I understand. Personally I prefer the objects way. Actually we can support both objects and route union ways, we could add a flag to the Void plugin config to generate the route unions instead of strings. Next.js does this.

const nextConfig = {
  typedRoutes: true,
}

I'll leave the decision to you as to which combination to allow:

  • Untyped values
  • Objects
  • Route unions:
    1. Opt-in (turns off untyped)
    2. Always on (I think this is considered a breaking change but no runtime difference, so might be ok?)

My recommendation would be untyped + objects + opt-in route unions.

@yangshun
yangshun marked this pull request as ready for review October 31, 2026 12:50
@yangshun
yangshun force-pushed the codex/typed-navigation-apis branch from 4d53fb1 to 68bd297 Compare October 31, 2026 12:59
@cpojer
cpojer merged commit 7a1791c into voidzero-dev:main Nov 2, 2026
2 checks passed
@cpojer

cpojer commented Nov 2, 2026

Copy link
Copy Markdown
Member

My recommendation would be untyped + objects + opt-in route unions.

Yes please.

@yangshun
yangshun deleted the codex/typed-navigation-apis branch November 2, 2026 02:19
cpojer pushed a commit that referenced this pull request Nov 2, 2026
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.

```ts
// 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.

```tsx
<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.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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