@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.
@@ -26,13 +26,68 @@ var RouterStateContext = React.createContext(null);
26
26
  var OutletContext = React.createContext(null);
27
27
  var MatchContext = React.createContext(null);
28
28
  var MANAGED_HEAD_ATTRIBUTE = "data-richie-router-head";
29
- var EMPTY_HEAD = { meta: [], links: [], scripts: [], styles: [] };
30
- function isHeadTagReference(head) {
31
- return typeof head === "string";
29
+ var EMPTY_HEAD = [];
30
+ function ensureLeadingSlash(value) {
31
+ return value.startsWith("/") ? value : `/${value}`;
32
+ }
33
+ function normalizeBasePath(basePath) {
34
+ if (!basePath) {
35
+ return "";
36
+ }
37
+ const trimmed = basePath.trim();
38
+ if (trimmed === "" || trimmed === "/") {
39
+ return "";
40
+ }
41
+ const normalized = ensureLeadingSlash(trimmed).replace(/\/+$/u, "");
42
+ return normalized === "/" ? "" : normalized;
43
+ }
44
+ function parseHref(href) {
45
+ if (href.startsWith("http://") || href.startsWith("https://")) {
46
+ return new URL(href);
47
+ }
48
+ return new URL(ensureLeadingSlash(href), "http://richie-router.local");
49
+ }
50
+ function stripBasePathFromPathname(pathname, basePath) {
51
+ if (!basePath) {
52
+ return pathname;
53
+ }
54
+ if (pathname === basePath) {
55
+ return "/";
56
+ }
57
+ return pathname.startsWith(`${basePath}/`) ? pathname.slice(basePath.length) || "/" : pathname;
58
+ }
59
+ function stripBasePathFromHref(href, basePath) {
60
+ const normalizedBasePath = normalizeBasePath(basePath);
61
+ if (!normalizedBasePath) {
62
+ return href;
63
+ }
64
+ const url = parseHref(href);
65
+ return `${stripBasePathFromPathname(url.pathname, normalizedBasePath)}${url.search}${url.hash}`;
66
+ }
67
+ function prependBasePathToPathname(pathname, basePath) {
68
+ if (!basePath) {
69
+ return pathname;
70
+ }
71
+ return pathname === "/" ? basePath : `${basePath}${ensureLeadingSlash(pathname)}`;
72
+ }
73
+ function prependBasePathToHref(href, basePath) {
74
+ const normalizedBasePath = normalizeBasePath(basePath);
75
+ if (!normalizedBasePath) {
76
+ return href;
77
+ }
78
+ const url = parseHref(href);
79
+ return `${prependBasePathToPathname(url.pathname, normalizedBasePath)}${url.search}${url.hash}`;
32
80
  }
33
81
  function routeHasRecord(value) {
34
82
  return typeof value === "object" && value !== null;
35
83
  }
84
+ function routeHasInlineHead(route) {
85
+ const headOption = route.options.head;
86
+ return Boolean(headOption && typeof headOption !== "string");
87
+ }
88
+ function matchesHaveInlineHead(matches) {
89
+ return matches.some((match) => routeHasInlineHead(match.route));
90
+ }
36
91
  function resolveParamsInput(input, previous) {
37
92
  if (input === undefined) {
38
93
  return previous;
@@ -78,6 +133,8 @@ class Router {
78
133
  headCache = new Map;
79
134
  parseSearch;
80
135
  stringifySearch;
136
+ basePath;
137
+ initialHeadSnapshot;
81
138
  started = false;
82
139
  unsubscribeHistory;
83
140
  constructor(options) {
@@ -86,6 +143,7 @@ class Router {
86
143
  this.history = options.history ?? (typeof window === "undefined" ? createMemoryHistory() : createBrowserHistory());
87
144
  this.parseSearch = options.parseSearch ?? defaultParseSearch;
88
145
  this.stringifySearch = options.stringifySearch ?? defaultStringifySearch;
146
+ this.basePath = normalizeBasePath(options.basePath);
89
147
  for (const route of collectRoutes(this.routeTree)) {
90
148
  this.routesByFullPath.set(route.fullPath, route);
91
149
  }
@@ -93,15 +151,22 @@ class Router {
93
151
  this.routesByTo.set(branch.leaf.to, branch.leaf);
94
152
  }
95
153
  const location = this.readLocation();
154
+ const initialMatches = this.buildMatches(location);
155
+ const rawHistoryHref = this.history.location.href;
96
156
  const initialHeadSnapshot = typeof window !== "undefined" ? window.__RICHIE_ROUTER_HEAD__ : undefined;
97
- const initialHead = initialHeadSnapshot && initialHeadSnapshot.href === location.href ? initialHeadSnapshot.head : EMPTY_HEAD;
157
+ const hasMatchingInitialHeadSnapshot = Boolean(initialHeadSnapshot && (initialHeadSnapshot.href === location.href || initialHeadSnapshot.href === rawHistoryHref));
158
+ const initialHead = hasMatchingInitialHeadSnapshot && initialHeadSnapshot ? initialHeadSnapshot.head : EMPTY_HEAD;
159
+ if (hasMatchingInitialHeadSnapshot && this.options.loadRouteHead === undefined && initialHeadSnapshot?.routeHeads !== undefined) {
160
+ this.seedHeadCacheFromRouteHeads(initialMatches, initialHeadSnapshot.routeHeads);
161
+ }
98
162
  if (typeof window !== "undefined" && initialHeadSnapshot !== undefined) {
99
163
  delete window.__RICHIE_ROUTER_HEAD__;
100
164
  }
165
+ this.initialHeadSnapshot = hasMatchingInitialHeadSnapshot ? initialHeadSnapshot : undefined;
101
166
  this.state = {
102
167
  status: "loading",
103
168
  location,
104
- matches: this.buildMatches(location),
169
+ matches: initialMatches,
105
170
  head: initialHead,
106
171
  error: null
107
172
  };
@@ -132,15 +197,17 @@ class Router {
132
197
  }
133
198
  async load(options) {
134
199
  const nextLocation = this.readLocation();
200
+ const initialHeadSnapshot = this.initialHeadSnapshot?.href === nextLocation.href ? this.initialHeadSnapshot : undefined;
201
+ this.initialHeadSnapshot = undefined;
135
202
  await this.commitLocation(nextLocation, {
136
203
  request: options?.request,
137
204
  replace: true,
138
- writeHistory: false
205
+ writeHistory: false,
206
+ initialHeadSnapshot
139
207
  });
140
208
  }
141
209
  async navigate(options) {
142
- const href = this.buildHref(options);
143
- const location = createParsedLocation(href, options.state ?? null, this.parseSearch);
210
+ const location = this.buildLocation(options);
144
211
  await this.commitLocation(location, {
145
212
  replace: options.replace ?? false,
146
213
  writeHistory: true,
@@ -148,8 +215,7 @@ class Router {
148
215
  });
149
216
  }
150
217
  async preloadRoute(options) {
151
- const href = this.buildHref(options);
152
- const location = createParsedLocation(href, options.state ?? null, this.parseSearch);
218
+ const location = this.buildLocation(options);
153
219
  try {
154
220
  await this.resolveLocation(location);
155
221
  } catch {}
@@ -159,6 +225,12 @@ class Router {
159
225
  await this.load();
160
226
  }
161
227
  buildHref(options) {
228
+ return prependBasePathToHref(this.buildLocationHref(options), this.basePath);
229
+ }
230
+ buildLocation(options) {
231
+ return createParsedLocation(this.buildLocationHref(options), options.state ?? null, this.parseSearch);
232
+ }
233
+ buildLocationHref(options) {
162
234
  const targetRoute = this.routesByTo.get(options.to) ?? null;
163
235
  const fromMatch = options.from ? this.findMatchByTo(options.from) : null;
164
236
  const previousParams = fromMatch?.params ?? {};
@@ -173,7 +245,7 @@ class Router {
173
245
  }
174
246
  readLocation() {
175
247
  const location = this.history.location;
176
- return createParsedLocation(location.href, location.state, this.parseSearch);
248
+ return createParsedLocation(stripBasePathFromHref(location.href, this.basePath), location.state, this.parseSearch);
177
249
  }
178
250
  applyTrailingSlash(pathname, route) {
179
251
  const trailingSlash = this.options.trailingSlash ?? "preserve";
@@ -226,13 +298,9 @@ class Router {
226
298
  });
227
299
  }
228
300
  resolveSearch(route, rawSearch) {
229
- const fromHeadTagSchema = route.searchSchema ? route.searchSchema.parse(rawSearch) : {};
230
- const fromRoute = route.options.validateSearch ? route.options.validateSearch(rawSearch) : {};
231
- if (routeHasRecord(fromHeadTagSchema) || routeHasRecord(fromRoute)) {
232
- return {
233
- ...routeHasRecord(fromHeadTagSchema) ? fromHeadTagSchema : {},
234
- ...routeHasRecord(fromRoute) ? fromRoute : {}
235
- };
301
+ const fromSchema = route.searchSchema ? route.searchSchema.parse(rawSearch) : {};
302
+ if (routeHasRecord(fromSchema)) {
303
+ return fromSchema;
236
304
  }
237
305
  return rawSearch;
238
306
  }
@@ -272,57 +340,140 @@ class Router {
272
340
  to: route.to
273
341
  });
274
342
  }
275
- const head = await this.resolveLocationHead(matches, location, options?.request);
343
+ const head = await this.resolveLocationHead(matches, location, options?.request, options?.initialHeadSnapshot);
276
344
  return { matches, head, error: null };
277
345
  }
278
- async resolveLocationHead(matches, location, request) {
346
+ async resolveLocationHead(matches, location, request, initialHeadSnapshot) {
279
347
  const resolvedHeadByRoute = new Map;
280
- for (const match of matches) {
281
- const headOption = match.route.options.head;
282
- if (!isHeadTagReference(headOption)) {
348
+ const serverMatches = matches.filter((match) => match.route.serverHead);
349
+ if (serverMatches.length === 0) {
350
+ return resolveHeadConfig(matches, resolvedHeadByRoute);
351
+ }
352
+ if (this.options.loadRouteHead !== undefined) {
353
+ for (const match of serverMatches) {
354
+ resolvedHeadByRoute.set(match.route.fullPath, await this.loadRouteHead(match.route, match.params, match.search, location, request));
355
+ }
356
+ return resolveHeadConfig(matches, resolvedHeadByRoute);
357
+ }
358
+ if (initialHeadSnapshot?.href === location.href && initialHeadSnapshot.routeHeads === undefined && !matchesHaveInlineHead(matches)) {
359
+ return initialHeadSnapshot.head;
360
+ }
361
+ let needsDocumentHeadFetch = false;
362
+ for (const match of serverMatches) {
363
+ const cachedHead = this.getCachedRouteHead(match.route.fullPath, match.params, match.search);
364
+ if (cachedHead) {
365
+ resolvedHeadByRoute.set(match.route.fullPath, cachedHead);
366
+ continue;
367
+ }
368
+ needsDocumentHeadFetch = true;
369
+ }
370
+ if (!needsDocumentHeadFetch) {
371
+ return resolveHeadConfig(matches, resolvedHeadByRoute);
372
+ }
373
+ const documentHead = await this.fetchDocumentHead(location);
374
+ if ((documentHead.routeHeads?.length ?? 0) === 0 && !matchesHaveInlineHead(matches)) {
375
+ return documentHead.head;
376
+ }
377
+ const routeHeadsById = this.cacheRouteHeadsFromDocument(matches, documentHead.routeHeads ?? []);
378
+ for (const match of serverMatches) {
379
+ const responseHead = routeHeadsById.get(match.route.fullPath);
380
+ if (responseHead) {
381
+ resolvedHeadByRoute.set(match.route.fullPath, responseHead);
382
+ continue;
383
+ }
384
+ const cachedHead = this.getCachedRouteHead(match.route.fullPath, match.params, match.search);
385
+ if (cachedHead) {
386
+ resolvedHeadByRoute.set(match.route.fullPath, cachedHead);
283
387
  continue;
284
388
  }
285
- resolvedHeadByRoute.set(match.route.fullPath, await this.loadRouteHead(match.route, headOption, match.params, match.search, location, request));
389
+ const response = await this.fetchRouteHead(match.route, match.params, match.search);
390
+ this.setRouteHeadCache(match.route.fullPath, match.params, match.search, response);
391
+ resolvedHeadByRoute.set(match.route.fullPath, response.head);
286
392
  }
287
393
  return resolveHeadConfig(matches, resolvedHeadByRoute);
288
394
  }
289
- async loadRouteHead(route, headTagName, params, search, location, request) {
290
- const cacheKey = JSON.stringify({
291
- headTagName,
395
+ getRouteHeadCacheKey(routeId, params, search) {
396
+ return JSON.stringify({
397
+ routeId,
292
398
  params,
293
399
  search
294
400
  });
295
- const cached = this.headCache.get(cacheKey);
401
+ }
402
+ getCachedRouteHead(routeId, params, search) {
403
+ const cached = this.headCache.get(this.getRouteHeadCacheKey(routeId, params, search));
296
404
  if (cached && cached.expiresAt > Date.now()) {
297
405
  return cached.head;
298
406
  }
407
+ return null;
408
+ }
409
+ setRouteHeadCache(routeId, params, search, response) {
410
+ this.headCache.set(this.getRouteHeadCacheKey(routeId, params, search), {
411
+ head: response.head,
412
+ expiresAt: Date.now() + (response.staleTime ?? 0)
413
+ });
414
+ }
415
+ seedHeadCacheFromRouteHeads(matches, routeHeads) {
416
+ this.cacheRouteHeadsFromDocument(matches, routeHeads);
417
+ }
418
+ cacheRouteHeadsFromDocument(matches, routeHeads) {
419
+ const routeHeadsById = new Map(routeHeads.map((entry) => [entry.routeId, entry]));
420
+ const resolvedHeadByRoute = new Map;
421
+ for (const match of matches) {
422
+ if (!match.route.serverHead) {
423
+ continue;
424
+ }
425
+ const entry = routeHeadsById.get(match.route.fullPath);
426
+ if (!entry) {
427
+ continue;
428
+ }
429
+ this.setRouteHeadCache(match.route.fullPath, match.params, match.search, entry);
430
+ resolvedHeadByRoute.set(match.route.fullPath, entry.head);
431
+ }
432
+ return resolvedHeadByRoute;
433
+ }
434
+ async loadRouteHead(route, params, search, location, request) {
435
+ const cachedHead = this.getCachedRouteHead(route.fullPath, params, search);
436
+ if (cachedHead) {
437
+ return cachedHead;
438
+ }
299
439
  const response = this.options.loadRouteHead !== undefined ? await this.options.loadRouteHead({
300
440
  route,
301
- headTagName,
441
+ routeId: route.fullPath,
302
442
  params,
303
443
  search,
304
444
  location,
305
445
  request
306
- }) : await this.fetchRouteHead(route, headTagName, params, search);
307
- this.headCache.set(cacheKey, {
308
- head: response.head,
309
- expiresAt: Date.now() + (response.staleTime ?? 0)
310
- });
446
+ }) : await this.fetchRouteHead(route, params, search);
447
+ this.setRouteHeadCache(route.fullPath, params, search, response);
311
448
  return response.head;
312
449
  }
313
- async fetchRouteHead(route, headTagName, params, search) {
314
- const basePath = this.options.headBasePath ?? "/head-api";
450
+ async fetchRouteHead(route, params, search) {
451
+ const basePath = this.options.headBasePath ?? prependBasePathToHref("/head-api", this.basePath);
315
452
  const searchParams = new URLSearchParams({
316
453
  routeId: route.fullPath,
317
454
  params: JSON.stringify(params),
318
455
  search: JSON.stringify(search)
319
456
  });
320
- const response = await fetch(`${basePath}/${encodeURIComponent(headTagName)}?${searchParams.toString()}`);
457
+ const response = await fetch(`${basePath}?${searchParams.toString()}`);
458
+ if (!response.ok) {
459
+ if (response.status === 404) {
460
+ throw notFound();
461
+ }
462
+ throw new Error(`Failed to resolve server head for route "${route.fullPath}"`);
463
+ }
464
+ return await response.json();
465
+ }
466
+ async fetchDocumentHead(location) {
467
+ const basePath = this.options.headBasePath ?? prependBasePathToHref("/head-api", this.basePath);
468
+ const searchParams = new URLSearchParams({
469
+ href: prependBasePathToHref(location.href, this.basePath)
470
+ });
471
+ const response = await fetch(`${basePath}?${searchParams.toString()}`);
321
472
  if (!response.ok) {
322
473
  if (response.status === 404) {
323
474
  throw notFound();
324
475
  }
325
- throw new Error(`Failed to resolve head tag "${headTagName}" for route "${route.fullPath}"`);
476
+ throw new Error(`Failed to resolve server head for location "${location.href}"`);
326
477
  }
327
478
  return await response.json();
328
479
  }
@@ -335,13 +486,15 @@ class Router {
335
486
  this.notify();
336
487
  try {
337
488
  const resolved = await this.resolveLocation(location, {
338
- request: options.request
489
+ request: options.request,
490
+ initialHeadSnapshot: options.initialHeadSnapshot
339
491
  });
492
+ const historyHref = prependBasePathToHref(location.href, this.basePath);
340
493
  if (options.writeHistory) {
341
494
  if (options.replace) {
342
- this.history.replace(location.href, location.state);
495
+ this.history.replace(historyHref, location.state);
343
496
  } else {
344
- this.history.push(location.href, location.state);
497
+ this.history.push(historyHref, location.state);
345
498
  }
346
499
  }
347
500
  this.state = {
@@ -362,11 +515,12 @@ class Router {
362
515
  return;
363
516
  }
364
517
  const errorMatches = this.buildMatches(location);
518
+ const historyHref = prependBasePathToHref(location.href, this.basePath);
365
519
  if (options.writeHistory) {
366
520
  if (options.replace) {
367
- this.history.replace(location.href, location.state);
521
+ this.history.replace(historyHref, location.state);
368
522
  } else {
369
- this.history.push(location.href, location.state);
523
+ this.history.push(historyHref, location.state);
370
524
  }
371
525
  }
372
526
  this.state = {
@@ -456,61 +610,95 @@ function createManagedHeadElements(head) {
456
610
  element.setAttribute(MANAGED_HEAD_ATTRIBUTE, "true");
457
611
  return element;
458
612
  };
459
- for (const meta of head.meta ?? []) {
460
- if ("title" in meta) {
461
- const title = managed(document.createElement("title"));
462
- title.textContent = meta.title;
463
- elements.push(title);
613
+ const setAttributes = (element, attributes) => {
614
+ for (const [key, value] of Object.entries(attributes)) {
615
+ if (value === undefined || value === false) {
616
+ continue;
617
+ }
618
+ if (value === true) {
619
+ element.setAttribute(key, "");
620
+ continue;
621
+ }
622
+ element.setAttribute(key, value);
623
+ }
624
+ };
625
+ for (const element of head) {
626
+ if (element.tag === "title") {
627
+ continue;
628
+ }
629
+ if (element.tag === "meta") {
630
+ const tag2 = managed(document.createElement("meta"));
631
+ if ("charset" in element) {
632
+ tag2.setAttribute("charset", element.charset);
633
+ } else if ("name" in element) {
634
+ tag2.setAttribute("name", element.name);
635
+ tag2.setAttribute("content", element.content);
636
+ } else if ("property" in element) {
637
+ tag2.setAttribute("property", element.property);
638
+ tag2.setAttribute("content", element.content);
639
+ } else {
640
+ tag2.setAttribute("http-equiv", element.httpEquiv);
641
+ tag2.setAttribute("content", element.content);
642
+ }
643
+ elements.push(tag2);
464
644
  continue;
465
645
  }
466
- const tag = managed(document.createElement("meta"));
467
- if ("charset" in meta) {
468
- tag.setAttribute("charset", meta.charset);
469
- } else if ("name" in meta) {
470
- tag.setAttribute("name", meta.name);
471
- tag.setAttribute("content", meta.content);
472
- } else if ("property" in meta) {
473
- tag.setAttribute("property", meta.property);
474
- tag.setAttribute("content", meta.content);
475
- } else {
476
- tag.setAttribute("http-equiv", meta.httpEquiv);
477
- tag.setAttribute("content", meta.content);
646
+ if (element.tag === "link") {
647
+ const tag2 = managed(document.createElement("link"));
648
+ setAttributes(tag2, {
649
+ rel: element.rel,
650
+ href: element.href,
651
+ type: element.type,
652
+ media: element.media,
653
+ sizes: element.sizes,
654
+ crossorigin: element.crossorigin
655
+ });
656
+ elements.push(tag2);
657
+ continue;
658
+ }
659
+ if (element.tag === "style") {
660
+ const tag2 = managed(document.createElement("style"));
661
+ if (element.media) {
662
+ tag2.setAttribute("media", element.media);
663
+ }
664
+ tag2.textContent = element.children;
665
+ elements.push(tag2);
666
+ continue;
667
+ }
668
+ if (element.tag === "script") {
669
+ const tag2 = managed(document.createElement("script"));
670
+ if (element.src) {
671
+ tag2.setAttribute("src", element.src);
672
+ }
673
+ if (element.type) {
674
+ tag2.setAttribute("type", element.type);
675
+ }
676
+ if (element.async) {
677
+ tag2.async = true;
678
+ }
679
+ if (element.defer) {
680
+ tag2.defer = true;
681
+ }
682
+ if (element.children) {
683
+ tag2.textContent = element.children;
684
+ }
685
+ elements.push(tag2);
686
+ continue;
687
+ }
688
+ if (element.tag === "base") {
689
+ const tag2 = managed(document.createElement("base"));
690
+ tag2.setAttribute("href", element.href);
691
+ if (element.target) {
692
+ tag2.setAttribute("target", element.target);
693
+ }
694
+ elements.push(tag2);
695
+ continue;
696
+ }
697
+ const tag = managed(document.createElement(element.name));
698
+ setAttributes(tag, element.attrs ?? {});
699
+ if (element.children) {
700
+ tag.textContent = element.children;
478
701
  }
479
- elements.push(tag);
480
- }
481
- for (const link of head.links ?? []) {
482
- const tag = managed(document.createElement("link"));
483
- tag.setAttribute("rel", link.rel);
484
- tag.setAttribute("href", link.href);
485
- if (link.type)
486
- tag.setAttribute("type", link.type);
487
- if (link.media)
488
- tag.setAttribute("media", link.media);
489
- if (link.sizes)
490
- tag.setAttribute("sizes", link.sizes);
491
- if (link.crossorigin)
492
- tag.setAttribute("crossorigin", link.crossorigin);
493
- elements.push(tag);
494
- }
495
- for (const style of head.styles ?? []) {
496
- const tag = managed(document.createElement("style"));
497
- if (style.media)
498
- tag.setAttribute("media", style.media);
499
- tag.textContent = style.children;
500
- elements.push(tag);
501
- }
502
- for (const script of head.scripts ?? []) {
503
- const tag = managed(document.createElement("script"));
504
- if (script.src)
505
- tag.setAttribute("src", script.src);
506
- if (script.type)
507
- tag.setAttribute("type", script.type);
508
- if (script.async)
509
- tag.async = true;
510
- if (script.defer)
511
- tag.defer = true;
512
- if (script.children)
513
- tag.textContent = script.children;
514
702
  elements.push(tag);
515
703
  }
516
704
  return elements;
@@ -519,6 +707,10 @@ function reconcileDocumentHead(head) {
519
707
  if (typeof document === "undefined") {
520
708
  return;
521
709
  }
710
+ const title = [...head].reverse().find((element) => element.tag === "title");
711
+ if (title && title.tag === "title") {
712
+ document.title = title.children;
713
+ }
522
714
  for (const element of Array.from(document.head.querySelectorAll(`[${MANAGED_HEAD_ATTRIBUTE}]`))) {
523
715
  element.remove();
524
716
  }
@@ -663,7 +855,7 @@ function useResolvedLink(props) {
663
855
  const router = useRouterContext();
664
856
  const href = router.buildHref(props);
665
857
  const location = useLocation();
666
- const pathOnly = href.split(/[?#]/u)[0] ?? href;
858
+ const pathOnly = stripBasePathFromHref(href, router.options.basePath).split(/[?#]/u)[0] ?? href;
667
859
  const isActive = pathOnly === location.pathname;
668
860
  return { href, isActive, router };
669
861
  }