` for non-GET methods. Props: `href`, `method`, `data`, `prefetch`, `cacheFor`, `preserveScroll`, `preserveState`, `replace`, `reloadDocument`, `viewTransition`, `activeMatch`, `onNavigate`. Adds `aria-current="page"` and `data-void-active` to the link to the current page ([active links](../../guide/pages-routing/overview.md#active-links)), and `data-void-pending` to links to a page while it loads ([pending links](../../guide/pages-routing/overview.md#pending-links)). |
| `useRouter()` | Returns the Void Router with current route state (`url`, `path`, `query`, `params`) and navigation methods: `visit`, `refresh`, awaitable `prefetch`, `flush`, `flushAll`. React navigations are scheduled as transitions. |
-| `useParams()` | Returns dynamic route params for the page currently being rendered. |
+| `useParams()` | Returns dynamic route params for the page currently being rendered. Pass the page's pattern as the type argument, such as `useParams<'/users/:id'>()`, to type them. |
| `useNavigation()` | Returns pending navigation state: `{ state, location, method }`, where `state` is `"idle"`, `"loading"`, or `"submitting"` and `location` is the pending destination. |
| `useForm(url, defaults, options?)` | Form helper hook. Returns `{ data, setData, post, put, patch, delete, pending, errors, error, hasChanges, wasSuccessful, recentlySuccessful, reset, setError, clearErrors, clearError }`. |
| `useIslandForm(defaults)` | Form helper hook for island components. Same form state as `useForm()`. `post(url)`, `put(url)`, `patch(url)`, and `delete(url)` take the action URL and submit with `fetch()`. |
@@ -229,7 +229,7 @@ Imported from `"@void/svelte"`.
| `isCallSiteActionError(error)` | Returns `true` only for the expected `VoidActionError` statuses that `action()` returns, such as `409` or `422`. |
| `useShared()` | Returns shared data. Middleware sets it with `c.set("shared", {...})`; a loader or action can update it. See [Shared data](../../guide/pages-routing/layouts.md#shared-data). |
| `useRouter()` | Returns the Void Router with current route state (`url`, `path`, `query`, `params`) and navigation methods: `visit`, `refresh`, awaitable `prefetch`, `flush`, `flushAll`. |
-| `useParams()` | Returns dynamic route params for the page currently being rendered. |
+| `useParams()` | Returns dynamic route params for the page currently being rendered. Pass the page's pattern as the type argument, such as `useParams<'/users/:id'>()`, to type them. |
| `useNavigation()` | Returns pending navigation state: `{ state, location, method }`, where `state` is `"idle"`, `"loading"`, or `"submitting"` and `location` is the pending destination. |
Svelte `Link` GET `data` is merged into the rendered `href` query string. Primitive values are serialized with `String(value)`, arrays become repeated keys, `null` and `undefined` are omitted, and nested objects throw. `prefetch` and `reloadDocument` are GET-only and throw for mutation links.
@@ -282,7 +282,7 @@ Imported from `"@void/solid"`.
| `isCallSiteActionError(error)` | Returns `true` only for the expected `VoidActionError` statuses that `action()` returns, such as `409` or `422`. |
| `useShared()` | Returns shared data. Middleware sets it with `c.set("shared", {...})`; a loader or action can update it. See [Shared data](../../guide/pages-routing/layouts.md#shared-data). |
| `useRouter()` | Returns the Void Router with current route state (`url`, `path`, `query`, `params`) and navigation methods: `visit`, `refresh`, awaitable `prefetch`, `flush`, `flushAll`. |
-| `useParams()` | Returns dynamic route params for the page currently being rendered. |
+| `useParams()` | Returns dynamic route params for the page currently being rendered. Pass the page's pattern as the type argument, such as `useParams<'/users/:id'>()`, to type them. |
| `useNavigation()` | Returns pending navigation state: `{ state, location, method }`, where `state` is `"idle"`, `"loading"`, or `"submitting"` and `location` is the pending destination. |
Solid `Link` GET `data` is merged into the rendered `href` query string. Primitive values are serialized with `String(value)`, arrays become repeated keys, `null` and `undefined` are omitted, and nested objects throw. `prefetch` and `reloadDocument` are GET-only and throw for mutation links.
diff --git a/docs/reference/api/types.md b/docs/reference/api/types.md
index 4860b8c1a..010045066 100644
--- a/docs/reference/api/types.md
+++ b/docs/reference/api/types.md
@@ -127,6 +127,9 @@ Import these types from `void/routes`:
| --------------------- | ------------------------------------------------------------------------------------------------- |
| `RoutePathMap` | Generated map of page, HTTP, and WebSocket route patterns to their parameter names. |
| `RoutePath` | Union of the known route patterns in `RoutePathMap`. |
+| `PagePathMap` | Generated map of page patterns to their parameter names. |
+| `PagePath` | Union of the page patterns in `PagePathMap`; accepted by `useParams()`. |
+| `PageParams` | The params page `P` receives, such as `{ id: string }` for `/users/:id`. |
| `RouteOptions
` | Options for a route pattern `P`, including its required `params`, optional `query`, and `hash`. |
| `RouteDestination
` | `{ pathname: P }` with `RouteOptions
`. Defaults to all known routes when `P` is omitted. |
| `RouteUrl` | The URL of one of your routes, returned by `routeUrl()`. `string` unless `typedRoutes` is on. |
diff --git a/packages/react/src/runtime/use-params.ts b/packages/react/src/runtime/use-params.ts
index 3b03a8395..a288aaf58 100644
--- a/packages/react/src/runtime/use-params.ts
+++ b/packages/react/src/runtime/use-params.ts
@@ -1,10 +1,21 @@
import { useContext } from 'react';
+import type { PagePath, PageParams } from 'void/routes';
import { ParamsContext } from './context.ts';
-export function useParams = Record>(): T {
+/**
+ * Read the dynamic route params of the page being rendered.
+ *
+ * Pass the page's URL pattern as the type argument to type the result:
+ * `useParams<'/users/:id'>()` returns `{ id: string }`. This is a type-only
+ * check; the params are returned as they are.
+ */
+export function useParams = Record>(): T;
+export function useParams(): PageParams
;
+export function useParams(): Record;
+export function useParams(): Record {
const params = useContext(ParamsContext);
if (params === null) {
throw new Error('useParams(): must be used inside a Void page.');
}
- return params as T;
+ return params;
}
diff --git a/packages/solid/src/runtime/use-params.ts b/packages/solid/src/runtime/use-params.ts
index 2b50f97d3..71cac8621 100644
--- a/packages/solid/src/runtime/use-params.ts
+++ b/packages/solid/src/runtime/use-params.ts
@@ -1,10 +1,21 @@
import { useContext } from 'solid-js';
+import type { PagePath, PageParams } from 'void/routes';
import { ParamsContext } from './context.ts';
-export function useParams = Record>(): T {
+/**
+ * Read the dynamic route params of the page being rendered.
+ *
+ * Pass the page's URL pattern as the type argument to type the result:
+ * `useParams<'/users/:id'>()` returns `{ id: string }`. This is a type-only
+ * check; the params are returned as they are.
+ */
+export function useParams = Record>(): T;
+export function useParams(): PageParams
;
+export function useParams(): Record;
+export function useParams(): Record {
const params = useContext(ParamsContext);
if (params === null) {
throw new Error('useParams(): must be used inside a Void page.');
}
- return params as T;
+ return params;
}
diff --git a/packages/svelte/src/runtime/use-params.ts b/packages/svelte/src/runtime/use-params.ts
index 653b5665d..830c2747f 100644
--- a/packages/svelte/src/runtime/use-params.ts
+++ b/packages/svelte/src/runtime/use-params.ts
@@ -1,7 +1,18 @@
import { getContext } from 'svelte';
+import type { PagePath, PageParams } from 'void/routes';
-export function useParams = Record>(): T {
- const ctx = getContext<{ value: T }>('__void_params');
+/**
+ * Read the dynamic route params of the page being rendered.
+ *
+ * Pass the page's URL pattern as the type argument to type the result:
+ * `useParams<'/users/:id'>()` returns `{ id: string }`. This is a type-only
+ * check; the params are returned as they are.
+ */
+export function useParams = Record>(): T;
+export function useParams(): PageParams
;
+export function useParams(): Record;
+export function useParams(): Record {
+ const ctx = getContext<{ value: Record }>('__void_params');
if (!ctx) {
throw new Error('useParams(): must be used inside a Void page.');
}
diff --git a/packages/void/src/codegen/route-types.ts b/packages/void/src/codegen/route-types.ts
index 564e89152..654643a7d 100644
--- a/packages/void/src/codegen/route-types.ts
+++ b/packages/void/src/codegen/route-types.ts
@@ -169,6 +169,29 @@ export function generateRouteTypes(
}
lines.push(' }');
lines.push('');
+ lines.push(' export interface PagePathMap {');
+ // A .dev + .prod page pair shares one pattern and its params.
+ const pagePaths = new Map>();
+ for (const page of scan.pages.pages) {
+ const params = pagePaths.get(page.pattern) ?? new Set();
+ for (const name of page.params) {
+ params.add(name);
+ }
+ if (page.pattern.split('/').includes('*')) {
+ params.add('*');
+ }
+ pagePaths.set(page.pattern, params);
+ }
+ for (const [pattern, params] of pagePaths) {
+ const names = [...params].map((name) => JSON.stringify(name)).join(' | ') || 'never';
+ lines.push(` ${JSON.stringify(pattern)}: ${names};`);
+ }
+ lines.push(' }');
+ lines.push(' export type PagePath = keyof PagePathMap & string;');
+ lines.push(' export type PageParams = P extends PagePath');
+ lines.push(' ? { [K in Exclude & string]: string }');
+ lines.push(' : never;');
+ lines.push('');
// `routeUrl()` returns `/caf%C3%A9%20menu` for `/café menu`, so accept that spelling too.
lines.push(' export interface EncodedRoutePathMap {');
for (const pattern of paths.keys()) {
diff --git a/packages/void/src/routes-stub.ts b/packages/void/src/routes-stub.ts
index d9f67c7b4..7668ec1e3 100644
--- a/packages/void/src/routes-stub.ts
+++ b/packages/void/src/routes-stub.ts
@@ -13,6 +13,20 @@ export type {
/** Known URL patterns and their parameter names, augmented by .void/routes.d.ts. */
export interface RoutePathMap {}
+/** Page URL patterns and their parameter names, augmented by .void/routes.d.ts. */
+export interface PagePathMap {}
+
+/** Union of the app's page URL patterns, such as `/users/:id`. */
+export type PagePath = keyof PagePathMap & string;
+
+/**
+ * Params a page receives: `{ id: string }` for `/users/:id`. An unnamed `[...]`
+ * catch-all has no param. A union of patterns gives a union of params.
+ */
+export type PageParams = P extends PagePath
+ ? { [K in Exclude & string]: string }
+ : never;
+
/** Percent-encoded spellings of patterns with encoded fixed segments, augmented by .void/routes.d.ts. */
export interface EncodedRoutePathMap {}
diff --git a/packages/void/test/integration/route-url-builder.test.ts b/packages/void/test/integration/route-url-builder.test.ts
index ebb5aa7c6..7edc46d4a 100644
--- a/packages/void/test/integration/route-url-builder.test.ts
+++ b/packages/void/test/integration/route-url-builder.test.ts
@@ -137,6 +137,58 @@ const unknown: LinkProps['href'] = '/unknown';
void [same, known, unknown];
`;
+// Page params typed through each adapter's useParams().
+const useParamsCheck = `import type { PagePath } from 'void/routes';
+import { useParams as react } from ${adapterSource('react/src/runtime/use-params.ts')};
+import { useParams as vue } from ${adapterSource('vue/src/runtime/use-params.ts')};
+import { useParams as svelte } from ${adapterSource('svelte/src/runtime/use-params.ts')};
+import { useParams as solid } from ${adapterSource('solid/src/runtime/use-params.ts')};
+type Same = [A] extends [B] ? ([B] extends [A] ? true : false) : false;
+const pages: Array = ['/', '/about', '/users/:id', '/files/:path{.+}', '/wild/*'];
+// @ts-expect-error HTTP routes are not pages
+const api: PagePath = '/api/users/:id';
+void [pages, api];
+type Either = '/users/:id' | '/files/:path{.+}';
+${['react', 'vue', 'svelte', 'solid']
+ .map(
+ (useParams) => `{
+ const user = ${useParams}<'/users/:id'>();
+ const files = ${useParams}<'/files/:path{.+}'>();
+ const wild = ${useParams}<'/wild/*'>();
+ const about = ${useParams}<'/about'>();
+ const same: [
+ Same,
+ Same,
+ Same,
+ Same,
+ ] = [true, true, true, true];
+ // @ts-expect-error misspelled params are rejected
+ void user.idd;
+ // @ts-expect-error static pages have no params
+ void about.id;
+ // @ts-expect-error unknown pages are rejected
+ ${useParams}<'/unknown'>();
+ // @ts-expect-error HTTP routes are not pages
+ ${useParams}<'/api/users/:id'>();
+ // A pattern union gives a union of params, not params from every pattern.
+ const either = ${useParams}();
+ const union: Same = true;
+ // @ts-expect-error a param of one pattern is not guaranteed
+ void either.id;
+ void union;
+ const untyped: Record = ${useParams}();
+ const annotated: string = ${useParams}<{ slug: string }>().slug;
+ // Existing generic wrappers and ReturnType keep the record contract.
+ function wrapper>(): T {
+ return ${useParams}();
+ }
+ const derived: Same, Record> = true;
+ void [same, untyped, annotated, wrapper, derived];
+}
+`,
+ )
+ .join('')}`;
+
async function createFixture({
typedRoutes = false,
extraFiles = {},
@@ -147,6 +199,7 @@ async function createFixture({
'check-typed.ts': typedCheck,
'check-links.ts': linkHrefCheck,
'check-solid-link.ts': solidLinkHrefCheck,
+ 'check-use-params.ts': useParamsCheck,
'package.json': '{"type":"module"}',
'routes/api/users/[id].ts':
"import { defineHandler } from 'void/handler'; export const GET = defineHandler(() => ({ ok: true }));",
@@ -290,6 +343,18 @@ describe('standalone routeUrl utility', () => {
},
);
+ it.each(['generated', 'package'] as const)(
+ 'types useParams() in every adapter through the %s declarations',
+ async (declarations) => {
+ const root = await createFixture();
+ expect(
+ typecheck(root, declarations, ['check-use-params.ts'], {
+ allowImportingTsExtensions: true,
+ }),
+ ).toEqual({ output: '', status: 0 });
+ },
+ );
+
it('lets a root dynamic page match any single-segment URL with routing.typedRoutes', async () => {
const root = await createFixture({
typedRoutes: true,
diff --git a/packages/void/test/unit/route-types.test.ts b/packages/void/test/unit/route-types.test.ts
index a0b5edef9..fe888ed61 100644
--- a/packages/void/test/unit/route-types.test.ts
+++ b/packages/void/test/unit/route-types.test.ts
@@ -164,6 +164,46 @@ describe('generateRouteTypes', () => {
expect(pathMap).toContain('"/api/legacy": never;');
});
+ it('lists page patterns and their params in PagePathMap, without HTTP routes', () => {
+ const page = (pattern: string, params: Array, catchAll: boolean, file: string) => ({
+ ...pageRenderingDefaults,
+ pattern,
+ params,
+ catchAll,
+ componentPath: file,
+ serverPath: null,
+ methods: [],
+ componentId: file,
+ });
+ const scan = makeScan({
+ routes: [
+ {
+ pattern: '/api/users/:id',
+ methods: ['GET'],
+ params: ['id'],
+ catchAll: false,
+ env: null,
+ filePath: 'api/users/[id].ts',
+ },
+ ],
+ pages: [
+ page('/about', [], false, 'about.tsx'),
+ page('/users/:id', ['id'], false, 'users/[id].tsx'),
+ page('/files/:path{.+}', ['path'], true, 'files/[...path].tsx'),
+ page('/wild/*', [], true, 'wild/[...].tsx'),
+ ],
+ });
+
+ const { dts } = generateRouteTypes(scan, '/project/routes', '/project/.void', '/project/pages');
+ const pageMap = dts.split(' export interface PagePathMap {')[1]!.split(' }')[0]!;
+
+ expect(pageMap).toContain('"/about": never;');
+ expect(pageMap).toContain('"/users/:id": "id";');
+ expect(pageMap).toContain('"/files/:path{.+}": "path";');
+ expect(pageMap).toContain('"/wild/*": "*";');
+ expect(pageMap).not.toContain('/api/users/:id');
+ });
+
it('handles dynamic params in patterns', () => {
const scan = makeScan({
routes: [
diff --git a/packages/vue/src/runtime/use-params.ts b/packages/vue/src/runtime/use-params.ts
index a02522149..600d91ee8 100644
--- a/packages/vue/src/runtime/use-params.ts
+++ b/packages/vue/src/runtime/use-params.ts
@@ -1,7 +1,18 @@
+import type { PagePath, PageParams } from 'void/routes';
import { inject } from 'vue';
-export function useParams = Record>(): T {
- const params = inject('__void_params');
+/**
+ * Read the dynamic route params of the page being rendered.
+ *
+ * Pass the page's URL pattern as the type argument to type the result:
+ * `useParams<'/users/:id'>()` returns `{ id: string }`. This is a type-only
+ * check; the params are returned as they are.
+ */
+export function useParams = Record>(): T;
+export function useParams(): PageParams
;
+export function useParams(): Record;
+export function useParams(): Record {
+ const params = inject>('__void_params');
if (!params) {
throw new Error('useParams(): must be used inside a Void page.');
}
diff --git a/playground/react-pages/pages/router-params/[id].tsx b/playground/react-pages/pages/router-params/[id].tsx
index f520ea2c4..e036dc66f 100644
--- a/playground/react-pages/pages/router-params/[id].tsx
+++ b/playground/react-pages/pages/router-params/[id].tsx
@@ -1,7 +1,7 @@
import { Link, useParams, useRouter } from '@void/react';
export default function RouterParamsDetail() {
- const { id } = useParams<{ id: string }>();
+ const { id } = useParams<'/router-params/:id'>();
const router = useRouter();
return (