@lovrozagar/flare 0.1.1 → 0.2.0

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.
Files changed (85) hide show
  1. package/README.md +44 -19
  2. package/package.json +10 -7
  3. package/src/broadcast/index.ts +1 -1
  4. package/src/broadcast/provider.tsx +2 -2
  5. package/src/caches/index.ts +26 -0
  6. package/src/components/await.tsx +36 -39
  7. package/src/components/dev-error-overlay.tsx +8 -9
  8. package/src/components/direction-script.tsx +2 -2
  9. package/src/components/locale-script.tsx +2 -2
  10. package/src/components/reset-css.tsx +1 -1
  11. package/src/components/ssr-context.tsx +7 -14
  12. package/src/components/theme-script.tsx +2 -2
  13. package/src/components/view-transition-css.tsx +1 -1
  14. package/src/defer/index.ts +22 -0
  15. package/src/direction/index.tsx +61 -36
  16. package/src/errors/index.ts +45 -11
  17. package/src/fonts/component.tsx +2 -1
  18. package/src/form/index.tsx +18 -12
  19. package/src/generators/index.ts +73 -30
  20. package/src/head-client/index.ts +7 -0
  21. package/src/history/index.ts +6 -0
  22. package/src/hydrate/index.tsx +90 -60
  23. package/src/hydration/index.ts +26 -6
  24. package/src/i18n/index.ts +11 -2
  25. package/src/image/index.tsx +7 -4
  26. package/src/intercept-outlet/index.tsx +6 -2
  27. package/src/lazy/index.tsx +50 -50
  28. package/src/link/index.tsx +97 -58
  29. package/src/loader-pipeline/index.ts +93 -35
  30. package/src/locale/cookie.ts +15 -0
  31. package/src/locale/index.tsx +24 -35
  32. package/src/locale.ts +1 -0
  33. package/src/middleware/builtins/api-proxy.ts +58 -4
  34. package/src/middleware/builtins/cdn-proxy.ts +24 -3
  35. package/src/middleware/builtins/i18n.ts +23 -25
  36. package/src/middleware/builtins/keepalive.ts +2 -3
  37. package/src/mount/index.ts +8 -1
  38. package/src/navigation/index.ts +575 -129
  39. package/src/navigation/types.ts +11 -1
  40. package/src/navigation-progress/index.tsx +17 -16
  41. package/src/ndjson-client/index.ts +42 -17
  42. package/src/ndjson-server/index.ts +78 -29
  43. package/src/outlet/index.tsx +192 -97
  44. package/src/outlet/types.ts +9 -1
  45. package/src/plugins/dev-cdn-cache.ts +18 -5
  46. package/src/plugins/dev-dashboard/client.tsx +130 -118
  47. package/src/plugins/dev-prerender.ts +6 -1
  48. package/src/plugins/dev-server.ts +12 -3
  49. package/src/plugins/image-plugin.ts +18 -4
  50. package/src/plugins/index.ts +10 -7
  51. package/src/plugins/prerender-plugin.ts +3 -3
  52. package/src/plugins/server-fn.ts +15 -20
  53. package/src/plugins/solid-jsx.d.ts +3 -3
  54. package/src/plugins/sx-ast/atomic.ts +5 -5
  55. package/src/plugins/sx-ast/index.ts +9 -0
  56. package/src/plugins/sx.d.ts +4 -5
  57. package/src/plugins/virtual.ts +11 -6
  58. package/src/prerender/index.ts +68 -32
  59. package/src/protocol.ts +65 -0
  60. package/src/query-client/index.tsx +73 -14
  61. package/src/rewrite/index.ts +10 -4
  62. package/src/route-builder/create-layout.ts +1 -1
  63. package/src/route-builder/create-root-layout.ts +1 -1
  64. package/src/route-builder/types.ts +5 -1
  65. package/src/router-primitives/types.ts +2 -0
  66. package/src/security/index.ts +41 -5
  67. package/src/server/index.ts +1 -2
  68. package/src/server-fn/get-input.ts +90 -0
  69. package/src/server-fn/http-error.ts +13 -0
  70. package/src/server-fn/index.ts +94 -70
  71. package/src/server-fn-query.ts +9 -17
  72. package/src/server-handler/etag.ts +4 -2
  73. package/src/server-handler/headers.ts +1 -2
  74. package/src/server-handler/index.ts +164 -56
  75. package/src/server-handler/types.ts +2 -0
  76. package/src/service-worker/template.ts +10 -42
  77. package/src/ssr/head-prefix.ts +2 -1
  78. package/src/ssr/hoist-head-markers.ts +25 -0
  79. package/src/ssr/index.tsx +109 -92
  80. package/src/state-parser/index.ts +38 -26
  81. package/src/styles/index.ts +89 -60
  82. package/src/testing/index.ts +8 -3
  83. package/src/theme/index.tsx +35 -32
  84. package/src/tracing/timing.ts +6 -1
  85. package/src/url/index.ts +9 -0
@@ -1,10 +1,11 @@
1
- import { batch } from "solid-js";
2
- import type { DeferredTracker } from "../caches/index.ts";
3
- import { collectDeferredPromises, createDeferredTracker } from "../caches/index.ts";
1
+ import { flush } from "solid-js";
2
+ import type { CachedMatch, DeferredTracker } from "../caches/index.ts";
3
+ import { collectDeferredPromises, createDeferredTracker, resolveCacheTags } from "../caches/index.ts";
4
+ import { applyMatchCacheTags } from "../hydration/index.ts";
4
5
  import type { DirectionConfig } from "../direction.ts";
5
6
  import { getDirFromLocale } from "../direction.ts";
6
7
  import { parseMilliseconds } from "../duration/index.ts";
7
- import { NotFoundError, RedirectResponse } from "../errors/index.ts";
8
+ import { NotFoundError, RedirectResponse, UnauthenticatedError, UnauthorizedError } from "../errors/index.ts";
8
9
  import type { PerRouteHead } from "../head-client/index.ts";
9
10
  import { applyPerRouteHeads } from "../head-client/index.ts";
10
11
  import {
@@ -23,9 +24,13 @@ import {
23
24
  setHistoryIndex,
24
25
  } from "../history/index.ts";
25
26
  import { isChunkLoadError, isRenderFn } from "../internal.ts";
27
+ import { KEEPALIVE_PATH, STORAGE_CHUNK_RELOAD } from "../protocol.ts";
26
28
  import type { LocaleConfig } from "../locale.ts";
29
+ import { formatLocaleCookie } from "../locale/cookie.ts";
27
30
  import { warn } from "../logger.ts";
28
- import { fetchNDJSON } from "../ndjson-client/index.ts";
31
+ import { fetchNDJSON, type NDJSONFetchResult } from "../ndjson-client/index.ts";
32
+ import type { DeferredResolver } from "../state-parser/index.ts";
33
+ import { hasRawDeferredMarkers, hydrateLoaderData } from "../state-parser/index.ts";
29
34
  import type {
30
35
  FlareProviderContext,
31
36
  InternalNavigateOptions,
@@ -56,7 +61,12 @@ function extractRootIdentity(virtualPath: string): string {
56
61
  interface InternalProviderContext extends FlareProviderContext {
57
62
  _setNavigate?: (fn: (opts: InternalNavigateOptions) => Promise<void>) => void;
58
63
  _setPrefetch?: (
59
- fn: (opts: { params?: Record<string, unknown>; search?: Record<string, unknown>; to: string }) => Promise<void>,
64
+ fn: (opts: {
65
+ modulesOnly?: boolean;
66
+ params?: Record<string, unknown>;
67
+ search?: Record<string, unknown>;
68
+ to: string;
69
+ }) => Promise<void>,
60
70
  ) => void;
61
71
  }
62
72
 
@@ -110,24 +120,42 @@ let localeConfig: LocaleConfig | undefined;
110
120
  let directionConfig: DirectionConfig | undefined;
111
121
  const visitedRoutes = new Set<string>();
112
122
 
123
+ /** In-flight hover/viewport prefetch — navigate reuses this instead of starting a second NDJSON. */
124
+ const inflightPrefetch = new Map<string, { promise: Promise<NDJSONFetchResult>; startedAt: number }>();
125
+ /** Resolvers for a painted prefetch `l` shell waiting on enter `c` chunks. */
126
+ const deferredResumeResolvers = new Map<string, DeferredResolver>();
127
+
113
128
  /* Keepalive ping — keeps HTTP/2 connection and CF isolate warm */
114
129
  let keepaliveIntervalId: ReturnType<typeof setInterval> | null = null;
115
130
  let keepaliveVisibilityHandler: (() => void) | null = null;
116
131
 
117
132
  let clickCleanup: (() => void) | null = null;
118
133
 
119
- /* SPA navigation blocker — set by useBlocker, checked by navigate */
120
- let activeBlockerFn: (() => boolean) | null = null;
121
- let onBlockedCallback: (() => void) | null = null;
134
+ /* SPA navigation blockers — stacked so a child's cleanup cannot null a parent */
135
+ interface BlockerRegistration {
136
+ onBlocked: () => void;
137
+ when: () => boolean;
138
+ }
139
+ const blockers: BlockerRegistration[] = [];
122
140
  let pendingNavigation: InternalNavigateOptions | null = null;
123
141
 
124
- /** Reset navigation phase + clear controller in one place — used at all early-return sites */
142
+ /** Reset navigation phase. Keep currentController so the next navigate can abort late streams. */
125
143
  function stopNavigation(): void {
126
144
  if (ctx) {
127
145
  ctx.setNavigationPhase("idle");
128
146
  ctx.setViewTransition(null);
129
147
  }
130
- currentController = null;
148
+ }
149
+
150
+ function rejectDeferredResume(error: Error): void {
151
+ for (const r of deferredResumeResolvers.values()) {
152
+ try {
153
+ r.reject(error);
154
+ } catch {
155
+ /* already settled */
156
+ }
157
+ }
158
+ deferredResumeResolvers.clear();
131
159
  }
132
160
 
133
161
  /**
@@ -143,8 +171,9 @@ function syncLocale(params: Record<string, string | string[]>): void {
143
171
  const effectiveLocale = (typeof val === "string" ? val : undefined) ?? localeConfig.defaultLocale;
144
172
 
145
173
  /* Cookie — always write for localized routes to keep it current */
146
- const cookieName = localeConfig.cookieName ?? "flare.locale";
147
- document.cookie = `${cookieName}=${effectiveLocale}; path=/; max-age=31536000; samesite=lax`;
174
+ document.cookie = formatLocaleCookie(effectiveLocale, localeConfig.cookieName, {
175
+ https: location.protocol === "https:",
176
+ });
148
177
 
149
178
  /* html lang */
150
179
  document.documentElement.setAttribute("lang", effectiveLocale);
@@ -158,9 +187,25 @@ function syncLocale(params: Record<string, string | string[]>): void {
158
187
  }
159
188
  }
160
189
 
161
- export function setActiveBlocker(when: (() => boolean) | null, onBlocked?: () => void): void {
162
- activeBlockerFn = when;
163
- onBlockedCallback = onBlocked ?? null;
190
+ export function setActiveBlocker(when: (() => boolean) | null, onBlocked?: () => void): () => void {
191
+ if (when === null) {
192
+ blockers.length = 0;
193
+ return () => {};
194
+ }
195
+ const entry: BlockerRegistration = { onBlocked: onBlocked ?? (() => {}), when };
196
+ blockers.push(entry);
197
+ return () => {
198
+ const i = blockers.indexOf(entry);
199
+ if (i >= 0) blockers.splice(i, 1);
200
+ };
201
+ }
202
+
203
+ function findBlocking(): BlockerRegistration | undefined {
204
+ for (let i = blockers.length - 1; i >= 0; i--) {
205
+ const blocker = blockers[i];
206
+ if (blocker?.when()) return blocker;
207
+ }
208
+ return undefined;
164
209
  }
165
210
 
166
211
  export function clearPendingNavigation(): void {
@@ -344,12 +389,17 @@ export function setupNavigation(
344
389
  gcIntervalId = setInterval(() => {
345
390
  if (!ctx) return;
346
391
  const now = Date.now();
392
+ const defaultGc = parseMilliseconds(ctx.routerCacheDefaults?.gcTime ?? GC_MAX_AGE);
393
+ const prefetchGc = parseMilliseconds(
394
+ ctx.routerCacheDefaults?.prefetchGcTime ?? ctx.routerCacheDefaults?.gcTime ?? GC_MAX_AGE,
395
+ );
347
396
  for (const entry of ctx.matchCache.getAll()) {
348
- if (now - entry.updatedAt > GC_MAX_AGE) {
397
+ const maxAge = entry.gcTime ?? defaultGc;
398
+ if (now - entry.updatedAt > maxAge) {
349
399
  ctx.matchCache.delete(entry.matchId);
350
400
  }
351
401
  }
352
- ctx.prefetchCache.cleanup(GC_MAX_AGE);
402
+ ctx.prefetchCache.cleanup(prefetchGc);
353
403
  }, GC_INTERVAL);
354
404
 
355
405
  /* Keepalive ping — clear previous before setting up */
@@ -362,7 +412,7 @@ export function setupNavigation(
362
412
  const keepaliveMs = options.keepalive;
363
413
  if (typeof keepaliveMs === "number" && keepaliveMs > 0) {
364
414
  const ping = () => {
365
- fetch("/_flare/keepalive", { priority: "low" }).catch(() => {});
415
+ fetch(KEEPALIVE_PATH, { priority: "low" }).catch(() => {});
366
416
  };
367
417
 
368
418
  keepaliveIntervalId = setInterval(ping, keepaliveMs);
@@ -406,6 +456,13 @@ function resolveNavigationUrl(options: InternalNavigateOptions): URL | null {
406
456
  }
407
457
 
408
458
  /** Save scroll position + push/replace history state. */
459
+ function samePathAndSearch(url: URL, loc: { pathname: string; search: SearchParams }): boolean {
460
+ return (
461
+ url.pathname === loc.pathname &&
462
+ serializeSearchParams(parseSearchParams(url.searchParams)) === serializeSearchParams(loc.search)
463
+ );
464
+ }
465
+
409
466
  function handleHistoryUpdate(
410
467
  options: InternalNavigateOptions,
411
468
  url: URL,
@@ -433,24 +490,163 @@ function handleHistoryUpdate(
433
490
  currentHistoryKey = newState.key;
434
491
  }
435
492
 
493
+ function matchIdForModule(
494
+ mod: LoadedRouteModule,
495
+ search: SearchParams,
496
+ params: Record<string, string | string[]>,
497
+ ): string {
498
+ const deps = mod.effectsConfig?.loaderDeps?.({ search }) ?? [];
499
+ return computeMatchId({
500
+ loaderDeps: () => deps,
501
+ params,
502
+ routeId: mod.virtualPath,
503
+ search,
504
+ });
505
+ }
506
+
507
+ function applyPrefetchMatches(navCtx: FlareProviderContext, fetchResult: NDJSONFetchResult, startedAt: number): void {
508
+ const headByMatchId = new Map<string, HeadConfig>();
509
+ for (const h of fetchResult.perRouteHeads) {
510
+ headByMatchId.set(h.matchId, h.head);
511
+ }
512
+ const now = Date.now();
513
+ for (const m of fetchResult.matches) {
514
+ const existing = navCtx.matchCache.get(m.matchId);
515
+ /* A newer navigate already committed this match — do not clobber it. */
516
+ if (existing && existing.updatedAt > startedAt) continue;
517
+ navCtx.matchCache.set({
518
+ data: m.loaderData,
519
+ error: m.error,
520
+ hasDeferred: m.hasDeferredMarkers,
521
+ headConfig: headByMatchId.get(m.matchId),
522
+ invalid: false,
523
+ matchId: m.matchId,
524
+ preloaderContext: m.preloaderContext,
525
+ updatedAt: now,
526
+ });
527
+ }
528
+ }
529
+
530
+ function routeHasDeferredShell(
531
+ allModules: LoadedRouteModule[],
532
+ search: SearchParams,
533
+ params: Record<string, string | string[]>,
534
+ ): boolean {
535
+ if (!ctx) return false;
536
+ for (const mod of allModules) {
537
+ if (ctx.matchCache.get(matchIdForModule(mod, search, params))?.hasDeferred) return true;
538
+ }
539
+ return false;
540
+ }
541
+
542
+ function hydrateCachedDeferred(cached: CachedMatch): boolean {
543
+ if (!ctx || !cached.hasDeferred || !hasRawDeferredMarkers(cached.data)) return false;
544
+ const matchCache = ctx.matchCache;
545
+ const local = new Map<string, DeferredResolver>();
546
+ cached.data = hydrateLoaderData(cached.matchId, cached.data, local);
547
+ if (local.size === 0) return false;
548
+ let remaining = local.size;
549
+ const matchId = cached.matchId;
550
+ for (const [key, r] of local) {
551
+ deferredResumeResolvers.set(key, {
552
+ reject(err) {
553
+ r.reject(err);
554
+ remaining--;
555
+ if (remaining <= 0) {
556
+ const cur = matchCache.get(matchId);
557
+ if (cur) {
558
+ cur.hasDeferred = false;
559
+ matchCache.set(cur);
560
+ }
561
+ }
562
+ },
563
+ resolve(data) {
564
+ r.resolve(data);
565
+ remaining--;
566
+ if (remaining <= 0) {
567
+ const cur = matchCache.get(matchId);
568
+ if (cur) {
569
+ cur.hasDeferred = false;
570
+ cur.updatedAt = Date.now();
571
+ matchCache.set(cur);
572
+ }
573
+ }
574
+ },
575
+ });
576
+ }
577
+ matchCache.set(cached);
578
+ return true;
579
+ }
580
+
581
+ /**
582
+ * Paint cached/prefetched matches immediately so click is not blocked on NDJSON.
583
+ * Hydrates prefetch `{ __deferred, key }` markers so Await can track enter `c` chunks.
584
+ */
585
+ function commitCachedShell(
586
+ c: FlareProviderContext,
587
+ allModules: LoadedRouteModule[],
588
+ search: SearchParams,
589
+ params: Record<string, string | string[]>,
590
+ ): { hadShell: boolean; keepMatchIds: string[] } {
591
+ if (!ctx) return { hadShell: false, keepMatchIds: [] };
592
+ let found = false;
593
+ const heads: PerRouteHead[] = [];
594
+ const keepMatchIds: string[] = [];
595
+ for (const mod of allModules) {
596
+ const matchId = matchIdForModule(mod, search, params);
597
+ const cached = ctx.matchCache.get(matchId);
598
+ if (!cached || cached.invalid) continue;
599
+ found = true;
600
+ if (cached.hasDeferred && hydrateCachedDeferred(cached)) {
601
+ keepMatchIds.push(matchId);
602
+ }
603
+ if (cached.headConfig) heads.push({ head: cached.headConfig, matchId });
604
+ }
605
+ if (!found) return { hadShell: false, keepMatchIds: [] };
606
+
607
+ c.setIntercepted(null);
608
+ c.setNotFound(false);
609
+ assignMatches(c, buildClientMatches(allModules, search, params));
610
+ c.setParams(params);
611
+ c.setSearch(search);
612
+ syncLocale(params);
613
+ if (heads.length > 0) applyPerRouteHeads(heads);
614
+ return { hadShell: true, keepMatchIds };
615
+ }
616
+
617
+ function sameRouteParams(a: Record<string, string | string[]>, b: Record<string, string | string[]>): boolean {
618
+ const keys = Object.keys(a);
619
+ if (keys.length !== Object.keys(b).length) return false;
620
+ for (const key of keys) {
621
+ const av = a[key];
622
+ const bv = b[key];
623
+ if (av === bv) continue;
624
+ if (Array.isArray(av) && Array.isArray(bv)) {
625
+ if (av.length !== bv.length) return false;
626
+ for (let i = 0; i < av.length; i++) {
627
+ if (av[i] !== bv[i]) return false;
628
+ }
629
+ continue;
630
+ }
631
+ return false;
632
+ }
633
+ return true;
634
+ }
635
+
436
636
  /** Map loaded modules to client match objects using cached data. */
437
637
  function buildClientMatches(
438
638
  allModules: LoadedRouteModule[],
439
639
  search: SearchParams,
440
640
  params: Record<string, string | string[]>,
441
641
  ) {
442
- return allModules.map((mod) => {
443
- const deps = mod.effectsConfig?.loaderDeps?.({ search }) ?? [];
444
- const matchId = computeMatchId({
445
- loaderDeps: () => deps,
446
- params,
447
- routeId: mod.virtualPath,
448
- search,
449
- });
642
+ const current = ctx?.matches() ?? [];
643
+ const paramsUnchanged = sameRouteParams(ctx?.params() ?? {}, params);
644
+ return allModules.map((mod, i) => {
645
+ const matchId = matchIdForModule(mod, search, params);
450
646
  const cached = ctx?.matchCache.get(matchId);
451
647
  const error = cached?.error instanceof Error ? cached.error : undefined;
452
648
 
453
- return {
649
+ const next = {
454
650
  _type: mod._type,
455
651
  error,
456
652
  errorRender: isRenderFn(mod.errorRender) ? mod.errorRender : undefined,
@@ -463,9 +659,39 @@ function buildClientMatches(
463
659
  variablePath: mod.variablePath,
464
660
  virtualPath: mod.virtualPath,
465
661
  };
662
+ /* Reuse the previous object when the route slot is unchanged so
663
+ Outlet <Show keyed when={match()}> does not remount (local page
664
+ signals). Param changes are a new page instance — render bodies
665
+ that snapshot loaderData (translators, query keys) must re-run.
666
+ Search-only updates keep identity and refresh via matchesEpoch.
667
+ Error identity must change — Errored stays in fallback if the same
668
+ object is mutated from error to success. */
669
+ const prev = current[i];
670
+ if (
671
+ prev &&
672
+ paramsUnchanged &&
673
+ prev.virtualPath === next.virtualPath &&
674
+ prev._type === next._type &&
675
+ prev.error === next.error
676
+ ) {
677
+ Object.assign(prev, next);
678
+ return prev;
679
+ }
680
+ return next;
466
681
  });
467
682
  }
468
683
 
684
+ function assignMatches(c: FlareProviderContext, next: ReturnType<FlareProviderContext["matches"]>): void {
685
+ const current = c.matches();
686
+ if (next.length === current.length && next.length > 0 && next.every((m, i) => m === current[i])) {
687
+ /* Reused objects: do not write matches (keeps Outlet from remounting
688
+ * local page signals). Tick matchesEpoch so loader/t hooks re-read. */
689
+ c.touchMatches?.();
690
+ return;
691
+ }
692
+ c.setMatches(next);
693
+ }
694
+
469
695
  /** Apply view transition with VT API, or call update() directly as fallback.
470
696
  * Returns a promise that resolves after update() has executed so navigate()
471
697
  * callers can rely on state being settled when the promise resolves.
@@ -585,14 +811,15 @@ export async function navigate(options: InternalNavigateOptions, redirectCount =
585
811
  const c = ctx;
586
812
 
587
813
  /* Step 0: Check SPA blocker (skip for popstate — browser already changed URL) */
588
- if (activeBlockerFn && !options._popstate && !options._bypassBlocker && activeBlockerFn()) {
589
- pendingNavigation = options;
590
- /* Defer to avoid re-entrant navigation from SolidJS reactive effects */
591
- if (onBlockedCallback) {
592
- const cb = onBlockedCallback;
814
+ if (!options._popstate && !options._bypassBlocker) {
815
+ const blocker = findBlocking();
816
+ if (blocker) {
817
+ pendingNavigation = options;
818
+ /* Defer to avoid re-entrant navigation from SolidJS reactive effects */
819
+ const cb = blocker.onBlocked;
593
820
  queueMicrotask(() => cb());
821
+ return;
594
822
  }
595
- return;
596
823
  }
597
824
 
598
825
  /* Step 1: Resolve URL + same-URL guard */
@@ -601,6 +828,7 @@ export async function navigate(options: InternalNavigateOptions, redirectCount =
601
828
 
602
829
  /* Step 2: Abort previous + set navigating */
603
830
  if (currentController) currentController.abort();
831
+ rejectDeferredResume(new DOMException("The operation was aborted", "AbortError"));
604
832
  const controller = new AbortController();
605
833
  currentController = controller;
606
834
  navigationVersion++;
@@ -642,13 +870,12 @@ export async function navigate(options: InternalNavigateOptions, redirectCount =
642
870
  handleHistoryUpdate(options, url, partial.params);
643
871
  }
644
872
 
645
- batch(() => {
646
- c.setNotFound(false);
647
- c.setMatches(clientMatches);
648
- c.setParams(partial.params);
649
- c.setSearch(search);
650
- stopNavigation();
651
- });
873
+ c.setNotFound(false);
874
+ c.setMatches(clientMatches);
875
+ c.setParams(partial.params);
876
+ c.setSearch(search);
877
+ stopNavigation();
878
+
652
879
  syncLocale(partial.params);
653
880
  applyPerRouteHeads([{ head: { title: "Not Found" }, matchId: `${partial.route.x}/__notfound` }]);
654
881
  return;
@@ -660,13 +887,12 @@ export async function navigate(options: InternalNavigateOptions, redirectCount =
660
887
  handleHistoryUpdate(options, url, {});
661
888
  }
662
889
 
663
- batch(() => {
664
- c.setNotFound(true);
665
- c.setMatches([]);
666
- c.setParams({});
667
- c.setSearch(parseSearchParams(url.searchParams));
668
- stopNavigation();
669
- });
890
+ c.setNotFound(true);
891
+ c.setMatches([]);
892
+ c.setParams({});
893
+ c.setSearch(parseSearchParams(url.searchParams));
894
+ stopNavigation();
895
+
670
896
  applyPerRouteHeads([{ head: { title: "Not Found" }, matchId: "__notfound" }]);
671
897
  return;
672
898
  }
@@ -692,8 +918,20 @@ export async function navigate(options: InternalNavigateOptions, redirectCount =
692
918
  * matches one of the "from" routes, render as overlay instead of full navigation. */
693
919
  const interceptConfig = match.route.o.intercept;
694
920
  if (interceptConfig && !options._popstate && !options.revalidate) {
695
- const currentVarPath = ctx.location().variablePath;
696
- if (interceptConfig.from.includes(currentVarPath)) {
921
+ const loc = ctx.location();
922
+ /* window.location is the overlay URL while loc is the frozen background. */
923
+ const displayed =
924
+ typeof window !== "undefined"
925
+ ? {
926
+ pathname: window.location.pathname,
927
+ search: parseSearchParams(new URLSearchParams(window.location.search)),
928
+ }
929
+ : loc;
930
+ if (
931
+ !samePathAndSearch(url, loc) &&
932
+ !samePathAndSearch(url, displayed) &&
933
+ interceptConfig.from.includes(loc.variablePath)
934
+ ) {
697
935
  try {
698
936
  /* Load modules + fetch data for the intercepted target */
699
937
  const modules = await loadRouteModules(matchPath, ctx.routeTree, ctx.layouts);
@@ -709,17 +947,24 @@ export async function navigate(options: InternalNavigateOptions, redirectCount =
709
947
 
710
948
  /* Update matchCache with fetched data */
711
949
  const now = Date.now();
950
+ const interceptTags = resolveCacheTags(modules.page.cache, modules.params);
951
+ const interceptHeads = new Map(fetchResult.perRouteHeads.map((h) => [h.matchId, h.head]));
712
952
  for (const m of fetchResult.matches) {
713
953
  ctx.matchCache.set({
714
954
  data: m.loaderData,
715
955
  error: m.error,
716
956
  hasDeferred: m.hasDeferredMarkers,
957
+ headConfig: interceptHeads.get(m.matchId),
717
958
  invalid: false,
718
959
  matchId: m.matchId,
719
960
  preloaderContext: m.preloaderContext,
961
+ tags: interceptTags,
720
962
  updatedAt: now,
721
963
  });
722
964
  }
965
+ if (fetchResult.perRouteHeads.length > 0) {
966
+ applyPerRouteHeads(fetchResult.perRouteHeads);
967
+ }
723
968
 
724
969
  /* Build single ClientMatch for the intercepted page */
725
970
  const allModules: LoadedRouteModule[] = [modules.page];
@@ -749,10 +994,10 @@ export async function navigate(options: InternalNavigateOptions, redirectCount =
749
994
  if (typeof history !== "undefined") history.back();
750
995
  },
751
996
  match: interceptedMatch,
997
+ params: modules.params,
752
998
  render: interceptConfig.render,
999
+ search,
753
1000
  });
754
- c.setParams(modules.params);
755
- c.setSearch(search);
756
1001
  syncLocale(modules.params);
757
1002
  }
758
1003
 
@@ -765,27 +1010,39 @@ export async function navigate(options: InternalNavigateOptions, redirectCount =
765
1010
  }
766
1011
  }
767
1012
 
1013
+ const previousPathname = typeof window !== "undefined" ? window.location.pathname : "/";
1014
+ const previousSearch = typeof window !== "undefined" ? window.location.search : "";
1015
+ const previousHash = typeof window !== "undefined" ? window.location.hash : "";
1016
+ const previousIndex = getHistoryIndex();
1017
+ const previousKey = currentHistoryKey;
1018
+ const previousParams = ctx.location().params;
1019
+
768
1020
  /* Step 4: Save scroll + update history (skip for popstate — handled in listener) */
769
1021
  if (!options._popstate) {
770
1022
  handleHistoryUpdate(options, url, match.params);
771
1023
  }
772
1024
 
773
1025
  /* Step 4b: Hash-only change — skip loaders, just update hash + scroll.
774
- * Compare against ctx.location() since Step 4 already updated window.location.
775
- * Skip for popstate — browser already updated window.location, so ctx.location()
776
- * reads the new URL, making the comparison always match (false positive).
1026
+ * Compare only to the pre-Step-4 window URL. FlareProvider.location()
1027
+ * reads pathname from window, which handleHistoryUpdate already moved,
1028
+ * so using loc here treats every same-search path change as hash-only.
1029
+ * Overlay hash-only still matches: previousPathname is the overlay URL.
1030
+ * Skip for popstate — browser already updated window.location.
777
1031
  * Skip when revalidate is set — caller explicitly wants loaders to re-run. */
778
1032
  if (!options._popstate && !options.revalidate) {
779
- const loc = ctx.location();
780
- const currentSearch = serializeSearchParams(loc.search);
781
- if (url.pathname === loc.pathname && url.search === currentSearch) {
1033
+ const previous = {
1034
+ pathname: previousPathname,
1035
+ search: parseSearchParams(new URLSearchParams(previousSearch)),
1036
+ };
1037
+ if (samePathAndSearch(url, previous)) {
782
1038
  const search = parseSearchParams(url.searchParams);
783
- batch(() => {
1039
+
1040
+ if (!c.intercepted()) {
784
1041
  c.setIntercepted(null);
785
1042
  c.setParams(match.params);
786
1043
  c.setSearch(search);
787
- });
788
- syncLocale(match.params);
1044
+ syncLocale(match.params);
1045
+ }
789
1046
  if (url.hash) {
790
1047
  const el = typeof document !== "undefined" ? document.getElementById(url.hash.slice(1)) : null;
791
1048
  if (el) {
@@ -839,16 +1096,21 @@ export async function navigate(options: InternalNavigateOptions, redirectCount =
839
1096
  warn("nav", `shallow validation failed: ${e instanceof Error ? e.message : String(e)}`);
840
1097
  }
841
1098
 
842
- batch(() => {
843
- c.setParams(validatedParams);
844
- c.setSearch(validatedSearch);
845
- stopNavigation();
846
- });
1099
+ if (controller.signal.aborted || myVersion !== navigationVersion) return;
1100
+
1101
+ c.setParams(validatedParams);
1102
+ c.setSearch(validatedSearch);
1103
+ stopNavigation();
1104
+
847
1105
  syncLocale(validatedParams);
848
1106
  return;
849
1107
  }
850
1108
  }
851
1109
 
1110
+ let paintedShell = false;
1111
+ let paintedModules: LoadedRouteModule[] | undefined;
1112
+ let paintedSearch: SearchParams | undefined;
1113
+ let paintedParams: Record<string, string | string[]> | undefined;
852
1114
  try {
853
1115
  /* Step 6: Load route modules */
854
1116
  const isNewRoute = !visitedRoutes.has(match.route.x);
@@ -880,18 +1142,57 @@ export async function navigate(options: InternalNavigateOptions, redirectCount =
880
1142
  const nonRootLayouts = modules.layouts.filter((m) => m._type !== "root-layout");
881
1143
  const allModules: LoadedRouteModule[] = [...nonRootLayouts, modules.page];
882
1144
 
1145
+ /* Instant navigation: reuse in-flight prefetch and paint a cached shell
1146
+ * before the enter NDJSON hop. Skipped when this nav already fetched in
1147
+ * parallel (first visit, no cache). */
1148
+ let adoptedInflight = false;
1149
+ let hadShell = false;
1150
+ let keepMatchIds: string[] = [];
1151
+ const sequential = !fetchResult;
1152
+ if (!fetchResult) {
1153
+ const inflight = inflightPrefetch.get(url.href);
1154
+ if (inflight) {
1155
+ try {
1156
+ await inflight.promise;
1157
+ adoptedInflight = true;
1158
+ } catch {
1159
+ /* Prefetch failed — navigate fetches below */
1160
+ }
1161
+ }
1162
+ if (controller.signal.aborted || myVersion !== navigationVersion) return;
1163
+ const shell = commitCachedShell(c, allModules, search, modules.params);
1164
+ hadShell = shell.hadShell;
1165
+ paintedShell = shell.hadShell;
1166
+ if (shell.hadShell) {
1167
+ paintedModules = allModules;
1168
+ paintedSearch = search;
1169
+ paintedParams = modules.params;
1170
+ }
1171
+ keepMatchIds = shell.keepMatchIds;
1172
+ /* Restore before the next paint so back/forward does not flash at y=0
1173
+ * while the enter hop (or the post-update rAF) is still outstanding. */
1174
+ if (hadShell && scrollRestorationEnabled) {
1175
+ flush();
1176
+ if (options._restoreScroll) {
1177
+ restoreScroll(options._restoreScroll, "auto");
1178
+ } else if (options.scroll !== false) {
1179
+ if (url.hash) {
1180
+ const el = typeof document !== "undefined" ? document.getElementById(url.hash.slice(1)) : null;
1181
+ if (el) el.scrollIntoView();
1182
+ else scrollToTop();
1183
+ } else {
1184
+ scrollToTop();
1185
+ }
1186
+ }
1187
+ }
1188
+ }
1189
+
883
1190
  /* Step 7: Compute match IDs + check staleness (only for known-cached routes) */
884
1191
  if (!fetchResult) {
885
1192
  const staleMatchIds: string[] = [];
886
1193
 
887
1194
  for (const mod of allModules) {
888
- const deps = mod.effectsConfig?.loaderDeps?.({ search }) ?? [];
889
- const matchId = computeMatchId({
890
- loaderDeps: () => deps,
891
- params: modules.params,
892
- routeId: mod.virtualPath,
893
- search,
894
- });
1195
+ const matchId = matchIdForModule(mod, search, modules.params);
895
1196
 
896
1197
  const cc = mod.cache?.client;
897
1198
  const staleTime =
@@ -925,17 +1226,28 @@ export async function navigate(options: InternalNavigateOptions, redirectCount =
925
1226
  }
926
1227
  }
927
1228
 
1229
+ /* In-flight prefetch *is* the navigation fetch for non-deferred
1230
+ * shells. Deferred still needs an enter hop to stream `c` chunks. */
1231
+ const skipEnterFetch =
1232
+ adoptedInflight && !options.revalidate && !routeHasDeferredShell(allModules, search, modules.params);
1233
+
928
1234
  /* Step 8: Fetch if needed */
929
- if (staleMatchIds.length > 0) {
1235
+ if (!skipEnterFetch && staleMatchIds.length > 0) {
930
1236
  fetchResult = await fetchNDJSON({
1237
+ keepMatchIds: keepMatchIds.length > 0 ? keepMatchIds : undefined,
931
1238
  matchIds: staleMatchIds,
932
1239
  queryClient: queryClientRef,
1240
+ resolvers: keepMatchIds.length > 0 ? deferredResumeResolvers : undefined,
933
1241
  signal: controller.signal,
934
1242
  url: url.href,
935
1243
  });
936
1244
  }
937
1245
  }
938
1246
 
1247
+ if (import.meta.env.DEV && sequential && !hadShell && fetchResult && !options._popstate) {
1248
+ warn("nav", `navigation to ${url.pathname} waited on loaders with no prefetched shell`);
1249
+ }
1250
+
939
1251
  if (controller.signal.aborted || myVersion !== navigationVersion) return;
940
1252
 
941
1253
  /* Step 9: Update matchCache (includes headConfig for per-route head management) */
@@ -962,8 +1274,19 @@ export async function navigate(options: InternalNavigateOptions, redirectCount =
962
1274
  }
963
1275
 
964
1276
  const now = Date.now();
1277
+ const gcByMatchId = new Map<string, number>();
1278
+ const tagsByMatchId = new Map<string, string[]>();
1279
+ for (const mod of allModules) {
1280
+ const id = matchIdForModule(mod, search, modules.params);
1281
+ const cc = mod.cache?.client;
1282
+ if (cc !== false && cc !== undefined && cc.gcTime !== undefined) {
1283
+ gcByMatchId.set(id, parseMilliseconds(cc.gcTime));
1284
+ }
1285
+ const tags = resolveCacheTags(mod.cache, modules.params);
1286
+ if (tags) tagsByMatchId.set(id, tags);
1287
+ }
965
1288
  for (const m of fetchResult.matches) {
966
- /* x-m skip runs the route with loader stripped → null data.
1289
+ /* flare-stale skip runs the route with loader stripped → null data.
967
1290
  Do not clobber a live cache entry that already has data. */
968
1291
  const existing = ctx.matchCache.get(m.matchId);
969
1292
  if (m.loaderData == null && !m.error && existing && existing.data != null) {
@@ -974,14 +1297,26 @@ export async function navigate(options: InternalNavigateOptions, redirectCount =
974
1297
  }
975
1298
  continue;
976
1299
  }
1300
+ /* Prefetch `l` hydrated this nav — keep it, let `c` fill Await.
1301
+ * Do not keep a leftover hasDeferred entry: abort leaves dead promises. */
1302
+ if (!options.revalidate && existing && !m.error && m.keepShell) {
1303
+ const nextHead = headByMatchId.get(m.matchId);
1304
+ if (nextHead) {
1305
+ existing.headConfig = nextHead;
1306
+ ctx.matchCache.set(existing);
1307
+ }
1308
+ continue;
1309
+ }
977
1310
  ctx.matchCache.set({
978
1311
  data: m.loaderData,
979
1312
  error: m.error,
1313
+ gcTime: gcByMatchId.get(m.matchId),
980
1314
  hasDeferred: m.hasDeferredMarkers,
981
1315
  headConfig: headByMatchId.get(m.matchId),
982
1316
  invalid: false,
983
1317
  matchId: m.matchId,
984
1318
  preloaderContext: m.preloaderContext,
1319
+ tags: tagsByMatchId.get(m.matchId),
985
1320
  updatedAt: now,
986
1321
  });
987
1322
 
@@ -1007,12 +1342,45 @@ export async function navigate(options: InternalNavigateOptions, redirectCount =
1007
1342
  }
1008
1343
  }
1009
1344
 
1010
- /* Failed data fetch with no parsed matches: keep the current match
1011
- * tree. Rebuilding from cache would drop pipeline errors that were
1012
- * never written to matchCache (SSR hydrates them via errorName only). */
1345
+ /* Bare HTTP error with no NDJSON matches. 401/403 commit auth
1346
+ * boundaries; other 4xx/5xx commit a pipeline error so invalid
1347
+ * params and loader failures stay on the target URL. Network-level
1348
+ * failures (no status) revert — do not leave the address bar on a
1349
+ * path whose tree never committed. */
1013
1350
  if (fetchResult && fetchResult.success === false && fetchResult.matches.length === 0) {
1014
- stopNavigation();
1015
- return;
1351
+ const status = fetchResult.status;
1352
+ const err =
1353
+ status === 401
1354
+ ? new UnauthenticatedError()
1355
+ : status === 403
1356
+ ? new UnauthorizedError()
1357
+ : typeof status === "number" && status >= 400
1358
+ ? new Error(`Request failed (${status})`)
1359
+ : undefined;
1360
+ if (err) {
1361
+ const last = allModules.at(-1);
1362
+ if (last) {
1363
+ ctx.matchCache.set({
1364
+ data: null,
1365
+ error: err,
1366
+ hasDeferred: false,
1367
+ invalid: false,
1368
+ matchId: matchIdForModule(last, search, modules.params),
1369
+ updatedAt: Date.now(),
1370
+ });
1371
+ }
1372
+ } else {
1373
+ if (!options._popstate && !hadShell) {
1374
+ replaceHistoryState(previousPathname, previousParams, previousSearch, {
1375
+ hash: previousHash,
1376
+ historyIndex: previousIndex,
1377
+ });
1378
+ currentHistoryKey = previousKey;
1379
+ setHistoryIndex(previousIndex);
1380
+ }
1381
+ stopNavigation();
1382
+ return;
1383
+ }
1016
1384
  }
1017
1385
 
1018
1386
  /* Step 10: Build client matches */
@@ -1042,36 +1410,52 @@ export async function navigate(options: InternalNavigateOptions, redirectCount =
1042
1410
  /* Step 12: Update state + scroll + head + cleanup.
1043
1411
  * Everything inside update() so VT captures the complete state transition. */
1044
1412
  const update = () => {
1045
- batch(() => {
1046
- c.setIntercepted(null);
1047
- c.setNotFound(false);
1048
- c.setMatches(clientMatches);
1049
- c.setParams(modules.params);
1050
- c.setSearch(search);
1051
- });
1413
+ if (myVersion !== navigationVersion) return;
1414
+ c.setIntercepted(null);
1415
+ c.setNotFound(false);
1416
+ assignMatches(c, clientMatches);
1417
+ c.setParams(modules.params);
1418
+ c.setSearch(search);
1419
+
1052
1420
  syncLocale(modules.params);
1053
1421
 
1054
- /* Popstate scroll restoration — double rAF ensures SolidJS has flushed its
1055
- * reactive updates and the DOM is ready before we scroll.
1056
- * Version check prevents stale restore when a new navigation supersedes this one. */
1422
+ /* Popstate scroll restoration. A cached shell already restored in the
1423
+ * same turn as setMatches; restore again after this commit in case the
1424
+ * fetched tree changed height. Skip rAF on that path — rAF is after paint
1425
+ * and would flash y=0. No-shell popstate still waits for layout via rAF. */
1057
1426
  if (scrollRestorationEnabled && options._restoreScroll !== undefined) {
1058
- requestAnimationFrame(() => {
1427
+ if (hadShell && options._restoreScroll) {
1428
+ flush();
1429
+ restoreScroll(options._restoreScroll, "auto");
1430
+ } else {
1059
1431
  requestAnimationFrame(() => {
1060
- if (myVersion !== navigationVersion) return;
1061
- if (options._restoreScroll) {
1062
- restoreScroll(options._restoreScroll, scrollRestorationBehavior);
1063
- } else {
1064
- scrollToTop();
1065
- }
1432
+ requestAnimationFrame(() => {
1433
+ if (myVersion !== navigationVersion) return;
1434
+ if (options._restoreScroll) {
1435
+ restoreScroll(options._restoreScroll, scrollRestorationBehavior);
1436
+ } else {
1437
+ scrollToTop();
1438
+ }
1439
+ });
1066
1440
  });
1067
- });
1441
+ }
1068
1442
  } else if (options.scroll !== false) {
1069
1443
  if (url.hash) {
1070
- const el = typeof document !== "undefined" ? document.getElementById(url.hash.slice(1)) : null;
1444
+ flush();
1445
+ const id = url.hash.slice(1);
1446
+ const el = typeof document !== "undefined" ? document.getElementById(id) : null;
1071
1447
  if (el) {
1072
1448
  el.scrollIntoView();
1073
1449
  } else {
1074
- scrollToTop();
1450
+ /* Solid 2 may not have committed the new route yet. */
1451
+ requestAnimationFrame(() => {
1452
+ requestAnimationFrame(() => {
1453
+ if (myVersion !== navigationVersion) return;
1454
+ const late = typeof document !== "undefined" ? document.getElementById(id) : null;
1455
+ if (late) late.scrollIntoView();
1456
+ else scrollToTop();
1457
+ });
1458
+ });
1075
1459
  }
1076
1460
  } else {
1077
1461
  scrollToTop();
@@ -1116,7 +1500,7 @@ export async function navigate(options: InternalNavigateOptions, redirectCount =
1116
1500
  }
1117
1501
  if (isChunkLoadError(error) && typeof window !== "undefined") {
1118
1502
  try {
1119
- const key = "__flare_chunk_reload__";
1503
+ const key = STORAGE_CHUNK_RELOAD;
1120
1504
  const last = Number(sessionStorage.getItem(key) || 0);
1121
1505
  if (Date.now() - last > 10_000) {
1122
1506
  sessionStorage.setItem(key, String(Date.now()));
@@ -1124,12 +1508,25 @@ export async function navigate(options: InternalNavigateOptions, redirectCount =
1124
1508
  return;
1125
1509
  }
1126
1510
  } catch {
1127
- /* sessionStorage unavailable — reload once without guard */
1128
- window.location.reload();
1129
- return;
1511
+ /* No durable guard — a reload would loop in private mode. */
1130
1512
  }
1131
1513
  /* Already reloaded recently — don't loop, let the error propagate */
1132
1514
  }
1515
+ if (paintedShell && ctx && paintedModules && paintedSearch && paintedParams) {
1516
+ const err = error instanceof Error ? error : new Error(String(error));
1517
+ const last = paintedModules.at(-1);
1518
+ if (last) {
1519
+ ctx.matchCache.set({
1520
+ data: null,
1521
+ error: err,
1522
+ hasDeferred: false,
1523
+ invalid: false,
1524
+ matchId: matchIdForModule(last, paintedSearch, paintedParams),
1525
+ updatedAt: Date.now(),
1526
+ });
1527
+ }
1528
+ rejectDeferredResume(err);
1529
+ }
1133
1530
  stopNavigation();
1134
1531
  throw error;
1135
1532
  }
@@ -1137,7 +1534,17 @@ export async function navigate(options: InternalNavigateOptions, redirectCount =
1137
1534
 
1138
1535
  const DEFAULT_PREFETCH_STALE_TIME = 30_000;
1139
1536
 
1537
+ function matchCacheHasFreshRoute(routeId: string, staleTime: number): boolean {
1538
+ if (!ctx) return false;
1539
+ for (const entry of ctx.matchCache.getAll()) {
1540
+ if (entry.matchId !== routeId && !entry.matchId.startsWith(`${routeId}:`)) continue;
1541
+ if (!ctx.matchCache.isStale(entry.matchId, staleTime)) return true;
1542
+ }
1543
+ return false;
1544
+ }
1545
+
1140
1546
  export async function prefetch(options: {
1547
+ modulesOnly?: boolean;
1141
1548
  params?: Record<string, unknown>;
1142
1549
  search?: Record<string, unknown>;
1143
1550
  to: string;
@@ -1152,46 +1559,84 @@ export async function prefetch(options: {
1152
1559
  const match = matchRoute(ctx.routeTree, rewritePathname(url.pathname), caseSensitive, toLocaleMatch(localeConfig));
1153
1560
  if (!match) return;
1154
1561
 
1562
+ const navCtx = ctx;
1563
+ const loadMods = loadRouteModules;
1564
+ if (!navCtx || !loadMods) return;
1565
+
1566
+ /* Viewport/render: warm JS once per route. Do not NDJSON every URL. */
1567
+ if (options.modulesOnly) {
1568
+ visitedRoutes.add(match.route.x);
1569
+ try {
1570
+ await loadMods(rewritePathname(url.pathname), navCtx.routeTree, navCtx.layouts);
1571
+ } catch {
1572
+ /* Speculative */
1573
+ }
1574
+ return;
1575
+ }
1576
+
1155
1577
  const staleTime = parseMilliseconds(
1156
1578
  match?.route.o.client?.prefetchStaleTime ??
1157
1579
  ctx?.routerCacheDefaults?.prefetchStaleTime ??
1158
1580
  DEFAULT_PREFETCH_STALE_TIME,
1159
1581
  );
1160
1582
 
1161
- if (!ctx.prefetchCache.shouldPrefetch(url.href, staleTime)) return;
1583
+ const inflight = inflightPrefetch.get(url.href);
1584
+ if (inflight) {
1585
+ try {
1586
+ await inflight.promise;
1587
+ } catch {
1588
+ /* Speculative */
1589
+ }
1590
+ return;
1591
+ }
1162
1592
 
1163
- try {
1164
- const [fetchResult] = await Promise.all([
1165
- fetchNDJSON({ prefetch: true, queryClient: queryClientRef, url: url.href }),
1166
- loadRouteModules(rewritePathname(url.pathname), ctx.routeTree, ctx.layouts),
1167
- ]);
1593
+ if (!ctx.prefetchCache.shouldPrefetch(url.href, staleTime)) return;
1168
1594
 
1595
+ /* Hover/viewport prefetch must not refetch a route still inside client
1596
+ staleTime — that overwrites matchCache and looks like a cache miss. */
1597
+ const clientStaleTime = parseMilliseconds(match.route.o.client?.staleTime ?? ctx.routerCacheDefaults?.staleTime ?? 0);
1598
+ if (clientStaleTime > 0 && matchCacheHasFreshRoute(match.route.x, clientStaleTime)) {
1599
+ visitedRoutes.add(match.route.x);
1169
1600
  ctx.prefetchCache.mark(url.href);
1601
+ return;
1602
+ }
1603
+
1604
+ /* Mark visited immediately so a click during in-flight prefetch does not
1605
+ take navigate()'s parallel-fetch "new route" path. */
1606
+ visitedRoutes.add(match.route.x);
1170
1607
 
1171
- /* Mark route visited so navigate() checks staleness instead of blind re-fetch */
1172
- if (match) visitedRoutes.add(match.route.x);
1608
+ const startedAt = Date.now();
1609
+ const resultPromise = fetchNDJSON({ prefetch: true, queryClient: queryClientRef, url: url.href }).then(
1610
+ (fetchResult) => {
1611
+ applyPrefetchMatches(navCtx, fetchResult, startedAt);
1612
+ return fetchResult;
1613
+ },
1614
+ );
1615
+ inflightPrefetch.set(url.href, { promise: resultPromise, startedAt });
1173
1616
 
1174
- const headByMatchId = new Map<string, HeadConfig>();
1175
- for (const h of fetchResult.perRouteHeads) {
1176
- headByMatchId.set(h.matchId, h.head);
1617
+ try {
1618
+ const [fetchResult, modules] = await Promise.all([
1619
+ resultPromise,
1620
+ loadMods(rewritePathname(url.pathname), navCtx.routeTree, navCtx.layouts),
1621
+ ]);
1622
+ if (fetchResult.success !== false) {
1623
+ navCtx.prefetchCache.mark(url.href);
1177
1624
  }
1178
- const now = Date.now();
1179
- for (const m of fetchResult.matches) {
1180
- ctx.matchCache.set({
1181
- data: m.loaderData,
1182
- error: m.error,
1183
- hasDeferred: m.hasDeferredMarkers,
1184
- headConfig: headByMatchId.get(m.matchId),
1185
- invalid: false,
1186
- matchId: m.matchId,
1187
- preloaderContext: m.preloaderContext,
1188
- updatedAt: now,
1189
- });
1625
+ if (modules) {
1626
+ const nonRoot = modules.layouts.filter((m) => m._type !== "root-layout");
1627
+ applyMatchCacheTags(
1628
+ navCtx.matchCache,
1629
+ [...nonRoot, modules.page],
1630
+ modules.params,
1631
+ parseSearchParams(url.searchParams),
1632
+ );
1190
1633
  }
1191
1634
  } catch {
1192
1635
  /* Silently discard errors including redirects — prefetch is speculative
1193
1636
  * (hover/touch). Navigating on redirect would move the user away from
1194
1637
  * the current page before they actually click the link. */
1638
+ } finally {
1639
+ inflightPrefetch.delete(url.href);
1195
1640
  }
1196
1641
  }
1197
1642
 
@@ -1219,8 +1664,9 @@ export function resetNavigationState(): void {
1219
1664
  deferredTracker = null;
1220
1665
  }
1221
1666
  visitedRoutes.clear();
1222
- activeBlockerFn = null;
1223
- onBlockedCallback = null;
1667
+ inflightPrefetch.clear();
1668
+ deferredResumeResolvers.clear();
1669
+ blockers.length = 0;
1224
1670
  pendingNavigation = null;
1225
1671
  if (popstateCleanup) {
1226
1672
  popstateCleanup();