@timber-js/app 0.2.0-alpha.124 → 0.2.0-alpha.126
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/{build-output-helper-DYOWsPjZ.js → build-output-helper-K7oUCRqU.js} +1 -1
- package/dist/_chunks/{build-output-helper-DYOWsPjZ.js.map → build-output-helper-K7oUCRqU.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-DaERBEGV.js → navigation-root-B5-RmsPi.js} +35 -28
- package/dist/_chunks/navigation-root-B5-RmsPi.js.map +1 -0
- package/dist/_chunks/{stale-reload-BN2kcE06.js → stale-reload-CCqb62Z3.js} +22 -2
- package/dist/_chunks/{stale-reload-BN2kcE06.js.map → stale-reload-CCqb62Z3.js.map} +1 -1
- 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/adapters/cloudflare.js +1 -1
- package/dist/adapters/nitro.js +1 -1
- package/dist/client/browser-entry/hydrate.d.ts.map +1 -1
- package/dist/client/browser-entry/post-hydration.d.ts.map +1 -1
- package/dist/client/browser-entry/router-init.d.ts +4 -1
- package/dist/client/browser-entry/router-init.d.ts.map +1 -1
- package/dist/client/index.js +35 -79
- package/dist/client/index.js.map +1 -1
- package/dist/client/internal.js +128 -270
- package/dist/client/internal.js.map +1 -1
- package/dist/client/link-pending-store.d.ts +19 -17
- package/dist/client/link-pending-store.d.ts.map +1 -1
- 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 +4 -1
- package/dist/client/navigation-root.d.ts.map +1 -1
- package/dist/client/router.d.ts +10 -7
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/rsc-fetch.d.ts.map +1 -1
- package/dist/client/segment-cache.d.ts +2 -0
- package/dist/client/segment-cache.d.ts.map +1 -1
- package/dist/client/segment-outlet.d.ts +23 -37
- package/dist/client/segment-outlet.d.ts.map +1 -1
- package/dist/client/segment-update-context.d.ts +21 -0
- package/dist/client/segment-update-context.d.ts.map +1 -0
- 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/segment-params/index.js +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/internal.js +75 -4
- package/dist/server/internal.js.map +1 -1
- package/dist/server/pipeline-outcome.d.ts.map +1 -1
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/rsc-entry/helpers.d.ts +1 -0
- package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
- package/dist/server/state-tree-diff.d.ts +12 -5
- package/dist/server/state-tree-diff.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/client/browser-entry/hydrate.ts +7 -1
- package/src/client/browser-entry/index.ts +3 -1
- package/src/client/browser-entry/post-hydration.ts +0 -13
- package/src/client/browser-entry/router-init.ts +50 -24
- package/src/client/link-pending-store.ts +22 -17
- package/src/client/link.tsx +30 -79
- package/src/client/nav-link-store.ts +3 -13
- package/src/client/navigation-root.tsx +28 -23
- package/src/client/router.ts +109 -97
- package/src/client/rsc-fetch.ts +85 -6
- package/src/client/segment-cache.ts +5 -2
- package/src/client/segment-outlet.tsx +40 -49
- package/src/client/segment-update-context.ts +42 -0
- package/src/client/top-loader.tsx +5 -15
- package/src/client/use-pending-navigation.ts +18 -16
- package/src/server/pipeline-outcome.ts +23 -2
- package/src/server/route-element-builder.ts +69 -2
- package/src/server/rsc-entry/helpers.ts +18 -14
- package/src/server/state-tree-diff.ts +26 -22
- package/dist/_chunks/navigation-root-DaERBEGV.js.map +0 -1
- package/dist/client/segment-merger.d.ts +0 -119
- package/dist/client/segment-merger.d.ts.map +0 -1
- package/src/client/segment-merger.ts +0 -339
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SegmentUpdateContext — React context for partial navigation updates.
|
|
3
|
+
*
|
|
4
|
+
* During partial navigation (server skips unchanged sync layouts), the
|
|
5
|
+
* router builds a Map of segment path → ReactNode updates and passes it
|
|
6
|
+
* as the context value. Mounted SegmentOutlet components read from this
|
|
7
|
+
* context to decide whether to render the update or their cached content.
|
|
8
|
+
*
|
|
9
|
+
* SINGLETON GUARANTEE: Uses globalThis + Symbol.for — same pattern as
|
|
10
|
+
* NavigationContext. The RSC client bundler can duplicate this module
|
|
11
|
+
* across chunks (browser-entry graph + client-reference graph). With
|
|
12
|
+
* ESM output, each chunk gets its own module scope — a bare createContext
|
|
13
|
+
* at module level would create separate instances per chunk. globalThis
|
|
14
|
+
* guarantees a single instance regardless of duplication.
|
|
15
|
+
*
|
|
16
|
+
* See design/19-client-navigation.md §"Singleton Guarantee via globalThis"
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
'use client';
|
|
20
|
+
|
|
21
|
+
import React, { type ReactNode } from 'react';
|
|
22
|
+
|
|
23
|
+
export const EMPTY_SEGMENT_UPDATES = new Map<string, ReactNode>();
|
|
24
|
+
|
|
25
|
+
const CTX_KEY = Symbol.for('__timber_segment_update_ctx');
|
|
26
|
+
|
|
27
|
+
function getOrCreateContext(): React.Context<Map<string, ReactNode>> {
|
|
28
|
+
const existing = (globalThis as Record<symbol, unknown>)[CTX_KEY] as
|
|
29
|
+
| React.Context<Map<string, ReactNode>>
|
|
30
|
+
| undefined;
|
|
31
|
+
if (existing !== undefined) return existing;
|
|
32
|
+
if (typeof React.createContext === 'function') {
|
|
33
|
+
const ctx = React.createContext<Map<string, ReactNode>>(EMPTY_SEGMENT_UPDATES);
|
|
34
|
+
(globalThis as Record<symbol, unknown>)[CTX_KEY] = ctx;
|
|
35
|
+
return ctx;
|
|
36
|
+
}
|
|
37
|
+
// RSC environment — createContext not available. Return a dummy that
|
|
38
|
+
// won't be used (outlets only render on the client).
|
|
39
|
+
return undefined as unknown as React.Context<Map<string, ReactNode>>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const SegmentUpdateContext = getOrCreateContext();
|
|
@@ -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
|
}
|
|
@@ -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
|
|
@@ -44,12 +44,37 @@ import {
|
|
|
44
44
|
import type { DenyPageEntry } from './deny-boundary.js';
|
|
45
45
|
import { resolveSlotElement } from './slot-resolver.js';
|
|
46
46
|
import { SegmentProvider } from '../client/segment-context.js';
|
|
47
|
+
import { SegmentOutlet } from '../client/segment-outlet.js';
|
|
47
48
|
|
|
48
49
|
import { wrapSegmentWithErrorBoundaries } from './error-boundary-wrapper.js';
|
|
49
50
|
import type { InterceptionContext } from './pipeline.js';
|
|
50
51
|
import { shouldSkipSegment } from './state-tree-diff.js';
|
|
51
52
|
import { loadModule } from './safe-load.js';
|
|
52
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Replace the outermost SegmentOutlet in a partial payload with its
|
|
56
|
+
* SegmentProvider child. Walks through AccessGate wrappers (which sit
|
|
57
|
+
* outside SegmentOutlet) to find the outlet.
|
|
58
|
+
*/
|
|
59
|
+
function replaceOutermostSegmentOutlet(
|
|
60
|
+
element: React.ReactElement,
|
|
61
|
+
replacement: React.ReactElement
|
|
62
|
+
): React.ReactElement {
|
|
63
|
+
if (element.type === SegmentOutlet) {
|
|
64
|
+
return replacement;
|
|
65
|
+
}
|
|
66
|
+
// AccessGate wraps outside SegmentOutlet — recurse through it.
|
|
67
|
+
const props = element.props as Record<string, unknown>;
|
|
68
|
+
if (element.type === AccessGate && props.children) {
|
|
69
|
+
const inner = replaceOutermostSegmentOutlet(props.children as React.ReactElement, replacement);
|
|
70
|
+
return createElement(element.type as React.FunctionComponent<Record<string, unknown>>, {
|
|
71
|
+
...props,
|
|
72
|
+
children: inner,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
return element;
|
|
76
|
+
}
|
|
77
|
+
|
|
53
78
|
// ─── Client Reference Detection ──────────────────────────────────────────
|
|
54
79
|
|
|
55
80
|
/**
|
|
@@ -445,6 +470,12 @@ export async function buildRouteElement(
|
|
|
445
470
|
// Without this guard, skipping the innermost layout causes the merger
|
|
446
471
|
// to drop the layout entirely and replace it with just the page.
|
|
447
472
|
let hasRenderedLayoutBelow = false;
|
|
473
|
+
let outermostSegmentProvider: React.ReactElement | null = null;
|
|
474
|
+
// Track whether any rendered inner layout also exists in the client's
|
|
475
|
+
// state tree. This prevents cross-section skipping: e.g., navigating
|
|
476
|
+
// from /(group-a) to /dashboard shouldn't skip root "/" because the
|
|
477
|
+
// client has no mounted outlet at "/dashboard".
|
|
478
|
+
let innerRenderedInClientTree = false;
|
|
448
479
|
|
|
449
480
|
for (let i = segments.length - 1; i >= 0; i--) {
|
|
450
481
|
const segment = segments[i];
|
|
@@ -461,10 +492,14 @@ export async function buildRouteElement(
|
|
|
461
492
|
// - Route groups are never skipped because sibling groups share the
|
|
462
493
|
// same urlPath (e.g., /(marketing) and /(app) both have "/"),
|
|
463
494
|
// which would cause the wrong cached layout to be reused
|
|
495
|
+
// - At least one inner rendered layout must exist in the client's
|
|
496
|
+
// state tree, ensuring the client has a mounted outlet to receive
|
|
497
|
+
// the partial payload
|
|
464
498
|
const skip =
|
|
465
499
|
shouldSkipSegment(segment.urlPath, layoutComponent, isLeaf, clientStateTree ?? null) &&
|
|
466
500
|
hasRenderedLayoutBelow &&
|
|
467
|
-
segment.segmentType !== 'group'
|
|
501
|
+
segment.segmentType !== 'group' &&
|
|
502
|
+
innerRenderedInClientTree;
|
|
468
503
|
|
|
469
504
|
if (skip) {
|
|
470
505
|
// Skip this segment's layout/error boundaries — the client uses its cached version.
|
|
@@ -496,6 +531,17 @@ export async function buildRouteElement(
|
|
|
496
531
|
// a rendered layout below them and can safely be skipped.
|
|
497
532
|
if (layoutComponent) {
|
|
498
533
|
hasRenderedLayoutBelow = true;
|
|
534
|
+
// Use segmentId for the client state tree check. Route groups share
|
|
535
|
+
// their parent's urlPath (both "/"), but their segmentId includes
|
|
536
|
+
// the group name (e.g., "/(group-a)"), so the check correctly
|
|
537
|
+
// fails when the client has never visited that group.
|
|
538
|
+
const outletKey =
|
|
539
|
+
segment.segmentType === 'group'
|
|
540
|
+
? `${segment.urlPath === '/' ? '' : segment.urlPath}/${segment.segmentName}`
|
|
541
|
+
: segment.urlPath;
|
|
542
|
+
if (clientStateTree?.has(outletKey)) {
|
|
543
|
+
innerRenderedInClientTree = true;
|
|
544
|
+
}
|
|
499
545
|
}
|
|
500
546
|
|
|
501
547
|
// Wrap with error boundaries from this segment (inside layout).
|
|
@@ -585,12 +631,24 @@ export async function buildRouteElement(
|
|
|
585
631
|
});
|
|
586
632
|
}
|
|
587
633
|
|
|
588
|
-
|
|
634
|
+
const segmentProviderElement = h(SegmentProvider, {
|
|
589
635
|
segments: segmentPath,
|
|
590
636
|
segmentId,
|
|
591
637
|
parallelRouteKeys,
|
|
592
638
|
children: layoutElement,
|
|
593
639
|
});
|
|
640
|
+
|
|
641
|
+
element = h(SegmentOutlet, {
|
|
642
|
+
segmentPath: segmentId,
|
|
643
|
+
children: segmentProviderElement,
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
// Track the SegmentProvider for the outermost rendered layout.
|
|
647
|
+
// On partial navigation (segments skipped above), the client already
|
|
648
|
+
// has a SegmentOutlet mounted at this position. Sending another one
|
|
649
|
+
// in the payload causes double-wrapping and infinite context recursion.
|
|
650
|
+
// We'll strip the outermost SegmentOutlet after the loop.
|
|
651
|
+
outermostSegmentProvider = segmentProviderElement;
|
|
594
652
|
}
|
|
595
653
|
|
|
596
654
|
// Wrap in AccessGate OUTSIDE the layout.
|
|
@@ -611,6 +669,15 @@ export async function buildRouteElement(
|
|
|
611
669
|
}
|
|
612
670
|
}
|
|
613
671
|
|
|
672
|
+
// On partial navigation (some segments skipped), the client already has a
|
|
673
|
+
// SegmentOutlet mounted at the outermost non-skipped segment's position.
|
|
674
|
+
// Sending another SegmentOutlet in the payload causes double-wrapping —
|
|
675
|
+
// the inner outlet reads the same context update and recurses infinitely.
|
|
676
|
+
// Replace the outermost SegmentOutlet with just its SegmentProvider child.
|
|
677
|
+
if (skippedSegments.length > 0 && outermostSegmentProvider) {
|
|
678
|
+
element = replaceOutermostSegmentOutlet(element, outermostSegmentProvider);
|
|
679
|
+
}
|
|
680
|
+
|
|
614
681
|
return {
|
|
615
682
|
element,
|
|
616
683
|
headElements: headElements as HeadElement[],
|
|
@@ -180,32 +180,36 @@ export function buildSegmentInfo(
|
|
|
180
180
|
component: (...args: unknown[]) => unknown;
|
|
181
181
|
segment: ManifestSegmentNode;
|
|
182
182
|
}>
|
|
183
|
-
): Array<{ path: string; isAsync: boolean }> {
|
|
183
|
+
): Array<{ path: string; segmentId?: string; isAsync: boolean }> {
|
|
184
184
|
const layoutBySegment = new Map(
|
|
185
185
|
layoutComponents.map(({ component, segment }) => [segment, component])
|
|
186
186
|
);
|
|
187
187
|
|
|
188
|
-
|
|
189
|
-
// parent's urlPath. When a group has its own layout, update the entry
|
|
190
|
-
// to reflect the group's async status (the layout is what matters for
|
|
191
|
-
// segment diffing). Without dedup, the state tree would contain
|
|
192
|
-
// duplicate paths that break the server's skip logic.
|
|
193
|
-
const byPath = new Map<string, { path: string; isAsync: boolean }>();
|
|
188
|
+
const result: Array<{ path: string; segmentId?: string; isAsync: boolean }> = [];
|
|
194
189
|
|
|
195
190
|
for (const segment of segments) {
|
|
196
191
|
const component = layoutBySegment.get(segment);
|
|
197
192
|
const isAsync = component?.constructor?.name === 'AsyncFunction';
|
|
198
193
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
194
|
+
// Compute segmentId — matches the key used by SegmentOutlet.
|
|
195
|
+
// Route groups share their parent's urlPath, so include the group
|
|
196
|
+
// name to disambiguate (e.g., "/(marketing)" vs "/").
|
|
197
|
+
const segmentId =
|
|
198
|
+
segment.segmentType === 'group'
|
|
199
|
+
? `${segment.urlPath === '/' ? '' : segment.urlPath}/${segment.segmentName}`
|
|
200
|
+
: segment.urlPath;
|
|
201
|
+
|
|
202
|
+
const entry: { path: string; segmentId?: string; isAsync: boolean } = {
|
|
203
|
+
path: segment.urlPath,
|
|
204
|
+
isAsync,
|
|
205
|
+
};
|
|
206
|
+
if (segmentId !== segment.urlPath) {
|
|
207
|
+
entry.segmentId = segmentId;
|
|
205
208
|
}
|
|
209
|
+
result.push(entry);
|
|
206
210
|
}
|
|
207
211
|
|
|
208
|
-
return
|
|
212
|
+
return result;
|
|
209
213
|
}
|
|
210
214
|
|
|
211
215
|
/**
|
|
@@ -45,12 +45,19 @@ export function parseClientStateTree(req: Request): Set<string> | null {
|
|
|
45
45
|
* Determine whether a segment's layout rendering can be skipped.
|
|
46
46
|
*
|
|
47
47
|
* A segment is skipped when ALL of the following are true:
|
|
48
|
-
* 1. The client
|
|
49
|
-
* 2. The
|
|
50
|
-
* 3. The segment
|
|
48
|
+
* 1. The client sent a state tree (clientSegments is non-null)
|
|
49
|
+
* 2. The client has this segment cached (clientSegments contains urlPath)
|
|
50
|
+
* 3. The segment has a layout (otherwise nothing to skip)
|
|
51
|
+
* 4. The layout is sync (not an async function — async layouts may depend
|
|
52
|
+
* on per-request data and must always re-render)
|
|
53
|
+
* 5. The segment is NOT the leaf (pages are never cached across navigations)
|
|
54
|
+
*
|
|
55
|
+
* Additional constraints enforced by the caller (buildRouteElement):
|
|
56
|
+
* - Must have a rendered layout below (so SegmentOutlet has content to show)
|
|
57
|
+
* - Route groups are never skipped (siblings share urlPath)
|
|
51
58
|
*
|
|
52
59
|
* Access.ts still runs for skipped segments — this is enforced by the caller
|
|
53
|
-
*
|
|
60
|
+
* which wraps with AccessGate regardless of skip status.
|
|
54
61
|
*
|
|
55
62
|
* @param urlPath - The segment's URL path (e.g., "/", "/dashboard")
|
|
56
63
|
* @param layoutComponent - The loaded layout component function
|
|
@@ -58,23 +65,20 @@ export function parseClientStateTree(req: Request): Set<string> | null {
|
|
|
58
65
|
* @param clientSegments - Set of paths from X-Timber-State-Tree, or null
|
|
59
66
|
*/
|
|
60
67
|
export function shouldSkipSegment(
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
68
|
+
urlPath: string,
|
|
69
|
+
layoutComponent: ((...args: unknown[]) => unknown) | undefined,
|
|
70
|
+
isLeaf: boolean,
|
|
71
|
+
clientSegments: Set<string> | null
|
|
65
72
|
): boolean {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
//
|
|
78
|
-
// See design/19-client-navigation.md §"X-Timber-State-Tree Header"
|
|
79
|
-
return false;
|
|
73
|
+
if (!clientSegments) return false;
|
|
74
|
+
if (!clientSegments.has(urlPath)) return false;
|
|
75
|
+
if (!layoutComponent) return false;
|
|
76
|
+
if (isLeaf) return false;
|
|
77
|
+
|
|
78
|
+
// Async layouts always re-render — they may depend on per-request data
|
|
79
|
+
// (cookies, headers, database queries) that changes between navigations.
|
|
80
|
+
// constructor.name check: AsyncFunction.name === 'AsyncFunction'
|
|
81
|
+
if (layoutComponent.constructor.name === 'AsyncFunction') return false;
|
|
82
|
+
|
|
83
|
+
return true;
|
|
80
84
|
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"navigation-root-DaERBEGV.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 optimistic pending state.\n *\n * Tracks which link instance is currently navigating so that only the\n * clicked link shows pending. Uses `useOptimistic` from React 19 —\n * the optimistic value (isPending=true) is set inside NavigationRoot's\n * async startTransition so it persists for the duration of the RSC\n * fetch, then auto-reverts to idle when the transition settles.\n *\n * Flow:\n * 1. Link click → setLinkForCurrentNavigation(instance) stores the ref\n * 2. NavigationRoot startTransition → activateLinkPending() calls\n * instance.setIsPending(LINK_PENDING) inside the async transition\n * 3. Transition settles → useOptimistic auto-reverts to LINK_IDLE\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 inside NavigationRoot's startTransition via\n * activateLinkPending().\n *\n * Resets the previous link to idle immediately (the old link's\n * useOptimistic reverts since its transition already settled).\n */\nexport function setLinkForCurrentNavigation(link: LinkPendingInstance | null): void {\n const store = getStore();\n store.current = link;\n}\n\n/**\n * Activate pending state on the current link instance.\n * MUST be called inside NavigationRoot's async startTransition —\n * this is what makes useOptimistic persist for the navigation duration.\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. With useOptimistic this is\n * handled automatically when the transition settles. Kept for callers\n * that explicitly need to clear on error paths.\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 | ((pendingUrl: string, perform: () => Promise<ReactNode>) => 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 = (url: string, perform: () => Promise<ReactNode>) => {\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 return new Promise<void>((resolve, reject) => {\n startTransition(async () => {\n // Activate per-link pending state inside this async transition.\n // useOptimistic persists the isPending=true value for the duration\n // of this transition, then auto-reverts when it settles.\n activateLinkPending();\n try {\n const newElement = await perform();\n // Only commit state if this is still the active navigation.\n // A superseded transition's updates must be dropped entirely.\n if (counter.id === transId) {\n setElement(newElement);\n setPendingUrl(null);\n resolve();\n } else {\n // Stale transition — a newer navigation has superseded this one.\n // Reject so the caller (navigate/refresh/handlePopState) doesn't\n // run post-transition side effects (applyHead, scroll, event\n // dispatch) with stale data. All callers catch AbortError.\n reject(new DOMException('Navigation superseded', 'AbortError'));\n }\n } catch (err) {\n // Only clear pending if this is still the active navigation.\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<ReactNode>\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 (_pendingUrl: string, perform: () => Promise<ReactNode>) => {\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;;;;;;;;;;;AAcX,SAAgB,4BAA4B,MAAwC;CAClF,MAAM,QAAQ,UAAU;AACxB,OAAM,UAAU;;;;;AA2ClB,SAAgB,gCAAgC,MAAiC;CAC/E,MAAM,QAAQ,UAAU;AACxB,KAAI,MAAM,YAAY,KACpB,OAAM,UAAU;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AE7BpB,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"}
|
|
@@ -1,119 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Segment Merger — client-side tree merging for partial RSC payloads.
|
|
3
|
-
*
|
|
4
|
-
* When the server skips rendering sync layouts (because the client already
|
|
5
|
-
* has them cached), the RSC payload is missing outer segment wrappers.
|
|
6
|
-
* This module reconstructs the full element tree by splicing the partial
|
|
7
|
-
* payload into cached segment subtrees.
|
|
8
|
-
*
|
|
9
|
-
* The approach:
|
|
10
|
-
* 1. After each full RSC payload render, walk the decoded element tree
|
|
11
|
-
* and cache each segment's subtree (identified by SegmentProvider boundaries)
|
|
12
|
-
* 2. When a partial payload arrives, wrap it with cached segment elements
|
|
13
|
-
* using React.cloneElement to preserve component identity
|
|
14
|
-
*
|
|
15
|
-
* React.cloneElement preserves the element's `type` — React sees the same
|
|
16
|
-
* component at the same tree position and reconciles (preserving state)
|
|
17
|
-
* rather than remounting. This is how layout state survives navigations.
|
|
18
|
-
*
|
|
19
|
-
* Design docs: 19-client-navigation.md §"Navigation Reconciliation"
|
|
20
|
-
* Security: access.ts runs on the server regardless of skipping — this
|
|
21
|
-
* is a performance optimization only. See 13-security.md.
|
|
22
|
-
*/
|
|
23
|
-
import { type ReactElement, type ReactNode } from 'react';
|
|
24
|
-
/**
|
|
25
|
-
* A cached segment entry. Stores the full subtree rooted at a SegmentProvider
|
|
26
|
-
* and the path through the tree to the next SegmentProvider (or leaf).
|
|
27
|
-
*/
|
|
28
|
-
export interface CachedSegmentEntry {
|
|
29
|
-
/** The segment's URL path (e.g., "/", "/dashboard") */
|
|
30
|
-
segmentPath: string;
|
|
31
|
-
/** The SegmentProvider element for this segment */
|
|
32
|
-
element: ReactElement;
|
|
33
|
-
/**
|
|
34
|
-
* Whether this segment's cached element contains a nested SegmentProvider.
|
|
35
|
-
* Only segments with inner SegmentProviders are safe to skip — the merger
|
|
36
|
-
* can only replace inner SegmentProviders, not pages embedded in layout output.
|
|
37
|
-
* Used by the state tree serialization to exclude non-mergeable segments.
|
|
38
|
-
*/
|
|
39
|
-
hasMergeableChild: boolean;
|
|
40
|
-
}
|
|
41
|
-
/**
|
|
42
|
-
* Cache of React element subtrees per segment path.
|
|
43
|
-
* Updated after each navigation with the full decoded RSC element tree.
|
|
44
|
-
*/
|
|
45
|
-
export declare class SegmentElementCache {
|
|
46
|
-
private entries;
|
|
47
|
-
get(segmentPath: string): CachedSegmentEntry | undefined;
|
|
48
|
-
set(segmentPath: string, entry: CachedSegmentEntry): void;
|
|
49
|
-
has(segmentPath: string): boolean;
|
|
50
|
-
clear(): void;
|
|
51
|
-
get size(): number;
|
|
52
|
-
/**
|
|
53
|
-
* Get the set of segment paths that are safe for the server to skip.
|
|
54
|
-
* Only segments with an inner SegmentProvider (hasMergeableChild) are
|
|
55
|
-
* included — the merger can only replace inner SegmentProviders, not
|
|
56
|
-
* pages embedded in layout output. Used to filter the state tree.
|
|
57
|
-
*
|
|
58
|
-
* Returns an empty set if the element cache is empty (no elements
|
|
59
|
-
* cached yet). This is the safe default — an empty set means no
|
|
60
|
-
* segments pass the filter, so the state tree is empty and the server
|
|
61
|
-
* does a full render. The element cache is populated lazily after the
|
|
62
|
-
* first SPA navigation (RSC-decoded elements from hydration are
|
|
63
|
-
* thenables that can't be walked until React resolves them).
|
|
64
|
-
*/
|
|
65
|
-
getMergeablePaths(): Set<string>;
|
|
66
|
-
}
|
|
67
|
-
/**
|
|
68
|
-
* Check if a React element is a SegmentProvider by looking for the
|
|
69
|
-
* `segments` prop (an array of path segments). This is the only
|
|
70
|
-
* component that receives this prop shape.
|
|
71
|
-
*/
|
|
72
|
-
export declare function isSegmentProvider(element: unknown): element is ReactElement;
|
|
73
|
-
/**
|
|
74
|
-
* Extract the segment path from a SegmentProvider element.
|
|
75
|
-
*
|
|
76
|
-
* Uses the `segmentId` prop if available (set by the server for route groups
|
|
77
|
-
* to distinguish siblings that share the same urlPath). Falls back to
|
|
78
|
-
* reconstructing from the `segments` array prop.
|
|
79
|
-
*/
|
|
80
|
-
export declare function getSegmentPath(element: ReactElement): string;
|
|
81
|
-
/**
|
|
82
|
-
* Walk a React element tree and extract all SegmentProvider boundaries.
|
|
83
|
-
* Returns an ordered list of segment entries from outermost to innermost.
|
|
84
|
-
*
|
|
85
|
-
* This only finds SegmentProviders along the main children path — it does
|
|
86
|
-
* not descend into parallel routes/slots (those are separate subtrees).
|
|
87
|
-
*/
|
|
88
|
-
export declare function extractSegments(element: unknown): CachedSegmentEntry[];
|
|
89
|
-
/**
|
|
90
|
-
* Cache all segment subtrees from a fully-rendered RSC element tree.
|
|
91
|
-
* Call this after every full RSC payload render (navigate, refresh, hydration).
|
|
92
|
-
*/
|
|
93
|
-
export declare function cacheSegmentElements(element: unknown, cache: SegmentElementCache): void;
|
|
94
|
-
/**
|
|
95
|
-
* Replace a nested SegmentProvider within a cached element tree with
|
|
96
|
-
* new content. Uses cloneElement along the path to produce a new tree
|
|
97
|
-
* with preserved component identity at every level except the replaced node.
|
|
98
|
-
*
|
|
99
|
-
* @param cachedElement The cached SegmentProvider element for this segment
|
|
100
|
-
* @param newInnerContent The new React element to splice in at the inner segment position
|
|
101
|
-
* @param innerSegmentPath The path of the inner segment to replace (optional — replaces first found)
|
|
102
|
-
* @returns New element tree with the inner segment replaced
|
|
103
|
-
*/
|
|
104
|
-
export declare function replaceInnerSegment(cachedElement: ReactElement, newInnerContent: ReactNode, innerSegmentPath?: string): ReactElement;
|
|
105
|
-
/**
|
|
106
|
-
* Merge a partial RSC payload with cached segment elements.
|
|
107
|
-
*
|
|
108
|
-
* When the server skips segments, the partial payload starts from the
|
|
109
|
-
* first non-skipped segment. This function wraps it with cached elements
|
|
110
|
-
* for the skipped segments, producing a full tree that React can
|
|
111
|
-
* reconcile with the mounted tree (preserving layout state).
|
|
112
|
-
*
|
|
113
|
-
* @param partialPayload The RSC payload element (may be partial)
|
|
114
|
-
* @param skippedSegments Ordered list of segment paths that were skipped (outermost first)
|
|
115
|
-
* @param cache The segment element cache
|
|
116
|
-
* @returns The merged full element tree, or the partial payload if merging isn't possible
|
|
117
|
-
*/
|
|
118
|
-
export declare function mergeSegmentTree(partialPayload: unknown, skippedSegments: string[], cache: SegmentElementCache): unknown;
|
|
119
|
-
//# sourceMappingURL=segment-merger.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"segment-merger.d.ts","sourceRoot":"","sources":["../../src/client/segment-merger.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH,OAAO,EAAgC,KAAK,YAAY,EAAE,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAIxF;;;GAGG;AACH,MAAM,WAAW,kBAAkB;IACjC,uDAAuD;IACvD,WAAW,EAAE,MAAM,CAAC;IACpB,mDAAmD;IACnD,OAAO,EAAE,YAAY,CAAC;IACtB;;;;;OAKG;IACH,iBAAiB,EAAE,OAAO,CAAC;CAC5B;AAID;;;GAGG;AACH,qBAAa,mBAAmB;IAC9B,OAAO,CAAC,OAAO,CAAyC;IAExD,GAAG,CAAC,WAAW,EAAE,MAAM,GAAG,kBAAkB,GAAG,SAAS;IAIxD,GAAG,CAAC,WAAW,EAAE,MAAM,EAAE,KAAK,EAAE,kBAAkB,GAAG,IAAI;IAIzD,GAAG,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO;IAIjC,KAAK,IAAI,IAAI;IAIb,IAAI,IAAI,IAAI,MAAM,CAEjB;IAED;;;;;;;;;;;;OAYG;IACH,iBAAiB,IAAI,GAAG,CAAC,MAAM,CAAC;CASjC;AAID;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,IAAI,YAAY,CAI3E;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,YAAY,GAAG,MAAM,CAM5D;AAID;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,OAAO,GAAG,kBAAkB,EAAE,CAUtE;AAuCD;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,mBAAmB,GAAG,IAAI,CAKvF;AAkDD;;;;;;;;;GASG;AACH,wBAAgB,mBAAmB,CACjC,aAAa,EAAE,YAAY,EAC3B,eAAe,EAAE,SAAS,EAC1B,gBAAgB,CAAC,EAAE,MAAM,GACxB,YAAY,CAoCd;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,gBAAgB,CAC9B,cAAc,EAAE,OAAO,EACvB,eAAe,EAAE,MAAM,EAAE,EACzB,KAAK,EAAE,mBAAmB,GACzB,OAAO,CA0BT"}
|