@richie-router/react 0.1.1 → 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
+ });
@@ -1,6 +1,6 @@
1
1
  import React from 'react';
2
2
  import { RouteNode, isNotFound, isRedirect, notFound, redirect } from '@richie-router/core';
3
- import type { AnyComponent, AnyRoute, DehydratedHeadState, HeadConfig, NormalizeRouteId, ParsedLocation, ResolveAllParams, RouteMatch, RouteOptions as CoreRouteOptions, Simplify } from '@richie-router/core';
3
+ import type { AnyComponent, AnyRoute, DehydratedHeadState, HeadConfig, NormalizeRouteId, ParsedLocation, ResolveAllParams, RouteMatch, RouteOptions as CoreRouteOptions } from '@richie-router/core';
4
4
  import { createBrowserHistory, createHashHistory, createMemoryHistory } from './history';
5
5
  import type { MemoryHistoryOptions, RouterHistory } from './history';
6
6
  declare global {
@@ -10,12 +10,9 @@ declare global {
10
10
  }
11
11
  export interface Register {
12
12
  }
13
- type RegisteredHeadTagMap = Register extends {
14
- headTagMap: infer THeadTagMap;
15
- } ? THeadTagMap : Record<string, string>;
16
- type RegisteredHeadTagSearchSchema = Register extends {
17
- headTagSearchSchema: infer THeadTagSearchSchema;
18
- } ? THeadTagSearchSchema : Record<string, {}>;
13
+ type RegisteredRouteSearchSchema = Register extends {
14
+ routeSearchSchema: infer TRouteSearchSchema;
15
+ } ? TRouteSearchSchema : Record<string, {}>;
19
16
  type RegisteredRouteTree = Register extends {
20
17
  routeTree: infer TRouteTree;
21
18
  } ? TRouteTree : AnyRoute;
@@ -65,8 +62,7 @@ type MatchOfRoute<TRoute> = {
65
62
  type SafeRouteByTo<TTo extends string> = [RouteById<TTo>] extends [never] ? AnyRoute : RouteById<TTo>;
66
63
  type ParamsForTo<TTo extends string> = ParamsOfRoute<SafeRouteByTo<TTo>>;
67
64
  type SearchForTo<TTo extends string> = SearchOfRoute<SafeRouteByTo<TTo>>;
68
- type SearchForFullPath<TPath extends string> = TPath extends keyof RegisteredHeadTagSearchSchema ? RegisteredHeadTagSearchSchema[TPath] : {};
69
- type HeadTagNameForFullPath<TPath extends string> = TPath extends keyof RegisteredHeadTagMap ? RegisteredHeadTagMap[TPath] : string;
65
+ type SearchForRouteId<TPath extends string> = TPath extends keyof RegisteredRouteSearchSchema ? RegisteredRouteSearchSchema[TPath] : {};
70
66
  type ParamsInput<TParams> = TParams | ((previous: TParams) => TParams);
71
67
  type SearchInput<TSearch> = TSearch | ((previous: TSearch) => TSearch) | true;
72
68
  type ParamsOption<TParams> = keyof TParams extends never ? {
@@ -79,11 +75,12 @@ type ClientHeadOption<TPath extends string, TSearch> = HeadConfig | ((ctx: {
79
75
  search: TSearch;
80
76
  matches: RouteMatch[];
81
77
  }) => HeadConfig);
82
- type RouteHeadOption<TPath extends string, TSearch> = ClientHeadOption<TPath, TSearch> | (HeadTagNameForFullPath<TPath> extends never ? never : HeadTagNameForFullPath<TPath>);
83
78
  type RouteOptionsInput<TPath extends string, TSearch> = Omit<CoreRouteOptions<TPath, TSearch>, 'head'> & {
84
- head?: RouteHeadOption<TPath, TSearch>;
79
+ head?: ClientHeadOption<TPath, TSearch>;
80
+ };
81
+ type FileRouteInstance<TPath extends string, TSearch, TFileTypes = unknown, THasInlineHead extends boolean = boolean> = RouteNode<TPath, NormalizeRouteId<TPath>, ResolveAllParams<TPath>, TSearch, TFileTypes> & RouteApiMethods<RouteNode<TPath, NormalizeRouteId<TPath>, ResolveAllParams<TPath>, TSearch, TFileTypes>> & {
82
+ __hasInlineHead: THasInlineHead;
85
83
  };
86
- type FileRouteInstance<TPath extends string, TSearch, TFileTypes = unknown> = RouteNode<TPath, NormalizeRouteId<TPath>, ResolveAllParams<TPath>, TSearch, TFileTypes> & RouteApiMethods<RouteNode<TPath, NormalizeRouteId<TPath>, ResolveAllParams<TPath>, TSearch, TFileTypes>>;
87
84
  export interface TypedRouteMatch<TPath extends string, TSearch, TRoute extends AnyRoute = AnyRoute> extends Omit<RouteMatch<TRoute>, 'params' | 'search'> {
88
85
  params: ResolveAllParams<TPath>;
89
86
  search: TSearch;
@@ -128,6 +125,7 @@ export interface RouterState {
128
125
  export interface RouterOptions<TRouteTree extends AnyRoute> {
129
126
  routeTree: TRouteTree;
130
127
  history?: RouterHistory;
128
+ basePath?: string;
131
129
  defaultPreload?: 'intent' | 'render' | false;
132
130
  defaultPreloadDelay?: number;
133
131
  defaultPendingMs?: number;
@@ -142,7 +140,7 @@ export interface RouterOptions<TRouteTree extends AnyRoute> {
142
140
  stringifySearch?: (search: Record<string, unknown>) => string;
143
141
  loadRouteHead?: (ctx: {
144
142
  route: AnyRoute;
145
- headTagName: string;
143
+ routeId: string;
146
144
  params: Record<string, string>;
147
145
  search: unknown;
148
146
  location: ParsedLocation;
@@ -156,12 +154,12 @@ type InternalRouteMatch = RouteMatch & {
156
154
  id: string;
157
155
  };
158
156
  type Selector<TSelection> = (state: RouterState) => TSelection;
159
- export declare function createFileRoute<TPath extends string>(path: TPath): <TCustomSearch = {}>(options: RouteOptionsInput<TPath, Simplify<SearchForFullPath<TPath> & TCustomSearch>> & {
160
- validateSearch?: (raw: Record<string, unknown>) => TCustomSearch;
161
- }) => FileRouteInstance<TPath, Simplify<SearchForFullPath<TPath> & TCustomSearch>>;
162
- export declare function createRootRoute<TCustomSearch = {}>(options: CoreRouteOptions<'__root__', TCustomSearch> & {
163
- validateSearch?: (raw: Record<string, unknown>) => TCustomSearch;
164
- }): FileRouteInstance<'__root__', TCustomSearch>;
157
+ export declare function createFileRoute<TPath extends string>(path: TPath): <TOptions extends RouteOptionsInput<TPath, SearchForRouteId<TPath>>>(options: TOptions) => FileRouteInstance<TPath, SearchForRouteId<TPath>, unknown, TOptions extends {
158
+ head: any;
159
+ } ? true : false>;
160
+ export declare function createRootRoute<TOptions extends RouteOptionsInput<'__root__', SearchForRouteId<'__root__'>>>(options: TOptions): FileRouteInstance<'__root__', SearchForRouteId<'__root__'>, unknown, TOptions extends {
161
+ head: any;
162
+ } ? true : false>;
165
163
  export declare class Router<TRouteTree extends AnyRoute> {
166
164
  readonly routeTree: TRouteTree;
167
165
  readonly history: RouterHistory;
@@ -173,6 +171,8 @@ export declare class Router<TRouteTree extends AnyRoute> {
173
171
  private readonly headCache;
174
172
  private readonly parseSearch;
175
173
  private readonly stringifySearch;
174
+ private readonly basePath;
175
+ private initialHeadSnapshot?;
176
176
  private started;
177
177
  private unsubscribeHistory?;
178
178
  constructor(options: RouterOptions<TRouteTree>);
@@ -187,6 +187,8 @@ export declare class Router<TRouteTree extends AnyRoute> {
187
187
  preloadRoute<TTo extends RoutePaths>(options: NavigateOptions<TTo>): Promise<void>;
188
188
  invalidate(): Promise<void>;
189
189
  buildHref<TTo extends RoutePaths>(options: NavigateOptions<TTo>): string;
190
+ private buildLocation;
191
+ private buildLocationHref;
190
192
  private readLocation;
191
193
  private applyTrailingSlash;
192
194
  private notify;
@@ -195,14 +197,21 @@ export declare class Router<TRouteTree extends AnyRoute> {
195
197
  private resolveSearch;
196
198
  resolveLocation(location: ParsedLocation, options?: {
197
199
  request?: Request;
200
+ initialHeadSnapshot?: DehydratedHeadState;
198
201
  }): Promise<{
199
202
  matches: InternalRouteMatch[];
200
203
  head: HeadConfig;
201
204
  error: unknown;
202
205
  }>;
203
206
  private resolveLocationHead;
207
+ private getRouteHeadCacheKey;
208
+ private getCachedRouteHead;
209
+ private setRouteHeadCache;
210
+ private seedHeadCacheFromRouteHeads;
211
+ private cacheRouteHeadsFromDocument;
204
212
  private loadRouteHead;
205
213
  private fetchRouteHead;
214
+ private fetchDocumentHead;
206
215
  private commitLocation;
207
216
  private restoreScroll;
208
217
  private handleHistoryChange;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@richie-router/react",
3
- "version": "0.1.1",
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.1"
16
+ "@richie-router/core": "^0.1.3"
17
17
  },
18
18
  "peerDependencies": {
19
19
  "react": "^19"