@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.
@@ -26,10 +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: [] };
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}`;
80
+ }
30
81
  function routeHasRecord(value) {
31
82
  return typeof value === "object" && value !== null;
32
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
+ }
33
91
  function resolveParamsInput(input, previous) {
34
92
  if (input === undefined) {
35
93
  return previous;
@@ -75,6 +133,8 @@ class Router {
75
133
  headCache = new Map;
76
134
  parseSearch;
77
135
  stringifySearch;
136
+ basePath;
137
+ initialHeadSnapshot;
78
138
  started = false;
79
139
  unsubscribeHistory;
80
140
  constructor(options) {
@@ -83,6 +143,7 @@ class Router {
83
143
  this.history = options.history ?? (typeof window === "undefined" ? createMemoryHistory() : createBrowserHistory());
84
144
  this.parseSearch = options.parseSearch ?? defaultParseSearch;
85
145
  this.stringifySearch = options.stringifySearch ?? defaultStringifySearch;
146
+ this.basePath = normalizeBasePath(options.basePath);
86
147
  for (const route of collectRoutes(this.routeTree)) {
87
148
  this.routesByFullPath.set(route.fullPath, route);
88
149
  }
@@ -90,15 +151,22 @@ class Router {
90
151
  this.routesByTo.set(branch.leaf.to, branch.leaf);
91
152
  }
92
153
  const location = this.readLocation();
154
+ const initialMatches = this.buildMatches(location);
155
+ const rawHistoryHref = this.history.location.href;
93
156
  const initialHeadSnapshot = typeof window !== "undefined" ? window.__RICHIE_ROUTER_HEAD__ : undefined;
94
- 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
+ }
95
162
  if (typeof window !== "undefined" && initialHeadSnapshot !== undefined) {
96
163
  delete window.__RICHIE_ROUTER_HEAD__;
97
164
  }
165
+ this.initialHeadSnapshot = hasMatchingInitialHeadSnapshot ? initialHeadSnapshot : undefined;
98
166
  this.state = {
99
167
  status: "loading",
100
168
  location,
101
- matches: this.buildMatches(location),
169
+ matches: initialMatches,
102
170
  head: initialHead,
103
171
  error: null
104
172
  };
@@ -129,15 +197,17 @@ class Router {
129
197
  }
130
198
  async load(options) {
131
199
  const nextLocation = this.readLocation();
200
+ const initialHeadSnapshot = this.initialHeadSnapshot?.href === nextLocation.href ? this.initialHeadSnapshot : undefined;
201
+ this.initialHeadSnapshot = undefined;
132
202
  await this.commitLocation(nextLocation, {
133
203
  request: options?.request,
134
204
  replace: true,
135
- writeHistory: false
205
+ writeHistory: false,
206
+ initialHeadSnapshot
136
207
  });
137
208
  }
138
209
  async navigate(options) {
139
- const href = this.buildHref(options);
140
- const location = createParsedLocation(href, options.state ?? null, this.parseSearch);
210
+ const location = this.buildLocation(options);
141
211
  await this.commitLocation(location, {
142
212
  replace: options.replace ?? false,
143
213
  writeHistory: true,
@@ -145,8 +215,7 @@ class Router {
145
215
  });
146
216
  }
147
217
  async preloadRoute(options) {
148
- const href = this.buildHref(options);
149
- const location = createParsedLocation(href, options.state ?? null, this.parseSearch);
218
+ const location = this.buildLocation(options);
150
219
  try {
151
220
  await this.resolveLocation(location);
152
221
  } catch {}
@@ -156,6 +225,12 @@ class Router {
156
225
  await this.load();
157
226
  }
158
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) {
159
234
  const targetRoute = this.routesByTo.get(options.to) ?? null;
160
235
  const fromMatch = options.from ? this.findMatchByTo(options.from) : null;
161
236
  const previousParams = fromMatch?.params ?? {};
@@ -170,7 +245,7 @@ class Router {
170
245
  }
171
246
  readLocation() {
172
247
  const location = this.history.location;
173
- return createParsedLocation(location.href, location.state, this.parseSearch);
248
+ return createParsedLocation(stripBasePathFromHref(location.href, this.basePath), location.state, this.parseSearch);
174
249
  }
175
250
  applyTrailingSlash(pathname, route) {
176
251
  const trailingSlash = this.options.trailingSlash ?? "preserve";
@@ -265,29 +340,102 @@ class Router {
265
340
  to: route.to
266
341
  });
267
342
  }
268
- const head = await this.resolveLocationHead(matches, location, options?.request);
343
+ const head = await this.resolveLocationHead(matches, location, options?.request, options?.initialHeadSnapshot);
269
344
  return { matches, head, error: null };
270
345
  }
271
- async resolveLocationHead(matches, location, request) {
346
+ async resolveLocationHead(matches, location, request, initialHeadSnapshot) {
272
347
  const resolvedHeadByRoute = new Map;
273
- for (const match of matches) {
274
- if (!match.route.serverHead) {
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);
275
366
  continue;
276
367
  }
277
- resolvedHeadByRoute.set(match.route.fullPath, await this.loadRouteHead(match.route, match.params, match.search, location, request));
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);
387
+ continue;
388
+ }
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);
278
392
  }
279
393
  return resolveHeadConfig(matches, resolvedHeadByRoute);
280
394
  }
281
- async loadRouteHead(route, params, search, location, request) {
282
- const cacheKey = JSON.stringify({
283
- routeId: route.fullPath,
395
+ getRouteHeadCacheKey(routeId, params, search) {
396
+ return JSON.stringify({
397
+ routeId,
284
398
  params,
285
399
  search
286
400
  });
287
- const cached = this.headCache.get(cacheKey);
401
+ }
402
+ getCachedRouteHead(routeId, params, search) {
403
+ const cached = this.headCache.get(this.getRouteHeadCacheKey(routeId, params, search));
288
404
  if (cached && cached.expiresAt > Date.now()) {
289
405
  return cached.head;
290
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
+ }
291
439
  const response = this.options.loadRouteHead !== undefined ? await this.options.loadRouteHead({
292
440
  route,
293
441
  routeId: route.fullPath,
@@ -296,14 +444,11 @@ class Router {
296
444
  location,
297
445
  request
298
446
  }) : await this.fetchRouteHead(route, params, search);
299
- this.headCache.set(cacheKey, {
300
- head: response.head,
301
- expiresAt: Date.now() + (response.staleTime ?? 0)
302
- });
447
+ this.setRouteHeadCache(route.fullPath, params, search, response);
303
448
  return response.head;
304
449
  }
305
450
  async fetchRouteHead(route, params, search) {
306
- const basePath = this.options.headBasePath ?? "/head-api";
451
+ const basePath = this.options.headBasePath ?? prependBasePathToHref("/head-api", this.basePath);
307
452
  const searchParams = new URLSearchParams({
308
453
  routeId: route.fullPath,
309
454
  params: JSON.stringify(params),
@@ -318,6 +463,20 @@ class Router {
318
463
  }
319
464
  return await response.json();
320
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()}`);
472
+ if (!response.ok) {
473
+ if (response.status === 404) {
474
+ throw notFound();
475
+ }
476
+ throw new Error(`Failed to resolve server head for location "${location.href}"`);
477
+ }
478
+ return await response.json();
479
+ }
321
480
  async commitLocation(location, options) {
322
481
  this.state = {
323
482
  ...this.state,
@@ -327,13 +486,15 @@ class Router {
327
486
  this.notify();
328
487
  try {
329
488
  const resolved = await this.resolveLocation(location, {
330
- request: options.request
489
+ request: options.request,
490
+ initialHeadSnapshot: options.initialHeadSnapshot
331
491
  });
492
+ const historyHref = prependBasePathToHref(location.href, this.basePath);
332
493
  if (options.writeHistory) {
333
494
  if (options.replace) {
334
- this.history.replace(location.href, location.state);
495
+ this.history.replace(historyHref, location.state);
335
496
  } else {
336
- this.history.push(location.href, location.state);
497
+ this.history.push(historyHref, location.state);
337
498
  }
338
499
  }
339
500
  this.state = {
@@ -354,11 +515,12 @@ class Router {
354
515
  return;
355
516
  }
356
517
  const errorMatches = this.buildMatches(location);
518
+ const historyHref = prependBasePathToHref(location.href, this.basePath);
357
519
  if (options.writeHistory) {
358
520
  if (options.replace) {
359
- this.history.replace(location.href, location.state);
521
+ this.history.replace(historyHref, location.state);
360
522
  } else {
361
- this.history.push(location.href, location.state);
523
+ this.history.push(historyHref, location.state);
362
524
  }
363
525
  }
364
526
  this.state = {
@@ -448,61 +610,95 @@ function createManagedHeadElements(head) {
448
610
  element.setAttribute(MANAGED_HEAD_ATTRIBUTE, "true");
449
611
  return element;
450
612
  };
451
- for (const meta of head.meta ?? []) {
452
- if ("title" in meta) {
453
- const title = managed(document.createElement("title"));
454
- title.textContent = meta.title;
455
- 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);
456
644
  continue;
457
645
  }
458
- const tag = managed(document.createElement("meta"));
459
- if ("charset" in meta) {
460
- tag.setAttribute("charset", meta.charset);
461
- } else if ("name" in meta) {
462
- tag.setAttribute("name", meta.name);
463
- tag.setAttribute("content", meta.content);
464
- } else if ("property" in meta) {
465
- tag.setAttribute("property", meta.property);
466
- tag.setAttribute("content", meta.content);
467
- } else {
468
- tag.setAttribute("http-equiv", meta.httpEquiv);
469
- 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;
470
701
  }
471
- elements.push(tag);
472
- }
473
- for (const link of head.links ?? []) {
474
- const tag = managed(document.createElement("link"));
475
- tag.setAttribute("rel", link.rel);
476
- tag.setAttribute("href", link.href);
477
- if (link.type)
478
- tag.setAttribute("type", link.type);
479
- if (link.media)
480
- tag.setAttribute("media", link.media);
481
- if (link.sizes)
482
- tag.setAttribute("sizes", link.sizes);
483
- if (link.crossorigin)
484
- tag.setAttribute("crossorigin", link.crossorigin);
485
- elements.push(tag);
486
- }
487
- for (const style of head.styles ?? []) {
488
- const tag = managed(document.createElement("style"));
489
- if (style.media)
490
- tag.setAttribute("media", style.media);
491
- tag.textContent = style.children;
492
- elements.push(tag);
493
- }
494
- for (const script of head.scripts ?? []) {
495
- const tag = managed(document.createElement("script"));
496
- if (script.src)
497
- tag.setAttribute("src", script.src);
498
- if (script.type)
499
- tag.setAttribute("type", script.type);
500
- if (script.async)
501
- tag.async = true;
502
- if (script.defer)
503
- tag.defer = true;
504
- if (script.children)
505
- tag.textContent = script.children;
506
702
  elements.push(tag);
507
703
  }
508
704
  return elements;
@@ -511,6 +707,10 @@ function reconcileDocumentHead(head) {
511
707
  if (typeof document === "undefined") {
512
708
  return;
513
709
  }
710
+ const title = [...head].reverse().find((element) => element.tag === "title");
711
+ if (title && title.tag === "title") {
712
+ document.title = title.children;
713
+ }
514
714
  for (const element of Array.from(document.head.querySelectorAll(`[${MANAGED_HEAD_ATTRIBUTE}]`))) {
515
715
  element.remove();
516
716
  }
@@ -655,7 +855,7 @@ function useResolvedLink(props) {
655
855
  const router = useRouterContext();
656
856
  const href = router.buildHref(props);
657
857
  const location = useLocation();
658
- const pathOnly = href.split(/[?#]/u)[0] ?? href;
858
+ const pathOnly = stripBasePathFromHref(href, router.options.basePath).split(/[?#]/u)[0] ?? href;
659
859
  const isActive = pathOnly === location.pathname;
660
860
  return { href, isActive, router };
661
861
  }