@richie-router/react 0.1.2 → 0.1.3

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.
@@ -0,0 +1,418 @@
1
+ // packages/react/src/router.test.ts
2
+ import { describe, expect, test } from "bun:test";
3
+ import {
4
+ createFileRoute,
5
+ createMemoryHistory,
6
+ createRootRoute,
7
+ createRouter
8
+ } from "./router.mjs";
9
+ function createTestRouteTree(options) {
10
+ const rootRoute = createRootRoute({
11
+ component: () => null
12
+ });
13
+ const indexRoute = createFileRoute("/")({
14
+ component: () => null
15
+ });
16
+ const aboutRoute = createFileRoute("/about")({
17
+ component: () => null
18
+ });
19
+ aboutRoute._setServerHead(options?.serverHead);
20
+ return rootRoute._addFileChildren({
21
+ index: indexRoute,
22
+ about: aboutRoute
23
+ });
24
+ }
25
+ function createNestedHeadRouteTree() {
26
+ const rootRoute = createRootRoute({
27
+ component: () => null
28
+ });
29
+ const postsRoute = createFileRoute("/posts")({
30
+ component: () => null,
31
+ head: [
32
+ { tag: "title", children: "Posts" }
33
+ ]
34
+ });
35
+ const postRoute = createFileRoute("/posts/$postId")({
36
+ component: () => null
37
+ });
38
+ rootRoute._setServerHead(true);
39
+ postRoute._setServerHead(true);
40
+ postsRoute._addFileChildren({
41
+ post: postRoute
42
+ });
43
+ return rootRoute._addFileChildren({
44
+ posts: postsRoute
45
+ });
46
+ }
47
+ function createRootServerHeadTree() {
48
+ const rootRoute = createRootRoute({
49
+ component: () => null
50
+ });
51
+ const indexRoute = createFileRoute("/")({
52
+ component: () => null
53
+ });
54
+ const aboutRoute = createFileRoute("/about")({
55
+ component: () => null
56
+ });
57
+ rootRoute._setServerHead(true);
58
+ return rootRoute._addFileChildren({
59
+ index: indexRoute,
60
+ about: aboutRoute
61
+ });
62
+ }
63
+ function createNestedServerHeadTree() {
64
+ const rootRoute = createRootRoute({
65
+ component: () => null
66
+ });
67
+ const postsRoute = createFileRoute("/posts")({
68
+ component: () => null
69
+ });
70
+ const postRoute = createFileRoute("/posts/$postId")({
71
+ component: () => null
72
+ });
73
+ rootRoute._setServerHead(true);
74
+ postsRoute._setServerHead(true);
75
+ postRoute._setServerHead(true);
76
+ postsRoute._addFileChildren({
77
+ post: postRoute
78
+ });
79
+ return rootRoute._addFileChildren({
80
+ posts: postsRoute
81
+ });
82
+ }
83
+ describe("createRouter basePath", () => {
84
+ test("strips the basePath from the current history location", () => {
85
+ const history = createMemoryHistory({
86
+ initialEntries: ["/project/about?tab=team#bio"]
87
+ });
88
+ const router = createRouter({
89
+ routeTree: createTestRouteTree(),
90
+ history,
91
+ basePath: "/project"
92
+ });
93
+ expect(router.state.location.pathname).toBe("/about");
94
+ expect(router.state.location.href).toBe("/about?tab=team#bio");
95
+ expect(router.state.matches.at(-1)?.route.fullPath).toBe("/about");
96
+ });
97
+ test("prefixes generated hrefs and history writes with the basePath", async () => {
98
+ const history = createMemoryHistory({
99
+ initialEntries: ["/project"]
100
+ });
101
+ const router = createRouter({
102
+ routeTree: createTestRouteTree(),
103
+ history,
104
+ basePath: "/project"
105
+ });
106
+ expect(router.buildHref({ to: "/about" })).toBe("/project/about");
107
+ await router.navigate({
108
+ to: "/about",
109
+ search: {
110
+ tab: "team"
111
+ }
112
+ });
113
+ expect(history.location.href).toBe("/project/about?tab=team");
114
+ expect(router.state.location.href).toBe("/about?tab=team");
115
+ });
116
+ test("uses the basePath for the default head API endpoint", async () => {
117
+ const history = createMemoryHistory({
118
+ initialEntries: ["/project/about"]
119
+ });
120
+ const fetchCalls = [];
121
+ const originalFetch = globalThis.fetch;
122
+ globalThis.fetch = async (input) => {
123
+ fetchCalls.push(typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url);
124
+ return new Response(JSON.stringify({
125
+ head: [],
126
+ routeHeads: [
127
+ { routeId: "/about", head: [] }
128
+ ]
129
+ }), {
130
+ status: 200,
131
+ headers: {
132
+ "content-type": "application/json"
133
+ }
134
+ });
135
+ };
136
+ try {
137
+ const router = createRouter({
138
+ routeTree: createTestRouteTree({ serverHead: true }),
139
+ history,
140
+ basePath: "/project"
141
+ });
142
+ await router.load();
143
+ expect(fetchCalls).toHaveLength(1);
144
+ expect(fetchCalls[0]).toBe("/project/head-api?href=%2Fproject%2Fabout");
145
+ } finally {
146
+ globalThis.fetch = originalFetch;
147
+ }
148
+ });
149
+ test("uses one document head request and preserves inline head precedence", async () => {
150
+ const history = createMemoryHistory({
151
+ initialEntries: ["/posts/alpha"]
152
+ });
153
+ const fetchCalls = [];
154
+ const originalFetch = globalThis.fetch;
155
+ globalThis.fetch = async (input) => {
156
+ const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
157
+ fetchCalls.push(url);
158
+ return new Response(JSON.stringify({
159
+ head: [
160
+ { tag: "title", children: "Site" },
161
+ { tag: "title", children: "Alpha" }
162
+ ],
163
+ routeHeads: [
164
+ {
165
+ routeId: "__root__",
166
+ head: [{ tag: "title", children: "Site" }],
167
+ staleTime: 60000
168
+ },
169
+ {
170
+ routeId: "/posts/$postId",
171
+ head: [{ tag: "title", children: "Alpha" }],
172
+ staleTime: 1e4
173
+ }
174
+ ],
175
+ staleTime: 1e4
176
+ }), {
177
+ status: 200,
178
+ headers: {
179
+ "content-type": "application/json"
180
+ }
181
+ });
182
+ };
183
+ try {
184
+ const router = createRouter({
185
+ routeTree: createNestedHeadRouteTree(),
186
+ history
187
+ });
188
+ await router.load();
189
+ expect(fetchCalls).toEqual(["/head-api?href=%2Fposts%2Falpha"]);
190
+ expect(router.state.head).toEqual([
191
+ { tag: "title", children: "Alpha" }
192
+ ]);
193
+ } finally {
194
+ globalThis.fetch = originalFetch;
195
+ }
196
+ });
197
+ test("reuses a fresh root server head across navigations without refetching", async () => {
198
+ const history = createMemoryHistory({
199
+ initialEntries: ["/"]
200
+ });
201
+ const fetchCalls = [];
202
+ const originalFetch = globalThis.fetch;
203
+ globalThis.fetch = async (input) => {
204
+ fetchCalls.push(typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url);
205
+ return new Response(JSON.stringify({
206
+ head: [
207
+ { tag: "title", children: "Site" }
208
+ ],
209
+ routeHeads: [
210
+ {
211
+ routeId: "__root__",
212
+ head: [{ tag: "title", children: "Site" }],
213
+ staleTime: 60000
214
+ }
215
+ ],
216
+ staleTime: 60000
217
+ }), {
218
+ status: 200,
219
+ headers: {
220
+ "content-type": "application/json"
221
+ }
222
+ });
223
+ };
224
+ try {
225
+ const router = createRouter({
226
+ routeTree: createRootServerHeadTree(),
227
+ history
228
+ });
229
+ await router.load();
230
+ await router.navigate({
231
+ to: "/about"
232
+ });
233
+ expect(fetchCalls).toEqual(["/head-api?href=%2F"]);
234
+ } finally {
235
+ globalThis.fetch = originalFetch;
236
+ }
237
+ });
238
+ test("seeds the route head cache from the dehydrated snapshot", async () => {
239
+ const history = createMemoryHistory({
240
+ initialEntries: ["/about"]
241
+ });
242
+ const fetchCalls = [];
243
+ const originalFetch = globalThis.fetch;
244
+ const globalWithWindow = globalThis;
245
+ const originalWindow = globalWithWindow.window;
246
+ globalThis.fetch = async (input) => {
247
+ fetchCalls.push(typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url);
248
+ return new Response(JSON.stringify({
249
+ head: [
250
+ { tag: "title", children: "Site" }
251
+ ],
252
+ routeHeads: [
253
+ {
254
+ routeId: "__root__",
255
+ head: [{ tag: "title", children: "Site" }],
256
+ staleTime: 60000
257
+ }
258
+ ],
259
+ staleTime: 60000
260
+ }), {
261
+ status: 200,
262
+ headers: {
263
+ "content-type": "application/json"
264
+ }
265
+ });
266
+ };
267
+ globalWithWindow.window = {
268
+ __RICHIE_ROUTER_HEAD__: {
269
+ href: "/about",
270
+ head: [
271
+ { tag: "title", children: "Site" }
272
+ ],
273
+ routeHeads: [
274
+ {
275
+ routeId: "__root__",
276
+ head: [{ tag: "title", children: "Site" }],
277
+ staleTime: 60000
278
+ }
279
+ ]
280
+ }
281
+ };
282
+ try {
283
+ const router = createRouter({
284
+ routeTree: createRootServerHeadTree(),
285
+ history
286
+ });
287
+ await router.load();
288
+ expect(fetchCalls).toHaveLength(0);
289
+ expect(router.state.head).toEqual([
290
+ { tag: "title", children: "Site" }
291
+ ]);
292
+ } finally {
293
+ globalThis.fetch = originalFetch;
294
+ if (originalWindow === undefined) {
295
+ Reflect.deleteProperty(globalWithWindow, "window");
296
+ } else {
297
+ originalWindow.__RICHIE_ROUTER_HEAD__ = undefined;
298
+ globalWithWindow.window = originalWindow;
299
+ }
300
+ }
301
+ });
302
+ test("reuses the initial merged head snapshot without fetching when the branch has no inline head", async () => {
303
+ const history = createMemoryHistory({
304
+ initialEntries: ["/about"]
305
+ });
306
+ const fetchCalls = [];
307
+ const originalFetch = globalThis.fetch;
308
+ const globalWithWindow = globalThis;
309
+ const originalWindow = globalWithWindow.window;
310
+ globalThis.fetch = async (input) => {
311
+ fetchCalls.push(typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url);
312
+ return new Response("{}", {
313
+ status: 500
314
+ });
315
+ };
316
+ globalWithWindow.window = {
317
+ __RICHIE_ROUTER_HEAD__: {
318
+ href: "/about",
319
+ head: [
320
+ { tag: "title", children: "About from SSR" }
321
+ ]
322
+ }
323
+ };
324
+ try {
325
+ const router = createRouter({
326
+ routeTree: createTestRouteTree({ serverHead: true }),
327
+ history
328
+ });
329
+ await router.load();
330
+ expect(fetchCalls).toHaveLength(0);
331
+ expect(router.state.head).toEqual([
332
+ { tag: "title", children: "About from SSR" }
333
+ ]);
334
+ } finally {
335
+ globalThis.fetch = originalFetch;
336
+ if (originalWindow === undefined) {
337
+ Reflect.deleteProperty(globalWithWindow, "window");
338
+ } else {
339
+ originalWindow.__RICHIE_ROUTER_HEAD__ = undefined;
340
+ globalWithWindow.window = originalWindow;
341
+ }
342
+ }
343
+ });
344
+ test("uses the merged document head without route fallback requests when the branch has no inline head", async () => {
345
+ const history = createMemoryHistory({
346
+ initialEntries: ["/posts/alpha"]
347
+ });
348
+ const fetchCalls = [];
349
+ const originalFetch = globalThis.fetch;
350
+ globalThis.fetch = async (input) => {
351
+ const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
352
+ fetchCalls.push(url);
353
+ return new Response(JSON.stringify({
354
+ head: [
355
+ { tag: "meta", name: "description", content: "Nested server head" },
356
+ { tag: "title", children: "Alpha" }
357
+ ],
358
+ staleTime: 1e4
359
+ }), {
360
+ status: 200,
361
+ headers: {
362
+ "content-type": "application/json"
363
+ }
364
+ });
365
+ };
366
+ try {
367
+ const router = createRouter({
368
+ routeTree: createNestedServerHeadTree(),
369
+ history
370
+ });
371
+ await router.load();
372
+ expect(fetchCalls).toEqual(["/head-api?href=%2Fposts%2Falpha"]);
373
+ expect(router.state.head).toEqual([
374
+ { tag: "meta", name: "description", content: "Nested server head" },
375
+ { tag: "title", children: "Alpha" }
376
+ ]);
377
+ } finally {
378
+ globalThis.fetch = originalFetch;
379
+ }
380
+ });
381
+ test("keeps loadRouteHead as the route-scoped override path", async () => {
382
+ const history = createMemoryHistory({
383
+ initialEntries: ["/about"]
384
+ });
385
+ const fetchCalls = [];
386
+ const loadRouteHeadCalls = [];
387
+ const originalFetch = globalThis.fetch;
388
+ globalThis.fetch = async (input) => {
389
+ fetchCalls.push(typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url);
390
+ return new Response("{}", {
391
+ status: 500
392
+ });
393
+ };
394
+ try {
395
+ const router = createRouter({
396
+ routeTree: createTestRouteTree({ serverHead: true }),
397
+ history,
398
+ loadRouteHead: async ({ routeId }) => {
399
+ loadRouteHeadCalls.push(routeId);
400
+ return {
401
+ head: [
402
+ { tag: "title", children: "About override" }
403
+ ],
404
+ staleTime: 1000
405
+ };
406
+ }
407
+ });
408
+ await router.load();
409
+ expect(loadRouteHeadCalls).toEqual(["/about"]);
410
+ expect(fetchCalls).toHaveLength(0);
411
+ expect(router.state.head).toEqual([
412
+ { tag: "title", children: "About override" }
413
+ ]);
414
+ } finally {
415
+ globalThis.fetch = originalFetch;
416
+ }
417
+ });
418
+ });
@@ -125,6 +125,7 @@ export interface RouterState {
125
125
  export interface RouterOptions<TRouteTree extends AnyRoute> {
126
126
  routeTree: TRouteTree;
127
127
  history?: RouterHistory;
128
+ basePath?: string;
128
129
  defaultPreload?: 'intent' | 'render' | false;
129
130
  defaultPreloadDelay?: number;
130
131
  defaultPendingMs?: number;
@@ -170,6 +171,8 @@ export declare class Router<TRouteTree extends AnyRoute> {
170
171
  private readonly headCache;
171
172
  private readonly parseSearch;
172
173
  private readonly stringifySearch;
174
+ private readonly basePath;
175
+ private initialHeadSnapshot?;
173
176
  private started;
174
177
  private unsubscribeHistory?;
175
178
  constructor(options: RouterOptions<TRouteTree>);
@@ -184,6 +187,8 @@ export declare class Router<TRouteTree extends AnyRoute> {
184
187
  preloadRoute<TTo extends RoutePaths>(options: NavigateOptions<TTo>): Promise<void>;
185
188
  invalidate(): Promise<void>;
186
189
  buildHref<TTo extends RoutePaths>(options: NavigateOptions<TTo>): string;
190
+ private buildLocation;
191
+ private buildLocationHref;
187
192
  private readLocation;
188
193
  private applyTrailingSlash;
189
194
  private notify;
@@ -192,14 +197,21 @@ export declare class Router<TRouteTree extends AnyRoute> {
192
197
  private resolveSearch;
193
198
  resolveLocation(location: ParsedLocation, options?: {
194
199
  request?: Request;
200
+ initialHeadSnapshot?: DehydratedHeadState;
195
201
  }): Promise<{
196
202
  matches: InternalRouteMatch[];
197
203
  head: HeadConfig;
198
204
  error: unknown;
199
205
  }>;
200
206
  private resolveLocationHead;
207
+ private getRouteHeadCacheKey;
208
+ private getCachedRouteHead;
209
+ private setRouteHeadCache;
210
+ private seedHeadCacheFromRouteHeads;
211
+ private cacheRouteHeadsFromDocument;
201
212
  private loadRouteHead;
202
213
  private fetchRouteHead;
214
+ private fetchDocumentHead;
203
215
  private commitLocation;
204
216
  private restoreScroll;
205
217
  private handleHistoryChange;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@richie-router/react",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
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.2"
16
+ "@richie-router/core": "^0.1.3"
17
17
  },
18
18
  "peerDependencies": {
19
19
  "react": "^19"