@timber-js/app 0.2.0-alpha.67 → 0.2.0-alpha.69
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 +321 -167
- package/dist/client/index.js.map +1 -1
- package/dist/client/link-pending-store.d.ts +3 -3
- 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/{transition-root.d.ts → navigation-root.d.ts} +31 -9
- package/dist/client/navigation-root.d.ts.map +1 -0
- package/dist/client/nuqs-adapter.d.ts.map +1 -1
- package/dist/client/router.d.ts +46 -2
- 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 +2 -2
- package/dist/client/top-loader.d.ts.map +1 -1
- package/dist/server/index.js.map +1 -1
- package/dist/server/route-element-builder.d.ts +10 -0
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/slot-resolver.d.ts.map +1 -1
- package/dist/server/ssr-wrappers.d.ts +3 -3
- package/package.json +6 -7
- package/src/cli.ts +0 -0
- package/src/client/browser-entry.ts +92 -19
- package/src/client/history.ts +26 -4
- package/src/client/link-pending-store.ts +3 -3
- package/src/client/link.tsx +31 -9
- package/src/client/nav-link-store.ts +47 -0
- package/src/client/navigation-api-types.ts +112 -0
- package/src/client/navigation-api.ts +315 -0
- package/src/client/navigation-context.ts +22 -2
- package/src/client/navigation-root.tsx +346 -0
- package/src/client/nuqs-adapter.tsx +16 -3
- package/src/client/router.ts +186 -18
- package/src/client/rsc-fetch.ts +4 -3
- package/src/client/top-loader.tsx +12 -4
- package/src/client/use-navigation-pending.ts +1 -1
- package/src/server/route-element-builder.ts +69 -21
- package/src/server/slot-resolver.ts +37 -35
- package/src/server/ssr-entry.ts +1 -1
- package/src/server/ssr-wrappers.tsx +10 -10
- package/dist/client/transition-root.d.ts.map +0 -1
- package/src/client/transition-root.tsx +0 -205
package/src/client/router.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
ServerErrorResponse,
|
|
19
19
|
VersionSkewError,
|
|
20
20
|
} from './rsc-fetch.js';
|
|
21
|
+
import { setHardNavigating } from './navigation-root.js';
|
|
21
22
|
import type { FetchResult } from './rsc-fetch.js';
|
|
22
23
|
|
|
23
24
|
// ─── Types ───────────────────────────────────────────────────────
|
|
@@ -27,6 +28,19 @@ export interface NavigationOptions {
|
|
|
27
28
|
scroll?: boolean;
|
|
28
29
|
/** Use replaceState instead of pushState (replaces current history entry) */
|
|
29
30
|
replace?: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* @internal AbortSignal from the Navigation API's NavigateEvent.
|
|
33
|
+
* When provided, the signal is linked to the router's per-navigation
|
|
34
|
+
* AbortController so in-flight RSC fetches are cancelled when a new
|
|
35
|
+
* navigation starts.
|
|
36
|
+
*/
|
|
37
|
+
_signal?: AbortSignal;
|
|
38
|
+
/**
|
|
39
|
+
* @internal Skip pushState/replaceState — the Navigation API has already
|
|
40
|
+
* updated the URL via event.intercept(). Used for external navigations
|
|
41
|
+
* intercepted by the navigate event handler.
|
|
42
|
+
*/
|
|
43
|
+
_skipHistory?: boolean;
|
|
30
44
|
}
|
|
31
45
|
|
|
32
46
|
/**
|
|
@@ -77,7 +91,7 @@ export interface RouterDeps {
|
|
|
77
91
|
*
|
|
78
92
|
* The `perform` callback receives a `wrapPayload` function to wrap the
|
|
79
93
|
* decoded RSC payload with NavigationProvider + NuqsAdapter before
|
|
80
|
-
*
|
|
94
|
+
* NavigationRoot sets it as the new element. The `wrapPayload` function
|
|
81
95
|
* receives the NavigationState explicitly — no temporal coupling with
|
|
82
96
|
* getNavigationState().
|
|
83
97
|
*
|
|
@@ -89,6 +103,42 @@ export interface RouterDeps {
|
|
|
89
103
|
wrapPayload: (payload: unknown, navState: NavigationState) => unknown
|
|
90
104
|
) => Promise<unknown>
|
|
91
105
|
) => Promise<void>;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Whether the Navigation API is active and handling traversals.
|
|
109
|
+
* When true, the popstate handler is a no-op — the Navigation API's
|
|
110
|
+
* navigate event covers back/forward button presses.
|
|
111
|
+
*/
|
|
112
|
+
navigationApiActive?: boolean;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Called around pushState/replaceState to set a flag that prevents
|
|
116
|
+
* the Navigation API's navigate listener from double-handling
|
|
117
|
+
* router-initiated navigations.
|
|
118
|
+
*/
|
|
119
|
+
setRouterNavigating?: (value: boolean) => void;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Save scroll position via the Navigation API's per-entry state.
|
|
123
|
+
* When provided, used instead of history.replaceState for scroll storage.
|
|
124
|
+
*/
|
|
125
|
+
saveNavigationEntryScroll?: (scrollY: number) => void;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Signal that a router-initiated navigation has completed. Resolves the
|
|
129
|
+
* deferred promise that ties the browser's native loading state to the
|
|
130
|
+
* navigation lifecycle. Called in the finally block of navigate/refresh,
|
|
131
|
+
* aligned with when the TopLoader's pendingUrl clears.
|
|
132
|
+
*/
|
|
133
|
+
completeRouterNavigation?: () => void;
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Initiate a navigation via the Navigation API (`navigation.navigate()`).
|
|
137
|
+
* Fires the navigate event BEFORE committing the URL, allowing Chrome
|
|
138
|
+
* to show its native loading indicator. Falls back to pushState when
|
|
139
|
+
* unavailable.
|
|
140
|
+
*/
|
|
141
|
+
navigationNavigate?: (url: string, replace: boolean) => void;
|
|
92
142
|
}
|
|
93
143
|
|
|
94
144
|
export interface RouterInstance {
|
|
@@ -97,7 +147,7 @@ export interface RouterInstance {
|
|
|
97
147
|
/** Full re-render of the current URL — no state tree sent */
|
|
98
148
|
refresh(): Promise<void>;
|
|
99
149
|
/** Handle a popstate event (back/forward button). scrollY is read from history.state. */
|
|
100
|
-
handlePopState(url: string, scrollY?: number): Promise<void>;
|
|
150
|
+
handlePopState(url: string, scrollY?: number, externalSignal?: AbortSignal): Promise<void>;
|
|
101
151
|
/** Whether a navigation is currently in flight */
|
|
102
152
|
isPending(): boolean;
|
|
103
153
|
/** The URL currently being navigated to, or null if idle */
|
|
@@ -167,6 +217,36 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
167
217
|
let routerPhase: RouterPhase = { phase: 'idle' };
|
|
168
218
|
const pendingListeners = new Set<(pending: boolean) => void>();
|
|
169
219
|
|
|
220
|
+
// AbortController for the current in-flight navigation.
|
|
221
|
+
// When a new navigation starts, the previous controller is aborted,
|
|
222
|
+
// cancelling any in-progress RSC fetch. This provides automatic
|
|
223
|
+
// cancellation of stale fetches regardless of Navigation API support.
|
|
224
|
+
let currentNavAbort: AbortController | null = null;
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Create a new AbortController for a navigation, aborting any
|
|
228
|
+
* previous in-flight navigation. Optionally links to an external
|
|
229
|
+
* signal (e.g., from the Navigation API's NavigateEvent.signal).
|
|
230
|
+
*/
|
|
231
|
+
function createNavAbort(externalSignal?: AbortSignal): AbortController {
|
|
232
|
+
// Abort previous navigation's fetch
|
|
233
|
+
currentNavAbort?.abort();
|
|
234
|
+
const controller = new AbortController();
|
|
235
|
+
currentNavAbort = controller;
|
|
236
|
+
|
|
237
|
+
// If an external signal is provided (e.g., Navigation API),
|
|
238
|
+
// forward its abort to our controller.
|
|
239
|
+
if (externalSignal) {
|
|
240
|
+
if (externalSignal.aborted) {
|
|
241
|
+
controller.abort();
|
|
242
|
+
} else {
|
|
243
|
+
externalSignal.addEventListener('abort', () => controller.abort(), { once: true });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return controller;
|
|
248
|
+
}
|
|
249
|
+
|
|
170
250
|
function setPending(value: boolean, url?: string): void {
|
|
171
251
|
const next: RouterPhase =
|
|
172
252
|
value && url ? { phase: 'navigating', targetUrl: url } : { phase: 'idle' };
|
|
@@ -183,7 +263,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
183
263
|
routerPhase = next;
|
|
184
264
|
// Notify external store listeners (non-React consumers).
|
|
185
265
|
// React-facing pending state is handled by useOptimistic in
|
|
186
|
-
//
|
|
266
|
+
// NavigationRoot via navigateTransition — not this function.
|
|
187
267
|
for (const listener of pendingListeners) {
|
|
188
268
|
listener(value);
|
|
189
269
|
}
|
|
@@ -326,7 +406,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
326
406
|
*/
|
|
327
407
|
async function performNavigationFetch(
|
|
328
408
|
url: string,
|
|
329
|
-
options: { replace: boolean }
|
|
409
|
+
options: { replace: boolean; signal?: AbortSignal; skipHistory?: boolean }
|
|
330
410
|
): Promise<FetchResult & { navState: NavigationState }> {
|
|
331
411
|
// Check prefetch cache first. PrefetchResult has optional segmentInfo/params
|
|
332
412
|
// fields — normalize to null for FetchResult compatibility.
|
|
@@ -349,14 +429,21 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
349
429
|
const currentUrl = rawCurrentUrl.startsWith('http')
|
|
350
430
|
? new URL(rawCurrentUrl).pathname
|
|
351
431
|
: new URL(rawCurrentUrl, 'http://localhost').pathname;
|
|
352
|
-
result = await fetchRscPayload(url, deps, stateTree, currentUrl);
|
|
432
|
+
result = await fetchRscPayload(url, deps, stateTree, currentUrl, options.signal);
|
|
353
433
|
}
|
|
354
434
|
|
|
355
|
-
// Update the browser history —
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
435
|
+
// Update the browser history — skip when the Navigation API has already
|
|
436
|
+
// updated the URL via event.intercept() (external navigations).
|
|
437
|
+
if (!options.skipHistory) {
|
|
438
|
+
// Set the router-navigating flag so the Navigation API's navigate
|
|
439
|
+
// listener doesn't double-intercept this pushState/replaceState.
|
|
440
|
+
deps.setRouterNavigating?.(true);
|
|
441
|
+
if (options.replace) {
|
|
442
|
+
deps.replaceState({ timber: true, scrollY: 0 }, '', url);
|
|
443
|
+
} else {
|
|
444
|
+
deps.pushState({ timber: true, scrollY: 0 }, '', url);
|
|
445
|
+
}
|
|
446
|
+
deps.setRouterNavigating?.(false);
|
|
360
447
|
}
|
|
361
448
|
|
|
362
449
|
// NOTE: History push is deferred — the merged payload (after segment
|
|
@@ -376,20 +463,48 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
376
463
|
async function navigate(url: string, options: NavigationOptions = {}): Promise<void> {
|
|
377
464
|
const scroll = options.scroll !== false;
|
|
378
465
|
const replace = options.replace === true;
|
|
466
|
+
const externalSignal = options._signal as AbortSignal | undefined;
|
|
467
|
+
const skipHistory = options._skipHistory === true;
|
|
468
|
+
|
|
469
|
+
// Create an abort controller for this navigation. Links to the external
|
|
470
|
+
// signal (Navigation API's event.signal) when provided.
|
|
471
|
+
const navAbort = createNavAbort(externalSignal);
|
|
379
472
|
|
|
380
473
|
// Capture the departing page's scroll position for scroll={false} preservation.
|
|
381
474
|
const currentScrollY = deps.getScrollY();
|
|
382
475
|
|
|
383
|
-
// Save the departing page's scroll position
|
|
384
|
-
//
|
|
385
|
-
|
|
386
|
-
|
|
476
|
+
// Save the departing page's scroll position — use Navigation API entry
|
|
477
|
+
// state when available, otherwise fall back to history.state.
|
|
478
|
+
if (deps.saveNavigationEntryScroll) {
|
|
479
|
+
deps.saveNavigationEntryScroll(currentScrollY);
|
|
480
|
+
} else {
|
|
481
|
+
deps.replaceState({ timber: true, scrollY: currentScrollY }, '', deps.getCurrentUrl());
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// When Navigation API is active, initiate the navigation via
|
|
485
|
+
// navigation.navigate() BEFORE the fetch. Unlike history.pushState()
|
|
486
|
+
// which commits the URL synchronously (so Chrome sees it as "done"),
|
|
487
|
+
// navigation.navigate() fires the navigate event before committing.
|
|
488
|
+
// Our handler intercepts with a deferred promise, and Chrome shows
|
|
489
|
+
// its native loading indicator until completeRouterNavigation()
|
|
490
|
+
// resolves it in the finally block (same time as TopLoader clears).
|
|
491
|
+
let effectiveSkipHistory = skipHistory;
|
|
492
|
+
if (!skipHistory && deps.navigationNavigate) {
|
|
493
|
+
deps.setRouterNavigating?.(true);
|
|
494
|
+
deps.navigationNavigate(url, replace);
|
|
495
|
+
deps.setRouterNavigating?.(false);
|
|
496
|
+
effectiveSkipHistory = true;
|
|
497
|
+
}
|
|
387
498
|
|
|
388
499
|
setPending(true, url);
|
|
389
500
|
|
|
390
501
|
try {
|
|
391
502
|
const headElements = await renderViaTransition(url, () =>
|
|
392
|
-
performNavigationFetch(url, {
|
|
503
|
+
performNavigationFetch(url, {
|
|
504
|
+
replace,
|
|
505
|
+
signal: navAbort.signal,
|
|
506
|
+
skipHistory: effectiveSkipHistory,
|
|
507
|
+
})
|
|
393
508
|
);
|
|
394
509
|
|
|
395
510
|
// Update document.title and <meta> tags with the new page's metadata
|
|
@@ -405,7 +520,10 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
405
520
|
} catch (error) {
|
|
406
521
|
// Version skew — server has been redeployed. Trigger full page reload
|
|
407
522
|
// so the browser fetches the new bundle. See TIM-446.
|
|
523
|
+
// Set hard-navigating flag to prevent Navigation API interception
|
|
524
|
+
// and React from rendering during page teardown. See TIM-626.
|
|
408
525
|
if (error instanceof VersionSkewError) {
|
|
526
|
+
setHardNavigating(true);
|
|
409
527
|
// Import triggerStaleReload dynamically to avoid circular deps
|
|
410
528
|
// and keep the reload logic centralized with its loop guard.
|
|
411
529
|
const { triggerStaleReload } = await import('./stale-reload.js');
|
|
@@ -414,15 +532,24 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
414
532
|
return new Promise(() => {}) as never;
|
|
415
533
|
}
|
|
416
534
|
// Server-side redirect during RSC fetch → soft router navigation.
|
|
535
|
+
// The redirect navigate will push/replace its own URL.
|
|
417
536
|
if (error instanceof RedirectError) {
|
|
418
537
|
setPending(false);
|
|
538
|
+
deps.completeRouterNavigation?.();
|
|
419
539
|
await navigate(error.redirectUrl, { replace: true });
|
|
420
540
|
return;
|
|
421
541
|
}
|
|
422
542
|
// Server 5xx error — hard-navigate so the server renders the
|
|
423
543
|
// error page as HTML. See design/10-error-handling.md
|
|
424
544
|
// §"Error Page Rendering for Client Navigation".
|
|
545
|
+
//
|
|
546
|
+
// Set hard-navigating flag BEFORE setting window.location.href:
|
|
547
|
+
// 1. Prevents Navigation API from intercepting → infinite loop
|
|
548
|
+
// 2. Causes NavigationRoot to throw unresolvedThenable → prevents
|
|
549
|
+
// React from rendering children during page teardown (avoids
|
|
550
|
+
// "Rendered more hooks" crashes). See TIM-626.
|
|
425
551
|
if (error instanceof ServerErrorResponse) {
|
|
552
|
+
setHardNavigating(true);
|
|
426
553
|
window.location.href = error.url;
|
|
427
554
|
return new Promise(() => {}) as never;
|
|
428
555
|
}
|
|
@@ -430,19 +557,38 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
430
557
|
if (isAbortError(error)) return;
|
|
431
558
|
throw error;
|
|
432
559
|
} finally {
|
|
560
|
+
// Clear the abort controller so we don't abort a completed navigation
|
|
561
|
+
// when the next one starts. In dev mode, the RSC body stream stays
|
|
562
|
+
// open after data arrives (React's Flight client waits for debug rows).
|
|
563
|
+
// Aborting a "completed" navigation kills the open stream reader →
|
|
564
|
+
// "BodyStreamBuffer was aborted". By clearing the controller here,
|
|
565
|
+
// createNavAbort() becomes a no-op for completed navigations.
|
|
566
|
+
if (currentNavAbort === navAbort) {
|
|
567
|
+
currentNavAbort = null;
|
|
568
|
+
}
|
|
433
569
|
setPending(false);
|
|
570
|
+
// Resolve the Navigation API deferred — clears the browser's native
|
|
571
|
+
// loading state (tab spinner) at the same time as the TopLoader.
|
|
572
|
+
deps.completeRouterNavigation?.();
|
|
434
573
|
}
|
|
435
574
|
}
|
|
436
575
|
|
|
437
576
|
async function refresh(): Promise<void> {
|
|
438
577
|
const currentUrl = deps.getCurrentUrl();
|
|
578
|
+
const navAbort = createNavAbort();
|
|
439
579
|
|
|
440
580
|
setPending(true, currentUrl);
|
|
441
581
|
|
|
442
582
|
try {
|
|
443
583
|
const headElements = await renderViaTransition(currentUrl, async () => {
|
|
444
584
|
// No state tree sent — server renders the complete RSC payload
|
|
445
|
-
const result = await fetchRscPayload(
|
|
585
|
+
const result = await fetchRscPayload(
|
|
586
|
+
currentUrl,
|
|
587
|
+
deps,
|
|
588
|
+
undefined,
|
|
589
|
+
undefined,
|
|
590
|
+
navAbort.signal
|
|
591
|
+
);
|
|
446
592
|
// History push handled by renderViaTransition (stores merged payload)
|
|
447
593
|
updateSegmentCache(result.segmentInfo);
|
|
448
594
|
const navState = updateNavigationState(result.params, currentUrl);
|
|
@@ -450,12 +596,25 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
450
596
|
});
|
|
451
597
|
|
|
452
598
|
applyHead(headElements);
|
|
599
|
+
} catch (error) {
|
|
600
|
+
// Stale transition (superseded by a newer navigation) or aborted
|
|
601
|
+
// fetch — silently ignore. See TIM-629.
|
|
602
|
+
if (isAbortError(error)) return;
|
|
603
|
+
throw error;
|
|
453
604
|
} finally {
|
|
605
|
+
if (currentNavAbort === navAbort) {
|
|
606
|
+
currentNavAbort = null;
|
|
607
|
+
}
|
|
454
608
|
setPending(false);
|
|
609
|
+
deps.completeRouterNavigation?.();
|
|
455
610
|
}
|
|
456
611
|
}
|
|
457
612
|
|
|
458
|
-
async function handlePopState(
|
|
613
|
+
async function handlePopState(
|
|
614
|
+
url: string,
|
|
615
|
+
scrollY: number = 0,
|
|
616
|
+
externalSignal?: AbortSignal
|
|
617
|
+
): Promise<void> {
|
|
459
618
|
// Scroll position is read from history.state by the caller (browser-entry.ts)
|
|
460
619
|
// and passed in. This is more reliable than tracking scroll per-URL in memory
|
|
461
620
|
// because the browser maintains per-entry state even with duplicate URLs.
|
|
@@ -472,13 +631,14 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
472
631
|
// This happens when navigating back to the initial SSR'd page
|
|
473
632
|
// (its payload is null since it was rendered via SSR, not RSC fetch)
|
|
474
633
|
// or when the entry doesn't exist at all.
|
|
634
|
+
const navAbort = createNavAbort(externalSignal);
|
|
475
635
|
setPending(true, url);
|
|
476
636
|
try {
|
|
477
637
|
const headElements = await renderViaTransition(url, async () => {
|
|
478
638
|
const stateTree = segmentCache.serializeStateTree(
|
|
479
639
|
segmentElementCache.getMergeablePaths()
|
|
480
640
|
);
|
|
481
|
-
const result = await fetchRscPayload(url, deps, stateTree);
|
|
641
|
+
const result = await fetchRscPayload(url, deps, stateTree, undefined, navAbort.signal);
|
|
482
642
|
updateSegmentCache(result.segmentInfo);
|
|
483
643
|
const navState = updateNavigationState(result.params, url);
|
|
484
644
|
// History push handled by renderViaTransition (stores merged payload)
|
|
@@ -487,7 +647,15 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
487
647
|
|
|
488
648
|
applyHead(headElements);
|
|
489
649
|
restoreScrollAfterPaint(scrollY);
|
|
650
|
+
} catch (error) {
|
|
651
|
+
// Stale transition (superseded by a newer navigation) or aborted
|
|
652
|
+
// fetch — silently ignore. See TIM-629.
|
|
653
|
+
if (isAbortError(error)) return;
|
|
654
|
+
throw error;
|
|
490
655
|
} finally {
|
|
656
|
+
if (currentNavAbort === navAbort) {
|
|
657
|
+
currentNavAbort = null;
|
|
658
|
+
}
|
|
491
659
|
setPending(false);
|
|
492
660
|
}
|
|
493
661
|
}
|
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');
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Shows an animated progress bar at the top of the viewport while an RSC
|
|
5
5
|
* navigation is in flight. Injected automatically by the framework into
|
|
6
|
-
*
|
|
6
|
+
* NavigationRoot — users never render this component directly.
|
|
7
7
|
*
|
|
8
8
|
* Configuration is via timber.config.ts `topLoader` key. Enabled by default.
|
|
9
9
|
* Users who want a fully custom progress indicator disable the built-in one
|
|
@@ -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
|
|
|
@@ -97,7 +97,7 @@ function ensureKeyframes(): void {
|
|
|
97
97
|
// ─── Component ───────────────────────────────────────────────────
|
|
98
98
|
|
|
99
99
|
/**
|
|
100
|
-
* Internal top-loader component. Injected by
|
|
100
|
+
* Internal top-loader component. Injected by NavigationRoot.
|
|
101
101
|
*
|
|
102
102
|
* Reads pending navigation state from PendingNavigationContext.
|
|
103
103
|
* Phase transitions are derived synchronously during render:
|
|
@@ -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;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// useNavigationPending — returns true while an RSC navigation is in flight.
|
|
2
2
|
// See design/19-client-navigation.md §"useNavigationPending()"
|
|
3
3
|
//
|
|
4
|
-
// Reads from PendingNavigationContext (provided by
|
|
4
|
+
// Reads from PendingNavigationContext (provided by NavigationRoot) so the
|
|
5
5
|
// pending state shows immediately (urgent update) and clears atomically
|
|
6
6
|
// with the new tree (same startTransition commit).
|
|
7
7
|
|
|
@@ -34,6 +34,34 @@ import type { InterceptionContext } from './pipeline.js';
|
|
|
34
34
|
import { shouldSkipSegment } from './state-tree-diff.js';
|
|
35
35
|
import { loadModule } from './safe-load.js';
|
|
36
36
|
|
|
37
|
+
// ─── Client Reference Detection ──────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Symbol used by React Flight to mark client references.
|
|
41
|
+
* Client references are proxy objects created by @vitejs/plugin-rsc for
|
|
42
|
+
* 'use client' modules in the RSC environment. They must be passed to
|
|
43
|
+
* createElement() — calling them as functions throws:
|
|
44
|
+
* "Unexpectedly client reference export 'default' is called on server"
|
|
45
|
+
*/
|
|
46
|
+
const CLIENT_REFERENCE_TAG = Symbol.for('react.client.reference');
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Detect whether a component is a React client reference.
|
|
50
|
+
* Client references have $$typeof set to Symbol.for('react.client.reference')
|
|
51
|
+
* by registerClientReference() in the React Flight server runtime.
|
|
52
|
+
*
|
|
53
|
+
* Used to skip OTEL tracing wrappers that would call the component as a
|
|
54
|
+
* function. Client components must go through createElement only — they are
|
|
55
|
+
* serialized as references in the RSC Flight stream, not executed on the server.
|
|
56
|
+
*/
|
|
57
|
+
export function isClientReference(component: unknown): boolean {
|
|
58
|
+
return (
|
|
59
|
+
component != null &&
|
|
60
|
+
typeof component === 'function' &&
|
|
61
|
+
(component as unknown as Record<string, unknown>).$$typeof === CLIENT_REFERENCE_TAG
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
37
65
|
// ─── Param Coercion Error ─────────────────────────────────────────────────
|
|
38
66
|
|
|
39
67
|
/**
|
|
@@ -308,16 +336,25 @@ export async function buildRouteElement(
|
|
|
308
336
|
// Build element tree: page wrapped in layouts (innermost to outermost)
|
|
309
337
|
const h = createElement as (...args: unknown[]) => React.ReactElement;
|
|
310
338
|
|
|
311
|
-
//
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
339
|
+
// Build the page element.
|
|
340
|
+
// Client references ('use client' pages) must NOT be called as functions —
|
|
341
|
+
// they are proxy objects that throw when invoked. They must go through
|
|
342
|
+
// createElement only, which serializes them as client references in the
|
|
343
|
+
// RSC Flight stream. OTEL tracing is skipped for client components.
|
|
344
|
+
// See TIM-627 for the original bug.
|
|
345
|
+
let element: React.ReactElement;
|
|
346
|
+
if (isClientReference(PageComponent)) {
|
|
347
|
+
element = h(PageComponent, {});
|
|
348
|
+
} else {
|
|
349
|
+
const TracedPage = async (props: Record<string, unknown>) => {
|
|
350
|
+
return withSpan(
|
|
351
|
+
'timber.page',
|
|
352
|
+
{ 'timber.route': match.segments[match.segments.length - 1]?.urlPath ?? '/' },
|
|
353
|
+
() => (PageComponent as (props: Record<string, unknown>) => unknown)(props)
|
|
354
|
+
);
|
|
355
|
+
};
|
|
356
|
+
element = h(TracedPage, {});
|
|
357
|
+
}
|
|
321
358
|
|
|
322
359
|
// Build a lookup of layout components by segment for O(1) access.
|
|
323
360
|
const layoutBySegment = new Map(
|
|
@@ -429,22 +466,33 @@ export async function buildRouteElement(
|
|
|
429
466
|
? `${segment.urlPath === '/' ? '' : segment.urlPath}/${segment.segmentName}`
|
|
430
467
|
: segment.urlPath;
|
|
431
468
|
|
|
432
|
-
//
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
469
|
+
// Build the layout element.
|
|
470
|
+
// Same client reference guard as pages — client layouts must not be
|
|
471
|
+
// called as functions. OTEL tracing is skipped for client components.
|
|
472
|
+
let layoutElement: React.ReactElement;
|
|
473
|
+
if (isClientReference(layoutComponent)) {
|
|
474
|
+
layoutElement = h(layoutComponent, {
|
|
475
|
+
...slotProps,
|
|
476
|
+
children: element,
|
|
477
|
+
});
|
|
478
|
+
} else {
|
|
479
|
+
const layoutComponentRef = layoutComponent;
|
|
480
|
+
const TracedLayout = async (props: Record<string, unknown>) => {
|
|
481
|
+
return withSpan('timber.layout', { 'timber.segment': segmentId }, () =>
|
|
482
|
+
(layoutComponentRef as (props: Record<string, unknown>) => unknown)(props)
|
|
483
|
+
);
|
|
484
|
+
};
|
|
485
|
+
layoutElement = h(TracedLayout, {
|
|
486
|
+
...slotProps,
|
|
487
|
+
children: element,
|
|
488
|
+
});
|
|
489
|
+
}
|
|
439
490
|
|
|
440
491
|
element = h(SegmentProvider, {
|
|
441
492
|
segments: segmentPath,
|
|
442
493
|
segmentId,
|
|
443
494
|
parallelRouteKeys,
|
|
444
|
-
children:
|
|
445
|
-
...slotProps,
|
|
446
|
-
children: element,
|
|
447
|
-
}),
|
|
495
|
+
children: layoutElement,
|
|
448
496
|
});
|
|
449
497
|
}
|
|
450
498
|
}
|
|
@@ -20,6 +20,7 @@ import { TimberErrorBoundary } from '../client/error-boundary.js';
|
|
|
20
20
|
import SlotErrorFallback from '../client/slot-error-fallback.js';
|
|
21
21
|
import { SlotAccessGate } from './access-gate.js';
|
|
22
22
|
import { wrapSegmentWithErrorBoundaries } from './error-boundary-wrapper.js';
|
|
23
|
+
import { isClientReference } from './route-element-builder.js';
|
|
23
24
|
import { loadModule } from './safe-load.js';
|
|
24
25
|
import type { InterceptionContext, RouteMatch } from './pipeline.js';
|
|
25
26
|
import { DenySignal, RedirectSignal } from './primitives.js';
|
|
@@ -177,44 +178,45 @@ export async function resolveSlotElement(
|
|
|
177
178
|
// §"Slot Access Failure = Graceful Degradation"
|
|
178
179
|
const denyFallback = await renderDefaultFallback(slotNode, h);
|
|
179
180
|
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
// the
|
|
185
|
-
//
|
|
186
|
-
// references. By catching all errors here and returning a fallback,
|
|
187
|
-
// React sees a resolved component and emits a proper Flight row.
|
|
181
|
+
// Build the slot page element.
|
|
182
|
+
// Client references ('use client' pages) must NOT be called as functions —
|
|
183
|
+
// they are proxy objects that throw when invoked. For client references,
|
|
184
|
+
// use createElement directly. Error catching is unnecessary because client
|
|
185
|
+
// references are serialized as references in the RSC Flight stream — they
|
|
186
|
+
// don't execute on the server. See TIM-627.
|
|
188
187
|
//
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
188
|
+
// For server components, wrap in SafeSlotPage to catch ALL errors at the
|
|
189
|
+
// component level. This prevents errors from leaving unresolved Flight
|
|
190
|
+
// rows in the RSC stream — see TIM-524 for details.
|
|
191
|
+
let element: React.ReactElement;
|
|
192
|
+
if (isClientReference(SlotPage)) {
|
|
193
|
+
element = h(SlotPage, {});
|
|
194
|
+
} else {
|
|
195
|
+
const SafeSlotPage = async (props: Record<string, unknown>) => {
|
|
196
|
+
try {
|
|
197
|
+
return await (SlotPage as (props: Record<string, unknown>) => unknown)(props);
|
|
198
|
+
} catch (error) {
|
|
199
|
+
// RedirectSignal must propagate — the pipeline handles redirects
|
|
200
|
+
// at the top level. Swallowing it here would silently return
|
|
201
|
+
// fallback content instead of redirecting. See TIM-554.
|
|
202
|
+
if (error instanceof RedirectSignal) {
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
205
|
+
if (error instanceof DenySignal) {
|
|
206
|
+
return denyFallback;
|
|
207
|
+
}
|
|
208
|
+
// Log the error but don't re-throw — returning fallback ensures
|
|
209
|
+
// the Flight row is resolved and the page hydrates correctly.
|
|
210
|
+
logRenderError({
|
|
211
|
+
method: '',
|
|
212
|
+
path: '',
|
|
213
|
+
error,
|
|
214
|
+
});
|
|
204
215
|
return denyFallback;
|
|
205
216
|
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
method: '',
|
|
210
|
-
path: '',
|
|
211
|
-
error,
|
|
212
|
-
});
|
|
213
|
-
return denyFallback;
|
|
214
|
-
}
|
|
215
|
-
};
|
|
216
|
-
|
|
217
|
-
let element: React.ReactElement = h(SafeSlotPage, {});
|
|
217
|
+
};
|
|
218
|
+
element = h(SafeSlotPage, {});
|
|
219
|
+
}
|
|
218
220
|
|
|
219
221
|
// Wrap with error boundaries and layouts from intermediate slot segments
|
|
220
222
|
// (everything between slot root and leaf). Process innermost-first, same
|
package/src/server/ssr-entry.ts
CHANGED
|
@@ -227,7 +227,7 @@ export async function handleSsr(
|
|
|
227
227
|
const _decodeEnd = performance.now();
|
|
228
228
|
|
|
229
229
|
// Wrap with the same component tree structure as the client hydration
|
|
230
|
-
// tree (
|
|
230
|
+
// tree (NavigationRoot → PendingNavigationProvider → TopLoader →
|
|
231
231
|
// TimberNuqsAdapter → NuqsAdapterProvider → NavigationProvider).
|
|
232
232
|
// This ensures useId() produces matching IDs on both sides, preventing
|
|
233
233
|
// hydration mismatches in libraries like Radix UI. See TIM-532.
|