@timber-js/app 0.2.0-alpha.66 → 0.2.0-alpha.68

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.
@@ -27,6 +27,19 @@ export interface NavigationOptions {
27
27
  scroll?: boolean;
28
28
  /** Use replaceState instead of pushState (replaces current history entry) */
29
29
  replace?: boolean;
30
+ /**
31
+ * @internal AbortSignal from the Navigation API's NavigateEvent.
32
+ * When provided, the signal is linked to the router's per-navigation
33
+ * AbortController so in-flight RSC fetches are cancelled when a new
34
+ * navigation starts.
35
+ */
36
+ _signal?: AbortSignal;
37
+ /**
38
+ * @internal Skip pushState/replaceState — the Navigation API has already
39
+ * updated the URL via event.intercept(). Used for external navigations
40
+ * intercepted by the navigate event handler.
41
+ */
42
+ _skipHistory?: boolean;
30
43
  }
31
44
 
32
45
  /**
@@ -89,6 +102,42 @@ export interface RouterDeps {
89
102
  wrapPayload: (payload: unknown, navState: NavigationState) => unknown
90
103
  ) => Promise<unknown>
91
104
  ) => Promise<void>;
105
+
106
+ /**
107
+ * Whether the Navigation API is active and handling traversals.
108
+ * When true, the popstate handler is a no-op — the Navigation API's
109
+ * navigate event covers back/forward button presses.
110
+ */
111
+ navigationApiActive?: boolean;
112
+
113
+ /**
114
+ * Called around pushState/replaceState to set a flag that prevents
115
+ * the Navigation API's navigate listener from double-handling
116
+ * router-initiated navigations.
117
+ */
118
+ setRouterNavigating?: (value: boolean) => void;
119
+
120
+ /**
121
+ * Save scroll position via the Navigation API's per-entry state.
122
+ * When provided, used instead of history.replaceState for scroll storage.
123
+ */
124
+ saveNavigationEntryScroll?: (scrollY: number) => void;
125
+
126
+ /**
127
+ * Signal that a router-initiated navigation has completed. Resolves the
128
+ * deferred promise that ties the browser's native loading state to the
129
+ * navigation lifecycle. Called in the finally block of navigate/refresh,
130
+ * aligned with when the TopLoader's pendingUrl clears.
131
+ */
132
+ completeRouterNavigation?: () => void;
133
+
134
+ /**
135
+ * Initiate a navigation via the Navigation API (`navigation.navigate()`).
136
+ * Fires the navigate event BEFORE committing the URL, allowing Chrome
137
+ * to show its native loading indicator. Falls back to pushState when
138
+ * unavailable.
139
+ */
140
+ navigationNavigate?: (url: string, replace: boolean) => void;
92
141
  }
93
142
 
94
143
  export interface RouterInstance {
@@ -97,7 +146,7 @@ export interface RouterInstance {
97
146
  /** Full re-render of the current URL — no state tree sent */
98
147
  refresh(): Promise<void>;
99
148
  /** Handle a popstate event (back/forward button). scrollY is read from history.state. */
100
- handlePopState(url: string, scrollY?: number): Promise<void>;
149
+ handlePopState(url: string, scrollY?: number, externalSignal?: AbortSignal): Promise<void>;
101
150
  /** Whether a navigation is currently in flight */
102
151
  isPending(): boolean;
103
152
  /** The URL currently being navigated to, or null if idle */
@@ -167,6 +216,36 @@ export function createRouter(deps: RouterDeps): RouterInstance {
167
216
  let routerPhase: RouterPhase = { phase: 'idle' };
168
217
  const pendingListeners = new Set<(pending: boolean) => void>();
169
218
 
219
+ // AbortController for the current in-flight navigation.
220
+ // When a new navigation starts, the previous controller is aborted,
221
+ // cancelling any in-progress RSC fetch. This provides automatic
222
+ // cancellation of stale fetches regardless of Navigation API support.
223
+ let currentNavAbort: AbortController | null = null;
224
+
225
+ /**
226
+ * Create a new AbortController for a navigation, aborting any
227
+ * previous in-flight navigation. Optionally links to an external
228
+ * signal (e.g., from the Navigation API's NavigateEvent.signal).
229
+ */
230
+ function createNavAbort(externalSignal?: AbortSignal): AbortController {
231
+ // Abort previous navigation's fetch
232
+ currentNavAbort?.abort();
233
+ const controller = new AbortController();
234
+ currentNavAbort = controller;
235
+
236
+ // If an external signal is provided (e.g., Navigation API),
237
+ // forward its abort to our controller.
238
+ if (externalSignal) {
239
+ if (externalSignal.aborted) {
240
+ controller.abort();
241
+ } else {
242
+ externalSignal.addEventListener('abort', () => controller.abort(), { once: true });
243
+ }
244
+ }
245
+
246
+ return controller;
247
+ }
248
+
170
249
  function setPending(value: boolean, url?: string): void {
171
250
  const next: RouterPhase =
172
251
  value && url ? { phase: 'navigating', targetUrl: url } : { phase: 'idle' };
@@ -326,7 +405,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
326
405
  */
327
406
  async function performNavigationFetch(
328
407
  url: string,
329
- options: { replace: boolean }
408
+ options: { replace: boolean; signal?: AbortSignal; skipHistory?: boolean }
330
409
  ): Promise<FetchResult & { navState: NavigationState }> {
331
410
  // Check prefetch cache first. PrefetchResult has optional segmentInfo/params
332
411
  // fields — normalize to null for FetchResult compatibility.
@@ -349,14 +428,21 @@ export function createRouter(deps: RouterDeps): RouterInstance {
349
428
  const currentUrl = rawCurrentUrl.startsWith('http')
350
429
  ? new URL(rawCurrentUrl).pathname
351
430
  : new URL(rawCurrentUrl, 'http://localhost').pathname;
352
- result = await fetchRscPayload(url, deps, stateTree, currentUrl);
431
+ result = await fetchRscPayload(url, deps, stateTree, currentUrl, options.signal);
353
432
  }
354
433
 
355
- // Update the browser history — replace mode overwrites the current entry
356
- if (options.replace) {
357
- deps.replaceState({ timber: true, scrollY: 0 }, '', url);
358
- } else {
359
- deps.pushState({ timber: true, scrollY: 0 }, '', url);
434
+ // Update the browser history — skip when the Navigation API has already
435
+ // updated the URL via event.intercept() (external navigations).
436
+ if (!options.skipHistory) {
437
+ // Set the router-navigating flag so the Navigation API's navigate
438
+ // listener doesn't double-intercept this pushState/replaceState.
439
+ deps.setRouterNavigating?.(true);
440
+ if (options.replace) {
441
+ deps.replaceState({ timber: true, scrollY: 0 }, '', url);
442
+ } else {
443
+ deps.pushState({ timber: true, scrollY: 0 }, '', url);
444
+ }
445
+ deps.setRouterNavigating?.(false);
360
446
  }
361
447
 
362
448
  // NOTE: History push is deferred — the merged payload (after segment
@@ -376,20 +462,48 @@ export function createRouter(deps: RouterDeps): RouterInstance {
376
462
  async function navigate(url: string, options: NavigationOptions = {}): Promise<void> {
377
463
  const scroll = options.scroll !== false;
378
464
  const replace = options.replace === true;
465
+ const externalSignal = options._signal as AbortSignal | undefined;
466
+ const skipHistory = options._skipHistory === true;
467
+
468
+ // Create an abort controller for this navigation. Links to the external
469
+ // signal (Navigation API's event.signal) when provided.
470
+ const navAbort = createNavAbort(externalSignal);
379
471
 
380
472
  // Capture the departing page's scroll position for scroll={false} preservation.
381
473
  const currentScrollY = deps.getScrollY();
382
474
 
383
- // Save the departing page's scroll position in history.state before
384
- // pushing a new entry. This ensures back/forward navigation can restore
385
- // the correct scroll position from the browser's per-entry state.
386
- deps.replaceState({ timber: true, scrollY: currentScrollY }, '', deps.getCurrentUrl());
475
+ // Save the departing page's scroll position use Navigation API entry
476
+ // state when available, otherwise fall back to history.state.
477
+ if (deps.saveNavigationEntryScroll) {
478
+ deps.saveNavigationEntryScroll(currentScrollY);
479
+ } else {
480
+ deps.replaceState({ timber: true, scrollY: currentScrollY }, '', deps.getCurrentUrl());
481
+ }
482
+
483
+ // When Navigation API is active, initiate the navigation via
484
+ // navigation.navigate() BEFORE the fetch. Unlike history.pushState()
485
+ // which commits the URL synchronously (so Chrome sees it as "done"),
486
+ // navigation.navigate() fires the navigate event before committing.
487
+ // Our handler intercepts with a deferred promise, and Chrome shows
488
+ // its native loading indicator until completeRouterNavigation()
489
+ // resolves it in the finally block (same time as TopLoader clears).
490
+ let effectiveSkipHistory = skipHistory;
491
+ if (!skipHistory && deps.navigationNavigate) {
492
+ deps.setRouterNavigating?.(true);
493
+ deps.navigationNavigate(url, replace);
494
+ deps.setRouterNavigating?.(false);
495
+ effectiveSkipHistory = true;
496
+ }
387
497
 
388
498
  setPending(true, url);
389
499
 
390
500
  try {
391
501
  const headElements = await renderViaTransition(url, () =>
392
- performNavigationFetch(url, { replace })
502
+ performNavigationFetch(url, {
503
+ replace,
504
+ signal: navAbort.signal,
505
+ skipHistory: effectiveSkipHistory,
506
+ })
393
507
  );
394
508
 
395
509
  // Update document.title and <meta> tags with the new page's metadata
@@ -414,8 +528,10 @@ export function createRouter(deps: RouterDeps): RouterInstance {
414
528
  return new Promise(() => {}) as never;
415
529
  }
416
530
  // Server-side redirect during RSC fetch → soft router navigation.
531
+ // The redirect navigate will push/replace its own URL.
417
532
  if (error instanceof RedirectError) {
418
533
  setPending(false);
534
+ deps.completeRouterNavigation?.();
419
535
  await navigate(error.redirectUrl, { replace: true });
420
536
  return;
421
537
  }
@@ -431,18 +547,28 @@ export function createRouter(deps: RouterDeps): RouterInstance {
431
547
  throw error;
432
548
  } finally {
433
549
  setPending(false);
550
+ // Resolve the Navigation API deferred — clears the browser's native
551
+ // loading state (tab spinner) at the same time as the TopLoader.
552
+ deps.completeRouterNavigation?.();
434
553
  }
435
554
  }
436
555
 
437
556
  async function refresh(): Promise<void> {
438
557
  const currentUrl = deps.getCurrentUrl();
558
+ const navAbort = createNavAbort();
439
559
 
440
560
  setPending(true, currentUrl);
441
561
 
442
562
  try {
443
563
  const headElements = await renderViaTransition(currentUrl, async () => {
444
564
  // No state tree sent — server renders the complete RSC payload
445
- const result = await fetchRscPayload(currentUrl, deps);
565
+ const result = await fetchRscPayload(
566
+ currentUrl,
567
+ deps,
568
+ undefined,
569
+ undefined,
570
+ navAbort.signal
571
+ );
446
572
  // History push handled by renderViaTransition (stores merged payload)
447
573
  updateSegmentCache(result.segmentInfo);
448
574
  const navState = updateNavigationState(result.params, currentUrl);
@@ -452,10 +578,15 @@ export function createRouter(deps: RouterDeps): RouterInstance {
452
578
  applyHead(headElements);
453
579
  } finally {
454
580
  setPending(false);
581
+ deps.completeRouterNavigation?.();
455
582
  }
456
583
  }
457
584
 
458
- async function handlePopState(url: string, scrollY: number = 0): Promise<void> {
585
+ async function handlePopState(
586
+ url: string,
587
+ scrollY: number = 0,
588
+ externalSignal?: AbortSignal
589
+ ): Promise<void> {
459
590
  // Scroll position is read from history.state by the caller (browser-entry.ts)
460
591
  // and passed in. This is more reliable than tracking scroll per-URL in memory
461
592
  // because the browser maintains per-entry state even with duplicate URLs.
@@ -472,13 +603,14 @@ export function createRouter(deps: RouterDeps): RouterInstance {
472
603
  // This happens when navigating back to the initial SSR'd page
473
604
  // (its payload is null since it was rendered via SSR, not RSC fetch)
474
605
  // or when the entry doesn't exist at all.
606
+ const navAbort = createNavAbort(externalSignal);
475
607
  setPending(true, url);
476
608
  try {
477
609
  const headElements = await renderViaTransition(url, async () => {
478
610
  const stateTree = segmentCache.serializeStateTree(
479
611
  segmentElementCache.getMergeablePaths()
480
612
  );
481
- const result = await fetchRscPayload(url, deps, stateTree);
613
+ const result = await fetchRscPayload(url, deps, stateTree, undefined, navAbort.signal);
482
614
  updateSegmentCache(result.segmentInfo);
483
615
  const navState = updateNavigationState(result.params, url);
484
616
  // History push handled by renderViaTransition (stores merged payload)
@@ -249,7 +249,8 @@ export async function fetchRscPayload(
249
249
  url: string,
250
250
  deps: RouterDeps,
251
251
  stateTree?: { segments: string[] },
252
- currentUrl?: string
252
+ currentUrl?: string,
253
+ signal?: AbortSignal
253
254
  ): Promise<FetchResult> {
254
255
  const rscUrl = appendRscParam(url);
255
256
  const headers = buildRscHeaders(stateTree, currentUrl);
@@ -260,7 +261,7 @@ export async function fetchRscPayload(
260
261
  //
261
262
  // Intercept the response to read X-Timber-Head before createFromFetch
262
263
  // consumes the body. Reading headers does NOT consume the body stream.
263
- const fetchPromise = deps.fetch(rscUrl, { headers, redirect: 'manual' });
264
+ const fetchPromise = deps.fetch(rscUrl, { headers, redirect: 'manual', signal });
264
265
  let headElements: HeadElement[] | null = null;
265
266
  let segmentInfo: SegmentInfo[] | null = null;
266
267
  let params: Record<string, string | string[]> | null = null;
@@ -303,7 +304,7 @@ export async function fetchRscPayload(
303
304
  return { payload, headElements, segmentInfo, params, skippedSegments };
304
305
  }
305
306
  // Test/fallback path: return raw text
306
- const response = await deps.fetch(rscUrl, { headers, redirect: 'manual' });
307
+ const response = await deps.fetch(rscUrl, { headers, redirect: 'manual', signal });
307
308
  // Check for redirect in test path too
308
309
  if (response.status >= 300 && response.status < 400) {
309
310
  const location = response.headers.get('Location');
@@ -28,7 +28,7 @@
28
28
  'use client';
29
29
 
30
30
  import { useState, createElement } from 'react';
31
- import { usePendingNavigationUrl } from './navigation-context.js';
31
+ import { usePendingNavigationUrl, hasNativeNavigationTransition } from './navigation-context.js';
32
32
 
33
33
  // ─── Types ───────────────────────────────────────────────────────
34
34
 
@@ -112,7 +112,15 @@ function ensureKeyframes(): void {
112
112
  */
113
113
  export function TopLoader({ config }: { config?: TopLoaderConfig }): React.ReactElement | null {
114
114
  const pendingUrl = usePendingNavigationUrl();
115
- const isPending = pendingUrl !== null;
115
+ // Navigation is pending when either:
116
+ // 1. Our React-based pending URL is set (standard path), OR
117
+ // 2. The Navigation API has an active transition (external navigations
118
+ // intercepted by the navigate event that haven't completed yet).
119
+ // In practice these are almost always in sync — the Navigation API
120
+ // transition is active while our pendingUrl is set. This check ensures
121
+ // the top-loader also shows for external navigations caught by the
122
+ // Navigation API before our React state updates.
123
+ const isPending = pendingUrl !== null || hasNativeNavigationTransition();
116
124
 
117
125
  const color = config?.color ?? DEFAULT_COLOR;
118
126
  const height = config?.height ?? DEFAULT_HEIGHT;