Skip to content

Commit c9c4dc2

Browse files
authored
feat(router): useParams_UNSTABLE (#2151)
## Motivation Page components get their route params through `PageProps`, but client and shared components that do not receive page props had no typed way to read them. ## API `useParams_UNSTABLE({ from })` (from `waku/router/client`) returns the current route's params typed from the pattern, or `null` when the path does not match: ```tsx 'use client'; import { useParams_UNSTABLE as useParams } from 'waku/router/client'; const params = useParams({ from: '/posts/[slug]' }); // { slug: string } | null (a catch-all gives { path: string[] }) ``` `from` is type-checked against your routes the same way `router.push({ to })` is, so an unknown pattern (`{ from: 'foo' }`) is a type error, and the params are typed from it. The `_UNSTABLE` suffix is intentional while we collect feedback.
1 parent 519736a commit c9c4dc2

9 files changed

Lines changed: 321 additions & 14 deletions

File tree

docs/guides/typed-routes.mdx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,26 @@ export const PostButton = ({ slug }: { slug: string }) => {
4343
- `to` is a route pattern (e.g. `/posts/[slug]`, `/docs/[...path]`). `params` is required when the pattern has slugs and is typed from it; a catch-all takes a `string[]`.
4444
- `search` is an object of string query values (use an array for repeated keys); `undefined` values are omitted. `hash` is optional.
4545
- The plain string form (`router.push('/posts/hello')`) keeps working.
46+
47+
## Reading Route Params
48+
49+
`useParams()` reads the current route's params, typed from the route pattern you pass. It returns `null` when the current path does not match that pattern, so it is safe to call from a component rendered under more than one route.
50+
51+
```tsx
52+
'use client';
53+
54+
import { useParams_UNSTABLE as useParams } from 'waku/router/client';
55+
56+
export const PostTitle = () => {
57+
const params = useParams({ from: '/posts/[slug]' });
58+
if (!params) {
59+
return null;
60+
}
61+
return <h1>{params.slug}</h1>;
62+
};
63+
```
64+
65+
- The result is typed from the pattern: `/posts/[slug]` gives `{ slug: string }`, and a catch-all like `/docs/[...path]` gives `{ path: string[] }`.
66+
- Values are URL-decoded, mirroring how `router.push` encodes them.
67+
- It re-renders when the current route path changes.
68+
- Page components already receive their params through props; `useParams()` is for client and shared components that do not.

packages/waku/src/router/common-utils/build-route-href.ts renamed to packages/waku/src/router/client-utils/build-route-href.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,18 @@ import { getGrouplessPath } from '../../lib/utils/create-pages.js';
22
import { getPathMapping, parsePathWithSlug } from '../../lib/utils/path.js';
33
import type { CreatePagesConfig } from '../base-types.js';
44
import type {
5-
ApiParams,
65
PagePath,
6+
RouteParams,
77
} from '../create-pages-utils/inferred-path-types.js';
88

99
export type RoutePattern = [PagePath<CreatePagesConfig>] extends [never]
1010
? string
1111
: PagePath<CreatePagesConfig>;
1212

13-
type RouteParams<Pattern extends RoutePattern> = {
14-
[Key in keyof ApiParams<Pattern>]: ApiParams<Pattern>[Key] extends string[]
13+
type RouteParamsInput<Pattern extends RoutePattern> = {
14+
[Key in keyof RouteParams<Pattern>]: RouteParams<Pattern>[Key] extends string[]
1515
? readonly string[]
16-
: ApiParams<Pattern>[Key];
16+
: RouteParams<Pattern>[Key];
1717
};
1818

1919
type SearchValue = string | readonly string[] | undefined;
@@ -24,9 +24,9 @@ export type BuildRouteHrefTarget<Pattern extends RoutePattern> = {
2424
to: Pattern;
2525
search?: BuildRouteHrefSearch;
2626
hash?: string;
27-
} & (keyof RouteParams<Pattern> extends never
27+
} & (keyof RouteParamsInput<Pattern> extends never
2828
? { params?: never }
29-
: { params: RouteParams<Pattern> });
29+
: { params: RouteParamsInput<Pattern> });
3030

3131
const serializeSearch = (search: BuildRouteHrefSearch | undefined): string => {
3232
if (search === undefined) {
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { getGrouplessPath } from '../../lib/utils/create-pages.js';
2+
import { getPathMapping, parsePathWithSlug } from '../../lib/utils/path.js';
3+
import type { RouteParams } from '../create-pages-utils/inferred-path-types.js';
4+
import type { RoutePattern } from './build-route-href.js';
5+
6+
const safeDecodeURIComponent = (value: string): string | null => {
7+
try {
8+
return decodeURIComponent(value);
9+
} catch {
10+
return null;
11+
}
12+
};
13+
14+
/**
15+
* Match a concrete pathname against a route pattern and return its params, or
16+
* null when the pathname does not match. This is the inverse of buildRouteHref:
17+
* route groups are stripped, the existing matcher decides the match, and each
18+
* matched segment is decoded once. The pathname must be the encoded form stored
19+
* by the router (e.g. useRouter().path); a pre-decoded path would double-decode
20+
* values containing "%". Malformed percent-encoding yields null rather than
21+
* throwing, since this runs during render. Catch-all matching follows the
22+
* router: a prefixed terminal catch-all (/docs/[...path]) does not match its
23+
* base (/docs), while a root catch-all (/[...path]) matches / as { path: [] }.
24+
*/
25+
export const matchRouteParams = <Pattern extends RoutePattern>(
26+
pattern: Pattern,
27+
pathname: string,
28+
): RouteParams<Pattern> | null => {
29+
const pathSpec = parsePathWithSlug(getGrouplessPath(pattern));
30+
const mapping = getPathMapping(pathSpec, pathname);
31+
if (mapping === null) {
32+
return null;
33+
}
34+
const params: Record<string, string | string[]> = {};
35+
for (const [key, value] of Object.entries(mapping)) {
36+
if (Array.isArray(value)) {
37+
const decoded: string[] = [];
38+
for (const part of value) {
39+
const decodedPart = safeDecodeURIComponent(part);
40+
if (decodedPart === null) {
41+
return null;
42+
}
43+
decoded.push(decodedPart);
44+
}
45+
params[key] = decoded;
46+
} else {
47+
const decodedValue = safeDecodeURIComponent(value);
48+
if (decodedValue === null) {
49+
return null;
50+
}
51+
params[key] = decodedValue;
52+
}
53+
}
54+
return params as RouteParams<Pattern>;
55+
};

packages/waku/src/router/client.tsx

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,12 @@ import {
3535
useRefetch,
3636
} from '../minimal/client.js';
3737
import type { RouteConfig } from './base-types.js';
38-
import { buildRouteHref } from './common-utils/build-route-href.js';
38+
import { buildRouteHref } from './client-utils/build-route-href.js';
3939
import type {
4040
BuildRouteHrefTarget,
4141
RoutePattern,
42-
} from './common-utils/build-route-href.js';
42+
} from './client-utils/build-route-href.js';
43+
import { matchRouteParams } from './client-utils/match-route-params.js';
4344
import {
4445
ETAG_ID_PREFIX,
4546
HAS404_ID,
@@ -51,6 +52,7 @@ import {
5152
pathnameToRoutePath,
5253
} from './common-utils/route-path.js';
5354
import type { RouteProps } from './common-utils/route-path.js';
55+
import type { RouteParams } from './create-pages-utils/inferred-path-types.js';
5456

5557
type AllowTrailingSlash<Path extends string> = Path extends '/'
5658
? Path
@@ -316,6 +318,22 @@ export function useRouter() {
316318
};
317319
}
318320

321+
/**
322+
* Read the current route's params, typed from the `from` pattern, or null when
323+
* the current path does not match it. Re-renders when the route path changes.
324+
* The result is memoized by path, so its identity changes on navigation to a
325+
* different path; read its fields rather than using the object itself as an
326+
* effect dependency.
327+
*/
328+
export function useParams_UNSTABLE<Pattern extends RoutePattern>({
329+
from,
330+
}: {
331+
from: Pattern;
332+
}): RouteParams<Pattern> | null {
333+
const { path } = useRouter();
334+
return useMemo(() => matchRouteParams(from, path), [from, path]);
335+
}
336+
319337
function useSharedRef<T>(
320338
ref: Ref<T | null> | undefined,
321339
): [RefObject<T | null>, (node: T | null) => void | (() => void)] {

packages/waku/src/router/create-pages-utils/inferred-path-types.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -199,12 +199,12 @@ type SlugTypes<Path extends string> =
199199
}
200200
: never;
201201

202-
/** Extracts route parameters from an API path pattern. */
203-
export type ApiParams<Path extends string> = Prettify<SlugTypes<Path>>;
202+
/** Extracts route parameters from a route path pattern. */
203+
export type RouteParams<Path extends string> = Prettify<SlugTypes<Path>>;
204204

205205
/** Context object passed to API route handlers with typed route parameters. */
206206
export interface ApiContext<Path extends string> {
207-
readonly params: ApiParams<Path>;
207+
readonly params: RouteParams<Path>;
208208
}
209209

210210
export type PropsForPages<Path extends string> = Prettify<

packages/waku/tests/build-route-href.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, test } from 'vitest';
2-
import { buildRouteHref } from '../src/router/common-utils/build-route-href.js';
2+
import { buildRouteHref } from '../src/router/client-utils/build-route-href.js';
33

44
describe('buildRouteHref', () => {
55
test('static routes', () => {
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { describe, expect, test } from 'vitest';
2+
import { buildRouteHref } from '../src/router/client-utils/build-route-href.js';
3+
import { matchRouteParams } from '../src/router/client-utils/match-route-params.js';
4+
5+
describe('matchRouteParams', () => {
6+
test('matches a static route', () => {
7+
expect(matchRouteParams('/about', '/about')).toEqual({});
8+
expect(matchRouteParams('/about', '/posts')).toBeNull();
9+
});
10+
11+
test('matches a slug', () => {
12+
expect(matchRouteParams('/posts/[slug]', '/posts/hello')).toEqual({
13+
slug: 'hello',
14+
});
15+
});
16+
17+
test('returns null on mismatch', () => {
18+
expect(matchRouteParams('/posts/[slug]', '/about')).toBeNull();
19+
expect(matchRouteParams('/posts/[slug]', '/posts/a/b')).toBeNull();
20+
});
21+
22+
test('URL-decodes matched values', () => {
23+
expect(matchRouteParams('/posts/[slug]', '/posts/a%20b%2Fc')).toEqual({
24+
slug: 'a b/c',
25+
});
26+
});
27+
28+
test('returns null for malformed percent-encoding', () => {
29+
expect(matchRouteParams('/posts/[slug]', '/posts/%E0%A4%A')).toBeNull();
30+
expect(matchRouteParams('/docs/[...path]', '/docs/ok/%E0%A4%A')).toBeNull();
31+
});
32+
33+
test('matches a prefixed slug', () => {
34+
expect(matchRouteParams('/@[name]', '/@foo')).toEqual({ name: 'foo' });
35+
});
36+
37+
test('matches a catch-all and decodes each part', () => {
38+
expect(matchRouteParams('/docs/[...path]', '/docs/a/b')).toEqual({
39+
path: ['a', 'b'],
40+
});
41+
expect(matchRouteParams('/docs/[...path]', '/docs/a%20b/c')).toEqual({
42+
path: ['a b', 'c'],
43+
});
44+
});
45+
46+
test('catch-all empty behavior matches the matcher', () => {
47+
expect(matchRouteParams('/[...path]', '/')).toEqual({ path: [] });
48+
expect(matchRouteParams('/docs/[...path]', '/docs')).toBeNull();
49+
});
50+
51+
test('strips route groups', () => {
52+
expect(matchRouteParams('/(marketing)/about', '/about')).toEqual({});
53+
expect(
54+
matchRouteParams('/(marketing)/posts/[slug]', '/posts/hello'),
55+
).toEqual({ slug: 'hello' });
56+
});
57+
58+
test('round-trips with buildRouteHref', () => {
59+
const slugHref = buildRouteHref({
60+
to: '/posts/[slug]',
61+
params: { slug: 'a b/c' },
62+
}).split(/[?#]/, 1)[0]!;
63+
expect(matchRouteParams('/posts/[slug]', slugHref)).toEqual({
64+
slug: 'a b/c',
65+
});
66+
67+
const catchAllHref = buildRouteHref({
68+
to: '/docs/[...path]',
69+
params: { path: ['a b', 'c'] },
70+
}).split(/[?#]/, 1)[0]!;
71+
expect(matchRouteParams('/docs/[...path]', catchAllHref)).toEqual({
72+
path: ['a b', 'c'],
73+
});
74+
});
75+
});
76+
77+
describe('matchRouteParams types', () => {
78+
test('params are derived from the route pattern', () => {
79+
// Type-level assertions; the closure is never invoked.
80+
const assertTypes = () => {
81+
const slugParams = matchRouteParams('/posts/[slug]', '/posts/x');
82+
if (slugParams) {
83+
const slug: string = slugParams.slug;
84+
void slug;
85+
// @ts-expect-error unknown param name
86+
void slugParams.id;
87+
}
88+
const catchAllParams = matchRouteParams('/docs/[...path]', '/docs/x');
89+
if (catchAllParams) {
90+
const path: string[] = catchAllParams.path;
91+
void path;
92+
}
93+
};
94+
expect(typeof assertTypes).toBe('function');
95+
});
96+
});

0 commit comments

Comments
 (0)