@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.
- package/LICENSE +8 -0
- package/dist/client/history.d.ts +19 -4
- package/dist/client/history.d.ts.map +1 -1
- package/dist/client/index.js +105 -23
- package/dist/client/index.js.map +1 -1
- package/dist/client/link.d.ts.map +1 -1
- package/dist/client/nav-link-store.d.ts +36 -0
- package/dist/client/nav-link-store.d.ts.map +1 -0
- package/dist/client/navigation-api-types.d.ts +90 -0
- package/dist/client/navigation-api-types.d.ts.map +1 -0
- package/dist/client/navigation-api.d.ts +115 -0
- package/dist/client/navigation-api.d.ts.map +1 -0
- package/dist/client/navigation-context.d.ts +11 -0
- package/dist/client/navigation-context.d.ts.map +1 -1
- package/dist/client/nuqs-adapter.d.ts.map +1 -1
- package/dist/client/router.d.ts +45 -1
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/rsc-fetch.d.ts +1 -1
- package/dist/client/rsc-fetch.d.ts.map +1 -1
- package/dist/client/top-loader.d.ts.map +1 -1
- package/package.json +6 -7
- package/src/cli.ts +0 -0
- package/src/client/browser-entry.ts +77 -8
- package/src/client/history.ts +26 -4
- package/src/client/link.tsx +29 -7
- package/src/client/nav-link-store.ts +47 -0
- package/src/client/navigation-api-types.ts +112 -0
- package/src/client/navigation-api.ts +305 -0
- package/src/client/navigation-context.ts +20 -0
- package/src/client/nuqs-adapter.tsx +16 -3
- package/src/client/router.ts +148 -16
- package/src/client/rsc-fetch.ts +4 -3
- package/src/client/top-loader.tsx +10 -2
package/src/client/router.ts
CHANGED
|
@@ -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 —
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
|
384
|
-
//
|
|
385
|
-
|
|
386
|
-
|
|
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, {
|
|
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(
|
|
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(
|
|
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)
|
package/src/client/rsc-fetch.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|