vorma 0.83.0 → 0.85.0-pre.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.
@@ -59,23 +59,22 @@ export type NavigateProps = {
59
59
  scrollToTop?: boolean;
60
60
  };
61
61
 
62
- type NavigationResult =
63
- | ({
62
+ // Discriminated union for navigation outcomes -- provides exhaustiveness checking
63
+ export type NavigationOutcome =
64
+ | { type: "aborted" }
65
+ | { type: "redirect"; redirectData: RedirectData; props: NavigateProps }
66
+ | {
67
+ type: "success";
64
68
  response: Response;
69
+ json: GetRouteDataOutput;
70
+ cssBundlePromises: Array<Promise<any>>;
71
+ waitFnPromise: Promise<ClientLoadersResult> | undefined;
65
72
  props: NavigateProps;
66
- } & (
67
- | {
68
- json: GetRouteDataOutput;
69
- cssBundlePromises: Array<Promise<any>>;
70
- waitFnPromise: Promise<ClientLoadersResult> | undefined;
71
- }
72
- | { redirectData: RedirectData }
73
- ))
74
- | undefined;
73
+ };
75
74
 
76
75
  export type NavigationControl = {
77
76
  abortController: AbortController | undefined;
78
- promise: Promise<NavigationResult>;
77
+ promise: Promise<NavigationOutcome>;
79
78
  };
80
79
 
81
80
  /////////////////////////////////////////////////////////////////////
@@ -109,14 +108,183 @@ interface NavigationEntry {
109
108
  }
110
109
 
111
110
  interface SubmissionEntry {
112
- control: NavigationControl;
111
+ control: {
112
+ abortController: AbortController | undefined;
113
+ promise: Promise<any>;
114
+ };
113
115
  startTime: number;
114
116
  skipGlobalLoadingIndicator?: boolean;
115
117
  }
116
118
 
119
+ /////////////////////////////////////////////////////////////////////
120
+ // canSkipServerFetch HELPERS - extracted for readability
121
+ /////////////////////////////////////////////////////////////////////
122
+
123
+ type SkipCheckContext = {
124
+ routeManifest: Record<string, number>;
125
+ patternRegistry: any;
126
+ patternToWaitFnMap: Record<string, any>;
127
+ clientModuleMap: Record<
128
+ string,
129
+ { importURL: string; exportKey: string; errorExportKey: string }
130
+ >;
131
+ currentMatchedPatterns: string[];
132
+ currentParams: Record<string, string>;
133
+ currentSplatValues: string[];
134
+ currentLoadersData: any[];
135
+ url: URL;
136
+ matchResult: any;
137
+ };
138
+
139
+ type SkipCheckResult =
140
+ | { canSkip: false }
141
+ | {
142
+ canSkip: true;
143
+ matchResult: any;
144
+ importURLs: string[];
145
+ exportKeys: string[];
146
+ loadersData: any[];
147
+ };
148
+
149
+ function hasServerLoaderRemoval(ctx: SkipCheckContext): boolean {
150
+ for (const pattern of ctx.currentMatchedPatterns) {
151
+ const hasServerLoader = ctx.routeManifest[pattern] === 1;
152
+ if (hasServerLoader) {
153
+ const stillMatched = ctx.matchResult.matches.some(
154
+ (m: Match) => m.registeredPattern.originalPattern === pattern,
155
+ );
156
+ if (!stillMatched) {
157
+ return true;
158
+ }
159
+ }
160
+ }
161
+ return false;
162
+ }
163
+
164
+ function hasNewClientLoader(ctx: SkipCheckContext): boolean {
165
+ for (const m of ctx.matchResult.matches) {
166
+ const pattern = m.registeredPattern.originalPattern;
167
+ const hasClientLoader = !!ctx.patternToWaitFnMap[pattern];
168
+ const wasAlreadyMatched = ctx.currentMatchedPatterns.includes(pattern);
169
+ if (hasClientLoader && !wasAlreadyMatched) {
170
+ return true;
171
+ }
172
+ }
173
+ return false;
174
+ }
175
+
176
+ function findOutermostLoaderIndex(ctx: SkipCheckContext): number {
177
+ for (let i = ctx.matchResult.matches.length - 1; i >= 0; i--) {
178
+ const match: Match | undefined = ctx.matchResult.matches[i];
179
+ if (!match) continue;
180
+
181
+ const pattern = match.registeredPattern.originalPattern;
182
+ const hasServerLoader = ctx.routeManifest[pattern] === 1;
183
+ const hasClientLoader = !!ctx.patternToWaitFnMap[pattern];
184
+
185
+ if (hasServerLoader || hasClientLoader) {
186
+ return i;
187
+ }
188
+ }
189
+ return -1;
190
+ }
191
+
192
+ function didSearchParamsChange(ctx: SkipCheckContext): boolean {
193
+ const currentUrlObj = new URL(window.location.href);
194
+ const currentParamsSorted = Array.from(
195
+ currentUrlObj.searchParams.entries(),
196
+ ).sort();
197
+ const targetParamsSorted = Array.from(
198
+ ctx.url.searchParams.entries(),
199
+ ).sort();
200
+ return !jsonDeepEquals(currentParamsSorted, targetParamsSorted);
201
+ }
202
+
203
+ function didOutermostParamsChange(
204
+ ctx: SkipCheckContext,
205
+ outermostLoaderIndex: number,
206
+ ): boolean {
207
+ const outermostMatch = ctx.matchResult.matches[outermostLoaderIndex];
208
+ if (!outermostMatch) return false;
209
+
210
+ for (const seg of outermostMatch.registeredPattern.normalizedSegments) {
211
+ if (seg.segType === "dynamic") {
212
+ const paramName = seg.normalizedVal.substring(1);
213
+ if (
214
+ ctx.matchResult.params[paramName] !==
215
+ ctx.currentParams[paramName]
216
+ ) {
217
+ return true;
218
+ }
219
+ }
220
+ }
221
+
222
+ const hasSplat = outermostMatch.registeredPattern.lastSegType === "splat";
223
+ if (hasSplat) {
224
+ if (
225
+ !jsonDeepEquals(ctx.matchResult.splatValues, ctx.currentSplatValues)
226
+ ) {
227
+ return true;
228
+ }
229
+ }
230
+
231
+ return false;
232
+ }
233
+
234
+ function buildSkipResult(ctx: SkipCheckContext): SkipCheckResult {
235
+ const importURLs: string[] = [];
236
+ const exportKeys: string[] = [];
237
+ const loadersData: any[] = [];
238
+
239
+ for (let i = 0; i < ctx.matchResult.matches.length; i++) {
240
+ const match: Match | undefined = ctx.matchResult.matches[i];
241
+ if (!match) continue;
242
+
243
+ const pattern = match.registeredPattern.originalPattern;
244
+ const moduleInfo = ctx.clientModuleMap[pattern];
245
+ if (!moduleInfo) {
246
+ return { canSkip: false };
247
+ }
248
+
249
+ importURLs.push(moduleInfo.importURL);
250
+ exportKeys.push(moduleInfo.exportKey);
251
+
252
+ const hasServerLoader = ctx.routeManifest[pattern] === 1;
253
+ if (!hasServerLoader) {
254
+ loadersData.push(undefined);
255
+ } else {
256
+ const currentPatternIndex =
257
+ ctx.currentMatchedPatterns.indexOf(pattern);
258
+ if (currentPatternIndex === -1) {
259
+ return { canSkip: false };
260
+ }
261
+ loadersData.push(ctx.currentLoadersData[currentPatternIndex]);
262
+ }
263
+ }
264
+
265
+ return {
266
+ canSkip: true,
267
+ matchResult: ctx.matchResult,
268
+ importURLs,
269
+ exportKeys,
270
+ loadersData,
271
+ };
272
+ }
273
+
274
+ /////////////////////////////////////////////////////////////////////
275
+ // NAVIGATION STATE MANAGER CLASS
276
+ /////////////////////////////////////////////////////////////////////
277
+
117
278
  class NavigationStateManager {
118
- private _navigations = new Map<string, NavigationEntry>();
279
+ // Single slot for active user/browser/redirect navigation
280
+ private _activeNavigation: NavigationEntry | null = null;
281
+ // Separate cache for prefetches (can have multiple to different URLs)
282
+ private _prefetchCache = new Map<string, NavigationEntry>();
283
+ // Single slot for pending revalidation (at most one, coalesced)
284
+ private _pendingRevalidation: NavigationEntry | null = null;
285
+ // Submissions tracked separately
119
286
  private _submissions = new Map<string | symbol, SubmissionEntry>();
287
+
120
288
  private lastDispatchedStatus: StatusEventDetail | null = null;
121
289
  private dispatchStatusEventDebounced: () => void;
122
290
  private readonly REVALIDATION_COALESCE_MS = 8;
@@ -131,30 +299,67 @@ class NavigationStateManager {
131
299
  const control = this.beginNavigation(props);
132
300
 
133
301
  try {
134
- const result = await control.promise;
135
- if (!result) {
136
- return { didNavigate: false };
137
- }
302
+ const outcome = await control.promise;
303
+
304
+ // Handle based on outcome type (discriminated union)
305
+ switch (outcome.type) {
306
+ case "aborted":
307
+ return { didNavigate: false };
308
+
309
+ case "redirect": {
310
+ const targetUrl = new URL(props.href, window.location.href)
311
+ .href;
312
+ const entry = this.findNavigationEntry(targetUrl);
313
+ if (!entry) {
314
+ return { didNavigate: false };
315
+ }
138
316
 
139
- // Process based on navigation entry state
140
- const targetUrl = new URL(props.href, window.location.href).href;
141
- const entry = this._navigations.get(targetUrl);
142
- if (!entry) {
143
- return { didNavigate: false };
144
- }
317
+ // Skip redirect effectuation for pure prefetches
318
+ if (entry.type === "prefetch" && entry.intent === "none") {
319
+ this.deleteNavigation(targetUrl);
320
+ return { didNavigate: false };
321
+ }
145
322
 
146
- if (entry.intent === "navigate" || entry.intent === "revalidate") {
147
- const now = Date.now();
148
- lastTriggeredNavOrRevalidateTimestampMS = now;
149
- }
323
+ this.deleteNavigation(targetUrl);
324
+ await effectuateRedirectDataResult(
325
+ outcome.redirectData,
326
+ props.redirectCount || 0,
327
+ props,
328
+ );
329
+ return { didNavigate: false };
330
+ }
150
331
 
151
- // Always call processNavigationResult so the module map and other caches are populated.
152
- await this.processNavigationResult(result, entry);
332
+ case "success": {
333
+ const targetUrl = new URL(props.href, window.location.href)
334
+ .href;
335
+ const entry = this.findNavigationEntry(targetUrl);
336
+ if (!entry) {
337
+ return { didNavigate: false };
338
+ }
339
+
340
+ if (
341
+ entry.intent === "navigate" ||
342
+ entry.intent === "revalidate"
343
+ ) {
344
+ lastTriggeredNavOrRevalidateTimestampMS = Date.now();
345
+ }
153
346
 
154
- // After processing, if it was just a prefetch, then we can return
155
- // and signal that no UI navigation occurred.
156
- if (entry.intent === "none" && entry.type === "prefetch") {
157
- return { didNavigate: false };
347
+ await this.processSuccessfulNavigation(outcome, entry);
348
+
349
+ if (entry.intent === "none" && entry.type === "prefetch") {
350
+ return { didNavigate: false };
351
+ }
352
+
353
+ return { didNavigate: true };
354
+ }
355
+
356
+ default: {
357
+ // Exhaustiveness check - TypeScript will error if a case is missing
358
+ const _exhaustive: never = outcome;
359
+ throw new Error(
360
+ `Unexpected navigation outcome type: ${(_exhaustive as any).type}`,
361
+ );
362
+ }
158
363
  }
159
364
  } catch (error) {
160
365
  const targetUrl = new URL(props.href, window.location.href).href;
@@ -164,65 +369,107 @@ class NavigationStateManager {
164
369
  }
165
370
  return { didNavigate: false };
166
371
  }
167
- return { didNavigate: true };
168
372
  }
169
373
 
170
374
  beginNavigation(props: NavigateProps): NavigationControl {
171
- const existing = this._navigations.get(
172
- new URL(props.href, window.location.href).href,
173
- );
375
+ const targetUrl = new URL(props.href, window.location.href).href;
174
376
 
175
377
  switch (props.navigationType) {
176
378
  case "userNavigation":
177
- return this.beginUserNavigation(props, existing);
379
+ return this.beginUserNavigation(props, targetUrl);
178
380
  case "prefetch":
179
- return this.beginPrefetch(props, existing);
381
+ return this.beginPrefetch(props, targetUrl);
180
382
  case "revalidation":
181
383
  return this.beginRevalidation(props);
182
384
  case "browserHistory":
183
385
  case "redirect":
184
386
  default:
185
- return this.createNavigation(props, "navigate");
387
+ return this.createActiveNavigation(props, "navigate");
186
388
  }
187
389
  }
188
390
 
189
391
  private beginUserNavigation(
190
392
  props: NavigateProps,
191
- existing: NavigationEntry | undefined,
393
+ targetUrl: string,
192
394
  ): NavigationControl {
193
- const targetUrl = new URL(props.href, window.location.href).href;
395
+ // Abort active navigation if it's to a different URL
396
+ if (
397
+ this._activeNavigation &&
398
+ this._activeNavigation.targetUrl !== targetUrl
399
+ ) {
400
+ this._activeNavigation.control.abortController?.abort();
401
+ this._activeNavigation = null;
402
+ }
194
403
 
195
- // Abort all other navigations
196
- this.abortAllNavigationsExcept(targetUrl);
197
-
198
- if (existing) {
199
- if (existing.type === "prefetch") {
200
- // Upgrade prefetch to user navigation
201
- this.upgradeNavigation(targetUrl, {
202
- type: "userNavigation",
203
- intent: "navigate",
204
- scrollToTop: props.scrollToTop,
205
- replace: props.replace,
206
- state: props.state,
207
- });
208
- return existing.control;
404
+ // Abort all prefetches except the one we might upgrade
405
+ for (const [url, prefetch] of this._prefetchCache.entries()) {
406
+ if (url !== targetUrl) {
407
+ prefetch.control.abortController?.abort();
408
+ this._prefetchCache.delete(url);
209
409
  }
410
+ }
411
+
412
+ // Abort pending revalidation only if it's to a different URL
413
+ if (
414
+ this._pendingRevalidation &&
415
+ this._pendingRevalidation.targetUrl !== targetUrl
416
+ ) {
417
+ this._pendingRevalidation.control.abortController?.abort();
418
+ this._pendingRevalidation = null;
419
+ }
210
420
 
211
- // Already navigating to this URL, return existing
212
- return existing.control;
421
+ // Check if there's already an active navigation to this URL
422
+ if (this._activeNavigation?.targetUrl === targetUrl) {
423
+ return this._activeNavigation.control;
213
424
  }
214
425
 
215
- return this.createNavigation(props, "navigate");
426
+ // Check if there's a prefetch to upgrade
427
+ const existingPrefetch = this._prefetchCache.get(targetUrl);
428
+ if (existingPrefetch) {
429
+ // Upgrade prefetch: move from cache to active slot, change intent
430
+ this._prefetchCache.delete(targetUrl);
431
+ existingPrefetch.type = "userNavigation";
432
+ existingPrefetch.intent = "navigate";
433
+ existingPrefetch.scrollToTop = props.scrollToTop;
434
+ existingPrefetch.replace = props.replace;
435
+ existingPrefetch.state = props.state;
436
+ this._activeNavigation = existingPrefetch;
437
+ this.scheduleStatusUpdate();
438
+ return existingPrefetch.control;
439
+ }
440
+
441
+ // Check if there's a pending revalidation to the same URL - upgrade it
442
+ if (this._pendingRevalidation?.targetUrl === targetUrl) {
443
+ // Upgrade revalidation: change intent so user gets proper link semantics
444
+ this._pendingRevalidation.type = "userNavigation";
445
+ this._pendingRevalidation.intent = "navigate";
446
+ this._pendingRevalidation.scrollToTop = props.scrollToTop;
447
+ this._pendingRevalidation.replace = props.replace;
448
+ this._pendingRevalidation.state = props.state;
449
+ return this._pendingRevalidation.control;
450
+ }
451
+
452
+ return this.createActiveNavigation(props, "navigate");
216
453
  }
217
454
 
218
455
  private beginPrefetch(
219
456
  props: NavigateProps,
220
- existing: NavigationEntry | undefined,
457
+ targetUrl: string,
221
458
  ): NavigationControl {
222
- const targetUrl = new URL(props.href, window.location.href).href;
459
+ // If there's already an active navigation to this URL, return its control
460
+ if (this._activeNavigation?.targetUrl === targetUrl) {
461
+ return this._activeNavigation.control;
462
+ }
463
+
464
+ // If there's already a prefetch to this URL, return its control
465
+ const existingPrefetch = this._prefetchCache.get(targetUrl);
466
+ if (existingPrefetch) {
467
+ return existingPrefetch.control;
468
+ }
223
469
 
224
- if (existing) {
225
- return existing.control;
470
+ // If there's a pending revalidation to this URL, return its control
471
+ if (this._pendingRevalidation?.targetUrl === targetUrl) {
472
+ return this._pendingRevalidation.control;
226
473
  }
227
474
 
228
475
  // Don't prefetch current page
@@ -231,45 +478,37 @@ class NavigationStateManager {
231
478
  currentUrl.hash = "";
232
479
  targetUrlObj.hash = "";
233
480
  if (currentUrl.href === targetUrlObj.href) {
234
- // Return a no-op control
235
481
  return {
236
482
  abortController: new AbortController(),
237
- promise: Promise.resolve(undefined),
483
+ promise: Promise.resolve({ type: "aborted" as const }),
238
484
  };
239
485
  }
240
486
 
241
- return this.createNavigation(props, "none");
487
+ return this.createPrefetch(props, targetUrl);
242
488
  }
243
489
 
244
490
  private beginRevalidation(props: NavigateProps): NavigationControl {
245
- // Store current URL to validate against later
246
491
  const currentUrl = window.location.href;
247
492
 
248
- // Check for recent revalidation to same URL
249
- const existing = this._navigations.get(currentUrl);
493
+ // Coalesce recent revalidations
250
494
  if (
251
- existing?.type === "revalidation" &&
252
- Date.now() - existing.startTime < this.REVALIDATION_COALESCE_MS
495
+ this._pendingRevalidation &&
496
+ Date.now() - this._pendingRevalidation.startTime <
497
+ this.REVALIDATION_COALESCE_MS
253
498
  ) {
254
- return existing.control;
499
+ return this._pendingRevalidation.control;
255
500
  }
256
501
 
257
- // Abort other revalidations
258
- for (const [key, nav] of this._navigations.entries()) {
259
- if (nav.type === "revalidation") {
260
- nav.control.abortController?.abort();
261
- this.deleteNavigation(key);
262
- }
502
+ // Abort existing revalidation
503
+ if (this._pendingRevalidation) {
504
+ this._pendingRevalidation.control.abortController?.abort();
505
+ this._pendingRevalidation = null;
263
506
  }
264
507
 
265
- // Create revalidation with current URL
266
- return this.createNavigation(
267
- { ...props, href: currentUrl },
268
- "revalidate",
269
- );
508
+ return this.createRevalidation({ ...props, href: currentUrl });
270
509
  }
271
510
 
272
- private createNavigation(
511
+ private createActiveNavigation(
273
512
  props: NavigateProps,
274
513
  intent: NavigationIntent,
275
514
  ): NavigationControl {
@@ -297,206 +536,168 @@ class NavigationStateManager {
297
536
  state: props.state,
298
537
  };
299
538
 
300
- this.setNavigation(targetUrl, entry);
539
+ this._activeNavigation = entry;
540
+ this.scheduleStatusUpdate();
301
541
  return entry.control;
302
542
  }
303
543
 
304
- private upgradeNavigation(
305
- href: string,
306
- updates: Partial<
307
- Pick<
308
- NavigationEntry,
309
- "type" | "intent" | "scrollToTop" | "replace" | "state"
310
- >
311
- >,
312
- ): void {
313
- const existing = this._navigations.get(href);
314
- if (!existing) return;
315
-
316
- this.setNavigation(href, {
317
- ...existing,
318
- ...updates,
319
- });
544
+ private createPrefetch(
545
+ props: NavigateProps,
546
+ targetUrl: string,
547
+ ): NavigationControl {
548
+ const controller = new AbortController();
549
+
550
+ const entry: NavigationEntry = {
551
+ control: {
552
+ abortController: controller,
553
+ promise: this.fetchRouteData(controller, props).catch(
554
+ (error) => {
555
+ this._prefetchCache.delete(targetUrl);
556
+ throw error;
557
+ },
558
+ ),
559
+ },
560
+ type: "prefetch",
561
+ intent: "none",
562
+ phase: "fetching",
563
+ startTime: Date.now(),
564
+ targetUrl,
565
+ originUrl: window.location.href,
566
+ scrollToTop: props.scrollToTop,
567
+ replace: props.replace,
568
+ state: props.state,
569
+ };
570
+
571
+ this._prefetchCache.set(targetUrl, entry);
572
+ // No status update needed - prefetches don't affect status
573
+ return entry.control;
320
574
  }
321
575
 
322
- private transitionPhase(href: string, phase: NavigationPhase): void {
323
- const existing = this._navigations.get(href);
324
- if (!existing) return;
576
+ private createRevalidation(props: NavigateProps): NavigationControl {
577
+ const controller = new AbortController();
578
+ const targetUrl = new URL(props.href, window.location.href).href;
325
579
 
326
- this.setNavigation(href, {
327
- ...existing,
328
- phase,
329
- });
580
+ const entry: NavigationEntry = {
581
+ control: {
582
+ abortController: controller,
583
+ promise: this.fetchRouteData(controller, props).catch(
584
+ (error) => {
585
+ if (
586
+ this._pendingRevalidation?.targetUrl === targetUrl
587
+ ) {
588
+ this._pendingRevalidation = null;
589
+ this.scheduleStatusUpdate();
590
+ }
591
+ throw error;
592
+ },
593
+ ),
594
+ },
595
+ type: "revalidation",
596
+ intent: "revalidate",
597
+ phase: "fetching",
598
+ startTime: Date.now(),
599
+ targetUrl,
600
+ originUrl: window.location.href,
601
+ scrollToTop: props.scrollToTop,
602
+ replace: props.replace,
603
+ state: props.state,
604
+ };
605
+
606
+ this._pendingRevalidation = entry;
607
+ this.scheduleStatusUpdate();
608
+ return entry.control;
330
609
  }
331
610
 
332
- private canSkipServerFetch(targetUrl: string): {
333
- canSkip: boolean;
334
- matchResult?: any;
335
- importURLs?: string[];
336
- exportKeys?: string[];
337
- loadersData?: any[];
338
- } {
611
+ private transitionPhase(targetUrl: string, phase: NavigationPhase): void {
612
+ if (this._activeNavigation?.targetUrl === targetUrl) {
613
+ this._activeNavigation.phase = phase;
614
+ this.scheduleStatusUpdate();
615
+ return;
616
+ }
617
+
618
+ const prefetch = this._prefetchCache.get(targetUrl);
619
+ if (prefetch) {
620
+ prefetch.phase = phase;
621
+ // No status update for prefetches
622
+ return;
623
+ }
624
+
625
+ if (this._pendingRevalidation?.targetUrl === targetUrl) {
626
+ this._pendingRevalidation.phase = phase;
627
+ this.scheduleStatusUpdate();
628
+ }
629
+ }
630
+
631
+ private canSkipServerFetch(targetUrl: string): SkipCheckResult {
632
+ // Early return: no route manifest
339
633
  const routeManifest = __vormaClientGlobal.get("routeManifest");
340
634
  if (!routeManifest) {
341
635
  return { canSkip: false };
342
636
  }
343
637
 
638
+ // Early return: no pattern registry
344
639
  const patternRegistry = __vormaClientGlobal.get("patternRegistry");
345
640
  if (!patternRegistry) {
346
641
  return { canSkip: false };
347
642
  }
348
643
 
349
- const patternToWaitFnMap =
350
- __vormaClientGlobal.get("patternToWaitFnMap") || {};
351
-
644
+ // Early return: no match
352
645
  const url = new URL(targetUrl);
353
646
  const matchResult = findNestedMatches(patternRegistry, url.pathname);
354
647
  if (!matchResult) {
355
648
  return { canSkip: false };
356
649
  }
357
650
 
358
- const clientModuleMap =
359
- __vormaClientGlobal.get("clientModuleMap") || {};
360
- const currentMatchedPatterns =
361
- __vormaClientGlobal.get("matchedPatterns") || [];
362
- const currentParams = __vormaClientGlobal.get("params") || {};
363
- const currentSplatValues = __vormaClientGlobal.get("splatValues") || [];
364
- const currentLoadersData = __vormaClientGlobal.get("loadersData") || [];
365
-
366
- // Check if any current server loaders are being removed
367
- for (const pattern of currentMatchedPatterns) {
368
- const hasServerLoader = routeManifest[pattern] === 1;
369
- if (hasServerLoader) {
370
- const stillMatched = matchResult.matches.some(
371
- (m: any) => m.registeredPattern.originalPattern === pattern,
372
- );
373
- if (!stillMatched) {
374
- // A server loader is being removed - must fetch from server
375
- return { canSkip: false };
376
- }
377
- }
378
- }
379
-
380
- // Block skip if the target introduces a new client loader
381
- for (const m of matchResult.matches) {
382
- const pattern = m.registeredPattern.originalPattern;
383
- const hasClientLoader = !!patternToWaitFnMap[pattern];
384
- const wasAlreadyMatched = currentMatchedPatterns.includes(pattern);
385
- if (hasClientLoader && !wasAlreadyMatched) {
386
- return { canSkip: false };
387
- }
388
- }
389
-
390
- let outermostLoaderIndex = -1;
391
- for (let i = matchResult.matches.length - 1; i >= 0; i--) {
392
- const match: Match | undefined = matchResult.matches[i];
393
- if (!match) continue;
394
-
395
- const pattern = match.registeredPattern.originalPattern;
396
- const hasServerLoader = routeManifest[pattern] === 1;
397
- const hasClientLoader = !!patternToWaitFnMap[pattern];
651
+ // Build context for helper functions
652
+ const ctx: SkipCheckContext = {
653
+ routeManifest,
654
+ patternRegistry,
655
+ patternToWaitFnMap:
656
+ __vormaClientGlobal.get("patternToWaitFnMap") || {},
657
+ clientModuleMap: __vormaClientGlobal.get("clientModuleMap") || {},
658
+ currentMatchedPatterns:
659
+ __vormaClientGlobal.get("matchedPatterns") || [],
660
+ currentParams: __vormaClientGlobal.get("params") || {},
661
+ currentSplatValues: __vormaClientGlobal.get("splatValues") || [],
662
+ currentLoadersData: __vormaClientGlobal.get("loadersData") || [],
663
+ url,
664
+ matchResult,
665
+ };
398
666
 
399
- if (hasServerLoader || hasClientLoader) {
400
- outermostLoaderIndex = i;
401
- break;
402
- }
667
+ // Early return: server loader being removed
668
+ if (hasServerLoaderRemoval(ctx)) {
669
+ return { canSkip: false };
403
670
  }
404
671
 
405
- const currentUrlObj = new URL(window.location.href);
406
- const currentParamsSorted = Array.from(
407
- currentUrlObj.searchParams.entries(),
408
- ).sort();
409
- const targetParamsSorted = Array.from(
410
- url.searchParams.entries(),
411
- ).sort();
412
- const searchChanged = !jsonDeepEquals(
413
- currentParamsSorted,
414
- targetParamsSorted,
415
- );
416
-
417
- if (searchChanged && outermostLoaderIndex !== -1) {
672
+ // Early return: new client loader introduced
673
+ if (hasNewClientLoader(ctx)) {
418
674
  return { canSkip: false };
419
675
  }
420
676
 
421
- if (outermostLoaderIndex !== -1) {
422
- const outermostMatch = matchResult.matches[outermostLoaderIndex];
423
- if (outermostMatch) {
424
- for (const seg of outermostMatch.registeredPattern
425
- .normalizedSegments) {
426
- if (seg.segType === "dynamic") {
427
- const paramName = seg.normalizedVal.substring(1);
428
- if (
429
- matchResult.params[paramName] !==
430
- currentParams[paramName]
431
- ) {
432
- return { canSkip: false };
433
- }
434
- }
435
- }
436
-
437
- const hasSplat =
438
- outermostMatch.registeredPattern.lastSegType === "splat";
677
+ // Find outermost loader index for param/search change checks
678
+ const outermostLoaderIndex = findOutermostLoaderIndex(ctx);
439
679
 
440
- if (hasSplat) {
441
- if (
442
- !jsonDeepEquals(
443
- matchResult.splatValues,
444
- currentSplatValues,
445
- )
446
- ) {
447
- return { canSkip: false };
448
- }
449
- }
450
- }
680
+ // Early return: search params changed with loaders present
681
+ if (outermostLoaderIndex !== -1 && didSearchParamsChange(ctx)) {
682
+ return { canSkip: false };
451
683
  }
452
684
 
453
- const importURLs: string[] = [];
454
- const exportKeys: string[] = [];
455
- const loadersData: any[] = [];
456
-
457
- for (let i = 0; i < matchResult.matches.length; i++) {
458
- const match: Match | undefined = matchResult.matches[i];
459
- if (!match) continue;
460
-
461
- const pattern = match.registeredPattern.originalPattern;
462
-
463
- const moduleInfo = clientModuleMap[pattern];
464
- if (!moduleInfo) {
465
- return { canSkip: false };
466
- }
467
-
468
- importURLs.push(moduleInfo.importURL);
469
- exportKeys.push(moduleInfo.exportKey);
470
-
471
- const hasServerLoader = routeManifest[pattern] === 1;
472
-
473
- if (!hasServerLoader) {
474
- loadersData.push(undefined);
475
- } else {
476
- const currentPatternIndex =
477
- currentMatchedPatterns.indexOf(pattern);
478
-
479
- if (currentPatternIndex === -1) {
480
- // New server loader that we don't have data for
481
- return { canSkip: false };
482
- }
483
- loadersData.push(currentLoadersData[currentPatternIndex]);
484
- }
685
+ // Early return: outermost loader params changed
686
+ if (
687
+ outermostLoaderIndex !== -1 &&
688
+ didOutermostParamsChange(ctx, outermostLoaderIndex)
689
+ ) {
690
+ return { canSkip: false };
485
691
  }
486
692
 
487
- return {
488
- canSkip: true,
489
- matchResult,
490
- importURLs,
491
- exportKeys,
492
- loadersData,
493
- };
693
+ // Build and return skip result
694
+ return buildSkipResult(ctx);
494
695
  }
495
696
 
496
697
  private async fetchRouteData(
497
698
  controller: AbortController,
498
699
  props: NavigateProps,
499
- ): Promise<NavigationResult> {
700
+ ): Promise<NavigationOutcome> {
500
701
  try {
501
702
  const url = new URL(props.href, window.location.href);
502
703
 
@@ -507,91 +708,12 @@ class NavigationStateManager {
507
708
  ) {
508
709
  const skipCheck = this.canSkipServerFetch(url.href);
509
710
 
510
- if (skipCheck.canSkip && skipCheck.matchResult) {
511
- // We can use client-only navigation
512
- const { importURLs, exportKeys, loadersData } = skipCheck;
513
-
514
- // Build the response as if it came from the server
515
- const json: GetRouteDataOutput = {
516
- matchedPatterns: skipCheck.matchResult.matches.map(
517
- (m: any) => m.registeredPattern.originalPattern,
518
- ),
519
- loadersData: loadersData!,
520
- importURLs: importURLs!,
521
- exportKeys: exportKeys!,
522
- hasRootData: __vormaClientGlobal.get("hasRootData"),
523
- params: skipCheck.matchResult.params,
524
- splatValues: skipCheck.matchResult.splatValues,
525
- deps: [],
526
- cssBundles: [],
527
- outermostServerError: undefined,
528
- outermostServerErrorIdx: undefined,
529
- errorExportKeys: [],
530
- title: undefined,
531
- metaHeadEls: undefined,
532
- restHeadEls: undefined,
533
- activeComponents: undefined as unknown as [],
534
- };
535
-
536
- // Create a response object
537
- const response = new Response(JSON.stringify(json), {
538
- status: 200,
539
- headers: {
540
- "Content-Type": "application/json",
541
- "X-Vorma-Build-Id":
542
- __vormaClientGlobal.get("buildID") || "1",
543
- },
544
- });
545
-
546
- const currentClientLoadersData =
547
- __vormaClientGlobal.get("clientLoadersData") || [];
548
- const patternToWaitFnMap =
549
- __vormaClientGlobal.get("patternToWaitFnMap") || {};
550
- const runningLoaders = new Map<string, Promise<any>>();
551
-
552
- for (let i = 0; i < json.matchedPatterns.length; i++) {
553
- const pattern = json.matchedPatterns[i];
554
- if (!pattern) continue;
555
-
556
- if (patternToWaitFnMap[pattern]) {
557
- const currentMatchedPatterns =
558
- __vormaClientGlobal.get("matchedPatterns") ||
559
- [];
560
- const currentPatternIndex =
561
- currentMatchedPatterns.indexOf(pattern);
562
-
563
- if (
564
- currentPatternIndex !== -1 &&
565
- currentClientLoadersData[
566
- currentPatternIndex
567
- ] !== undefined
568
- ) {
569
- runningLoaders.set(
570
- pattern,
571
- Promise.resolve(
572
- currentClientLoadersData[
573
- currentPatternIndex
574
- ],
575
- ),
576
- );
577
- }
578
- }
579
- }
580
-
581
- const waitFnPromise = completeClientLoaders(
582
- json,
583
- __vormaClientGlobal.get("buildID") || "1",
584
- runningLoaders,
585
- controller.signal,
586
- );
587
-
588
- return {
589
- response,
711
+ if (skipCheck.canSkip) {
712
+ return this.buildClientOnlyOutcome(
713
+ skipCheck,
590
714
  props,
591
- json,
592
- cssBundlePromises: [],
593
- waitFnPromise,
594
- };
715
+ controller,
716
+ );
595
717
  }
596
718
  }
597
719
 
@@ -614,7 +736,6 @@ class NavigationStateManager {
614
736
  isPrefetch: props.navigationType === "prefetch",
615
737
  redirectCount: props.redirectCount,
616
738
  }).then(async (result) => {
617
- // Read the response body once and return both the original result and parsed JSON
618
739
  if (
619
740
  result.response &&
620
741
  result.response.ok &&
@@ -645,7 +766,6 @@ class NavigationStateManager {
645
766
  const loaderFn = patternToWaitFnMap[pattern];
646
767
 
647
768
  if (loaderFn) {
648
- // Create a promise for this pattern's server data
649
769
  const serverDataPromise = serverPromise
650
770
  .then(
651
771
  ({
@@ -707,22 +827,18 @@ class NavigationStateManager {
707
827
  const responseNotOK = !response?.ok && response?.status !== 304;
708
828
 
709
829
  if (redirected || !response) {
710
- // This is a valid end to a navigation attempt (e.g., a redirect occurred
711
- // or the request was aborted). It's not an error.
712
830
  controller.abort();
713
- return undefined;
831
+ return { type: "aborted" };
714
832
  }
715
833
 
716
834
  if (responseNotOK) {
717
- // This is a server error. Throwing an exception allows our .catch()
718
- // blocks to handle cleanup and reset the loading state.
719
835
  controller.abort();
720
836
  throw new Error(`Fetch failed with status ${response.status}`);
721
837
  }
722
838
 
723
839
  if (redirectData?.status === "should") {
724
840
  controller.abort();
725
- return { response, redirectData, props };
841
+ return { type: "redirect", redirectData, props };
726
842
  }
727
843
 
728
844
  if (!json) {
@@ -731,9 +847,6 @@ class NavigationStateManager {
731
847
  }
732
848
 
733
849
  // deps are only present in prod because they stem from the rollup metafile
734
- // (same for CSS bundles -- vite handles them in dev)
735
- // so in dev, to get similar behavior, we use the importURLs
736
- // (which is a subset of what the deps would be in prod)
737
850
  const depsToPreload = import.meta.env.DEV
738
851
  ? [...new Set(json.importURLs)]
739
852
  : json.deps;
@@ -756,7 +869,14 @@ class NavigationStateManager {
756
869
  cssBundlePromises.push(AssetManager.preloadCSS(bundle));
757
870
  }
758
871
 
759
- return { response, json, props, cssBundlePromises, waitFnPromise };
872
+ return {
873
+ type: "success",
874
+ response,
875
+ json,
876
+ props,
877
+ cssBundlePromises,
878
+ waitFnPromise,
879
+ };
760
880
  } catch (error) {
761
881
  if (!isAbortError(error)) {
762
882
  logError("Navigation failed", error);
@@ -765,49 +885,109 @@ class NavigationStateManager {
765
885
  }
766
886
  }
767
887
 
768
- private async processNavigationResult(
769
- result: NavigationResult,
770
- entry: NavigationEntry,
771
- ): Promise<void> {
772
- try {
773
- if (!result) return;
888
+ private buildClientOnlyOutcome(
889
+ skipCheck: Extract<SkipCheckResult, { canSkip: true }>,
890
+ props: NavigateProps,
891
+ controller: AbortController,
892
+ ): NavigationOutcome {
893
+ const { matchResult, importURLs, exportKeys, loadersData } = skipCheck;
894
+
895
+ const json: GetRouteDataOutput = {
896
+ matchedPatterns: matchResult.matches.map(
897
+ (m: Match) => m.registeredPattern.originalPattern,
898
+ ),
899
+ loadersData: loadersData,
900
+ importURLs: importURLs,
901
+ exportKeys: exportKeys,
902
+ hasRootData: __vormaClientGlobal.get("hasRootData"),
903
+ params: matchResult.params,
904
+ splatValues: matchResult.splatValues,
905
+ deps: [],
906
+ cssBundles: [],
907
+ outermostServerError: undefined,
908
+ outermostServerErrorIdx: undefined,
909
+ errorExportKeys: [],
910
+ title: undefined,
911
+ metaHeadEls: undefined,
912
+ restHeadEls: undefined,
913
+ activeComponents: undefined as unknown as [],
914
+ };
774
915
 
775
- if ("redirectData" in result) {
776
- // Skip redirect effectuation for pure prefetches
777
- if (entry.type === "prefetch" && entry.intent === "none") {
778
- this.deleteNavigation(entry.targetUrl);
779
- return;
780
- }
916
+ const response = new Response(JSON.stringify(json), {
917
+ status: 200,
918
+ headers: {
919
+ "Content-Type": "application/json",
920
+ "X-Vorma-Build-Id": __vormaClientGlobal.get("buildID") || "1",
921
+ },
922
+ });
781
923
 
782
- // Clean up before redirect to prevent race conditions
783
- this.deleteNavigation(entry.targetUrl);
924
+ const currentClientLoadersData =
925
+ __vormaClientGlobal.get("clientLoadersData") || [];
926
+ const patternToWaitFnMap =
927
+ __vormaClientGlobal.get("patternToWaitFnMap") || {};
928
+ const runningLoaders = new Map<string, Promise<any>>();
784
929
 
785
- await effectuateRedirectDataResult(
786
- result.redirectData,
787
- result.props.redirectCount || 0,
788
- result.props,
789
- );
790
- return;
791
- }
930
+ for (let i = 0; i < json.matchedPatterns.length; i++) {
931
+ const pattern = json.matchedPatterns[i];
932
+ if (!pattern) continue;
792
933
 
793
- // Sanity check -- should not happen
794
- if (!("json" in result)) {
795
- logError("Invalid navigation result: no JSON or redirect");
796
- return;
934
+ if (patternToWaitFnMap[pattern]) {
935
+ const currentMatchedPatterns =
936
+ __vormaClientGlobal.get("matchedPatterns") || [];
937
+ const currentPatternIndex =
938
+ currentMatchedPatterns.indexOf(pattern);
939
+
940
+ if (
941
+ currentPatternIndex !== -1 &&
942
+ currentClientLoadersData[currentPatternIndex] !== undefined
943
+ ) {
944
+ runningLoaders.set(
945
+ pattern,
946
+ Promise.resolve(
947
+ currentClientLoadersData[currentPatternIndex],
948
+ ),
949
+ );
950
+ }
797
951
  }
952
+ }
953
+
954
+ const waitFnPromise = completeClientLoaders(
955
+ json,
956
+ __vormaClientGlobal.get("buildID") || "1",
957
+ runningLoaders,
958
+ controller.signal,
959
+ );
960
+
961
+ return {
962
+ type: "success",
963
+ response,
964
+ props,
965
+ json,
966
+ cssBundlePromises: [],
967
+ waitFnPromise,
968
+ };
969
+ }
970
+
971
+ async processSuccessfulNavigation(
972
+ outcome: Extract<NavigationOutcome, { type: "success" }>,
973
+ entry: NavigationEntry,
974
+ ): Promise<void> {
975
+ try {
976
+ const { response, json, props, cssBundlePromises, waitFnPromise } =
977
+ outcome;
798
978
 
799
979
  // Only update module map and apply CSS if build IDs match
800
980
  const currentBuildID = __vormaClientGlobal.get("buildID");
801
- const responseBuildID = getBuildIDFromResponse(result.response);
981
+ const responseBuildID = getBuildIDFromResponse(response);
802
982
 
803
983
  if (responseBuildID === currentBuildID) {
804
984
  // Update module map only when builds match
805
985
  const clientModuleMap =
806
986
  __vormaClientGlobal.get("clientModuleMap") || {};
807
- const matchedPatterns = result.json.matchedPatterns || [];
808
- const importURLs = result.json.importURLs || [];
809
- const exportKeys = result.json.exportKeys || [];
810
- const errorExportKeys = result.json.errorExportKeys || [];
987
+ const matchedPatterns = json.matchedPatterns || [];
988
+ const importURLs = json.importURLs || [];
989
+ const exportKeys = json.exportKeys || [];
990
+ const errorExportKeys = json.errorExportKeys || [];
811
991
 
812
992
  for (let i = 0; i < matchedPatterns.length; i++) {
813
993
  const pattern = matchedPatterns[i];
@@ -826,15 +1006,9 @@ class NavigationStateManager {
826
1006
 
827
1007
  __vormaClientGlobal.set("clientModuleMap", clientModuleMap);
828
1008
 
829
- // Apply CSS bundles immediately, even for prefetches.
830
- // This ensures that if the user doesn't actually click now,
831
- // but they do later (and it happens to be eligible for skip),
832
- // everything still works.
833
- if (
834
- result.json.cssBundles &&
835
- result.json.cssBundles.length > 0
836
- ) {
837
- AssetManager.applyCSS(result.json.cssBundles);
1009
+ // Apply CSS bundles immediately, even for prefetches
1010
+ if (json.cssBundles && json.cssBundles.length > 0) {
1011
+ AssetManager.applyCSS(json.cssBundles);
838
1012
  }
839
1013
  }
840
1014
 
@@ -851,25 +1025,25 @@ class NavigationStateManager {
851
1025
  this.transitionPhase(entry.targetUrl, "waiting");
852
1026
 
853
1027
  // Skip if navigation was aborted
854
- if (!this._navigations.has(entry.targetUrl)) {
1028
+ if (!this.findNavigationEntry(entry.targetUrl)) {
855
1029
  return;
856
1030
  }
857
1031
 
858
1032
  // Update build ID if needed
859
1033
  const oldID = __vormaClientGlobal.get("buildID");
860
- const newID = getBuildIDFromResponse(result.response);
1034
+ const newID = getBuildIDFromResponse(response);
861
1035
  if (newID && newID !== oldID) {
862
1036
  dispatchBuildIDEvent({ newID, oldID });
863
1037
  }
864
1038
 
865
1039
  // Wait for client loaders and set state
866
- const clientLoadersResult = await result.waitFnPromise;
1040
+ const clientLoadersResult = await waitFnPromise;
867
1041
  setClientLoadersState(clientLoadersResult);
868
1042
 
869
1043
  // Wait for CSS
870
- if (result.cssBundlePromises.length > 0) {
1044
+ if (cssBundlePromises.length > 0) {
871
1045
  try {
872
- await Promise.all(result.cssBundlePromises);
1046
+ await Promise.all(cssBundlePromises);
873
1047
  } catch (error) {
874
1048
  logError("Error preloading CSS bundles:", error);
875
1049
  }
@@ -895,16 +1069,15 @@ class NavigationStateManager {
895
1069
  // Render the app
896
1070
  try {
897
1071
  await __reRenderApp({
898
- json: result.json,
1072
+ json,
899
1073
  navigationType: entry.type,
900
1074
  runHistoryOptions:
901
1075
  entry.intent === "navigate"
902
1076
  ? {
903
1077
  href: entry.targetUrl,
904
1078
  scrollStateToRestore:
905
- result.props.scrollStateToRestore,
906
- replace:
907
- entry.replace || result.props.replace,
1079
+ props.scrollStateToRestore,
1080
+ replace: entry.replace || props.replace,
908
1081
  scrollToTop: entry.scrollToTop,
909
1082
  state: entry.state,
910
1083
  }
@@ -993,7 +1166,7 @@ class NavigationStateManager {
993
1166
 
994
1167
  if (redirectData?.status === "should") {
995
1168
  await effectuateRedirectDataResult(redirectData, 0);
996
- return { success: true, data: undefined as T }; // No data on redirect
1169
+ return { success: true, data: undefined as T };
997
1170
  }
998
1171
 
999
1172
  const data = await response.json();
@@ -1021,61 +1194,100 @@ class NavigationStateManager {
1021
1194
  }
1022
1195
  }
1023
1196
 
1024
- private setNavigation(key: string, entry: NavigationEntry): void {
1025
- this._navigations.set(key, entry);
1026
- this.scheduleStatusUpdate();
1197
+ private findNavigationEntry(
1198
+ targetUrl: string,
1199
+ ): NavigationEntry | undefined {
1200
+ if (this._activeNavigation?.targetUrl === targetUrl) {
1201
+ return this._activeNavigation;
1202
+ }
1203
+ const prefetch = this._prefetchCache.get(targetUrl);
1204
+ if (prefetch) {
1205
+ return prefetch;
1206
+ }
1207
+ if (this._pendingRevalidation?.targetUrl === targetUrl) {
1208
+ return this._pendingRevalidation;
1209
+ }
1210
+ return undefined;
1027
1211
  }
1028
1212
 
1029
1213
  private deleteNavigation(key: string): boolean {
1030
- const result = this._navigations.delete(key);
1031
- if (result) {
1214
+ // Check active navigation
1215
+ if (this._activeNavigation?.targetUrl === key) {
1216
+ this._activeNavigation = null;
1217
+ this.scheduleStatusUpdate();
1218
+ return true;
1219
+ }
1220
+
1221
+ // Check prefetch cache
1222
+ if (this._prefetchCache.has(key)) {
1223
+ this._prefetchCache.delete(key);
1224
+ // No status update for prefetches
1225
+ return true;
1226
+ }
1227
+
1228
+ // Check pending revalidation
1229
+ if (this._pendingRevalidation?.targetUrl === key) {
1230
+ this._pendingRevalidation = null;
1032
1231
  this.scheduleStatusUpdate();
1232
+ return true;
1033
1233
  }
1034
- return result;
1234
+
1235
+ return false;
1035
1236
  }
1036
1237
 
1037
1238
  removeNavigation(key: string): void {
1038
- this.deleteNavigation(key);
1239
+ const entry = this.findNavigationEntry(key);
1240
+ if (entry) {
1241
+ entry.control.abortController?.abort();
1242
+ this.deleteNavigation(key);
1243
+ }
1039
1244
  }
1040
1245
 
1041
1246
  getNavigation(key: string): NavigationEntry | undefined {
1042
- return this._navigations.get(key);
1247
+ return this.findNavigationEntry(key);
1043
1248
  }
1044
1249
 
1045
1250
  hasNavigation(key: string): boolean {
1046
- return this._navigations.has(key);
1251
+ return this.findNavigationEntry(key) !== undefined;
1047
1252
  }
1048
1253
 
1049
1254
  getNavigationsSize(): number {
1050
- return this._navigations.size;
1255
+ let size = 0;
1256
+ if (this._activeNavigation) size++;
1257
+ size += this._prefetchCache.size;
1258
+ if (this._pendingRevalidation) size++;
1259
+ return size;
1051
1260
  }
1052
1261
 
1053
1262
  getNavigations(): Map<string, NavigationEntry> {
1054
- return this._navigations;
1055
- }
1056
-
1057
- private abortAllNavigationsExcept(excludeHref?: string): void {
1058
- for (const [href, nav] of this._navigations.entries()) {
1059
- if (href !== excludeHref) {
1060
- nav.control.abortController?.abort();
1061
- this.deleteNavigation(href);
1062
- }
1263
+ // Reconstruct Map for compatibility with existing code
1264
+ const map = new Map<string, NavigationEntry>();
1265
+ if (this._activeNavigation) {
1266
+ map.set(this._activeNavigation.targetUrl, this._activeNavigation);
1267
+ }
1268
+ for (const [key, entry] of this._prefetchCache) {
1269
+ map.set(key, entry);
1270
+ }
1271
+ if (this._pendingRevalidation) {
1272
+ map.set(
1273
+ this._pendingRevalidation.targetUrl,
1274
+ this._pendingRevalidation,
1275
+ );
1063
1276
  }
1277
+ return map;
1064
1278
  }
1065
1279
 
1066
1280
  getStatus(): StatusEventDetail {
1067
- const navigations = Array.from(this._navigations.values());
1068
- const submissions = Array.from(this._submissions.values());
1281
+ const isNavigating =
1282
+ this._activeNavigation !== null &&
1283
+ this._activeNavigation.intent === "navigate" &&
1284
+ this._activeNavigation.phase !== "complete";
1069
1285
 
1070
- const isNavigating = navigations.some(
1071
- (nav) => nav.intent === "navigate" && nav.phase !== "complete",
1072
- );
1073
-
1074
- const isRevalidating = navigations.some(
1075
- (nav) => nav.type === "revalidation" && nav.phase !== "complete",
1076
- );
1286
+ const isRevalidating =
1287
+ this._pendingRevalidation !== null &&
1288
+ this._pendingRevalidation.phase !== "complete";
1077
1289
 
1078
- const isSubmitting = submissions.some(
1290
+ const isSubmitting = Array.from(this._submissions.values()).some(
1079
1291
  (x) => !x.skipGlobalLoadingIndicator,
1080
1292
  );
1081
1293
 
@@ -1083,14 +1295,26 @@ class NavigationStateManager {
1083
1295
  }
1084
1296
 
1085
1297
  clearAll(): void {
1086
- for (const nav of this._navigations.values()) {
1087
- nav.control.abortController?.abort();
1298
+ if (this._activeNavigation) {
1299
+ this._activeNavigation.control.abortController?.abort();
1300
+ this._activeNavigation = null;
1301
+ }
1302
+
1303
+ for (const prefetch of this._prefetchCache.values()) {
1304
+ prefetch.control.abortController?.abort();
1088
1305
  }
1089
- this._navigations.clear();
1306
+ this._prefetchCache.clear();
1307
+
1308
+ if (this._pendingRevalidation) {
1309
+ this._pendingRevalidation.control.abortController?.abort();
1310
+ this._pendingRevalidation = null;
1311
+ }
1312
+
1090
1313
  for (const sub of this._submissions.values()) {
1091
1314
  sub.control.abortController?.abort();
1092
1315
  }
1093
1316
  this._submissions.clear();
1317
+
1094
1318
  this.scheduleStatusUpdate();
1095
1319
  }
1096
1320