@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.
@@ -103,13 +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: [] };
107
- function isHeadTagReference(head) {
108
- return typeof head === "string";
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}`;
109
157
  }
110
158
  function routeHasRecord(value) {
111
159
  return typeof value === "object" && value !== null;
112
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
+ }
113
168
  function resolveParamsInput(input, previous) {
114
169
  if (input === undefined) {
115
170
  return previous;
@@ -155,6 +210,8 @@ class Router {
155
210
  headCache = new Map;
156
211
  parseSearch;
157
212
  stringifySearch;
213
+ basePath;
214
+ initialHeadSnapshot;
158
215
  started = false;
159
216
  unsubscribeHistory;
160
217
  constructor(options) {
@@ -163,6 +220,7 @@ class Router {
163
220
  this.history = options.history ?? (typeof window === "undefined" ? import_history.createMemoryHistory() : import_history.createBrowserHistory());
164
221
  this.parseSearch = options.parseSearch ?? import_core.defaultParseSearch;
165
222
  this.stringifySearch = options.stringifySearch ?? import_core.defaultStringifySearch;
223
+ this.basePath = normalizeBasePath(options.basePath);
166
224
  for (const route of import_core.collectRoutes(this.routeTree)) {
167
225
  this.routesByFullPath.set(route.fullPath, route);
168
226
  }
@@ -170,15 +228,22 @@ class Router {
170
228
  this.routesByTo.set(branch.leaf.to, branch.leaf);
171
229
  }
172
230
  const location = this.readLocation();
231
+ const initialMatches = this.buildMatches(location);
232
+ const rawHistoryHref = this.history.location.href;
173
233
  const initialHeadSnapshot = typeof window !== "undefined" ? window.__RICHIE_ROUTER_HEAD__ : undefined;
174
- 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
+ }
175
239
  if (typeof window !== "undefined" && initialHeadSnapshot !== undefined) {
176
240
  delete window.__RICHIE_ROUTER_HEAD__;
177
241
  }
242
+ this.initialHeadSnapshot = hasMatchingInitialHeadSnapshot ? initialHeadSnapshot : undefined;
178
243
  this.state = {
179
244
  status: "loading",
180
245
  location,
181
- matches: this.buildMatches(location),
246
+ matches: initialMatches,
182
247
  head: initialHead,
183
248
  error: null
184
249
  };
@@ -209,15 +274,17 @@ class Router {
209
274
  }
210
275
  async load(options) {
211
276
  const nextLocation = this.readLocation();
277
+ const initialHeadSnapshot = this.initialHeadSnapshot?.href === nextLocation.href ? this.initialHeadSnapshot : undefined;
278
+ this.initialHeadSnapshot = undefined;
212
279
  await this.commitLocation(nextLocation, {
213
280
  request: options?.request,
214
281
  replace: true,
215
- writeHistory: false
282
+ writeHistory: false,
283
+ initialHeadSnapshot
216
284
  });
217
285
  }
218
286
  async navigate(options) {
219
- const href = this.buildHref(options);
220
- const location = import_core.createParsedLocation(href, options.state ?? null, this.parseSearch);
287
+ const location = this.buildLocation(options);
221
288
  await this.commitLocation(location, {
222
289
  replace: options.replace ?? false,
223
290
  writeHistory: true,
@@ -225,8 +292,7 @@ class Router {
225
292
  });
226
293
  }
227
294
  async preloadRoute(options) {
228
- const href = this.buildHref(options);
229
- const location = import_core.createParsedLocation(href, options.state ?? null, this.parseSearch);
295
+ const location = this.buildLocation(options);
230
296
  try {
231
297
  await this.resolveLocation(location);
232
298
  } catch {}
@@ -236,6 +302,12 @@ class Router {
236
302
  await this.load();
237
303
  }
238
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) {
239
311
  const targetRoute = this.routesByTo.get(options.to) ?? null;
240
312
  const fromMatch = options.from ? this.findMatchByTo(options.from) : null;
241
313
  const previousParams = fromMatch?.params ?? {};
@@ -250,7 +322,7 @@ class Router {
250
322
  }
251
323
  readLocation() {
252
324
  const location = this.history.location;
253
- return import_core.createParsedLocation(location.href, location.state, this.parseSearch);
325
+ return import_core.createParsedLocation(stripBasePathFromHref(location.href, this.basePath), location.state, this.parseSearch);
254
326
  }
255
327
  applyTrailingSlash(pathname, route) {
256
328
  const trailingSlash = this.options.trailingSlash ?? "preserve";
@@ -303,13 +375,9 @@ class Router {
303
375
  });
304
376
  }
305
377
  resolveSearch(route, rawSearch) {
306
- const fromHeadTagSchema = route.searchSchema ? route.searchSchema.parse(rawSearch) : {};
307
- const fromRoute = route.options.validateSearch ? route.options.validateSearch(rawSearch) : {};
308
- if (routeHasRecord(fromHeadTagSchema) || routeHasRecord(fromRoute)) {
309
- return {
310
- ...routeHasRecord(fromHeadTagSchema) ? fromHeadTagSchema : {},
311
- ...routeHasRecord(fromRoute) ? fromRoute : {}
312
- };
378
+ const fromSchema = route.searchSchema ? route.searchSchema.parse(rawSearch) : {};
379
+ if (routeHasRecord(fromSchema)) {
380
+ return fromSchema;
313
381
  }
314
382
  return rawSearch;
315
383
  }
@@ -349,57 +417,140 @@ class Router {
349
417
  to: route.to
350
418
  });
351
419
  }
352
- const head = await this.resolveLocationHead(matches, location, options?.request);
420
+ const head = await this.resolveLocationHead(matches, location, options?.request, options?.initialHeadSnapshot);
353
421
  return { matches, head, error: null };
354
422
  }
355
- async resolveLocationHead(matches, location, request) {
423
+ async resolveLocationHead(matches, location, request, initialHeadSnapshot) {
356
424
  const resolvedHeadByRoute = new Map;
357
- for (const match of matches) {
358
- const headOption = match.route.options.head;
359
- if (!isHeadTagReference(headOption)) {
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);
443
+ continue;
444
+ }
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);
360
464
  continue;
361
465
  }
362
- resolvedHeadByRoute.set(match.route.fullPath, await this.loadRouteHead(match.route, headOption, match.params, match.search, location, request));
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);
363
469
  }
364
470
  return import_core.resolveHeadConfig(matches, resolvedHeadByRoute);
365
471
  }
366
- async loadRouteHead(route, headTagName, params, search, location, request) {
367
- const cacheKey = JSON.stringify({
368
- headTagName,
472
+ getRouteHeadCacheKey(routeId, params, search) {
473
+ return JSON.stringify({
474
+ routeId,
369
475
  params,
370
476
  search
371
477
  });
372
- const cached = this.headCache.get(cacheKey);
478
+ }
479
+ getCachedRouteHead(routeId, params, search) {
480
+ const cached = this.headCache.get(this.getRouteHeadCacheKey(routeId, params, search));
373
481
  if (cached && cached.expiresAt > Date.now()) {
374
482
  return cached.head;
375
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
+ }
376
516
  const response = this.options.loadRouteHead !== undefined ? await this.options.loadRouteHead({
377
517
  route,
378
- headTagName,
518
+ routeId: route.fullPath,
379
519
  params,
380
520
  search,
381
521
  location,
382
522
  request
383
- }) : await this.fetchRouteHead(route, headTagName, params, search);
384
- this.headCache.set(cacheKey, {
385
- head: response.head,
386
- expiresAt: Date.now() + (response.staleTime ?? 0)
387
- });
523
+ }) : await this.fetchRouteHead(route, params, search);
524
+ this.setRouteHeadCache(route.fullPath, params, search, response);
388
525
  return response.head;
389
526
  }
390
- async fetchRouteHead(route, headTagName, params, search) {
391
- const basePath = this.options.headBasePath ?? "/head-api";
527
+ async fetchRouteHead(route, params, search) {
528
+ const basePath = this.options.headBasePath ?? prependBasePathToHref("/head-api", this.basePath);
392
529
  const searchParams = new URLSearchParams({
393
530
  routeId: route.fullPath,
394
531
  params: JSON.stringify(params),
395
532
  search: JSON.stringify(search)
396
533
  });
397
- const response = await fetch(`${basePath}/${encodeURIComponent(headTagName)}?${searchParams.toString()}`);
534
+ const response = await fetch(`${basePath}?${searchParams.toString()}`);
535
+ if (!response.ok) {
536
+ if (response.status === 404) {
537
+ throw import_core.notFound();
538
+ }
539
+ throw new Error(`Failed to resolve server head for route "${route.fullPath}"`);
540
+ }
541
+ return await response.json();
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()}`);
398
549
  if (!response.ok) {
399
550
  if (response.status === 404) {
400
551
  throw import_core.notFound();
401
552
  }
402
- throw new Error(`Failed to resolve head tag "${headTagName}" for route "${route.fullPath}"`);
553
+ throw new Error(`Failed to resolve server head for location "${location.href}"`);
403
554
  }
404
555
  return await response.json();
405
556
  }
@@ -412,13 +563,15 @@ class Router {
412
563
  this.notify();
413
564
  try {
414
565
  const resolved = await this.resolveLocation(location, {
415
- request: options.request
566
+ request: options.request,
567
+ initialHeadSnapshot: options.initialHeadSnapshot
416
568
  });
569
+ const historyHref = prependBasePathToHref(location.href, this.basePath);
417
570
  if (options.writeHistory) {
418
571
  if (options.replace) {
419
- this.history.replace(location.href, location.state);
572
+ this.history.replace(historyHref, location.state);
420
573
  } else {
421
- this.history.push(location.href, location.state);
574
+ this.history.push(historyHref, location.state);
422
575
  }
423
576
  }
424
577
  this.state = {
@@ -439,11 +592,12 @@ class Router {
439
592
  return;
440
593
  }
441
594
  const errorMatches = this.buildMatches(location);
595
+ const historyHref = prependBasePathToHref(location.href, this.basePath);
442
596
  if (options.writeHistory) {
443
597
  if (options.replace) {
444
- this.history.replace(location.href, location.state);
598
+ this.history.replace(historyHref, location.state);
445
599
  } else {
446
- this.history.push(location.href, location.state);
600
+ this.history.push(historyHref, location.state);
447
601
  }
448
602
  }
449
603
  this.state = {
@@ -533,61 +687,95 @@ function createManagedHeadElements(head) {
533
687
  element.setAttribute(MANAGED_HEAD_ATTRIBUTE, "true");
534
688
  return element;
535
689
  };
536
- for (const meta of head.meta ?? []) {
537
- if ("title" in meta) {
538
- const title = managed(document.createElement("title"));
539
- title.textContent = meta.title;
540
- 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);
541
721
  continue;
542
722
  }
543
- const tag = managed(document.createElement("meta"));
544
- if ("charset" in meta) {
545
- tag.setAttribute("charset", meta.charset);
546
- } else if ("name" in meta) {
547
- tag.setAttribute("name", meta.name);
548
- tag.setAttribute("content", meta.content);
549
- } else if ("property" in meta) {
550
- tag.setAttribute("property", meta.property);
551
- tag.setAttribute("content", meta.content);
552
- } else {
553
- tag.setAttribute("http-equiv", meta.httpEquiv);
554
- 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;
555
778
  }
556
- elements.push(tag);
557
- }
558
- for (const link of head.links ?? []) {
559
- const tag = managed(document.createElement("link"));
560
- tag.setAttribute("rel", link.rel);
561
- tag.setAttribute("href", link.href);
562
- if (link.type)
563
- tag.setAttribute("type", link.type);
564
- if (link.media)
565
- tag.setAttribute("media", link.media);
566
- if (link.sizes)
567
- tag.setAttribute("sizes", link.sizes);
568
- if (link.crossorigin)
569
- tag.setAttribute("crossorigin", link.crossorigin);
570
- elements.push(tag);
571
- }
572
- for (const style of head.styles ?? []) {
573
- const tag = managed(document.createElement("style"));
574
- if (style.media)
575
- tag.setAttribute("media", style.media);
576
- tag.textContent = style.children;
577
- elements.push(tag);
578
- }
579
- for (const script of head.scripts ?? []) {
580
- const tag = managed(document.createElement("script"));
581
- if (script.src)
582
- tag.setAttribute("src", script.src);
583
- if (script.type)
584
- tag.setAttribute("type", script.type);
585
- if (script.async)
586
- tag.async = true;
587
- if (script.defer)
588
- tag.defer = true;
589
- if (script.children)
590
- tag.textContent = script.children;
591
779
  elements.push(tag);
592
780
  }
593
781
  return elements;
@@ -596,6 +784,10 @@ function reconcileDocumentHead(head) {
596
784
  if (typeof document === "undefined") {
597
785
  return;
598
786
  }
787
+ const title = [...head].reverse().find((element) => element.tag === "title");
788
+ if (title && title.tag === "title") {
789
+ document.title = title.children;
790
+ }
599
791
  for (const element of Array.from(document.head.querySelectorAll(`[${MANAGED_HEAD_ATTRIBUTE}]`))) {
600
792
  element.remove();
601
793
  }
@@ -740,7 +932,7 @@ function useResolvedLink(props) {
740
932
  const router = useRouterContext();
741
933
  const href = router.buildHref(props);
742
934
  const location = useLocation();
743
- const pathOnly = href.split(/[?#]/u)[0] ?? href;
935
+ const pathOnly = stripBasePathFromHref(href, router.options.basePath).split(/[?#]/u)[0] ?? href;
744
936
  const isActive = pathOnly === location.pathname;
745
937
  return { href, isActive, router };
746
938
  }