Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions docs/guide/pages-routing/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,17 +254,20 @@ You can also pass URL strings or `URL` instances to these router methods. See [T

`router.visit()` updates the page in place for same-origin URLs. `http(s)` URLs on another origin get a full page load, and other schemes such as `javascript:` are rejected.

For dynamic route params, use `useParams()` from the same adapter package:
For dynamic route params, use `useParams()` from the same adapter package. Pass the page's URL pattern as the type argument to type the result:

```tsx
// pages/posts/[id].tsx
import { useParams } from '@void/react';

export default function PostPage() {
const { id } = useParams<{ id: string }>();
const { id } = useParams<'/posts/:id'>(); // { id: string }
return <h1>Post {id}</h1>;
}
```

See [Page params](../type-safety.md#page-params) for how patterns are checked.

### Active links

`Link` marks the link to the current page with `aria-current="page"` and `data-void-active`. Use them to style navigation:
Expand Down
17 changes: 17 additions & 0 deletions docs/guide/type-safety.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,22 @@ routeUrl('/files/:path{.+}', { params: { path: 'docs/get started' } });
// /files/docs/get%20started
```

### Page params

Pass a page's URL pattern as the type argument of `useParams()` to type the params it reads. For `pages/users/[id].tsx`, the pattern is `/users/:id`:

```tsx
import { useParams } from '@void/react';

const { id } = useParams<'/users/:id'>(); // id: string
useParams<'/users/:id'>().idd; // Type error: no such param
useParams<'/userz/:id'>(); // Type error: no page matches
```

The pattern must be one of your pages; HTTP and WebSocket routes are not accepted. A catch-all page such as `pages/files/[...path].tsx` gives `{ path: string }`. An unnamed catch-all (`[...].tsx`) has no params.

The pattern is checked by TypeScript only. Pass the pattern of the page the component renders in; without a pattern, `useParams()` returns `Record<string, string>`.

### Checked URL strings

[`routing.typedRoutes`](../reference/config.md#routing-typedroutes) checks plain URL strings in `Link`'s `href`, `router.visit()`, `router.prefetch()`, `router.flush()`, and `c.rewrite()`. Apps created with `void init` have it on. To turn it on in an existing app:
Expand Down Expand Up @@ -192,6 +208,7 @@ Route types update with the dev server. For CI or standalone typechecking, run `
| Fetch client | Route paths, HTTP methods, `body`/`query`/`params`, response type |
| Navigation | Route patterns and required parameter names and values in `routeUrl()` and destination objects |
| Links | With `routing.typedRoutes`, URL strings in `Link`, router methods, and `c.rewrite()` |
| `useParams` | Page patterns and the names of the params they return |
| `useForm` | Action URLs, `data` fields, `errors` keys, `reset()` args (including `{ initial: true }`), `params`, `resetOnSuccess` |
| `action()` | Action URLs, `data` payload, `params` |

Expand Down
8 changes: 4 additions & 4 deletions docs/reference/api/pages.md

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions docs/reference/api/types.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<P>` | The params page `P` receives, such as `{ id: string }` for `/users/:id`. |
| `RouteOptions<P>` | Options for a route pattern `P`, including its required `params`, optional `query`, and `hash`. |
| `RouteDestination<P>` | `{ pathname: P }` with `RouteOptions<P>`. 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. |
Expand Down
15 changes: 13 additions & 2 deletions packages/react/src/runtime/use-params.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,21 @@
import { useContext } from 'react';
import type { PagePath, PageParams } from 'void/routes';
import { ParamsContext } from './context.ts';

export function useParams<T extends Record<string, string> = Record<string, string>>(): 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<T extends Record<string, string> = Record<string, string>>(): T;
export function useParams<P extends PagePath>(): PageParams<P>;
export function useParams(): Record<string, string>;
export function useParams(): Record<string, string> {
const params = useContext(ParamsContext);
if (params === null) {
throw new Error('useParams(): must be used inside a Void page.');
}
return params as T;
return params;
}
15 changes: 13 additions & 2 deletions packages/solid/src/runtime/use-params.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,21 @@
import { useContext } from 'solid-js';
import type { PagePath, PageParams } from 'void/routes';
import { ParamsContext } from './context.ts';

export function useParams<T extends Record<string, string> = Record<string, string>>(): 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<T extends Record<string, string> = Record<string, string>>(): T;
export function useParams<P extends PagePath>(): PageParams<P>;
export function useParams(): Record<string, string>;
export function useParams(): Record<string, string> {
const params = useContext(ParamsContext);
if (params === null) {
throw new Error('useParams(): must be used inside a Void page.');
}
return params as T;
return params;
}
15 changes: 13 additions & 2 deletions packages/svelte/src/runtime/use-params.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,18 @@
import { getContext } from 'svelte';
import type { PagePath, PageParams } from 'void/routes';

export function useParams<T extends Record<string, string> = Record<string, string>>(): 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<T extends Record<string, string> = Record<string, string>>(): T;
export function useParams<P extends PagePath>(): PageParams<P>;
export function useParams(): Record<string, string>;
export function useParams(): Record<string, string> {
const ctx = getContext<{ value: Record<string, string> }>('__void_params');
if (!ctx) {
throw new Error('useParams(): must be used inside a Void page.');
}
Expand Down
23 changes: 23 additions & 0 deletions packages/void/src/codegen/route-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Set<string>>();
for (const page of scan.pages.pages) {
const params = pagePaths.get(page.pattern) ?? new Set<string>();
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> = P extends PagePath');
lines.push(' ? { [K in Exclude<PagePathMap[P], "*"> & 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()) {
Expand Down
14 changes: 14 additions & 0 deletions packages/void/src/routes-stub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> = P extends PagePath
? { [K in Exclude<PagePathMap[P], '*'> & string]: string }
: never;

/** Percent-encoded spellings of patterns with encoded fixed segments, augmented by .void/routes.d.ts. */
export interface EncodedRoutePathMap {}

Expand Down
65 changes: 65 additions & 0 deletions packages/void/test/integration/route-url-builder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,58 @@ const unknown: LinkProps['href'] = '/unknown';
void [same, known, unknown];
`;

// Page params typed through each adapter's useParams<Pattern>().
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, B> = [A] extends [B] ? ([B] extends [A] ? true : false) : false;
const pages: Array<PagePath> = ['/', '/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<typeof user, { id: string }>,
Same<typeof files, { path: string }>,
Same<typeof wild, {}>,
Same<typeof about, {}>,
] = [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}<Either>();
const union: Same<typeof either, { id: string } | { path: string }> = true;
// @ts-expect-error a param of one pattern is not guaranteed
void either.id;
void union;
const untyped: Record<string, string> = ${useParams}();
const annotated: string = ${useParams}<{ slug: string }>().slug;
// Existing generic wrappers and ReturnType keep the record contract.
function wrapper<T extends Record<string, string>>(): T {
return ${useParams}<T>();
}
const derived: Same<ReturnType<typeof ${useParams}>, Record<string, string>> = true;
void [same, untyped, annotated, wrapper, derived];
}
`,
)
.join('')}`;

async function createFixture({
typedRoutes = false,
extraFiles = {},
Expand All @@ -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 }));",
Expand Down Expand Up @@ -290,6 +343,18 @@ describe('standalone routeUrl utility', () => {
},
);

it.each(['generated', 'package'] as const)(
'types useParams<Pattern>() 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,
Expand Down
40 changes: 40 additions & 0 deletions packages/void/test/unit/route-types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>, 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: [
Expand Down
15 changes: 13 additions & 2 deletions packages/vue/src/runtime/use-params.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,18 @@
import type { PagePath, PageParams } from 'void/routes';
import { inject } from 'vue';

export function useParams<T extends Record<string, string> = Record<string, string>>(): T {
const params = inject<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<T extends Record<string, string> = Record<string, string>>(): T;
export function useParams<P extends PagePath>(): PageParams<P>;
export function useParams(): Record<string, string>;
export function useParams(): Record<string, string> {
const params = inject<Record<string, string>>('__void_params');
if (!params) {
throw new Error('useParams(): must be used inside a Void page.');
}
Expand Down
2 changes: 1 addition & 1 deletion playground/react-pages/pages/router-params/[id].tsx
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down
Loading