@richie-router/react 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/router.cjs +352 -107
- package/dist/cjs/router.test.cjs +574 -0
- package/dist/esm/router.mjs +353 -107
- package/dist/esm/router.test.mjs +552 -0
- package/dist/types/router.d.ts +31 -2
- package/package.json +2 -2
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
// packages/react/src/router.test.ts
|
|
2
|
+
import { describe, expect, test } from "bun:test";
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
5
|
+
import {
|
|
6
|
+
Link,
|
|
7
|
+
useMatchRoute,
|
|
8
|
+
RouterProvider,
|
|
9
|
+
createLink,
|
|
10
|
+
createFileRoute,
|
|
11
|
+
createMemoryHistory,
|
|
12
|
+
createRootRoute,
|
|
13
|
+
createRouter
|
|
14
|
+
} from "./router.mjs";
|
|
15
|
+
function createTestRouteTree(options) {
|
|
16
|
+
const rootRoute = createRootRoute({
|
|
17
|
+
component: () => null
|
|
18
|
+
});
|
|
19
|
+
const indexRoute = createFileRoute("/")({
|
|
20
|
+
component: () => null
|
|
21
|
+
});
|
|
22
|
+
const aboutRoute = createFileRoute("/about")({
|
|
23
|
+
component: () => null
|
|
24
|
+
});
|
|
25
|
+
aboutRoute._setServerHead(options?.serverHead);
|
|
26
|
+
return rootRoute._addFileChildren({
|
|
27
|
+
index: indexRoute,
|
|
28
|
+
about: aboutRoute
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
function createNestedHeadRouteTree() {
|
|
32
|
+
const rootRoute = createRootRoute({
|
|
33
|
+
component: () => null
|
|
34
|
+
});
|
|
35
|
+
const postsRoute = createFileRoute("/posts")({
|
|
36
|
+
component: () => null,
|
|
37
|
+
head: [
|
|
38
|
+
{ tag: "title", children: "Posts" }
|
|
39
|
+
]
|
|
40
|
+
});
|
|
41
|
+
const postRoute = createFileRoute("/posts/$postId")({
|
|
42
|
+
component: () => null
|
|
43
|
+
});
|
|
44
|
+
rootRoute._setServerHead(true);
|
|
45
|
+
postRoute._setServerHead(true);
|
|
46
|
+
postsRoute._addFileChildren({
|
|
47
|
+
post: postRoute
|
|
48
|
+
});
|
|
49
|
+
return rootRoute._addFileChildren({
|
|
50
|
+
posts: postsRoute
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
function createRootServerHeadTree() {
|
|
54
|
+
const rootRoute = createRootRoute({
|
|
55
|
+
component: () => null
|
|
56
|
+
});
|
|
57
|
+
const indexRoute = createFileRoute("/")({
|
|
58
|
+
component: () => null
|
|
59
|
+
});
|
|
60
|
+
const aboutRoute = createFileRoute("/about")({
|
|
61
|
+
component: () => null
|
|
62
|
+
});
|
|
63
|
+
rootRoute._setServerHead(true);
|
|
64
|
+
return rootRoute._addFileChildren({
|
|
65
|
+
index: indexRoute,
|
|
66
|
+
about: aboutRoute
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
function createNestedServerHeadTree() {
|
|
70
|
+
const rootRoute = createRootRoute({
|
|
71
|
+
component: () => null
|
|
72
|
+
});
|
|
73
|
+
const postsRoute = createFileRoute("/posts")({
|
|
74
|
+
component: () => null
|
|
75
|
+
});
|
|
76
|
+
const postRoute = createFileRoute("/posts/$postId")({
|
|
77
|
+
component: () => null
|
|
78
|
+
});
|
|
79
|
+
rootRoute._setServerHead(true);
|
|
80
|
+
postsRoute._setServerHead(true);
|
|
81
|
+
postRoute._setServerHead(true);
|
|
82
|
+
postsRoute._addFileChildren({
|
|
83
|
+
post: postRoute
|
|
84
|
+
});
|
|
85
|
+
return rootRoute._addFileChildren({
|
|
86
|
+
posts: postsRoute
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
function createLinkTestRouteTree(component) {
|
|
90
|
+
const rootRoute = createRootRoute({
|
|
91
|
+
component
|
|
92
|
+
});
|
|
93
|
+
const postRoute = createFileRoute("/post")({
|
|
94
|
+
component: () => null
|
|
95
|
+
});
|
|
96
|
+
const postsRoute = createFileRoute("/posts")({
|
|
97
|
+
component: () => null
|
|
98
|
+
});
|
|
99
|
+
const postDetailRoute = createFileRoute("/posts/$postId")({
|
|
100
|
+
component: () => null
|
|
101
|
+
});
|
|
102
|
+
postsRoute._addFileChildren({
|
|
103
|
+
detail: postDetailRoute
|
|
104
|
+
});
|
|
105
|
+
return rootRoute._addFileChildren({
|
|
106
|
+
post: postRoute,
|
|
107
|
+
posts: postsRoute
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
function renderLinkMarkup(initialEntry, component) {
|
|
111
|
+
const history = createMemoryHistory({
|
|
112
|
+
initialEntries: [initialEntry]
|
|
113
|
+
});
|
|
114
|
+
const router = createRouter({
|
|
115
|
+
routeTree: createLinkTestRouteTree(component),
|
|
116
|
+
history
|
|
117
|
+
});
|
|
118
|
+
return renderToStaticMarkup(React.createElement(RouterProvider, { router }));
|
|
119
|
+
}
|
|
120
|
+
describe("createRouter basePath", () => {
|
|
121
|
+
test('treats "/" as the root basePath', () => {
|
|
122
|
+
const history = createMemoryHistory({
|
|
123
|
+
initialEntries: ["/about?tab=team#bio"]
|
|
124
|
+
});
|
|
125
|
+
const router = createRouter({
|
|
126
|
+
routeTree: createTestRouteTree(),
|
|
127
|
+
history,
|
|
128
|
+
basePath: "/"
|
|
129
|
+
});
|
|
130
|
+
expect(router.state.location.pathname).toBe("/about");
|
|
131
|
+
expect(router.state.location.href).toBe("/about?tab=team#bio");
|
|
132
|
+
expect(router.buildHref({ to: "/about" })).toBe("/about");
|
|
133
|
+
});
|
|
134
|
+
test("normalizes a trailing slash in the basePath", async () => {
|
|
135
|
+
const history = createMemoryHistory({
|
|
136
|
+
initialEntries: ["/project/about?tab=team#bio"]
|
|
137
|
+
});
|
|
138
|
+
const router = createRouter({
|
|
139
|
+
routeTree: createTestRouteTree(),
|
|
140
|
+
history,
|
|
141
|
+
basePath: "/project/"
|
|
142
|
+
});
|
|
143
|
+
expect(router.state.location.pathname).toBe("/about");
|
|
144
|
+
expect(router.buildHref({ to: "/about" })).toBe("/project/about");
|
|
145
|
+
await router.navigate({
|
|
146
|
+
to: "/about"
|
|
147
|
+
});
|
|
148
|
+
expect(history.location.href).toBe("/project/about");
|
|
149
|
+
});
|
|
150
|
+
test("strips the basePath from the current history location", () => {
|
|
151
|
+
const history = createMemoryHistory({
|
|
152
|
+
initialEntries: ["/project/about?tab=team#bio"]
|
|
153
|
+
});
|
|
154
|
+
const router = createRouter({
|
|
155
|
+
routeTree: createTestRouteTree(),
|
|
156
|
+
history,
|
|
157
|
+
basePath: "/project"
|
|
158
|
+
});
|
|
159
|
+
expect(router.state.location.pathname).toBe("/about");
|
|
160
|
+
expect(router.state.location.href).toBe("/about?tab=team#bio");
|
|
161
|
+
expect(router.state.matches.at(-1)?.route.fullPath).toBe("/about");
|
|
162
|
+
});
|
|
163
|
+
test("prefixes generated hrefs and history writes with the basePath", async () => {
|
|
164
|
+
const history = createMemoryHistory({
|
|
165
|
+
initialEntries: ["/project"]
|
|
166
|
+
});
|
|
167
|
+
const router = createRouter({
|
|
168
|
+
routeTree: createTestRouteTree(),
|
|
169
|
+
history,
|
|
170
|
+
basePath: "/project"
|
|
171
|
+
});
|
|
172
|
+
expect(router.buildHref({ to: "/about" })).toBe("/project/about");
|
|
173
|
+
await router.navigate({
|
|
174
|
+
to: "/about",
|
|
175
|
+
search: {
|
|
176
|
+
tab: "team"
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
expect(history.location.href).toBe("/project/about?tab=team");
|
|
180
|
+
expect(router.state.location.href).toBe("/about?tab=team");
|
|
181
|
+
});
|
|
182
|
+
test("uses the basePath for the default head API endpoint", async () => {
|
|
183
|
+
const history = createMemoryHistory({
|
|
184
|
+
initialEntries: ["/project/about"]
|
|
185
|
+
});
|
|
186
|
+
const fetchCalls = [];
|
|
187
|
+
const originalFetch = globalThis.fetch;
|
|
188
|
+
globalThis.fetch = async (input) => {
|
|
189
|
+
fetchCalls.push(typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url);
|
|
190
|
+
return new Response(JSON.stringify({
|
|
191
|
+
head: [],
|
|
192
|
+
routeHeads: [
|
|
193
|
+
{ routeId: "/about", head: [] }
|
|
194
|
+
]
|
|
195
|
+
}), {
|
|
196
|
+
status: 200,
|
|
197
|
+
headers: {
|
|
198
|
+
"content-type": "application/json"
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
};
|
|
202
|
+
try {
|
|
203
|
+
const router = createRouter({
|
|
204
|
+
routeTree: createTestRouteTree({ serverHead: true }),
|
|
205
|
+
history,
|
|
206
|
+
basePath: "/project"
|
|
207
|
+
});
|
|
208
|
+
await router.load();
|
|
209
|
+
expect(fetchCalls).toHaveLength(1);
|
|
210
|
+
expect(fetchCalls[0]).toBe("/project/head-api?href=%2Fproject%2Fabout");
|
|
211
|
+
} finally {
|
|
212
|
+
globalThis.fetch = originalFetch;
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
test("uses one document head request and preserves inline head precedence", async () => {
|
|
216
|
+
const history = createMemoryHistory({
|
|
217
|
+
initialEntries: ["/posts/alpha"]
|
|
218
|
+
});
|
|
219
|
+
const fetchCalls = [];
|
|
220
|
+
const originalFetch = globalThis.fetch;
|
|
221
|
+
globalThis.fetch = async (input) => {
|
|
222
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
223
|
+
fetchCalls.push(url);
|
|
224
|
+
return new Response(JSON.stringify({
|
|
225
|
+
head: [
|
|
226
|
+
{ tag: "title", children: "Site" },
|
|
227
|
+
{ tag: "title", children: "Alpha" }
|
|
228
|
+
],
|
|
229
|
+
routeHeads: [
|
|
230
|
+
{
|
|
231
|
+
routeId: "__root__",
|
|
232
|
+
head: [{ tag: "title", children: "Site" }],
|
|
233
|
+
staleTime: 60000
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
routeId: "/posts/$postId",
|
|
237
|
+
head: [{ tag: "title", children: "Alpha" }],
|
|
238
|
+
staleTime: 1e4
|
|
239
|
+
}
|
|
240
|
+
],
|
|
241
|
+
staleTime: 1e4
|
|
242
|
+
}), {
|
|
243
|
+
status: 200,
|
|
244
|
+
headers: {
|
|
245
|
+
"content-type": "application/json"
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
};
|
|
249
|
+
try {
|
|
250
|
+
const router = createRouter({
|
|
251
|
+
routeTree: createNestedHeadRouteTree(),
|
|
252
|
+
history
|
|
253
|
+
});
|
|
254
|
+
await router.load();
|
|
255
|
+
expect(fetchCalls).toEqual(["/head-api?href=%2Fposts%2Falpha"]);
|
|
256
|
+
expect(router.state.head).toEqual([
|
|
257
|
+
{ tag: "title", children: "Alpha" }
|
|
258
|
+
]);
|
|
259
|
+
} finally {
|
|
260
|
+
globalThis.fetch = originalFetch;
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
test("reuses a fresh root server head across navigations without refetching", async () => {
|
|
264
|
+
const history = createMemoryHistory({
|
|
265
|
+
initialEntries: ["/"]
|
|
266
|
+
});
|
|
267
|
+
const fetchCalls = [];
|
|
268
|
+
const originalFetch = globalThis.fetch;
|
|
269
|
+
globalThis.fetch = async (input) => {
|
|
270
|
+
fetchCalls.push(typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url);
|
|
271
|
+
return new Response(JSON.stringify({
|
|
272
|
+
head: [
|
|
273
|
+
{ tag: "title", children: "Site" }
|
|
274
|
+
],
|
|
275
|
+
routeHeads: [
|
|
276
|
+
{
|
|
277
|
+
routeId: "__root__",
|
|
278
|
+
head: [{ tag: "title", children: "Site" }],
|
|
279
|
+
staleTime: 60000
|
|
280
|
+
}
|
|
281
|
+
],
|
|
282
|
+
staleTime: 60000
|
|
283
|
+
}), {
|
|
284
|
+
status: 200,
|
|
285
|
+
headers: {
|
|
286
|
+
"content-type": "application/json"
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
};
|
|
290
|
+
try {
|
|
291
|
+
const router = createRouter({
|
|
292
|
+
routeTree: createRootServerHeadTree(),
|
|
293
|
+
history
|
|
294
|
+
});
|
|
295
|
+
await router.load();
|
|
296
|
+
await router.navigate({
|
|
297
|
+
to: "/about"
|
|
298
|
+
});
|
|
299
|
+
expect(fetchCalls).toEqual(["/head-api?href=%2F"]);
|
|
300
|
+
} finally {
|
|
301
|
+
globalThis.fetch = originalFetch;
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
test("seeds the route head cache from the dehydrated snapshot", async () => {
|
|
305
|
+
const history = createMemoryHistory({
|
|
306
|
+
initialEntries: ["/about"]
|
|
307
|
+
});
|
|
308
|
+
const fetchCalls = [];
|
|
309
|
+
const originalFetch = globalThis.fetch;
|
|
310
|
+
const globalWithWindow = globalThis;
|
|
311
|
+
const originalWindow = globalWithWindow.window;
|
|
312
|
+
globalThis.fetch = async (input) => {
|
|
313
|
+
fetchCalls.push(typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url);
|
|
314
|
+
return new Response(JSON.stringify({
|
|
315
|
+
head: [
|
|
316
|
+
{ tag: "title", children: "Site" }
|
|
317
|
+
],
|
|
318
|
+
routeHeads: [
|
|
319
|
+
{
|
|
320
|
+
routeId: "__root__",
|
|
321
|
+
head: [{ tag: "title", children: "Site" }],
|
|
322
|
+
staleTime: 60000
|
|
323
|
+
}
|
|
324
|
+
],
|
|
325
|
+
staleTime: 60000
|
|
326
|
+
}), {
|
|
327
|
+
status: 200,
|
|
328
|
+
headers: {
|
|
329
|
+
"content-type": "application/json"
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
};
|
|
333
|
+
globalWithWindow.window = {
|
|
334
|
+
__RICHIE_ROUTER_HEAD__: {
|
|
335
|
+
href: "/about",
|
|
336
|
+
head: [
|
|
337
|
+
{ tag: "title", children: "Site" }
|
|
338
|
+
],
|
|
339
|
+
routeHeads: [
|
|
340
|
+
{
|
|
341
|
+
routeId: "__root__",
|
|
342
|
+
head: [{ tag: "title", children: "Site" }],
|
|
343
|
+
staleTime: 60000
|
|
344
|
+
}
|
|
345
|
+
]
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
try {
|
|
349
|
+
const router = createRouter({
|
|
350
|
+
routeTree: createRootServerHeadTree(),
|
|
351
|
+
history
|
|
352
|
+
});
|
|
353
|
+
await router.load();
|
|
354
|
+
expect(fetchCalls).toHaveLength(0);
|
|
355
|
+
expect(router.state.head).toEqual([
|
|
356
|
+
{ tag: "title", children: "Site" }
|
|
357
|
+
]);
|
|
358
|
+
} finally {
|
|
359
|
+
globalThis.fetch = originalFetch;
|
|
360
|
+
if (originalWindow === undefined) {
|
|
361
|
+
Reflect.deleteProperty(globalWithWindow, "window");
|
|
362
|
+
} else {
|
|
363
|
+
originalWindow.__RICHIE_ROUTER_HEAD__ = undefined;
|
|
364
|
+
globalWithWindow.window = originalWindow;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
test("reuses the initial merged head snapshot without fetching when the branch has no inline head", async () => {
|
|
369
|
+
const history = createMemoryHistory({
|
|
370
|
+
initialEntries: ["/about"]
|
|
371
|
+
});
|
|
372
|
+
const fetchCalls = [];
|
|
373
|
+
const originalFetch = globalThis.fetch;
|
|
374
|
+
const globalWithWindow = globalThis;
|
|
375
|
+
const originalWindow = globalWithWindow.window;
|
|
376
|
+
globalThis.fetch = async (input) => {
|
|
377
|
+
fetchCalls.push(typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url);
|
|
378
|
+
return new Response("{}", {
|
|
379
|
+
status: 500
|
|
380
|
+
});
|
|
381
|
+
};
|
|
382
|
+
globalWithWindow.window = {
|
|
383
|
+
__RICHIE_ROUTER_HEAD__: {
|
|
384
|
+
href: "/about",
|
|
385
|
+
head: [
|
|
386
|
+
{ tag: "title", children: "About from SSR" }
|
|
387
|
+
]
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
try {
|
|
391
|
+
const router = createRouter({
|
|
392
|
+
routeTree: createTestRouteTree({ serverHead: true }),
|
|
393
|
+
history
|
|
394
|
+
});
|
|
395
|
+
await router.load();
|
|
396
|
+
expect(fetchCalls).toHaveLength(0);
|
|
397
|
+
expect(router.state.head).toEqual([
|
|
398
|
+
{ tag: "title", children: "About from SSR" }
|
|
399
|
+
]);
|
|
400
|
+
} finally {
|
|
401
|
+
globalThis.fetch = originalFetch;
|
|
402
|
+
if (originalWindow === undefined) {
|
|
403
|
+
Reflect.deleteProperty(globalWithWindow, "window");
|
|
404
|
+
} else {
|
|
405
|
+
originalWindow.__RICHIE_ROUTER_HEAD__ = undefined;
|
|
406
|
+
globalWithWindow.window = originalWindow;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
});
|
|
410
|
+
test("uses the merged document head without route fallback requests when the branch has no inline head", async () => {
|
|
411
|
+
const history = createMemoryHistory({
|
|
412
|
+
initialEntries: ["/posts/alpha"]
|
|
413
|
+
});
|
|
414
|
+
const fetchCalls = [];
|
|
415
|
+
const originalFetch = globalThis.fetch;
|
|
416
|
+
globalThis.fetch = async (input) => {
|
|
417
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
418
|
+
fetchCalls.push(url);
|
|
419
|
+
return new Response(JSON.stringify({
|
|
420
|
+
head: [
|
|
421
|
+
{ tag: "meta", name: "description", content: "Nested server head" },
|
|
422
|
+
{ tag: "title", children: "Alpha" }
|
|
423
|
+
],
|
|
424
|
+
staleTime: 1e4
|
|
425
|
+
}), {
|
|
426
|
+
status: 200,
|
|
427
|
+
headers: {
|
|
428
|
+
"content-type": "application/json"
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
};
|
|
432
|
+
try {
|
|
433
|
+
const router = createRouter({
|
|
434
|
+
routeTree: createNestedServerHeadTree(),
|
|
435
|
+
history
|
|
436
|
+
});
|
|
437
|
+
await router.load();
|
|
438
|
+
expect(fetchCalls).toEqual(["/head-api?href=%2Fposts%2Falpha"]);
|
|
439
|
+
expect(router.state.head).toEqual([
|
|
440
|
+
{ tag: "meta", name: "description", content: "Nested server head" },
|
|
441
|
+
{ tag: "title", children: "Alpha" }
|
|
442
|
+
]);
|
|
443
|
+
} finally {
|
|
444
|
+
globalThis.fetch = originalFetch;
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
test("keeps loadRouteHead as the route-scoped override path", async () => {
|
|
448
|
+
const history = createMemoryHistory({
|
|
449
|
+
initialEntries: ["/about"]
|
|
450
|
+
});
|
|
451
|
+
const fetchCalls = [];
|
|
452
|
+
const loadRouteHeadCalls = [];
|
|
453
|
+
const originalFetch = globalThis.fetch;
|
|
454
|
+
globalThis.fetch = async (input) => {
|
|
455
|
+
fetchCalls.push(typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url);
|
|
456
|
+
return new Response("{}", {
|
|
457
|
+
status: 500
|
|
458
|
+
});
|
|
459
|
+
};
|
|
460
|
+
try {
|
|
461
|
+
const router = createRouter({
|
|
462
|
+
routeTree: createTestRouteTree({ serverHead: true }),
|
|
463
|
+
history,
|
|
464
|
+
loadRouteHead: async ({ routeId }) => {
|
|
465
|
+
loadRouteHeadCalls.push(routeId);
|
|
466
|
+
return {
|
|
467
|
+
head: [
|
|
468
|
+
{ tag: "title", children: "About override" }
|
|
469
|
+
],
|
|
470
|
+
staleTime: 1000
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
await router.load();
|
|
475
|
+
expect(loadRouteHeadCalls).toEqual(["/about"]);
|
|
476
|
+
expect(fetchCalls).toHaveLength(0);
|
|
477
|
+
expect(router.state.head).toEqual([
|
|
478
|
+
{ tag: "title", children: "About override" }
|
|
479
|
+
]);
|
|
480
|
+
} finally {
|
|
481
|
+
globalThis.fetch = originalFetch;
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
});
|
|
485
|
+
describe("Link active state", () => {
|
|
486
|
+
test("keeps parent links active on child routes by default", () => {
|
|
487
|
+
const TestLink = Link;
|
|
488
|
+
const markup = renderLinkMarkup("/posts/alpha", () => React.createElement(TestLink, { to: "/posts", activeProps: { className: "active" } }, "Posts"));
|
|
489
|
+
expect(markup).toContain('class="active"');
|
|
490
|
+
});
|
|
491
|
+
test("supports exact-only active matching", () => {
|
|
492
|
+
const TestLink = Link;
|
|
493
|
+
const markup = renderLinkMarkup("/posts/alpha", () => React.createElement(TestLink, {
|
|
494
|
+
to: "/posts",
|
|
495
|
+
activeOptions: { exact: true },
|
|
496
|
+
activeProps: { className: "active" }
|
|
497
|
+
}, "Posts"));
|
|
498
|
+
expect(markup).not.toContain('class="active"');
|
|
499
|
+
});
|
|
500
|
+
test("matches path segments instead of raw string prefixes", () => {
|
|
501
|
+
const TestLink = Link;
|
|
502
|
+
const markup = renderLinkMarkup("/posts/alpha", () => React.createElement(TestLink, { to: "/post", activeProps: { className: "active" } }, "Post"));
|
|
503
|
+
expect(markup).not.toContain('class="active"');
|
|
504
|
+
});
|
|
505
|
+
test("applies activeOptions in custom links created with createLink", () => {
|
|
506
|
+
const AppLink = createLink((props) => React.createElement("a", props));
|
|
507
|
+
const markup = renderLinkMarkup("/posts/alpha", () => React.createElement(AppLink, {
|
|
508
|
+
to: "/posts",
|
|
509
|
+
activeOptions: { exact: true },
|
|
510
|
+
activeProps: { className: "active" }
|
|
511
|
+
}, "Posts"));
|
|
512
|
+
expect(markup).not.toContain('class="active"');
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
describe("useMatchRoute", () => {
|
|
516
|
+
test("returns matched params for exact matches", () => {
|
|
517
|
+
const markup = renderLinkMarkup("/posts/alpha", () => {
|
|
518
|
+
const matchRoute = useMatchRoute();
|
|
519
|
+
const match = matchRoute({ to: "/posts/$postId" });
|
|
520
|
+
return React.createElement("pre", null, match === false ? "false" : JSON.stringify(match));
|
|
521
|
+
});
|
|
522
|
+
expect(markup).toContain("{"postId":"alpha"}");
|
|
523
|
+
});
|
|
524
|
+
test("supports fuzzy parent matching", () => {
|
|
525
|
+
const markup = renderLinkMarkup("/posts/alpha", () => {
|
|
526
|
+
const matchRoute = useMatchRoute();
|
|
527
|
+
const match = matchRoute({ to: "/posts", fuzzy: true });
|
|
528
|
+
return React.createElement("pre", null, match === false ? "false" : JSON.stringify(match));
|
|
529
|
+
});
|
|
530
|
+
expect(markup).toContain("{}");
|
|
531
|
+
});
|
|
532
|
+
test("supports partial param filters", () => {
|
|
533
|
+
const markup = renderLinkMarkup("/posts/alpha", () => {
|
|
534
|
+
const matchRoute = useMatchRoute();
|
|
535
|
+
const match = matchRoute({ to: "/posts/$postId", params: { postId: "beta" } });
|
|
536
|
+
return React.createElement("pre", null, match === false ? "false" : JSON.stringify(match));
|
|
537
|
+
});
|
|
538
|
+
expect(markup).toContain("false");
|
|
539
|
+
});
|
|
540
|
+
test("can include search params in the match", () => {
|
|
541
|
+
const markup = renderLinkMarkup("/posts/alpha?tab=details&count=2", () => {
|
|
542
|
+
const matchRoute = useMatchRoute();
|
|
543
|
+
const match = matchRoute({
|
|
544
|
+
to: "/posts/$postId",
|
|
545
|
+
includeSearch: true,
|
|
546
|
+
search: { tab: "details" }
|
|
547
|
+
});
|
|
548
|
+
return React.createElement("pre", null, match === false ? "false" : JSON.stringify(match));
|
|
549
|
+
});
|
|
550
|
+
expect(markup).toContain("{"postId":"alpha"}");
|
|
551
|
+
});
|
|
552
|
+
});
|
package/dist/types/router.d.ts
CHANGED
|
@@ -67,6 +67,8 @@ type ParamsInput<TParams> = TParams | ((previous: TParams) => TParams);
|
|
|
67
67
|
type SearchInput<TSearch> = TSearch | ((previous: TSearch) => TSearch) | true;
|
|
68
68
|
type ParamsOption<TParams> = keyof TParams extends never ? {
|
|
69
69
|
params?: never;
|
|
70
|
+
} : string extends keyof TParams ? {
|
|
71
|
+
params?: ParamsInput<TParams>;
|
|
70
72
|
} : {
|
|
71
73
|
params: ParamsInput<TParams>;
|
|
72
74
|
};
|
|
@@ -107,8 +109,22 @@ export type NavigateOptions<TTo extends RoutePaths = RoutePaths> = {
|
|
|
107
109
|
search?: SearchInput<SearchForTo<TTo>>;
|
|
108
110
|
} & NavigateBaseOptions & ParamsOption<ParamsForTo<TTo>>;
|
|
109
111
|
export type NavigateFn = <TTo extends RoutePaths>(options: NavigateOptions<TTo>) => Promise<void>;
|
|
112
|
+
export interface MatchRouteOptions {
|
|
113
|
+
fuzzy?: boolean;
|
|
114
|
+
includeSearch?: boolean;
|
|
115
|
+
}
|
|
116
|
+
export type UseMatchRouteOptions<TTo extends RoutePaths = RoutePaths> = {
|
|
117
|
+
to: TTo;
|
|
118
|
+
params?: Partial<ParamsForTo<TTo>>;
|
|
119
|
+
search?: Record<string, unknown>;
|
|
120
|
+
} & MatchRouteOptions;
|
|
121
|
+
export type MatchRouteFn = <TTo extends RoutePaths>(options: UseMatchRouteOptions<TTo>) => false | ParamsForTo<TTo>;
|
|
122
|
+
export interface ActiveOptions {
|
|
123
|
+
exact?: boolean;
|
|
124
|
+
}
|
|
110
125
|
export type LinkOwnProps<TTo extends RoutePaths> = NavigateOptions<TTo> & {
|
|
111
126
|
preload?: 'intent' | 'render' | false;
|
|
127
|
+
activeOptions?: ActiveOptions;
|
|
112
128
|
activeProps?: React.AnchorHTMLAttributes<HTMLAnchorElement>;
|
|
113
129
|
children?: React.ReactNode | ((ctx: {
|
|
114
130
|
isActive: boolean;
|
|
@@ -125,6 +141,7 @@ export interface RouterState {
|
|
|
125
141
|
export interface RouterOptions<TRouteTree extends AnyRoute> {
|
|
126
142
|
routeTree: TRouteTree;
|
|
127
143
|
history?: RouterHistory;
|
|
144
|
+
basePath?: string;
|
|
128
145
|
defaultPreload?: 'intent' | 'render' | false;
|
|
129
146
|
defaultPreloadDelay?: number;
|
|
130
147
|
defaultPendingMs?: number;
|
|
@@ -170,6 +187,8 @@ export declare class Router<TRouteTree extends AnyRoute> {
|
|
|
170
187
|
private readonly headCache;
|
|
171
188
|
private readonly parseSearch;
|
|
172
189
|
private readonly stringifySearch;
|
|
190
|
+
private readonly basePath;
|
|
191
|
+
private initialHeadSnapshot?;
|
|
173
192
|
private started;
|
|
174
193
|
private unsubscribeHistory?;
|
|
175
194
|
constructor(options: RouterOptions<TRouteTree>);
|
|
@@ -184,6 +203,8 @@ export declare class Router<TRouteTree extends AnyRoute> {
|
|
|
184
203
|
preloadRoute<TTo extends RoutePaths>(options: NavigateOptions<TTo>): Promise<void>;
|
|
185
204
|
invalidate(): Promise<void>;
|
|
186
205
|
buildHref<TTo extends RoutePaths>(options: NavigateOptions<TTo>): string;
|
|
206
|
+
private buildLocation;
|
|
207
|
+
private buildLocationHref;
|
|
187
208
|
private readLocation;
|
|
188
209
|
private applyTrailingSlash;
|
|
189
210
|
private notify;
|
|
@@ -192,21 +213,28 @@ export declare class Router<TRouteTree extends AnyRoute> {
|
|
|
192
213
|
private resolveSearch;
|
|
193
214
|
resolveLocation(location: ParsedLocation, options?: {
|
|
194
215
|
request?: Request;
|
|
216
|
+
initialHeadSnapshot?: DehydratedHeadState;
|
|
195
217
|
}): Promise<{
|
|
196
218
|
matches: InternalRouteMatch[];
|
|
197
219
|
head: HeadConfig;
|
|
198
220
|
error: unknown;
|
|
199
221
|
}>;
|
|
200
222
|
private resolveLocationHead;
|
|
223
|
+
private getRouteHeadCacheKey;
|
|
224
|
+
private getCachedRouteHead;
|
|
225
|
+
private setRouteHeadCache;
|
|
226
|
+
private seedHeadCacheFromRouteHeads;
|
|
227
|
+
private cacheRouteHeadsFromDocument;
|
|
201
228
|
private loadRouteHead;
|
|
202
229
|
private fetchRouteHead;
|
|
230
|
+
private fetchDocumentHead;
|
|
203
231
|
private commitLocation;
|
|
204
232
|
private restoreScroll;
|
|
205
233
|
private handleHistoryChange;
|
|
206
234
|
}
|
|
207
235
|
export declare function createRouter<TRouteTree extends AnyRoute>(options: RouterOptions<TRouteTree>): Router<TRouteTree>;
|
|
208
|
-
export declare function RouterProvider({ router }: {
|
|
209
|
-
router:
|
|
236
|
+
export declare function RouterProvider<TRouteTree extends AnyRoute>({ router }: {
|
|
237
|
+
router: Router<TRouteTree>;
|
|
210
238
|
}): React.ReactElement;
|
|
211
239
|
export declare function Outlet(): React.ReactNode;
|
|
212
240
|
export declare function useRouter(): RegisteredRouter;
|
|
@@ -221,6 +249,7 @@ export declare function useSearch<TFrom extends RoutePaths>(options?: {
|
|
|
221
249
|
from?: TFrom;
|
|
222
250
|
}): SearchOfRoute<RouteById<TFrom>>;
|
|
223
251
|
export declare function useNavigate(): NavigateFn;
|
|
252
|
+
export declare function useMatchRoute(): MatchRouteFn;
|
|
224
253
|
export declare function useLocation(): ParsedLocation;
|
|
225
254
|
export declare function useRouterState<TSelection>(options: {
|
|
226
255
|
select: Selector<TSelection>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@richie-router/react",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "React runtime, components, and hooks for Richie Router",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"exports": {
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
}
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@richie-router/core": "^0.1.
|
|
16
|
+
"@richie-router/core": "^0.1.3"
|
|
17
17
|
},
|
|
18
18
|
"peerDependencies": {
|
|
19
19
|
"react": "^19"
|