@timber-js/app 0.2.0-alpha.125 → 0.2.0-alpha.127
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/dist/_chunks/{actions-418ygYKC.js → actions-DX2gkoOZ.js} +2 -2
- package/dist/_chunks/{actions-418ygYKC.js.map → actions-DX2gkoOZ.js.map} +1 -1
- package/dist/_chunks/{codec-registry-G9hD5LOq.js → codec-registry-DqkeAydt.js} +1 -1
- package/dist/_chunks/{codec-registry-G9hD5LOq.js.map → codec-registry-DqkeAydt.js.map} +1 -1
- package/dist/_chunks/{define-BJMwm0c4.js → define-DMq-Yjp7.js} +2 -2
- package/dist/_chunks/{define-BJMwm0c4.js.map → define-DMq-Yjp7.js.map} +1 -1
- package/dist/_chunks/{navigation-root-CeNTCxzS.js → navigation-root-B5-RmsPi.js} +35 -28
- package/dist/_chunks/navigation-root-B5-RmsPi.js.map +1 -0
- package/dist/_chunks/{use-segment-params-ByGqwUuO.js → use-segment-params-CIeinIx2.js} +2 -32
- package/dist/_chunks/{use-segment-params-ByGqwUuO.js.map → use-segment-params-CIeinIx2.js.map} +1 -1
- package/dist/_chunks/{walkers-DMSGZeCk.js → walkers-E5ss0qES.js} +7 -5
- package/dist/_chunks/walkers-E5ss0qES.js.map +1 -0
- package/dist/client/index.js +35 -79
- package/dist/client/index.js.map +1 -1
- package/dist/client/internal.js +2 -2
- package/dist/client/link.d.ts.map +1 -1
- package/dist/client/nav-link-store.d.ts +3 -12
- package/dist/client/nav-link-store.d.ts.map +1 -1
- package/dist/client/navigation-root.d.ts.map +1 -1
- package/dist/client/top-loader.d.ts.map +1 -1
- package/dist/client/use-pending-navigation.d.ts +3 -6
- package/dist/client/use-pending-navigation.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/params/index.js +1 -1
- package/dist/routing/codegen-shared.d.ts +5 -2
- package/dist/routing/codegen-shared.d.ts.map +1 -1
- package/dist/routing/index.js +1 -1
- package/dist/segment-params/index.js +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/internal.js +18 -3
- package/dist/server/internal.js.map +1 -1
- package/dist/server/pipeline-outcome.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/client/link.tsx +29 -84
- package/src/client/nav-link-store.ts +3 -13
- package/src/client/navigation-root.tsx +5 -22
- package/src/client/top-loader.tsx +5 -15
- package/src/client/use-pending-navigation.ts +18 -16
- package/src/routing/codegen-shared.ts +6 -3
- package/src/server/pipeline-outcome.ts +23 -2
- package/dist/_chunks/navigation-root-CeNTCxzS.js.map +0 -1
- package/dist/_chunks/walkers-DMSGZeCk.js.map +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"pipeline-outcome.d.ts","sourceRoot":"","sources":["../../src/server/pipeline-outcome.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAgBH,OAAO,EAAE,cAAc,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAE7D,OAAO,KAAK,EAAE,cAAc,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;
|
|
1
|
+
{"version":3,"file":"pipeline-outcome.d.ts","sourceRoot":"","sources":["../../src/server/pipeline-outcome.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAgBH,OAAO,EAAE,cAAc,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAE7D,OAAO,KAAK,EAAE,cAAc,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAchE,MAAM,MAAM,SAAS,GAAG,OAAO,GAAG,YAAY,GAAG,QAAQ,CAAC;AAE1D,MAAM,MAAM,YAAY,GACpB;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,KAAK,EAAE,SAAS,CAAC;IAAC,QAAQ,EAAE,QAAQ,CAAA;CAAE,GAC1D;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,KAAK,EAAE,SAAS,CAAC;IAAC,MAAM,EAAE,cAAc,CAAA;CAAE,GAC9D;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,SAAS,CAAC;IAAC,MAAM,EAAE,UAAU,CAAA;CAAE,GACtD;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,SAAS,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,CAAC;AAExD,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,OAAO,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B,KAAK,CAAC,EAAE,UAAU,CAAC;CACpB;AAID;;;;;;;;GAQG;AACH,wBAAsB,iBAAiB,CACrC,MAAM,EAAE,cAAc,EACtB,OAAO,EAAE,YAAY,EACrB,GAAG,EAAE,cAAc,GAClB,OAAO,CAAC,QAAQ,CAAC,CA2HnB"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@timber-js/app",
|
|
3
|
-
"version": "0.2.0-alpha.
|
|
3
|
+
"version": "0.2.0-alpha.127",
|
|
4
4
|
"description": "Vite-native React framework built for Servers and Serverless Platforms — correct HTTP semantics, real status codes, pages that work without JavaScript",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cloudflare-workers",
|
package/src/client/link.tsx
CHANGED
|
@@ -19,9 +19,7 @@
|
|
|
19
19
|
// - searchParams and inline query string are mutually exclusive
|
|
20
20
|
|
|
21
21
|
import {
|
|
22
|
-
|
|
23
|
-
useEffect,
|
|
24
|
-
useRef,
|
|
22
|
+
useTransition,
|
|
25
23
|
type AnchorHTMLAttributes,
|
|
26
24
|
type ReactNode,
|
|
27
25
|
type MouseEvent as ReactMouseEvent,
|
|
@@ -34,15 +32,11 @@ import { LinkStatusContext } from './use-link-status.js';
|
|
|
34
32
|
import { getRouterOrNull } from './router-ref.js';
|
|
35
33
|
import { getSsrData } from './ssr-data.js';
|
|
36
34
|
import { mergePreservedSearchParams } from '../shared/merge-search-params.js';
|
|
37
|
-
import {
|
|
38
|
-
setLinkForCurrentNavigation,
|
|
39
|
-
unmountLinkForCurrentNavigation,
|
|
40
|
-
LINK_IDLE,
|
|
41
|
-
type LinkPendingInstance,
|
|
42
|
-
} from './link-pending-store.js';
|
|
43
|
-
import { setNavLinkMetadata } from './nav-link-store.js';
|
|
44
|
-
import { hasNavigationApi } from './navigation-api.js';
|
|
45
35
|
import { getLinkCodec } from '../params/codec-registry.js';
|
|
36
|
+
import type { LinkStatus } from './use-link-status.js';
|
|
37
|
+
|
|
38
|
+
const LINK_PENDING: LinkStatus = { isPending: true };
|
|
39
|
+
const LINK_IDLE: LinkStatus = { isPending: false };
|
|
46
40
|
|
|
47
41
|
// ─── Current Search Params ────────────────────────────────────────
|
|
48
42
|
|
|
@@ -502,42 +496,12 @@ export const Link: LinkFunction = function LinkImpl(props: any) {
|
|
|
502
496
|
const { href: baseHref } = buildLinkProps({ href, params: segmentParams, searchParams });
|
|
503
497
|
|
|
504
498
|
// ─── Per-link pending state ─────────────────────────────────────────
|
|
505
|
-
// Each Link
|
|
506
|
-
//
|
|
507
|
-
//
|
|
508
|
-
//
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
// commits atomically with the new tree.
|
|
512
|
-
//
|
|
513
|
-
// Uses useState (not useOptimistic) to avoid TIM-964: useOptimistic
|
|
514
|
-
// setters inside async startTransition + await decodePromise
|
|
515
|
-
// trigger "Rendered more hooks" when two navigations overlap
|
|
516
|
-
// under the Navigation API.
|
|
517
|
-
//
|
|
518
|
-
// See design/19-client-navigation.md §"Per-Link Pending State"
|
|
519
|
-
const [linkStatus, setIsPending] = useState(LINK_IDLE);
|
|
520
|
-
|
|
521
|
-
// Build the link instance ref for the pending store.
|
|
522
|
-
// The ref is stable across renders — we update the setter on each
|
|
523
|
-
// render to keep it current.
|
|
524
|
-
const linkInstanceRef = useRef<LinkPendingInstance | null>(null);
|
|
525
|
-
if (!linkInstanceRef.current) {
|
|
526
|
-
linkInstanceRef.current = { setIsPending };
|
|
527
|
-
} else {
|
|
528
|
-
linkInstanceRef.current.setIsPending = setIsPending;
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
// Clean up if this link unmounts while it's the current navigation link.
|
|
532
|
-
// Prevents calling setOptimistic on an unmounted component.
|
|
533
|
-
useEffect(() => {
|
|
534
|
-
const instance = linkInstanceRef.current;
|
|
535
|
-
return () => {
|
|
536
|
-
if (instance) {
|
|
537
|
-
unmountLinkForCurrentNavigation(instance);
|
|
538
|
-
}
|
|
539
|
-
};
|
|
540
|
-
}, []);
|
|
499
|
+
// Each Link owns a useTransition. On click, it wraps
|
|
500
|
+
// router.navigate() in startTransition — only this Link re-renders
|
|
501
|
+
// for isPending, zero siblings touched. The transition tracks the
|
|
502
|
+
// full navigation lifecycle (fetch + render + commit).
|
|
503
|
+
const [isPending, startTransition] = useTransition();
|
|
504
|
+
const linkStatus = isPending ? LINK_PENDING : LINK_IDLE;
|
|
541
505
|
|
|
542
506
|
// Preserve search params from the current URL when requested.
|
|
543
507
|
// useSearchParams() works during both SSR (reads from request context)
|
|
@@ -577,49 +541,30 @@ export const Link: LinkFunction = function LinkImpl(props: any) {
|
|
|
577
541
|
}
|
|
578
542
|
|
|
579
543
|
const router = getRouterOrNull();
|
|
580
|
-
if (!router) return;
|
|
544
|
+
if (!router) return;
|
|
581
545
|
|
|
582
|
-
const
|
|
546
|
+
const navHref = preserveSearchParams
|
|
547
|
+
? mergePreservedSearchParams(baseHref, getCurrentSearch(), preserveSearchParams)
|
|
548
|
+
: resolvedHref;
|
|
583
549
|
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
// naturally — do NOT call preventDefault(). The navigate event
|
|
592
|
-
// handler intercepts it and runs the RSC pipeline. This is a
|
|
593
|
-
// user-initiated navigation, so Chrome shows the native loading
|
|
594
|
-
// indicator (tab spinner). Metadata (scroll, link instance) is
|
|
595
|
-
// passed via nav-link-store so the handler can configure the nav.
|
|
596
|
-
//
|
|
597
|
-
// Without Navigation API (fallback), preventDefault and drive
|
|
598
|
-
// navigation through the router as before.
|
|
599
|
-
if (hasNavigationApi()) {
|
|
600
|
-
setNavLinkMetadata({
|
|
601
|
-
scroll: shouldScroll,
|
|
602
|
-
linkInstance: linkInstanceRef.current,
|
|
603
|
-
});
|
|
604
|
-
// Don't preventDefault — let the <a> click fire the navigate event
|
|
550
|
+
const resolved = new URL(navHref, window.location.href);
|
|
551
|
+
|
|
552
|
+
if (
|
|
553
|
+
resolved.pathname === window.location.pathname &&
|
|
554
|
+
resolved.search === window.location.search &&
|
|
555
|
+
resolved.hash
|
|
556
|
+
) {
|
|
605
557
|
return;
|
|
606
558
|
}
|
|
607
559
|
|
|
608
|
-
// Hash-only hrefs — let the browser handle scroll-to-anchor natively.
|
|
609
|
-
// The Navigation API path already skips these via event.hashChange;
|
|
610
|
-
// this covers the fallback (Safari/Firefox) where we drive navigation manually.
|
|
611
|
-
if (resolvedHref.startsWith('#')) return;
|
|
612
|
-
|
|
613
|
-
// History API fallback — prevent default and navigate via router
|
|
614
560
|
event.preventDefault();
|
|
615
561
|
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
const navHref = preserveSearchParams
|
|
619
|
-
? mergePreservedSearchParams(baseHref, getCurrentSearch(), preserveSearchParams)
|
|
620
|
-
: resolvedHref;
|
|
562
|
+
const shouldScroll = scroll !== false;
|
|
563
|
+
const absoluteHref = resolved.pathname + resolved.search;
|
|
621
564
|
|
|
622
|
-
|
|
565
|
+
startTransition(async () => {
|
|
566
|
+
await router.navigate(absoluteHref, { scroll: shouldScroll });
|
|
567
|
+
});
|
|
623
568
|
}
|
|
624
569
|
: userOnClick; // External links — just pass through user's onClick
|
|
625
570
|
|
|
@@ -630,11 +575,11 @@ export const Link: LinkFunction = function LinkImpl(props: any) {
|
|
|
630
575
|
userOnMouseEnter?.(event);
|
|
631
576
|
const router = getRouterOrNull();
|
|
632
577
|
if (router) {
|
|
633
|
-
// Re-merge preserved search params at hover time for fresh prefetch URL
|
|
634
578
|
const prefetchHref = preserveSearchParams
|
|
635
579
|
? mergePreservedSearchParams(baseHref, getCurrentSearch(), preserveSearchParams)
|
|
636
580
|
: resolvedHref;
|
|
637
|
-
|
|
581
|
+
const resolved = new URL(prefetchHref, window.location.href);
|
|
582
|
+
router.prefetch(resolved.pathname + resolved.search);
|
|
638
583
|
}
|
|
639
584
|
}
|
|
640
585
|
: userOnMouseEnter;
|
|
@@ -2,26 +2,16 @@
|
|
|
2
2
|
* Navigation Link Store — passes per-link metadata from Link's onClick
|
|
3
3
|
* to the Navigation API's navigate event handler.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* The navigate event handler reads this metadata to configure the RSC
|
|
9
|
-
* navigation with the correct options.
|
|
10
|
-
*
|
|
11
|
-
* This store is consumed once per navigation — after reading, the metadata
|
|
12
|
-
* is cleared. If no metadata is present (e.g., a plain <a> tag without
|
|
13
|
-
* our Link component), the navigate handler uses default options.
|
|
5
|
+
* Used for external navigations (plain <a> tags intercepted by the
|
|
6
|
+
* Navigation API). Link components drive navigation directly via
|
|
7
|
+
* router.navigate() and don't use this store.
|
|
14
8
|
*
|
|
15
9
|
* See design/19-client-navigation.md §"Navigation API Integration"
|
|
16
10
|
*/
|
|
17
11
|
|
|
18
|
-
import type { LinkPendingInstance } from './link-pending-store.js';
|
|
19
|
-
|
|
20
12
|
export interface NavLinkMetadata {
|
|
21
13
|
/** Whether to scroll to top after navigation. Default: true. */
|
|
22
14
|
scroll: boolean;
|
|
23
|
-
/** The Link's pending state instance for per-link status tracking. */
|
|
24
|
-
linkInstance: LinkPendingInstance | null;
|
|
25
15
|
}
|
|
26
16
|
|
|
27
17
|
let pendingMetadata: NavLinkMetadata | null = null;
|
|
@@ -32,7 +32,6 @@
|
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
34
|
import { createElement, Fragment, startTransition, useState, type ReactNode } from 'react';
|
|
35
|
-
import { activateLinkPending, resetLinkPending } from './link-pending-store.js';
|
|
36
35
|
import { PendingNavigationProvider } from './navigation-context.js';
|
|
37
36
|
import { TopLoader, type TopLoaderConfig } from './top-loader.js';
|
|
38
37
|
|
|
@@ -201,29 +200,20 @@ export function NavigationRoot({
|
|
|
201
200
|
url: string,
|
|
202
201
|
perform: () => Promise<{ element: ReactNode; decodePromise: Promise<void> | null }>
|
|
203
202
|
) => {
|
|
204
|
-
// Urgent: show pending state immediately (for TopLoader / usePendingNavigation)
|
|
205
|
-
setPendingUrl(url);
|
|
206
|
-
|
|
207
203
|
// Increment the transition counter SYNCHRONOUSLY (before startTransition
|
|
208
204
|
// schedules the async work). Each call gets a unique transId; the counter
|
|
209
205
|
// is the same globalThis singleton, so a newer call always has a higher id.
|
|
210
206
|
const counter = getTransitionCounter();
|
|
211
207
|
const transId = ++counter.id;
|
|
212
208
|
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
// so it commits atomically with the new tree.
|
|
218
|
-
//
|
|
219
|
-
// CRITICAL: activateLinkPending must NOT be called inside the
|
|
220
|
-
// startTransition that awaits decodePromise. With useOptimistic,
|
|
221
|
-
// that combination triggers "Rendered more hooks" when two
|
|
222
|
-
// navigations overlap under the Navigation API (TIM-964).
|
|
223
|
-
activateLinkPending();
|
|
209
|
+
// NO urgent setPendingUrl here. Per-link pending state is driven by
|
|
210
|
+
// Link's own useTransition — only the clicked Link re-renders for
|
|
211
|
+
// isPending. setPendingUrl is set INSIDE startTransition so the
|
|
212
|
+
// TopLoader activates as a transition update (no full-tree re-render).
|
|
224
213
|
|
|
225
214
|
return new Promise<void>((resolve, reject) => {
|
|
226
215
|
startTransition(async () => {
|
|
216
|
+
setPendingUrl(url);
|
|
227
217
|
try {
|
|
228
218
|
const { element, decodePromise } = await perform();
|
|
229
219
|
if (counter.id !== transId) {
|
|
@@ -232,14 +222,8 @@ export function NavigationRoot({
|
|
|
232
222
|
return;
|
|
233
223
|
}
|
|
234
224
|
setElement(element);
|
|
235
|
-
// Await RSC stream completion inside the transition so the
|
|
236
|
-
// link's pending state (set urgently above) persists until
|
|
237
|
-
// the full payload is consumed. resetLinkPending() below
|
|
238
|
-
// commits atomically with the new tree when the transition
|
|
239
|
-
// settles — no frame where pending=false but old content shows.
|
|
240
225
|
if (decodePromise) await decodePromise;
|
|
241
226
|
if (counter.id === transId) {
|
|
242
|
-
resetLinkPending();
|
|
243
227
|
setPendingUrl(null);
|
|
244
228
|
resolve();
|
|
245
229
|
} else {
|
|
@@ -248,7 +232,6 @@ export function NavigationRoot({
|
|
|
248
232
|
} catch (err) {
|
|
249
233
|
if (counter.id === transId) {
|
|
250
234
|
setPendingUrl(null);
|
|
251
|
-
resetLinkPending();
|
|
252
235
|
}
|
|
253
236
|
reject(err);
|
|
254
237
|
}
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
'use client';
|
|
29
29
|
|
|
30
30
|
import { useState, createElement } from 'react';
|
|
31
|
-
import {
|
|
31
|
+
import { usePendingNavigation } from './use-pending-navigation.js';
|
|
32
32
|
|
|
33
33
|
// ─── Types ───────────────────────────────────────────────────────
|
|
34
34
|
|
|
@@ -111,20 +111,10 @@ function ensureKeyframes(): void {
|
|
|
111
111
|
* (getDerivedStateFromProps pattern) or triggered by DOM events.
|
|
112
112
|
*/
|
|
113
113
|
export function TopLoader({ config }: { config?: TopLoaderConfig }): React.ReactElement | null {
|
|
114
|
-
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
|
|
118
|
-
// immediately when a real RSC navigation starts.
|
|
119
|
-
//
|
|
120
|
-
// We intentionally do NOT check hasNativeNavigationTransition() here.
|
|
121
|
-
// The Navigation API's transition is active for ALL intercepted
|
|
122
|
-
// navigations, including shallow URL updates (nuqs search param
|
|
123
|
-
// changes with shallow: true) and prevented navigations. Those
|
|
124
|
-
// briefly set navigation.transition via event.intercept() but do
|
|
125
|
-
// NOT trigger RSC fetches — showing the TopLoader would be incorrect.
|
|
126
|
-
// pendingUrl is the authoritative signal for "we're fetching RSC data."
|
|
127
|
-
const isPending = pendingUrl !== null;
|
|
114
|
+
// Read pending state from the router's external store via
|
|
115
|
+
// useSyncExternalStore (inside usePendingNavigation). Only this
|
|
116
|
+
// component re-renders when pending changes — no full-tree re-render.
|
|
117
|
+
const isPending = usePendingNavigation();
|
|
128
118
|
|
|
129
119
|
const color = config?.color ?? DEFAULT_COLOR;
|
|
130
120
|
const height = config?.height ?? DEFAULT_HEIGHT;
|
|
@@ -1,21 +1,25 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
//
|
|
4
|
-
// Reads from PendingNavigationContext (provided by NavigationRoot) so the
|
|
5
|
-
// pending state shows immediately (urgent update) and clears atomically
|
|
6
|
-
// with the new tree (same startTransition commit).
|
|
1
|
+
import { useSyncExternalStore } from 'react';
|
|
2
|
+
import { getRouterOrNull } from './router-ref.js';
|
|
7
3
|
|
|
8
|
-
|
|
4
|
+
function subscribe(onStoreChange: () => void): () => void {
|
|
5
|
+
const router = getRouterOrNull();
|
|
6
|
+
if (!router) return () => {};
|
|
7
|
+
return router.onPendingChange(onStoreChange);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function getSnapshot(): boolean {
|
|
11
|
+
const router = getRouterOrNull();
|
|
12
|
+
return router ? router.isPending() : false;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const getServerSnapshot = getSnapshot;
|
|
9
16
|
|
|
10
17
|
/**
|
|
11
18
|
* Returns true while an RSC navigation is in flight.
|
|
12
19
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* It does NOT include Suspense streaming after the shell — only the
|
|
18
|
-
* initial shell reconciliation.
|
|
20
|
+
* Reads from the router's external pending store via useSyncExternalStore.
|
|
21
|
+
* Only components that call this hook re-render when pending state
|
|
22
|
+
* changes — no full-tree re-render.
|
|
19
23
|
*
|
|
20
24
|
* ```tsx
|
|
21
25
|
* 'use client'
|
|
@@ -32,7 +36,5 @@ import { usePendingNavigationUrl } from './navigation-context.js';
|
|
|
32
36
|
* ```
|
|
33
37
|
*/
|
|
34
38
|
export function usePendingNavigation(): boolean {
|
|
35
|
-
|
|
36
|
-
// During SSR or outside PendingNavigationProvider, no navigation is pending
|
|
37
|
-
return pendingUrl !== null;
|
|
39
|
+
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
|
38
40
|
}
|
|
@@ -85,13 +85,16 @@ export function buildCodecChainType(
|
|
|
85
85
|
* schema's InferCodecType. Falls back to the param's base type when
|
|
86
86
|
* schema doesn't declare this param (schemas are opt-in per key).
|
|
87
87
|
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
88
|
+
* Uses `infer` to extract the codec value rather than direct indexing
|
|
89
|
+
* because TypeScript doesn't narrow index validity inside a conditional
|
|
90
|
+
* type's true branch — `typeof schema.segmentParams[K]` produces TS2538
|
|
91
|
+
* even when the condition proves K is a valid key. In .d.ts files that
|
|
92
|
+
* error is suppressed and the type silently becomes `any`.
|
|
90
93
|
*/
|
|
91
94
|
export function buildSchemaParamType(p: ParamEntry, fallback?: string): string {
|
|
92
95
|
const key = JSON.stringify(p.bracketName);
|
|
93
96
|
const fb = fallback ?? p.type;
|
|
94
|
-
return
|
|
97
|
+
return `typeof schema['segmentParams'] extends Record<${key}, infer V> ? InferCodecType<V> : ${fb}`;
|
|
95
98
|
}
|
|
96
99
|
|
|
97
100
|
/**
|
|
@@ -28,6 +28,16 @@ import { RedirectSignal, DenySignal } from './primitives.js';
|
|
|
28
28
|
import { isDebug } from './debug.js';
|
|
29
29
|
import type { PipelineConfig, RouteMatch } from './pipeline.js';
|
|
30
30
|
|
|
31
|
+
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
function rscErrorResponse(isRsc: boolean, status: number, headers?: Headers): Response {
|
|
34
|
+
if (!isRsc) return new Response(null, { status });
|
|
35
|
+
const h = headers ?? new Headers();
|
|
36
|
+
h.set('X-Timber-Error', '1');
|
|
37
|
+
h.set('content-type', 'application/json; charset=utf-8');
|
|
38
|
+
return new Response(JSON.stringify({ error: true, status }), { status, headers: h });
|
|
39
|
+
}
|
|
40
|
+
|
|
31
41
|
// ─── Phase Outcome ─────────────────────────────────────────────────────────
|
|
32
42
|
|
|
33
43
|
export type PhaseName = 'proxy' | 'middleware' | 'render';
|
|
@@ -129,12 +139,18 @@ export async function outcomeToResponse(
|
|
|
129
139
|
}
|
|
130
140
|
|
|
131
141
|
case 'error': {
|
|
142
|
+
// RSC payload requests (client navigation) expect Flight data, not HTML.
|
|
143
|
+
// Signal the error via X-Timber-Error so the client hard-navigates
|
|
144
|
+
// to the server-rendered error page instead of feeding HTML to the
|
|
145
|
+
// Flight decoder (which crashes with "enqueueModel is not a function").
|
|
146
|
+
const isRsc = (ctx.req.headers.get('Accept') ?? '').includes('text/x-component');
|
|
147
|
+
|
|
132
148
|
if (outcome.phase === 'proxy') {
|
|
133
149
|
logProxyError({ error: outcome.error });
|
|
134
150
|
await fireOnRequestError(outcome.error, ctx.req, 'proxy');
|
|
135
151
|
if (config.onPipelineError && outcome.error instanceof Error)
|
|
136
152
|
config.onPipelineError(outcome.error, 'proxy');
|
|
137
|
-
return
|
|
153
|
+
return rscErrorResponse(isRsc, 500);
|
|
138
154
|
}
|
|
139
155
|
|
|
140
156
|
if (outcome.phase === 'middleware') {
|
|
@@ -143,7 +159,7 @@ export async function outcomeToResponse(
|
|
|
143
159
|
if (config.onPipelineError && outcome.error instanceof Error) {
|
|
144
160
|
config.onPipelineError(outcome.error, 'middleware');
|
|
145
161
|
}
|
|
146
|
-
return
|
|
162
|
+
return rscErrorResponse(isRsc, 500);
|
|
147
163
|
}
|
|
148
164
|
|
|
149
165
|
const headers = ctx.responseHeaders ?? new Headers();
|
|
@@ -152,6 +168,11 @@ export async function outcomeToResponse(
|
|
|
152
168
|
await fireOnRequestError(outcome.error, ctx.req, 'render');
|
|
153
169
|
if (config.onPipelineError && outcome.error instanceof Error)
|
|
154
170
|
config.onPipelineError(outcome.error, 'render');
|
|
171
|
+
|
|
172
|
+
if (isRsc) {
|
|
173
|
+
return rscErrorResponse(true, 500, headers);
|
|
174
|
+
}
|
|
175
|
+
|
|
155
176
|
if (config.renderFallbackError) {
|
|
156
177
|
try {
|
|
157
178
|
// Clone user-supplied fallback error responses so downstream
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"navigation-root-CeNTCxzS.js","names":[],"sources":["../../src/client/link-pending-store.ts","../../src/client/top-loader.tsx","../../src/client/navigation-root.tsx"],"sourcesContent":["/**\n * Link Pending Store — per-link pending state.\n *\n * Tracks which link instance is currently navigating so that only the\n * clicked link shows pending. Uses useState in Link — the pending\n * state is set as an urgent update (outside startTransition) so it\n * shows immediately, then reset inside the transition so it commits\n * atomically with the new tree.\n *\n * Flow:\n * 1. Link click → setLinkForCurrentNavigation(instance) stores the ref\n * 2. NavigationRoot → activateLinkPending() calls\n * instance.setIsPending(LINK_PENDING) as an urgent update\n * 3. Inside startTransition → resetLinkPending() commits with new tree\n *\n * SINGLETON GUARANTEE: Uses `globalThis` via `Symbol.for` keys because\n * the RSC client bundler can duplicate this module across chunks.\n *\n * See design/19-client-navigation.md §\"Per-Link Pending State\"\n */\n\nimport type { LinkStatus } from './use-link-status.js';\n\n// ─── Types ───────────────────────────────────────────────────────\n\nexport interface LinkPendingInstance {\n setIsPending: (status: LinkStatus) => void;\n}\n\n// ─── Constants ───────────────────────────────────────────────────\n\nconst LINK_PENDING_KEY = Symbol.for('__timber_link_pending');\n\n/** Status object: link navigation in flight */\nexport const LINK_PENDING: LinkStatus = { isPending: true };\n\n/** Status object: link idle */\nexport const LINK_IDLE: LinkStatus = { isPending: false };\n\n// ─── Singleton Storage ───────────────────────────────────────────\n\nfunction getStore(): { current: LinkPendingInstance | null } {\n const g = globalThis as Record<symbol, unknown>;\n if (!g[LINK_PENDING_KEY]) {\n g[LINK_PENDING_KEY] = { current: null };\n }\n return g[LINK_PENDING_KEY] as { current: LinkPendingInstance | null };\n}\n\n// ─── Public API ──────────────────────────────────────────────────\n\n/**\n * Register the link instance that initiated the current navigation.\n * Called from Link's click handler. Does NOT call setIsPending —\n * that happens via activateLinkPending() called from NavigationRoot.\n *\n * Resets the previous link to idle immediately if a different link\n * is being registered (handles overlapping link clicks).\n */\nexport function setLinkForCurrentNavigation(link: LinkPendingInstance | null): void {\n const store = getStore();\n if (store.current && store.current !== link) {\n store.current.setIsPending(LINK_IDLE);\n }\n store.current = link;\n}\n\n/**\n * Activate pending state on the current link instance.\n * Called as an urgent update (outside startTransition) so the link\n * shows pending immediately. The corresponding resetLinkPending()\n * is called inside the transition so it commits atomically with\n * the new tree.\n */\nexport function activateLinkPending(): void {\n const store = getStore();\n store.current?.setIsPending(LINK_PENDING);\n}\n\n/**\n * Reset the current link's pending state and clear the store.\n * Call inside startTransition so the reset commits atomically\n * with the new tree (no frame where pending=false but old content\n * is still visible).\n */\nexport function resetLinkPending(): void {\n const store = getStore();\n if (store.current) {\n store.current.setIsPending(LINK_IDLE);\n store.current = null;\n }\n}\n\n/**\n * @deprecated No longer needed with useOptimistic. Kept for callers.\n */\nexport function getCurrentNavId(): number {\n return 0;\n}\n\n/**\n * Clean up the link pending store entirely.\n */\nexport function clearLinkPendingSetter(): void {\n getStore().current = null;\n}\n\n/**\n * Unmount a link instance from navigation tracking.\n */\nexport function unmountLinkForCurrentNavigation(link: LinkPendingInstance): void {\n const store = getStore();\n if (store.current === link) {\n store.current = null;\n }\n}\n","/**\n * TopLoader — Built-in progress bar for client navigations.\n *\n * Shows an animated progress bar at the top of the viewport while an RSC\n * navigation is in flight. Injected automatically by the framework into\n * NavigationRoot — users never render this component directly.\n *\n * Configuration is via timber.config.ts `topLoader` key. Enabled by default.\n * Users who want a fully custom progress indicator disable the built-in one\n * (`topLoader: { enabled: false }`) and use `usePendingNavigation()` directly.\n *\n * Animation approach: pure CSS @keyframes. The bar crawls from 0% to ~90%\n * width over ~30s using ease-out timing. When navigation completes, the bar\n * snaps to 100% and fades out over 200ms. No JS animation loops (RAF, setInterval).\n *\n * Phase transitions are derived synchronously during render (React's\n * getDerivedStateFromProps pattern) — no useEffect needed for state tracking.\n * The finishing → hidden cleanup uses onTransitionEnd from the CSS transition.\n *\n * When delay > 0, CSS animation-delay + a visibility keyframe ensure the bar\n * stays invisible during the delay period. If navigation finishes before the\n * delay, the bar was never visible so the finish transition is also invisible.\n *\n * See design/19-client-navigation.md §\"usePendingNavigation()\"\n * See LOCAL-336 for design decisions.\n */\n\n'use client';\n\nimport { useState, createElement } from 'react';\nimport { usePendingNavigationUrl } from './navigation-context.js';\n\n// ─── Types ───────────────────────────────────────────────────────\n\nexport interface TopLoaderConfig {\n /** Whether the top-loader is enabled. Default: true. */\n enabled?: boolean;\n /** Bar color. Default: '#2299DD'. */\n color?: string;\n /** Bar height in pixels. Default: 3. */\n height?: number;\n /** Show subtle glow/shadow effect. Default: false. */\n shadow?: boolean;\n /** Delay in ms before showing the bar. Default: 0. */\n delay?: number;\n /** CSS z-index. Default: 1600. */\n zIndex?: number;\n}\n\n// ─── Defaults ────────────────────────────────────────────────────\n\nconst DEFAULT_COLOR = '#2299DD';\nconst DEFAULT_HEIGHT = 3;\nconst DEFAULT_SHADOW = false;\nconst DEFAULT_DELAY = 0;\nconst DEFAULT_Z_INDEX = 1600;\n\n// ─── Keyframes ───────────────────────────────────────────────────\n\n// Unique keyframes name to avoid collisions with user styles.\nconst CRAWL_KEYFRAMES = '__timber_top_loader_crawl';\nconst APPEAR_KEYFRAMES = '__timber_top_loader_appear';\nconst FINISH_KEYFRAMES = '__timber_top_loader_finish';\n\n// Track whether the @keyframes rules have been injected into the document.\nlet keyframesInjected = false;\n\n/**\n * Inject the @keyframes rules into the document head once.\n * Called during render (idempotent). Uses a <style> tag so the\n * animations are available for inline-styled elements.\n */\nfunction ensureKeyframes(): void {\n if (keyframesInjected) return;\n if (typeof document === 'undefined') return;\n\n const style = document.createElement('style');\n style.textContent = `\n@keyframes ${CRAWL_KEYFRAMES} {\n 0% { width: 0%; }\n 100% { width: 90%; }\n}\n@keyframes ${APPEAR_KEYFRAMES} {\n from { opacity: 0; }\n to { opacity: 1; }\n}\n@keyframes ${FINISH_KEYFRAMES} {\n 0% { width: 90%; opacity: 1; }\n 50% { width: 100%; opacity: 1; }\n 100% { width: 100%; opacity: 0; }\n}\n`;\n document.head.appendChild(style);\n keyframesInjected = true;\n}\n\n// ─── Component ───────────────────────────────────────────────────\n\n/**\n * Internal top-loader component. Injected by NavigationRoot.\n *\n * Reads pending navigation state from PendingNavigationContext.\n * Phase transitions are derived synchronously during render:\n *\n * hidden → crawling: when isPending becomes true\n * crawling → finishing: when isPending becomes false\n * finishing → hidden: when CSS transition ends (onTransitionEnd)\n * finishing → crawling: when isPending becomes true again\n *\n * No useEffect — all state changes are either derived during render\n * (getDerivedStateFromProps pattern) or triggered by DOM events.\n */\nexport function TopLoader({ config }: { config?: TopLoaderConfig }): React.ReactElement | null {\n const pendingUrl = usePendingNavigationUrl();\n // Navigation is pending when the React-based pending URL is set.\n // pendingUrl is set as an urgent update in navigateTransition() —\n // React commits it before the next paint, so the TopLoader appears\n // immediately when a real RSC navigation starts.\n //\n // We intentionally do NOT check hasNativeNavigationTransition() here.\n // The Navigation API's transition is active for ALL intercepted\n // navigations, including shallow URL updates (nuqs search param\n // changes with shallow: true) and prevented navigations. Those\n // briefly set navigation.transition via event.intercept() but do\n // NOT trigger RSC fetches — showing the TopLoader would be incorrect.\n // pendingUrl is the authoritative signal for \"we're fetching RSC data.\"\n const isPending = pendingUrl !== null;\n\n const color = config?.color ?? DEFAULT_COLOR;\n const height = config?.height ?? DEFAULT_HEIGHT;\n const shadow = config?.shadow ?? DEFAULT_SHADOW;\n const delay = config?.delay ?? DEFAULT_DELAY;\n const zIndex = config?.zIndex ?? DEFAULT_Z_INDEX;\n\n const [phase, setPhase] = useState<'hidden' | 'crawling' | 'finishing'>('hidden');\n\n // ─── Synchronous phase derivation (getDerivedStateFromProps) ──\n // React allows setState during render if the value changes — it\n // immediately re-renders with the updated state before committing.\n\n if (isPending && (phase === 'hidden' || phase === 'finishing')) {\n setPhase('crawling');\n }\n if (!isPending && phase === 'crawling') {\n setPhase('finishing');\n }\n\n // Inject keyframes on first visible render (idempotent)\n if (phase !== 'hidden') {\n ensureKeyframes();\n }\n\n if (phase === 'hidden') return null;\n\n // ─── Styles ──────────────────────────────────────────────────\n\n const containerStyle: React.CSSProperties = {\n position: 'fixed',\n top: 0,\n left: 0,\n width: '100%',\n height: `${height}px`,\n zIndex,\n pointerEvents: 'none',\n };\n\n const barStyle: React.CSSProperties = {\n height: '100%',\n backgroundColor: color,\n ...(phase === 'crawling'\n ? {\n // Crawl from 0% to 90% over 30s. When delay > 0, both the crawl\n // and a visibility animation are delayed — the bar stays at width 0%\n // and opacity 0 during the delay, then appears and starts crawling.\n // With delay 0, the appear animation is instant (0s duration, no delay).\n animation: [\n `${CRAWL_KEYFRAMES} 30s ease-out ${delay}ms forwards`,\n `${APPEAR_KEYFRAMES} 0s ${delay}ms both`,\n ].join(', '),\n }\n : {\n // Finishing: fill to 100% then fade out via a keyframe animation.\n // We use a keyframe instead of a CSS transition because the\n // animation-to-transition handoff is unreliable — the browser\n // may not capture the animated width as the transition's \"from\"\n // value when both the animation removal and transition are\n // applied in the same render frame.\n animation: `${FINISH_KEYFRAMES} 400ms ease forwards`,\n }),\n ...(shadow\n ? {\n boxShadow: `0 0 10px ${color}, 0 0 5px ${color}`,\n }\n : {}),\n };\n\n // Clean up the finishing phase when the finish animation completes.\n const handleAnimationEnd =\n phase === 'finishing'\n ? (e: React.AnimationEvent) => {\n if (e.animationName === FINISH_KEYFRAMES) {\n setPhase('hidden');\n }\n }\n : undefined;\n\n return createElement(\n 'div',\n {\n 'style': containerStyle,\n 'aria-hidden': 'true',\n 'data-timber-top-loader': '',\n },\n createElement('div', { style: barStyle, onAnimationEnd: handleAnimationEnd })\n );\n}\n","/**\n * NavigationRoot — Wrapper component for transition-based rendering.\n *\n * Solves the \"new boundary has no old content\" problem for client-side\n * navigation. When React renders a completely new Suspense boundary via\n * root.render(), it shows the fallback immediately — root.render() is\n * always an urgent update regardless of startTransition.\n *\n * NavigationRoot holds the current element in React state. Navigation\n * updates call startTransition(() => setState(newElement)), which IS\n * a transition update. React keeps the old committed tree visible while\n * any new Suspense boundaries in the transition resolve.\n *\n * Also manages `pendingUrl` as React state with an urgent/transition split:\n * - Navigation START: `setPendingUrl(url)` is an urgent update — React\n * commits it before the next paint, showing the spinner immediately.\n * - Navigation END: `setPendingUrl(null)` is inside `startTransition`\n * alongside `setElement(newTree)` — both commit atomically, so the\n * spinner disappears in the same frame as the new content appears.\n *\n * Hard navigation guard: When a hard navigation is triggered (500 error,\n * version skew), the component throws an unresolved thenable AFTER all\n * hooks to suspend forever — preventing React from rendering children\n * during page teardown. The throw must come after hooks to satisfy\n * React's rules (same hook count every render) while still preventing\n * child renders that could hit hook count mismatches in components\n * whose positions shift during teardown. This pattern is borrowed from\n * Next.js (app-router.tsx pushRef.mpaNavigation — also after hooks).\n *\n * See design/05-streaming.md §\"deferSuspenseFor\"\n * See design/19-client-navigation.md §\"NavigationContext\"\n */\n\nimport { createElement, Fragment, startTransition, useState, type ReactNode } from 'react';\nimport { activateLinkPending, resetLinkPending } from './link-pending-store.js';\nimport { PendingNavigationProvider } from './navigation-context.js';\nimport { TopLoader, type TopLoaderConfig } from './top-loader.js';\n\n// ─── Navigation Transition Counter ──────────────────────────────\n// Monotonically increasing counter that increments each time\n// navigateTransition() is called. Used to detect stale transitions:\n// if a newer transition started while the current one's perform()\n// was in flight, the current transition is stale and should reject.\n//\n// Separate from the link-pending navId (which only increments on\n// link clicks). This counter covers all navigation types: link clicks,\n// programmatic navigate(), refresh(), and handlePopState().\n//\n// Uses globalThis for singleton guarantee across chunks — same pattern\n// as NavigationContext and the link pending store.\n\nconst NAV_TRANSITION_KEY = Symbol.for('__timber_nav_transition_counter');\n\nfunction getTransitionCounter(): { id: number } {\n const g = globalThis as Record<symbol, unknown>;\n if (!g[NAV_TRANSITION_KEY]) {\n g[NAV_TRANSITION_KEY] = { id: 0 };\n }\n return g[NAV_TRANSITION_KEY] as { id: number };\n}\n\n// ─── Hard Navigation Guard ──────────────────────────────────────\n\n/**\n * Module-level flag indicating a hard (MPA) navigation is in progress.\n *\n * When true:\n * - NavigationRoot throws an unresolved thenable to suspend forever,\n * preventing React from rendering children during page teardown\n * (avoids \"Rendered more hooks\" crashes).\n * - The Navigation API handler skips interception, letting the browser\n * perform a full page load (prevents infinite loops where\n * window.location.href → navigate event → router.navigate → 500 →\n * window.location.href → ...).\n *\n * Uses globalThis for singleton guarantee across chunks (same pattern\n * as NavigationContext). See design/19-client-navigation.md §\"Singleton\n * Guarantee via globalThis\".\n */\nconst HARD_NAV_KEY = Symbol.for('__timber_hard_navigating');\n\nfunction getHardNavStore(): { value: boolean } {\n const g = globalThis as Record<symbol, unknown>;\n if (!g[HARD_NAV_KEY]) {\n g[HARD_NAV_KEY] = { value: false };\n }\n return g[HARD_NAV_KEY] as { value: boolean };\n}\n\n/**\n * Set the hard-navigating flag. Call this BEFORE setting\n * window.location.href or window.location.reload() to prevent:\n * 1. React from rendering children during page teardown\n * 2. Navigation API from intercepting the hard navigation\n */\nexport function setHardNavigating(value: boolean): void {\n getHardNavStore().value = value;\n}\n\n/**\n * Check if a hard navigation is in progress.\n * Used by NavigationRoot (throw unresolvedThenable) and by the\n * Navigation API handler (skip interception).\n */\nexport function isHardNavigating(): boolean {\n return getHardNavStore().value;\n}\n\n/**\n * A thenable that never resolves. When thrown during React render,\n * it causes the component to suspend forever — React keeps the\n * old committed tree visible and never attempts to render children.\n *\n * This is the same pattern Next.js uses in app-router.tsx for MPA\n * navigations (pushRef.mpaNavigation → throw unresolvedThenable).\n */\n// for React's Suspense mechanism. Same pattern as Next.js's unresolvedThenable.\n// eslint-disable-next-line unicorn/no-thenable -- Intentionally a never-resolving thenable\nconst unresolvedThenable = { then() {} } as PromiseLike<never>;\n\n// ─── Module-level functions ──────────────────────────────────────\n\n/**\n * Module-level reference to the state setter wrapped in startTransition.\n * Used for non-navigation renders (applyRevalidation, popstate replay).\n */\nlet _transitionRender: ((element: ReactNode) => void) | null = null;\n\n/**\n * Module-level reference to the navigation transition function.\n * Wraps a full navigation (fetch + render) in a single startTransition\n * with the pending URL.\n */\nlet _navigateTransition:\n | ((\n pendingUrl: string,\n perform: () => Promise<{ element: ReactNode; decodePromise: Promise<void> | null }>\n ) => Promise<void>)\n | null = null;\n\n// ─── Component ───────────────────────────────────────────────────\n\n/**\n * Root wrapper component that enables transition-based rendering.\n *\n * Renders PendingNavigationProvider around children for the pending URL\n * context. The DOM tree matches the server-rendered HTML during hydration\n * (the provider renders no extra DOM elements).\n *\n * Usage in browser-entry.ts:\n * const rootEl = createElement(NavigationRoot, { initial: wrapped });\n * reactRoot = hydrateRoot(document, rootEl);\n *\n * Subsequent navigations:\n * navigateTransition(url, async () => { fetch; return wrappedElement; });\n *\n * Non-navigation renders:\n * transitionRender(newWrappedElement);\n */\nexport function NavigationRoot({\n initial,\n topLoaderConfig,\n}: {\n initial: ReactNode;\n topLoaderConfig?: TopLoaderConfig;\n}): ReactNode {\n const [element, setElement] = useState<ReactNode>(initial);\n const [pendingUrl, setPendingUrl] = useState<string | null>(null);\n\n // NOTE: We use standalone `startTransition` (imported from 'react'),\n // NOT `useTransition`. The `useTransition` hook's `startTransition`\n // is tied to a single fiber and tracks one async callback at a time.\n // When two navigations overlap (click slow-page, then click dashboard),\n // calling useTransition's startTransition twice with concurrent async\n // callbacks corrupts React's internal hook tracking — causing\n // \"Rendered more hooks than during the previous render.\"\n //\n // Standalone `startTransition` creates independent transition lanes\n // for each call, so concurrent navigations don't interfere. We don't\n // need useTransition's `isPending` — we track pending state via our\n // own `pendingUrl` useState.\n //\n // This matches the Next.js pattern (TIM-625): \"No useTransition in\n // the router at all — only standalone startTransition.\"\n\n // Non-navigation render (revalidation, popstate cached replay).\n _transitionRender = (newElement: ReactNode) => {\n startTransition(() => {\n setElement(newElement);\n });\n };\n\n // Full navigation transition.\n // setPendingUrl(url) is an URGENT update — React commits it before the next\n // paint, so the pending spinner appears immediately when navigation starts.\n // Inside startTransition: the async fetch + setElement + setPendingUrl(null)\n // are deferred. When the transition commits, the new tree and pendingUrl=null\n // both apply in the same React commit — making the pending→active transition\n // atomic (no frame where pending is false but the old tree is still visible).\n _navigateTransition = (\n url: string,\n perform: () => Promise<{ element: ReactNode; decodePromise: Promise<void> | null }>\n ) => {\n // Urgent: show pending state immediately (for TopLoader / usePendingNavigation)\n setPendingUrl(url);\n\n // Increment the transition counter SYNCHRONOUSLY (before startTransition\n // schedules the async work). Each call gets a unique transId; the counter\n // is the same globalThis singleton, so a newer call always has a higher id.\n const counter = getTransitionCounter();\n const transId = ++counter.id;\n\n // Activate per-link pending state as an URGENT update (outside\n // startTransition). With useState (not useOptimistic), this commits\n // immediately so the clicked link shows pending before the next paint.\n // The corresponding resetLinkPending() is called INSIDE the transition\n // so it commits atomically with the new tree.\n //\n // CRITICAL: activateLinkPending must NOT be called inside the\n // startTransition that awaits decodePromise. With useOptimistic,\n // that combination triggers \"Rendered more hooks\" when two\n // navigations overlap under the Navigation API (TIM-964).\n activateLinkPending();\n\n return new Promise<void>((resolve, reject) => {\n startTransition(async () => {\n try {\n const { element, decodePromise } = await perform();\n if (counter.id !== transId) {\n decodePromise?.catch(() => {});\n reject(new DOMException('Navigation superseded', 'AbortError'));\n return;\n }\n setElement(element);\n // Await RSC stream completion inside the transition so the\n // link's pending state (set urgently above) persists until\n // the full payload is consumed. resetLinkPending() below\n // commits atomically with the new tree when the transition\n // settles — no frame where pending=false but old content shows.\n if (decodePromise) await decodePromise;\n if (counter.id === transId) {\n resetLinkPending();\n setPendingUrl(null);\n resolve();\n } else {\n reject(new DOMException('Navigation superseded', 'AbortError'));\n }\n } catch (err) {\n if (counter.id === transId) {\n setPendingUrl(null);\n resetLinkPending();\n }\n reject(err);\n }\n });\n });\n };\n\n // ─── Hard navigation guard ─────────────────────────────────\n // When a hard navigation is in progress (500 error, version skew),\n // suspend forever to prevent React from rendering children during\n // page teardown. This avoids \"Rendered more hooks\" crashes in\n // CHILD components whose hook counts may shift during teardown.\n //\n // CRITICAL: This throw MUST come AFTER all hooks (the two\n // useState calls above). React requires the same hooks to run on\n // every render. If we threw before hooks, React would see 0 hooks\n // on the re-render vs 2 hooks on the initial render — triggering\n // the exact \"Rendered more hooks\" error we're trying to prevent.\n //\n // By placing it after hooks but before the return, all hooks\n // satisfy React's rules, but the thrown thenable prevents any\n // children from rendering. Same pattern as Next.js app-router.tsx\n // (pushRef.mpaNavigation — also placed after all hooks).\n if (isHardNavigating()) {\n throw unresolvedThenable;\n }\n\n // Inject TopLoader alongside the element tree inside PendingNavigationProvider.\n // The TopLoader reads pendingUrl from context to show/hide the progress bar.\n // It is rendered only when not explicitly disabled via config.\n const showTopLoader = topLoaderConfig?.enabled !== false;\n const children = showTopLoader\n ? createElement(Fragment, null, createElement(TopLoader, { config: topLoaderConfig }), element)\n : element;\n return createElement(PendingNavigationProvider, { value: pendingUrl }, children);\n}\n\n// ─── Public API ──────────────────────────────────────────────────\n\n/**\n * Trigger a transition render for non-navigation updates.\n * React keeps the old committed tree visible while any new Suspense\n * boundaries in the update resolve.\n *\n * Used for: applyRevalidation, popstate replay with cached payload.\n */\nexport function transitionRender(element: ReactNode): void {\n if (_transitionRender) {\n _transitionRender(element);\n }\n}\n\n/**\n * Run a full navigation inside a React transition with optimistic pending URL.\n *\n * The `perform` callback runs inside `startTransition` — it should fetch the\n * RSC payload, update router state, and return the wrapped React element.\n * The pending URL shows immediately (urgent update) and reverts\n * to null when the transition commits (atomic with the new tree).\n *\n * Returns a Promise that resolves when the async work completes (note: the\n * React transition may not have committed yet, but all state updates are done).\n *\n * Used for: navigate(), refresh(), popstate with fetch.\n */\nexport function navigateTransition(\n pendingUrl: string,\n perform: () => Promise<{ element: ReactNode; decodePromise: Promise<void> | null }>\n): Promise<void> {\n if (_navigateTransition) {\n return _navigateTransition(pendingUrl, perform);\n }\n // Fallback: no NavigationRoot mounted (shouldn't happen in production)\n return perform().then(() => {});\n}\n\n/**\n * Check if the NavigationRoot is mounted and ready for renders.\n * Used by browser-entry.ts to guard against renders before hydration.\n */\nexport function isNavigationRootReady(): boolean {\n return _transitionRender !== null;\n}\n\n/**\n * Install one-shot deferred callbacks for the no-RSC bootstrap path (TIM-600).\n *\n * When there's no RSC payload, we can't create a React root immediately —\n * `createRoot(document).render(...)` would blank the SSR HTML. Instead,\n * this sets up `_transitionRender` and `_navigateTransition` so that the\n * first client navigation triggers root creation via `createAndMount`.\n *\n * After `createAndMount` runs, NavigationRoot renders and overwrites these\n * callbacks with its real `startTransition`-based implementations.\n */\nexport function installDeferredNavigation(createAndMount: (initial: ReactNode) => void): void {\n let mounted = false;\n const mountOnce = (element: ReactNode) => {\n if (mounted) return;\n mounted = true;\n createAndMount(element);\n };\n _transitionRender = (element: ReactNode) => {\n mountOnce(element);\n };\n _navigateTransition = async (\n _pendingUrl: string,\n perform: () => Promise<{ element: ReactNode; decodePromise: Promise<void> | null }>\n ) => {\n const { element } = await perform();\n mountOnce(element);\n };\n}\n"],"mappings":";;;AA+BA,IAAM,mBAAmB,OAAO,IAAI,wBAAwB;;AAM5D,IAAa,YAAwB,EAAE,WAAW,OAAO;AAIzD,SAAS,WAAoD;CAC3D,MAAM,IAAI;AACV,KAAI,CAAC,EAAE,kBACL,GAAE,oBAAoB,EAAE,SAAS,MAAM;AAEzC,QAAO,EAAE;;;;;;;;;;AAaX,SAAgB,4BAA4B,MAAwC;CAClF,MAAM,QAAQ,UAAU;AACxB,KAAI,MAAM,WAAW,MAAM,YAAY,KACrC,OAAM,QAAQ,aAAa,UAAU;AAEvC,OAAM,UAAU;;;;;AA8ClB,SAAgB,gCAAgC,MAAiC;CAC/E,MAAM,QAAQ,UAAU;AACxB,KAAI,MAAM,YAAY,KACpB,OAAM,UAAU;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AElCpB,IAAM,eAAe,OAAO,IAAI,2BAA2B;AAE3D,SAAS,kBAAsC;CAC7C,MAAM,IAAI;AACV,KAAI,CAAC,EAAE,cACL,GAAE,gBAAgB,EAAE,OAAO,OAAO;AAEpC,QAAO,EAAE;;;;;;;;AASX,SAAgB,kBAAkB,OAAsB;AACtD,kBAAiB,CAAC,QAAQ"}
|