@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.
- package/README.md +44 -19
- package/package.json +10 -7
- package/src/broadcast/index.ts +1 -1
- package/src/broadcast/provider.tsx +2 -2
- package/src/caches/index.ts +26 -0
- package/src/components/await.tsx +36 -39
- package/src/components/dev-error-overlay.tsx +8 -9
- package/src/components/direction-script.tsx +2 -2
- package/src/components/locale-script.tsx +2 -2
- package/src/components/reset-css.tsx +1 -1
- package/src/components/ssr-context.tsx +7 -14
- package/src/components/theme-script.tsx +2 -2
- package/src/components/view-transition-css.tsx +1 -1
- package/src/defer/index.ts +22 -0
- package/src/direction/index.tsx +61 -36
- package/src/errors/index.ts +45 -11
- package/src/fonts/component.tsx +2 -1
- package/src/form/index.tsx +18 -12
- package/src/generators/index.ts +73 -30
- package/src/head-client/index.ts +7 -0
- package/src/history/index.ts +6 -0
- package/src/hydrate/index.tsx +90 -60
- package/src/hydration/index.ts +26 -6
- package/src/i18n/index.ts +11 -2
- package/src/image/index.tsx +7 -4
- package/src/intercept-outlet/index.tsx +6 -2
- package/src/lazy/index.tsx +50 -50
- package/src/link/index.tsx +97 -58
- package/src/loader-pipeline/index.ts +93 -35
- package/src/locale/cookie.ts +15 -0
- package/src/locale/index.tsx +24 -35
- package/src/locale.ts +1 -0
- package/src/middleware/builtins/api-proxy.ts +58 -4
- package/src/middleware/builtins/cdn-proxy.ts +24 -3
- package/src/middleware/builtins/i18n.ts +23 -25
- package/src/middleware/builtins/keepalive.ts +2 -3
- package/src/mount/index.ts +8 -1
- package/src/navigation/index.ts +575 -129
- package/src/navigation/types.ts +11 -1
- package/src/navigation-progress/index.tsx +17 -16
- package/src/ndjson-client/index.ts +42 -17
- package/src/ndjson-server/index.ts +78 -29
- package/src/outlet/index.tsx +192 -97
- package/src/outlet/types.ts +9 -1
- package/src/plugins/dev-cdn-cache.ts +18 -5
- package/src/plugins/dev-dashboard/client.tsx +130 -118
- package/src/plugins/dev-prerender.ts +6 -1
- package/src/plugins/dev-server.ts +12 -3
- package/src/plugins/image-plugin.ts +18 -4
- package/src/plugins/index.ts +10 -7
- package/src/plugins/prerender-plugin.ts +3 -3
- package/src/plugins/server-fn.ts +15 -20
- package/src/plugins/solid-jsx.d.ts +3 -3
- package/src/plugins/sx-ast/atomic.ts +5 -5
- package/src/plugins/sx-ast/index.ts +9 -0
- package/src/plugins/sx.d.ts +4 -5
- package/src/plugins/virtual.ts +11 -6
- package/src/prerender/index.ts +68 -32
- package/src/protocol.ts +65 -0
- package/src/query-client/index.tsx +73 -14
- package/src/rewrite/index.ts +10 -4
- package/src/route-builder/create-layout.ts +1 -1
- package/src/route-builder/create-root-layout.ts +1 -1
- package/src/route-builder/types.ts +5 -1
- package/src/router-primitives/types.ts +2 -0
- package/src/security/index.ts +41 -5
- package/src/server/index.ts +1 -2
- package/src/server-fn/get-input.ts +90 -0
- package/src/server-fn/http-error.ts +13 -0
- package/src/server-fn/index.ts +94 -70
- package/src/server-fn-query.ts +9 -17
- package/src/server-handler/etag.ts +4 -2
- package/src/server-handler/headers.ts +1 -2
- package/src/server-handler/index.ts +164 -56
- package/src/server-handler/types.ts +2 -0
- package/src/service-worker/template.ts +10 -42
- package/src/ssr/head-prefix.ts +2 -1
- package/src/ssr/hoist-head-markers.ts +25 -0
- package/src/ssr/index.tsx +109 -92
- package/src/state-parser/index.ts +38 -26
- package/src/styles/index.ts +89 -60
- package/src/testing/index.ts +8 -3
- package/src/theme/index.tsx +35 -32
- package/src/tracing/timing.ts +6 -1
- package/src/url/index.ts +9 -0
package/src/navigation/index.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import {
|
|
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: {
|
|
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
|
|
120
|
-
|
|
121
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
147
|
-
|
|
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
|
-
|
|
163
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
-
|
|
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 (
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
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
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
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
|
|
696
|
-
|
|
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
|
|
775
|
-
*
|
|
776
|
-
*
|
|
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
|
|
780
|
-
|
|
781
|
-
|
|
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
|
-
|
|
1039
|
+
|
|
1040
|
+
if (!c.intercepted()) {
|
|
784
1041
|
c.setIntercepted(null);
|
|
785
1042
|
c.setParams(match.params);
|
|
786
1043
|
c.setSearch(search);
|
|
787
|
-
|
|
788
|
-
|
|
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
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
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
|
|
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
|
-
/*
|
|
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
|
-
/*
|
|
1011
|
-
*
|
|
1012
|
-
*
|
|
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
|
-
|
|
1015
|
-
|
|
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
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
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
|
|
1055
|
-
*
|
|
1056
|
-
*
|
|
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
|
-
|
|
1427
|
+
if (hadShell && options._restoreScroll) {
|
|
1428
|
+
flush();
|
|
1429
|
+
restoreScroll(options._restoreScroll, "auto");
|
|
1430
|
+
} else {
|
|
1059
1431
|
requestAnimationFrame(() => {
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
/*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1172
|
-
|
|
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
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
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
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
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
|
-
|
|
1223
|
-
|
|
1667
|
+
inflightPrefetch.clear();
|
|
1668
|
+
deferredResumeResolvers.clear();
|
|
1669
|
+
blockers.length = 0;
|
|
1224
1670
|
pendingNavigation = null;
|
|
1225
1671
|
if (popstateCleanup) {
|
|
1226
1672
|
popstateCleanup();
|