@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.
@@ -103,10 +103,68 @@ var RouterStateContext = import_react.default.createContext(null);
103
103
  var OutletContext = import_react.default.createContext(null);
104
104
  var MatchContext = import_react.default.createContext(null);
105
105
  var MANAGED_HEAD_ATTRIBUTE = "data-richie-router-head";
106
- var EMPTY_HEAD = { meta: [], links: [], scripts: [], styles: [] };
106
+ var EMPTY_HEAD = [];
107
+ function ensureLeadingSlash(value) {
108
+ return value.startsWith("/") ? value : `/${value}`;
109
+ }
110
+ function normalizeBasePath(basePath) {
111
+ if (!basePath) {
112
+ return "";
113
+ }
114
+ const trimmed = basePath.trim();
115
+ if (trimmed === "" || trimmed === "/") {
116
+ return "";
117
+ }
118
+ const normalized = ensureLeadingSlash(trimmed).replace(/\/+$/u, "");
119
+ return normalized === "/" ? "" : normalized;
120
+ }
121
+ function parseHref(href) {
122
+ if (href.startsWith("http://") || href.startsWith("https://")) {
123
+ return new URL(href);
124
+ }
125
+ return new URL(ensureLeadingSlash(href), "http://richie-router.local");
126
+ }
127
+ function stripBasePathFromPathname(pathname, basePath) {
128
+ if (!basePath) {
129
+ return pathname;
130
+ }
131
+ if (pathname === basePath) {
132
+ return "/";
133
+ }
134
+ return pathname.startsWith(`${basePath}/`) ? pathname.slice(basePath.length) || "/" : pathname;
135
+ }
136
+ function stripBasePathFromHref(href, basePath) {
137
+ const normalizedBasePath = normalizeBasePath(basePath);
138
+ if (!normalizedBasePath) {
139
+ return href;
140
+ }
141
+ const url = parseHref(href);
142
+ return `${stripBasePathFromPathname(url.pathname, normalizedBasePath)}${url.search}${url.hash}`;
143
+ }
144
+ function prependBasePathToPathname(pathname, basePath) {
145
+ if (!basePath) {
146
+ return pathname;
147
+ }
148
+ return pathname === "/" ? basePath : `${basePath}${ensureLeadingSlash(pathname)}`;
149
+ }
150
+ function prependBasePathToHref(href, basePath) {
151
+ const normalizedBasePath = normalizeBasePath(basePath);
152
+ if (!normalizedBasePath) {
153
+ return href;
154
+ }
155
+ const url = parseHref(href);
156
+ return `${prependBasePathToPathname(url.pathname, normalizedBasePath)}${url.search}${url.hash}`;
157
+ }
107
158
  function routeHasRecord(value) {
108
159
  return typeof value === "object" && value !== null;
109
160
  }
161
+ function routeHasInlineHead(route) {
162
+ const headOption = route.options.head;
163
+ return Boolean(headOption && typeof headOption !== "string");
164
+ }
165
+ function matchesHaveInlineHead(matches) {
166
+ return matches.some((match) => routeHasInlineHead(match.route));
167
+ }
110
168
  function resolveParamsInput(input, previous) {
111
169
  if (input === undefined) {
112
170
  return previous;
@@ -152,6 +210,8 @@ class Router {
152
210
  headCache = new Map;
153
211
  parseSearch;
154
212
  stringifySearch;
213
+ basePath;
214
+ initialHeadSnapshot;
155
215
  started = false;
156
216
  unsubscribeHistory;
157
217
  constructor(options) {
@@ -160,6 +220,7 @@ class Router {
160
220
  this.history = options.history ?? (typeof window === "undefined" ? import_history.createMemoryHistory() : import_history.createBrowserHistory());
161
221
  this.parseSearch = options.parseSearch ?? import_core.defaultParseSearch;
162
222
  this.stringifySearch = options.stringifySearch ?? import_core.defaultStringifySearch;
223
+ this.basePath = normalizeBasePath(options.basePath);
163
224
  for (const route of import_core.collectRoutes(this.routeTree)) {
164
225
  this.routesByFullPath.set(route.fullPath, route);
165
226
  }
@@ -167,15 +228,22 @@ class Router {
167
228
  this.routesByTo.set(branch.leaf.to, branch.leaf);
168
229
  }
169
230
  const location = this.readLocation();
231
+ const initialMatches = this.buildMatches(location);
232
+ const rawHistoryHref = this.history.location.href;
170
233
  const initialHeadSnapshot = typeof window !== "undefined" ? window.__RICHIE_ROUTER_HEAD__ : undefined;
171
- const initialHead = initialHeadSnapshot && initialHeadSnapshot.href === location.href ? initialHeadSnapshot.head : EMPTY_HEAD;
234
+ const hasMatchingInitialHeadSnapshot = Boolean(initialHeadSnapshot && (initialHeadSnapshot.href === location.href || initialHeadSnapshot.href === rawHistoryHref));
235
+ const initialHead = hasMatchingInitialHeadSnapshot && initialHeadSnapshot ? initialHeadSnapshot.head : EMPTY_HEAD;
236
+ if (hasMatchingInitialHeadSnapshot && this.options.loadRouteHead === undefined && initialHeadSnapshot?.routeHeads !== undefined) {
237
+ this.seedHeadCacheFromRouteHeads(initialMatches, initialHeadSnapshot.routeHeads);
238
+ }
172
239
  if (typeof window !== "undefined" && initialHeadSnapshot !== undefined) {
173
240
  delete window.__RICHIE_ROUTER_HEAD__;
174
241
  }
242
+ this.initialHeadSnapshot = hasMatchingInitialHeadSnapshot ? initialHeadSnapshot : undefined;
175
243
  this.state = {
176
244
  status: "loading",
177
245
  location,
178
- matches: this.buildMatches(location),
246
+ matches: initialMatches,
179
247
  head: initialHead,
180
248
  error: null
181
249
  };
@@ -206,15 +274,17 @@ class Router {
206
274
  }
207
275
  async load(options) {
208
276
  const nextLocation = this.readLocation();
277
+ const initialHeadSnapshot = this.initialHeadSnapshot?.href === nextLocation.href ? this.initialHeadSnapshot : undefined;
278
+ this.initialHeadSnapshot = undefined;
209
279
  await this.commitLocation(nextLocation, {
210
280
  request: options?.request,
211
281
  replace: true,
212
- writeHistory: false
282
+ writeHistory: false,
283
+ initialHeadSnapshot
213
284
  });
214
285
  }
215
286
  async navigate(options) {
216
- const href = this.buildHref(options);
217
- const location = import_core.createParsedLocation(href, options.state ?? null, this.parseSearch);
287
+ const location = this.buildLocation(options);
218
288
  await this.commitLocation(location, {
219
289
  replace: options.replace ?? false,
220
290
  writeHistory: true,
@@ -222,8 +292,7 @@ class Router {
222
292
  });
223
293
  }
224
294
  async preloadRoute(options) {
225
- const href = this.buildHref(options);
226
- const location = import_core.createParsedLocation(href, options.state ?? null, this.parseSearch);
295
+ const location = this.buildLocation(options);
227
296
  try {
228
297
  await this.resolveLocation(location);
229
298
  } catch {}
@@ -233,6 +302,12 @@ class Router {
233
302
  await this.load();
234
303
  }
235
304
  buildHref(options) {
305
+ return prependBasePathToHref(this.buildLocationHref(options), this.basePath);
306
+ }
307
+ buildLocation(options) {
308
+ return import_core.createParsedLocation(this.buildLocationHref(options), options.state ?? null, this.parseSearch);
309
+ }
310
+ buildLocationHref(options) {
236
311
  const targetRoute = this.routesByTo.get(options.to) ?? null;
237
312
  const fromMatch = options.from ? this.findMatchByTo(options.from) : null;
238
313
  const previousParams = fromMatch?.params ?? {};
@@ -247,7 +322,7 @@ class Router {
247
322
  }
248
323
  readLocation() {
249
324
  const location = this.history.location;
250
- return import_core.createParsedLocation(location.href, location.state, this.parseSearch);
325
+ return import_core.createParsedLocation(stripBasePathFromHref(location.href, this.basePath), location.state, this.parseSearch);
251
326
  }
252
327
  applyTrailingSlash(pathname, route) {
253
328
  const trailingSlash = this.options.trailingSlash ?? "preserve";
@@ -342,29 +417,102 @@ class Router {
342
417
  to: route.to
343
418
  });
344
419
  }
345
- const head = await this.resolveLocationHead(matches, location, options?.request);
420
+ const head = await this.resolveLocationHead(matches, location, options?.request, options?.initialHeadSnapshot);
346
421
  return { matches, head, error: null };
347
422
  }
348
- async resolveLocationHead(matches, location, request) {
423
+ async resolveLocationHead(matches, location, request, initialHeadSnapshot) {
349
424
  const resolvedHeadByRoute = new Map;
350
- for (const match of matches) {
351
- if (!match.route.serverHead) {
425
+ const serverMatches = matches.filter((match) => match.route.serverHead);
426
+ if (serverMatches.length === 0) {
427
+ return import_core.resolveHeadConfig(matches, resolvedHeadByRoute);
428
+ }
429
+ if (this.options.loadRouteHead !== undefined) {
430
+ for (const match of serverMatches) {
431
+ resolvedHeadByRoute.set(match.route.fullPath, await this.loadRouteHead(match.route, match.params, match.search, location, request));
432
+ }
433
+ return import_core.resolveHeadConfig(matches, resolvedHeadByRoute);
434
+ }
435
+ if (initialHeadSnapshot?.href === location.href && initialHeadSnapshot.routeHeads === undefined && !matchesHaveInlineHead(matches)) {
436
+ return initialHeadSnapshot.head;
437
+ }
438
+ let needsDocumentHeadFetch = false;
439
+ for (const match of serverMatches) {
440
+ const cachedHead = this.getCachedRouteHead(match.route.fullPath, match.params, match.search);
441
+ if (cachedHead) {
442
+ resolvedHeadByRoute.set(match.route.fullPath, cachedHead);
352
443
  continue;
353
444
  }
354
- resolvedHeadByRoute.set(match.route.fullPath, await this.loadRouteHead(match.route, match.params, match.search, location, request));
445
+ needsDocumentHeadFetch = true;
446
+ }
447
+ if (!needsDocumentHeadFetch) {
448
+ return import_core.resolveHeadConfig(matches, resolvedHeadByRoute);
449
+ }
450
+ const documentHead = await this.fetchDocumentHead(location);
451
+ if ((documentHead.routeHeads?.length ?? 0) === 0 && !matchesHaveInlineHead(matches)) {
452
+ return documentHead.head;
453
+ }
454
+ const routeHeadsById = this.cacheRouteHeadsFromDocument(matches, documentHead.routeHeads ?? []);
455
+ for (const match of serverMatches) {
456
+ const responseHead = routeHeadsById.get(match.route.fullPath);
457
+ if (responseHead) {
458
+ resolvedHeadByRoute.set(match.route.fullPath, responseHead);
459
+ continue;
460
+ }
461
+ const cachedHead = this.getCachedRouteHead(match.route.fullPath, match.params, match.search);
462
+ if (cachedHead) {
463
+ resolvedHeadByRoute.set(match.route.fullPath, cachedHead);
464
+ continue;
465
+ }
466
+ const response = await this.fetchRouteHead(match.route, match.params, match.search);
467
+ this.setRouteHeadCache(match.route.fullPath, match.params, match.search, response);
468
+ resolvedHeadByRoute.set(match.route.fullPath, response.head);
355
469
  }
356
470
  return import_core.resolveHeadConfig(matches, resolvedHeadByRoute);
357
471
  }
358
- async loadRouteHead(route, params, search, location, request) {
359
- const cacheKey = JSON.stringify({
360
- routeId: route.fullPath,
472
+ getRouteHeadCacheKey(routeId, params, search) {
473
+ return JSON.stringify({
474
+ routeId,
361
475
  params,
362
476
  search
363
477
  });
364
- const cached = this.headCache.get(cacheKey);
478
+ }
479
+ getCachedRouteHead(routeId, params, search) {
480
+ const cached = this.headCache.get(this.getRouteHeadCacheKey(routeId, params, search));
365
481
  if (cached && cached.expiresAt > Date.now()) {
366
482
  return cached.head;
367
483
  }
484
+ return null;
485
+ }
486
+ setRouteHeadCache(routeId, params, search, response) {
487
+ this.headCache.set(this.getRouteHeadCacheKey(routeId, params, search), {
488
+ head: response.head,
489
+ expiresAt: Date.now() + (response.staleTime ?? 0)
490
+ });
491
+ }
492
+ seedHeadCacheFromRouteHeads(matches, routeHeads) {
493
+ this.cacheRouteHeadsFromDocument(matches, routeHeads);
494
+ }
495
+ cacheRouteHeadsFromDocument(matches, routeHeads) {
496
+ const routeHeadsById = new Map(routeHeads.map((entry) => [entry.routeId, entry]));
497
+ const resolvedHeadByRoute = new Map;
498
+ for (const match of matches) {
499
+ if (!match.route.serverHead) {
500
+ continue;
501
+ }
502
+ const entry = routeHeadsById.get(match.route.fullPath);
503
+ if (!entry) {
504
+ continue;
505
+ }
506
+ this.setRouteHeadCache(match.route.fullPath, match.params, match.search, entry);
507
+ resolvedHeadByRoute.set(match.route.fullPath, entry.head);
508
+ }
509
+ return resolvedHeadByRoute;
510
+ }
511
+ async loadRouteHead(route, params, search, location, request) {
512
+ const cachedHead = this.getCachedRouteHead(route.fullPath, params, search);
513
+ if (cachedHead) {
514
+ return cachedHead;
515
+ }
368
516
  const response = this.options.loadRouteHead !== undefined ? await this.options.loadRouteHead({
369
517
  route,
370
518
  routeId: route.fullPath,
@@ -373,14 +521,11 @@ class Router {
373
521
  location,
374
522
  request
375
523
  }) : await this.fetchRouteHead(route, params, search);
376
- this.headCache.set(cacheKey, {
377
- head: response.head,
378
- expiresAt: Date.now() + (response.staleTime ?? 0)
379
- });
524
+ this.setRouteHeadCache(route.fullPath, params, search, response);
380
525
  return response.head;
381
526
  }
382
527
  async fetchRouteHead(route, params, search) {
383
- const basePath = this.options.headBasePath ?? "/head-api";
528
+ const basePath = this.options.headBasePath ?? prependBasePathToHref("/head-api", this.basePath);
384
529
  const searchParams = new URLSearchParams({
385
530
  routeId: route.fullPath,
386
531
  params: JSON.stringify(params),
@@ -395,6 +540,20 @@ class Router {
395
540
  }
396
541
  return await response.json();
397
542
  }
543
+ async fetchDocumentHead(location) {
544
+ const basePath = this.options.headBasePath ?? prependBasePathToHref("/head-api", this.basePath);
545
+ const searchParams = new URLSearchParams({
546
+ href: prependBasePathToHref(location.href, this.basePath)
547
+ });
548
+ const response = await fetch(`${basePath}?${searchParams.toString()}`);
549
+ if (!response.ok) {
550
+ if (response.status === 404) {
551
+ throw import_core.notFound();
552
+ }
553
+ throw new Error(`Failed to resolve server head for location "${location.href}"`);
554
+ }
555
+ return await response.json();
556
+ }
398
557
  async commitLocation(location, options) {
399
558
  this.state = {
400
559
  ...this.state,
@@ -404,13 +563,15 @@ class Router {
404
563
  this.notify();
405
564
  try {
406
565
  const resolved = await this.resolveLocation(location, {
407
- request: options.request
566
+ request: options.request,
567
+ initialHeadSnapshot: options.initialHeadSnapshot
408
568
  });
569
+ const historyHref = prependBasePathToHref(location.href, this.basePath);
409
570
  if (options.writeHistory) {
410
571
  if (options.replace) {
411
- this.history.replace(location.href, location.state);
572
+ this.history.replace(historyHref, location.state);
412
573
  } else {
413
- this.history.push(location.href, location.state);
574
+ this.history.push(historyHref, location.state);
414
575
  }
415
576
  }
416
577
  this.state = {
@@ -431,11 +592,12 @@ class Router {
431
592
  return;
432
593
  }
433
594
  const errorMatches = this.buildMatches(location);
595
+ const historyHref = prependBasePathToHref(location.href, this.basePath);
434
596
  if (options.writeHistory) {
435
597
  if (options.replace) {
436
- this.history.replace(location.href, location.state);
598
+ this.history.replace(historyHref, location.state);
437
599
  } else {
438
- this.history.push(location.href, location.state);
600
+ this.history.push(historyHref, location.state);
439
601
  }
440
602
  }
441
603
  this.state = {
@@ -525,61 +687,95 @@ function createManagedHeadElements(head) {
525
687
  element.setAttribute(MANAGED_HEAD_ATTRIBUTE, "true");
526
688
  return element;
527
689
  };
528
- for (const meta of head.meta ?? []) {
529
- if ("title" in meta) {
530
- const title = managed(document.createElement("title"));
531
- title.textContent = meta.title;
532
- elements.push(title);
690
+ const setAttributes = (element, attributes) => {
691
+ for (const [key, value] of Object.entries(attributes)) {
692
+ if (value === undefined || value === false) {
693
+ continue;
694
+ }
695
+ if (value === true) {
696
+ element.setAttribute(key, "");
697
+ continue;
698
+ }
699
+ element.setAttribute(key, value);
700
+ }
701
+ };
702
+ for (const element of head) {
703
+ if (element.tag === "title") {
704
+ continue;
705
+ }
706
+ if (element.tag === "meta") {
707
+ const tag2 = managed(document.createElement("meta"));
708
+ if ("charset" in element) {
709
+ tag2.setAttribute("charset", element.charset);
710
+ } else if ("name" in element) {
711
+ tag2.setAttribute("name", element.name);
712
+ tag2.setAttribute("content", element.content);
713
+ } else if ("property" in element) {
714
+ tag2.setAttribute("property", element.property);
715
+ tag2.setAttribute("content", element.content);
716
+ } else {
717
+ tag2.setAttribute("http-equiv", element.httpEquiv);
718
+ tag2.setAttribute("content", element.content);
719
+ }
720
+ elements.push(tag2);
533
721
  continue;
534
722
  }
535
- const tag = managed(document.createElement("meta"));
536
- if ("charset" in meta) {
537
- tag.setAttribute("charset", meta.charset);
538
- } else if ("name" in meta) {
539
- tag.setAttribute("name", meta.name);
540
- tag.setAttribute("content", meta.content);
541
- } else if ("property" in meta) {
542
- tag.setAttribute("property", meta.property);
543
- tag.setAttribute("content", meta.content);
544
- } else {
545
- tag.setAttribute("http-equiv", meta.httpEquiv);
546
- tag.setAttribute("content", meta.content);
723
+ if (element.tag === "link") {
724
+ const tag2 = managed(document.createElement("link"));
725
+ setAttributes(tag2, {
726
+ rel: element.rel,
727
+ href: element.href,
728
+ type: element.type,
729
+ media: element.media,
730
+ sizes: element.sizes,
731
+ crossorigin: element.crossorigin
732
+ });
733
+ elements.push(tag2);
734
+ continue;
735
+ }
736
+ if (element.tag === "style") {
737
+ const tag2 = managed(document.createElement("style"));
738
+ if (element.media) {
739
+ tag2.setAttribute("media", element.media);
740
+ }
741
+ tag2.textContent = element.children;
742
+ elements.push(tag2);
743
+ continue;
744
+ }
745
+ if (element.tag === "script") {
746
+ const tag2 = managed(document.createElement("script"));
747
+ if (element.src) {
748
+ tag2.setAttribute("src", element.src);
749
+ }
750
+ if (element.type) {
751
+ tag2.setAttribute("type", element.type);
752
+ }
753
+ if (element.async) {
754
+ tag2.async = true;
755
+ }
756
+ if (element.defer) {
757
+ tag2.defer = true;
758
+ }
759
+ if (element.children) {
760
+ tag2.textContent = element.children;
761
+ }
762
+ elements.push(tag2);
763
+ continue;
764
+ }
765
+ if (element.tag === "base") {
766
+ const tag2 = managed(document.createElement("base"));
767
+ tag2.setAttribute("href", element.href);
768
+ if (element.target) {
769
+ tag2.setAttribute("target", element.target);
770
+ }
771
+ elements.push(tag2);
772
+ continue;
773
+ }
774
+ const tag = managed(document.createElement(element.name));
775
+ setAttributes(tag, element.attrs ?? {});
776
+ if (element.children) {
777
+ tag.textContent = element.children;
547
778
  }
548
- elements.push(tag);
549
- }
550
- for (const link of head.links ?? []) {
551
- const tag = managed(document.createElement("link"));
552
- tag.setAttribute("rel", link.rel);
553
- tag.setAttribute("href", link.href);
554
- if (link.type)
555
- tag.setAttribute("type", link.type);
556
- if (link.media)
557
- tag.setAttribute("media", link.media);
558
- if (link.sizes)
559
- tag.setAttribute("sizes", link.sizes);
560
- if (link.crossorigin)
561
- tag.setAttribute("crossorigin", link.crossorigin);
562
- elements.push(tag);
563
- }
564
- for (const style of head.styles ?? []) {
565
- const tag = managed(document.createElement("style"));
566
- if (style.media)
567
- tag.setAttribute("media", style.media);
568
- tag.textContent = style.children;
569
- elements.push(tag);
570
- }
571
- for (const script of head.scripts ?? []) {
572
- const tag = managed(document.createElement("script"));
573
- if (script.src)
574
- tag.setAttribute("src", script.src);
575
- if (script.type)
576
- tag.setAttribute("type", script.type);
577
- if (script.async)
578
- tag.async = true;
579
- if (script.defer)
580
- tag.defer = true;
581
- if (script.children)
582
- tag.textContent = script.children;
583
779
  elements.push(tag);
584
780
  }
585
781
  return elements;
@@ -588,6 +784,10 @@ function reconcileDocumentHead(head) {
588
784
  if (typeof document === "undefined") {
589
785
  return;
590
786
  }
787
+ const title = [...head].reverse().find((element) => element.tag === "title");
788
+ if (title && title.tag === "title") {
789
+ document.title = title.children;
790
+ }
591
791
  for (const element of Array.from(document.head.querySelectorAll(`[${MANAGED_HEAD_ATTRIBUTE}]`))) {
592
792
  element.remove();
593
793
  }
@@ -732,7 +932,7 @@ function useResolvedLink(props) {
732
932
  const router = useRouterContext();
733
933
  const href = router.buildHref(props);
734
934
  const location = useLocation();
735
- const pathOnly = href.split(/[?#]/u)[0] ?? href;
935
+ const pathOnly = stripBasePathFromHref(href, router.options.basePath).split(/[?#]/u)[0] ?? href;
736
936
  const isActive = pathOnly === location.pathname;
737
937
  return { href, isActive, router };
738
938
  }