@timber-js/app 0.2.0-alpha.68 → 0.2.0-alpha.69
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client/index.js +216 -144
- package/dist/client/index.js.map +1 -1
- package/dist/client/link-pending-store.d.ts +3 -3
- package/dist/client/navigation-api.d.ts.map +1 -1
- package/dist/client/{transition-root.d.ts → navigation-root.d.ts} +31 -9
- package/dist/client/navigation-root.d.ts.map +1 -0
- package/dist/client/router.d.ts +1 -1
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/top-loader.d.ts +2 -2
- package/dist/server/index.js.map +1 -1
- package/dist/server/route-element-builder.d.ts +10 -0
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/slot-resolver.d.ts.map +1 -1
- package/dist/server/ssr-wrappers.d.ts +3 -3
- package/package.json +1 -1
- package/src/client/browser-entry.ts +15 -11
- package/src/client/link-pending-store.ts +3 -3
- package/src/client/link.tsx +2 -2
- package/src/client/navigation-api.ts +10 -0
- package/src/client/navigation-context.ts +2 -2
- package/src/client/navigation-root.tsx +346 -0
- package/src/client/router.ts +38 -2
- package/src/client/top-loader.tsx +2 -2
- package/src/client/use-navigation-pending.ts +1 -1
- package/src/server/route-element-builder.ts +69 -21
- package/src/server/slot-resolver.ts +37 -35
- package/src/server/ssr-entry.ts +1 -1
- package/src/server/ssr-wrappers.tsx +10 -10
- package/dist/client/transition-root.d.ts.map +0 -1
- package/src/client/transition-root.tsx +0 -205
|
@@ -18,6 +18,16 @@ import type { RouteMatch } from './pipeline.js';
|
|
|
18
18
|
import type { ManifestSegmentNode } from './route-matcher.js';
|
|
19
19
|
import { DenySignal, RedirectSignal } from './primitives.js';
|
|
20
20
|
import type { InterceptionContext } from './pipeline.js';
|
|
21
|
+
/**
|
|
22
|
+
* Detect whether a component is a React client reference.
|
|
23
|
+
* Client references have $$typeof set to Symbol.for('react.client.reference')
|
|
24
|
+
* by registerClientReference() in the React Flight server runtime.
|
|
25
|
+
*
|
|
26
|
+
* Used to skip OTEL tracing wrappers that would call the component as a
|
|
27
|
+
* function. Client components must go through createElement only — they are
|
|
28
|
+
* serialized as references in the RSC Flight stream, not executed on the server.
|
|
29
|
+
*/
|
|
30
|
+
export declare function isClientReference(component: unknown): boolean;
|
|
21
31
|
/**
|
|
22
32
|
* Thrown when a defineSegmentParams codec's parse() fails.
|
|
23
33
|
* The pipeline catches this and responds with 404.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"route-element-builder.d.ts","sourceRoot":"","sources":["../../src/server/route-element-builder.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAKH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAChD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAK9D,OAAO,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAM7D,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAC;
|
|
1
|
+
{"version":3,"file":"route-element-builder.d.ts","sourceRoot":"","sources":["../../src/server/route-element-builder.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAKH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAChD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAK9D,OAAO,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAM7D,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAC;AAezD;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,OAAO,GAAG,OAAO,CAM7D;AAID;;;GAGG;AACH,qBAAa,kBAAmB,SAAQ,KAAK;gBAC/B,OAAO,EAAE,MAAM;CAI5B;AAID,qDAAqD;AACrD,MAAM,WAAW,WAAW;IAC1B,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC,CAAC;CACvC;AAED,+CAA+C;AAC/C,MAAM,WAAW,oBAAoB;IACnC,SAAS,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,OAAO,CAAC;IAC3C,OAAO,EAAE,mBAAmB,CAAC;CAC9B;AAED,+CAA+C;AAC/C,MAAM,WAAW,kBAAkB;IACjC,wFAAwF;IACxF,OAAO,EAAE,KAAK,CAAC,YAAY,CAAC;IAC5B,2CAA2C;IAC3C,YAAY,EAAE,WAAW,EAAE,CAAC;IAC5B,wDAAwD;IACxD,gBAAgB,EAAE,oBAAoB,EAAE,CAAC;IACzC,qCAAqC;IACrC,QAAQ,EAAE,mBAAmB,EAAE,CAAC;IAChC,4DAA4D;IAC5D,gBAAgB,EAAE,MAAM,CAAC;IACzB;;;;;OAKG;IACH,eAAe,EAAE,MAAM,EAAE,CAAC;CAC3B;AAED;;;GAGG;AACH,qBAAa,sBAAuB,SAAQ,KAAK;aAE7B,MAAM,EAAE,UAAU,GAAG,cAAc;aACnC,gBAAgB,EAAE,oBAAoB,EAAE;aACxC,QAAQ,EAAE,mBAAmB,EAAE;gBAF/B,MAAM,EAAE,UAAU,GAAG,cAAc,EACnC,gBAAgB,EAAE,oBAAoB,EAAE,EACxC,QAAQ,EAAE,mBAAmB,EAAE;CAIlD;AA8DD;;;;;;;;;GASG;AACH,wBAAsB,iBAAiB,CACrC,GAAG,EAAE,OAAO,EACZ,KAAK,EAAE,UAAU,EACjB,YAAY,CAAC,EAAE,mBAAmB,EAClC,eAAe,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,GACnC,OAAO,CAAC,kBAAkB,CAAC,CAiT7B"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"slot-resolver.d.ts","sourceRoot":"","sources":["../../src/server/slot-resolver.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;
|
|
1
|
+
{"version":3,"file":"slot-resolver.d.ts","sourceRoot":"","sources":["../../src/server/slot-resolver.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAQH,OAAO,KAAK,EAAE,mBAAmB,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAGrE,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAE9D,KAAK,eAAe,GAAG,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,KAAK,CAAC,YAAY,CAAC;AAmHlE;;;;;;;;;;GAUG;AACH,wBAAsB,kBAAkB,CACtC,QAAQ,EAAE,mBAAmB,EAC7B,KAAK,EAAE,UAAU,EACjB,CAAC,EAAE,eAAe,EAClB,YAAY,CAAC,EAAE,mBAAmB,GACjC,OAAO,CAAC,KAAK,CAAC,YAAY,GAAG,IAAI,CAAC,CAgGpC"}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* Radix UI that rely on useId() internally.
|
|
9
9
|
*
|
|
10
10
|
* The client tree (browser-entry.ts) wraps the RSC element with:
|
|
11
|
-
*
|
|
11
|
+
* NavigationRoot → PendingNavigationProvider → Fragment(TopLoader, ...) →
|
|
12
12
|
* TimberNuqsAdapter → NuqsAdapterProvider → NavigationProvider → element
|
|
13
13
|
*
|
|
14
14
|
* The SSR tree must produce the same component boundaries. These wrappers
|
|
@@ -25,7 +25,7 @@ import { type ReactNode } from 'react';
|
|
|
25
25
|
* on both sides.
|
|
26
26
|
*
|
|
27
27
|
* Client tree (browser-entry.ts):
|
|
28
|
-
*
|
|
28
|
+
* NavigationRoot
|
|
29
29
|
* → PendingNavigationProvider
|
|
30
30
|
* → Fragment(TopLoader, element)
|
|
31
31
|
* → TimberNuqsAdapter
|
|
@@ -34,7 +34,7 @@ import { type ReactNode } from 'react';
|
|
|
34
34
|
* → [RSC element]
|
|
35
35
|
*
|
|
36
36
|
* SSR tree (this function):
|
|
37
|
-
*
|
|
37
|
+
* SsrNavigationRoot
|
|
38
38
|
* → SsrPendingProvider
|
|
39
39
|
* → Fragment(SsrTopLoader, element)
|
|
40
40
|
* → SsrNuqsWrapper
|
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.69",
|
|
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",
|
|
@@ -59,11 +59,12 @@ import { setupServerLogReplay, setupClientErrorForwarding } from './browser-dev.
|
|
|
59
59
|
// browser-links.ts removed — Link components own their click/hover handlers directly.
|
|
60
60
|
// See LOCAL-340.
|
|
61
61
|
import {
|
|
62
|
-
|
|
62
|
+
NavigationRoot,
|
|
63
63
|
transitionRender,
|
|
64
64
|
navigateTransition,
|
|
65
65
|
installDeferredNavigation,
|
|
66
|
-
|
|
66
|
+
setHardNavigating,
|
|
67
|
+
} from './navigation-root.js';
|
|
67
68
|
import {
|
|
68
69
|
isStaleClientReference,
|
|
69
70
|
isChunkLoadError,
|
|
@@ -157,7 +158,10 @@ setServerCallback(async (id: string, args: unknown[]) => {
|
|
|
157
158
|
const router = getRouter();
|
|
158
159
|
void router.navigate(wrapper._redirect);
|
|
159
160
|
} catch {
|
|
160
|
-
// Router not yet initialized — fall back to full navigation
|
|
161
|
+
// Router not yet initialized — fall back to full navigation.
|
|
162
|
+
// Set hard-navigating flag to prevent Navigation API interception
|
|
163
|
+
// and React from rendering during page teardown. See TIM-626.
|
|
164
|
+
setHardNavigating(true);
|
|
161
165
|
window.location.href = wrapper._redirect;
|
|
162
166
|
}
|
|
163
167
|
return undefined;
|
|
@@ -413,10 +417,10 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
413
417
|
// Hydrate on document — the root layout renders the full <html> tree,
|
|
414
418
|
// so React owns the entire document from the root.
|
|
415
419
|
// Wrap with NavigationProvider (for atomic useParams/usePathname),
|
|
416
|
-
// TimberNuqsAdapter (for nuqs context), and
|
|
420
|
+
// TimberNuqsAdapter (for nuqs context), and NavigationRoot (for
|
|
417
421
|
// transition-based rendering during client navigation).
|
|
418
422
|
//
|
|
419
|
-
//
|
|
423
|
+
// NavigationRoot holds the element in React state and updates via
|
|
420
424
|
// startTransition, so React keeps old UI visible while new Suspense
|
|
421
425
|
// boundaries resolve during navigation. See design/05-streaming.md.
|
|
422
426
|
const navState = getNavigationState();
|
|
@@ -426,7 +430,7 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
426
430
|
element as React.ReactNode
|
|
427
431
|
);
|
|
428
432
|
const wrapped = createElement(TimberNuqsAdapter, null, withNav);
|
|
429
|
-
const rootElement = createElement(
|
|
433
|
+
const rootElement = createElement(NavigationRoot, {
|
|
430
434
|
initial: wrapped,
|
|
431
435
|
topLoaderConfig: _config.topLoader,
|
|
432
436
|
});
|
|
@@ -470,13 +474,13 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
470
474
|
// Instead, installDeferredNavigation sets up one-shot callbacks so the
|
|
471
475
|
// first navigateTransition/transitionRender call creates the root on
|
|
472
476
|
// `document` with the navigated content. After that initial render,
|
|
473
|
-
//
|
|
477
|
+
// NavigationRoot's real startTransition-based callbacks take over.
|
|
474
478
|
//
|
|
475
479
|
// This also fixes TIM-580 (navigation from SSR-only pages) because the
|
|
476
|
-
// deferred callbacks ensure
|
|
480
|
+
// deferred callbacks ensure NavigationRoot is mounted before the first
|
|
477
481
|
// navigation completes.
|
|
478
482
|
installDeferredNavigation((initial) => {
|
|
479
|
-
const rootElement = createElement(
|
|
483
|
+
const rootElement = createElement(NavigationRoot, {
|
|
480
484
|
initial,
|
|
481
485
|
topLoaderConfig: _config.topLoader,
|
|
482
486
|
});
|
|
@@ -489,7 +493,7 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
489
493
|
// Extracted into a function so both the hydration and createRoot paths
|
|
490
494
|
// can call it. Must run before hydrateRoot so useRouter() works during
|
|
491
495
|
// the initial render. renderRoot uses transitionRender which is set
|
|
492
|
-
// by the
|
|
496
|
+
// by the NavigationRoot component during hydration.
|
|
493
497
|
function initRouter(): void {
|
|
494
498
|
// Feature-detect Navigation API. When available, the navigate event
|
|
495
499
|
// replaces popstate for back/forward and catches external navigations.
|
|
@@ -543,7 +547,7 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
543
547
|
}
|
|
544
548
|
},
|
|
545
549
|
|
|
546
|
-
// Render decoded RSC tree via
|
|
550
|
+
// Render decoded RSC tree via NavigationRoot's state-based mechanism.
|
|
547
551
|
// Used for non-navigation renders (popstate cached replay, applyRevalidation).
|
|
548
552
|
// Wraps with NavigationProvider + TimberNuqsAdapter.
|
|
549
553
|
//
|
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
* 1. Link click handler: setLinkForCurrentNavigation(instance) →
|
|
15
15
|
* resets previous link (urgent), sets new link pending (urgent),
|
|
16
16
|
* stores setter + increments navId
|
|
17
|
-
* 2.
|
|
18
|
-
* 3.
|
|
17
|
+
* 2. NavigationRoot startTransition: captures navId, does async work
|
|
18
|
+
* 3. NavigationRoot commit: resetLinkPending(capturedNavId) →
|
|
19
19
|
* calls setter(IDLE) inside the transition (batched, atomic with tree)
|
|
20
20
|
* Only clears if navId matches (prevents stale T1 from clearing T2's link)
|
|
21
21
|
*
|
|
@@ -96,7 +96,7 @@ export function getCurrentNavId(): number {
|
|
|
96
96
|
|
|
97
97
|
/**
|
|
98
98
|
* Reset the current link's pending state to IDLE, but only if the navId
|
|
99
|
-
* matches. Called inside
|
|
99
|
+
* matches. Called inside NavigationRoot's startTransition after the async
|
|
100
100
|
* work completes — the setter call is a transition update, so it commits
|
|
101
101
|
* atomically with the new tree.
|
|
102
102
|
*
|
package/src/client/link.tsx
CHANGED
|
@@ -409,7 +409,7 @@ export function Link({
|
|
|
409
409
|
// setter is invoked during navigation — zero other links re-render.
|
|
410
410
|
//
|
|
411
411
|
// Eager show: click handler calls setLinkStatus(PENDING) directly (urgent).
|
|
412
|
-
// Atomic clear:
|
|
412
|
+
// Atomic clear: NavigationRoot calls resetLinkPending(navId) inside
|
|
413
413
|
// startTransition — batched with the new tree commit.
|
|
414
414
|
//
|
|
415
415
|
// See design/19-client-navigation.md §"Per-Link Pending State"
|
|
@@ -482,7 +482,7 @@ export function Link({
|
|
|
482
482
|
// Only this Link re-renders — all other Links are unaffected.
|
|
483
483
|
setLinkStatus(PENDING_LINK_STATUS);
|
|
484
484
|
|
|
485
|
-
// Register this link in the pending store so
|
|
485
|
+
// Register this link in the pending store so NavigationRoot can
|
|
486
486
|
// reset it to IDLE inside startTransition (atomic with new tree).
|
|
487
487
|
// Also resets any previous pending link to IDLE.
|
|
488
488
|
setLinkForCurrentNavigation(linkInstanceRef.current);
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
import type { NavigationApi, NavigateEvent } from './navigation-api-types.js';
|
|
21
21
|
import { consumeNavLinkMetadata } from './nav-link-store.js';
|
|
22
|
+
import { isHardNavigating } from './navigation-root.js';
|
|
22
23
|
|
|
23
24
|
// ─── Feature Detection ───────────────────────────────────────────
|
|
24
25
|
|
|
@@ -152,6 +153,15 @@ export function setupNavigationApi(callbacks: NavigationApiCallbacks): Navigatio
|
|
|
152
153
|
// Skip non-interceptable navigations (cross-origin, etc.)
|
|
153
154
|
if (!event.canIntercept) return;
|
|
154
155
|
|
|
156
|
+
// Hard navigation guard: when the router has triggered a full page
|
|
157
|
+
// load (500 error, version skew), skip interception entirely so the
|
|
158
|
+
// browser performs the MPA navigation. Without this guard, setting
|
|
159
|
+
// window.location.href fires a navigate event that we'd intercept,
|
|
160
|
+
// running the RSC pipeline again → 500 → window.location.href →
|
|
161
|
+
// navigate event → infinite loop.
|
|
162
|
+
// See design/19-client-navigation.md §"Hard Navigation Guard"
|
|
163
|
+
if (isHardNavigating()) return;
|
|
164
|
+
|
|
155
165
|
// Skip download requests
|
|
156
166
|
if (event.downloadRequest) return;
|
|
157
167
|
|
|
@@ -62,7 +62,7 @@ export interface NavigationState {
|
|
|
62
62
|
* Context instances are stored on globalThis (NOT in module-level
|
|
63
63
|
* variables) because the ESM bundler can duplicate this module across
|
|
64
64
|
* chunks. Module-level variables would create separate instances per
|
|
65
|
-
* chunk — the provider in
|
|
65
|
+
* chunk — the provider in NavigationRoot (index chunk) would use
|
|
66
66
|
* context A while the consumer in useNavigationPending (shared chunk)
|
|
67
67
|
* reads from context B. globalThis guarantees a single instance.
|
|
68
68
|
*
|
|
@@ -168,7 +168,7 @@ export function getNavigationState(): NavigationState {
|
|
|
168
168
|
|
|
169
169
|
/**
|
|
170
170
|
* Separate context for the in-flight navigation URL. Provided by
|
|
171
|
-
*
|
|
171
|
+
* NavigationRoot (urgent useState), consumed by useNavigationPending
|
|
172
172
|
* and TopLoader. Per-link pending state uses useOptimistic instead
|
|
173
173
|
* (see link-pending-store.ts).
|
|
174
174
|
*
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NavigationRoot — Wrapper component for transition-based rendering.
|
|
3
|
+
*
|
|
4
|
+
* Solves the "new boundary has no old content" problem for client-side
|
|
5
|
+
* navigation. When React renders a completely new Suspense boundary via
|
|
6
|
+
* root.render(), it shows the fallback immediately — root.render() is
|
|
7
|
+
* always an urgent update regardless of startTransition.
|
|
8
|
+
*
|
|
9
|
+
* NavigationRoot holds the current element in React state. Navigation
|
|
10
|
+
* updates call startTransition(() => setState(newElement)), which IS
|
|
11
|
+
* a transition update. React keeps the old committed tree visible while
|
|
12
|
+
* any new Suspense boundaries in the transition resolve.
|
|
13
|
+
*
|
|
14
|
+
* Also manages `pendingUrl` as React state with an urgent/transition split:
|
|
15
|
+
* - Navigation START: `setPendingUrl(url)` is an urgent update — React
|
|
16
|
+
* commits it before the next paint, showing the spinner immediately.
|
|
17
|
+
* - Navigation END: `setPendingUrl(null)` is inside `startTransition`
|
|
18
|
+
* alongside `setElement(newTree)` — both commit atomically, so the
|
|
19
|
+
* spinner disappears in the same frame as the new content appears.
|
|
20
|
+
*
|
|
21
|
+
* Hard navigation guard: When a hard navigation is triggered (500 error,
|
|
22
|
+
* version skew), the component throws an unresolved thenable AFTER all
|
|
23
|
+
* hooks to suspend forever — preventing React from rendering children
|
|
24
|
+
* during page teardown. The throw must come after hooks to satisfy
|
|
25
|
+
* React's rules (same hook count every render) while still preventing
|
|
26
|
+
* child renders that could hit hook count mismatches in components
|
|
27
|
+
* whose positions shift during teardown. This pattern is borrowed from
|
|
28
|
+
* Next.js (app-router.tsx pushRef.mpaNavigation — also after hooks).
|
|
29
|
+
*
|
|
30
|
+
* See design/05-streaming.md §"deferSuspenseFor"
|
|
31
|
+
* See design/19-client-navigation.md §"NavigationContext"
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { useState, startTransition, createElement, Fragment, type ReactNode } from 'react';
|
|
35
|
+
import { PendingNavigationProvider } from './navigation-context.js';
|
|
36
|
+
import { TopLoader, type TopLoaderConfig } from './top-loader.js';
|
|
37
|
+
import { getCurrentNavId, resetLinkPending } from './link-pending-store.js';
|
|
38
|
+
|
|
39
|
+
// ─── Navigation Transition Counter ──────────────────────────────
|
|
40
|
+
// Monotonically increasing counter that increments each time
|
|
41
|
+
// navigateTransition() is called. Used to detect stale transitions:
|
|
42
|
+
// if a newer transition started while the current one's perform()
|
|
43
|
+
// was in flight, the current transition is stale and should reject.
|
|
44
|
+
//
|
|
45
|
+
// Separate from the link-pending navId (which only increments on
|
|
46
|
+
// link clicks). This counter covers all navigation types: link clicks,
|
|
47
|
+
// programmatic navigate(), refresh(), and handlePopState().
|
|
48
|
+
//
|
|
49
|
+
// Uses globalThis for singleton guarantee across chunks — same pattern
|
|
50
|
+
// as NavigationContext and the link pending store.
|
|
51
|
+
|
|
52
|
+
const NAV_TRANSITION_KEY = Symbol.for('__timber_nav_transition_counter');
|
|
53
|
+
|
|
54
|
+
function getTransitionCounter(): { id: number } {
|
|
55
|
+
const g = globalThis as Record<symbol, unknown>;
|
|
56
|
+
if (!g[NAV_TRANSITION_KEY]) {
|
|
57
|
+
g[NAV_TRANSITION_KEY] = { id: 0 };
|
|
58
|
+
}
|
|
59
|
+
return g[NAV_TRANSITION_KEY] as { id: number };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ─── Hard Navigation Guard ──────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Module-level flag indicating a hard (MPA) navigation is in progress.
|
|
66
|
+
*
|
|
67
|
+
* When true:
|
|
68
|
+
* - NavigationRoot throws an unresolved thenable to suspend forever,
|
|
69
|
+
* preventing React from rendering children during page teardown
|
|
70
|
+
* (avoids "Rendered more hooks" crashes).
|
|
71
|
+
* - The Navigation API handler skips interception, letting the browser
|
|
72
|
+
* perform a full page load (prevents infinite loops where
|
|
73
|
+
* window.location.href → navigate event → router.navigate → 500 →
|
|
74
|
+
* window.location.href → ...).
|
|
75
|
+
*
|
|
76
|
+
* Uses globalThis for singleton guarantee across chunks (same pattern
|
|
77
|
+
* as NavigationContext). See design/19-client-navigation.md §"Singleton
|
|
78
|
+
* Guarantee via globalThis".
|
|
79
|
+
*/
|
|
80
|
+
const HARD_NAV_KEY = Symbol.for('__timber_hard_navigating');
|
|
81
|
+
|
|
82
|
+
function getHardNavStore(): { value: boolean } {
|
|
83
|
+
const g = globalThis as Record<symbol, unknown>;
|
|
84
|
+
if (!g[HARD_NAV_KEY]) {
|
|
85
|
+
g[HARD_NAV_KEY] = { value: false };
|
|
86
|
+
}
|
|
87
|
+
return g[HARD_NAV_KEY] as { value: boolean };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Set the hard-navigating flag. Call this BEFORE setting
|
|
92
|
+
* window.location.href or window.location.reload() to prevent:
|
|
93
|
+
* 1. React from rendering children during page teardown
|
|
94
|
+
* 2. Navigation API from intercepting the hard navigation
|
|
95
|
+
*/
|
|
96
|
+
export function setHardNavigating(value: boolean): void {
|
|
97
|
+
getHardNavStore().value = value;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Check if a hard navigation is in progress.
|
|
102
|
+
* Used by NavigationRoot (throw unresolvedThenable) and by the
|
|
103
|
+
* Navigation API handler (skip interception).
|
|
104
|
+
*/
|
|
105
|
+
export function isHardNavigating(): boolean {
|
|
106
|
+
return getHardNavStore().value;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A thenable that never resolves. When thrown during React render,
|
|
111
|
+
* it causes the component to suspend forever — React keeps the
|
|
112
|
+
* old committed tree visible and never attempts to render children.
|
|
113
|
+
*
|
|
114
|
+
* This is the same pattern Next.js uses in app-router.tsx for MPA
|
|
115
|
+
* navigations (pushRef.mpaNavigation → throw unresolvedThenable).
|
|
116
|
+
*/
|
|
117
|
+
// eslint-disable-next-line unicorn/no-thenable -- Intentionally a never-resolving thenable
|
|
118
|
+
// for React's Suspense mechanism. Same pattern as Next.js's unresolvedThenable.
|
|
119
|
+
const unresolvedThenable = { then() {} } as PromiseLike<never>;
|
|
120
|
+
|
|
121
|
+
// ─── Module-level functions ──────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Module-level reference to the state setter wrapped in startTransition.
|
|
125
|
+
* Used for non-navigation renders (applyRevalidation, popstate replay).
|
|
126
|
+
*/
|
|
127
|
+
let _transitionRender: ((element: ReactNode) => void) | null = null;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Module-level reference to the navigation transition function.
|
|
131
|
+
* Wraps a full navigation (fetch + render) in a single startTransition
|
|
132
|
+
* with the pending URL.
|
|
133
|
+
*/
|
|
134
|
+
let _navigateTransition:
|
|
135
|
+
| ((pendingUrl: string, perform: () => Promise<ReactNode>) => Promise<void>)
|
|
136
|
+
| null = null;
|
|
137
|
+
|
|
138
|
+
// ─── Component ───────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Root wrapper component that enables transition-based rendering.
|
|
142
|
+
*
|
|
143
|
+
* Renders PendingNavigationProvider around children for the pending URL
|
|
144
|
+
* context. The DOM tree matches the server-rendered HTML during hydration
|
|
145
|
+
* (the provider renders no extra DOM elements).
|
|
146
|
+
*
|
|
147
|
+
* Usage in browser-entry.ts:
|
|
148
|
+
* const rootEl = createElement(NavigationRoot, { initial: wrapped });
|
|
149
|
+
* reactRoot = hydrateRoot(document, rootEl);
|
|
150
|
+
*
|
|
151
|
+
* Subsequent navigations:
|
|
152
|
+
* navigateTransition(url, async () => { fetch; return wrappedElement; });
|
|
153
|
+
*
|
|
154
|
+
* Non-navigation renders:
|
|
155
|
+
* transitionRender(newWrappedElement);
|
|
156
|
+
*/
|
|
157
|
+
export function NavigationRoot({
|
|
158
|
+
initial,
|
|
159
|
+
topLoaderConfig,
|
|
160
|
+
}: {
|
|
161
|
+
initial: ReactNode;
|
|
162
|
+
topLoaderConfig?: TopLoaderConfig;
|
|
163
|
+
}): ReactNode {
|
|
164
|
+
const [element, setElement] = useState<ReactNode>(initial);
|
|
165
|
+
const [pendingUrl, setPendingUrl] = useState<string | null>(null);
|
|
166
|
+
|
|
167
|
+
// NOTE: We use standalone `startTransition` (imported from 'react'),
|
|
168
|
+
// NOT `useTransition`. The `useTransition` hook's `startTransition`
|
|
169
|
+
// is tied to a single fiber and tracks one async callback at a time.
|
|
170
|
+
// When two navigations overlap (click slow-page, then click dashboard),
|
|
171
|
+
// calling useTransition's startTransition twice with concurrent async
|
|
172
|
+
// callbacks corrupts React's internal hook tracking — causing
|
|
173
|
+
// "Rendered more hooks than during the previous render."
|
|
174
|
+
//
|
|
175
|
+
// Standalone `startTransition` creates independent transition lanes
|
|
176
|
+
// for each call, so concurrent navigations don't interfere. We don't
|
|
177
|
+
// need useTransition's `isPending` — we track pending state via our
|
|
178
|
+
// own `pendingUrl` useState.
|
|
179
|
+
//
|
|
180
|
+
// This matches the Next.js pattern (TIM-625): "No useTransition in
|
|
181
|
+
// the router at all — only standalone startTransition."
|
|
182
|
+
|
|
183
|
+
// Non-navigation render (revalidation, popstate cached replay).
|
|
184
|
+
_transitionRender = (newElement: ReactNode) => {
|
|
185
|
+
startTransition(() => {
|
|
186
|
+
setElement(newElement);
|
|
187
|
+
});
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// Full navigation transition.
|
|
191
|
+
// setPendingUrl(url) is an URGENT update — React commits it before the next
|
|
192
|
+
// paint, so the pending spinner appears immediately when navigation starts.
|
|
193
|
+
// Inside startTransition: the async fetch + setElement + setPendingUrl(null)
|
|
194
|
+
// are deferred. When the transition commits, the new tree and pendingUrl=null
|
|
195
|
+
// both apply in the same React commit — making the pending→active transition
|
|
196
|
+
// atomic (no frame where pending is false but the old tree is still visible).
|
|
197
|
+
_navigateTransition = (url: string, perform: () => Promise<ReactNode>) => {
|
|
198
|
+
// Urgent: show pending state immediately (for TopLoader / useNavigationPending)
|
|
199
|
+
setPendingUrl(url);
|
|
200
|
+
|
|
201
|
+
// Increment the transition counter SYNCHRONOUSLY (before startTransition
|
|
202
|
+
// schedules the async work). Each call gets a unique transId; the counter
|
|
203
|
+
// is the same globalThis singleton, so a newer call always has a higher id.
|
|
204
|
+
const counter = getTransitionCounter();
|
|
205
|
+
const transId = ++counter.id;
|
|
206
|
+
|
|
207
|
+
return new Promise<void>((resolve, reject) => {
|
|
208
|
+
startTransition(async () => {
|
|
209
|
+
// Capture the link-level nav ID for resetLinkPending (which has its
|
|
210
|
+
// own guard). The transition counter (transId) is the primary stale
|
|
211
|
+
// detection — it covers all navigation types (link clicks, programmatic
|
|
212
|
+
// navigate, refresh, handlePopState), not just link-initiated ones.
|
|
213
|
+
const linkNavId = getCurrentNavId();
|
|
214
|
+
try {
|
|
215
|
+
const newElement = await perform();
|
|
216
|
+
// Only commit state if this is still the active navigation.
|
|
217
|
+
// A superseded transition's updates must be dropped entirely.
|
|
218
|
+
if (counter.id === transId) {
|
|
219
|
+
setElement(newElement);
|
|
220
|
+
setPendingUrl(null);
|
|
221
|
+
resetLinkPending(linkNavId);
|
|
222
|
+
resolve();
|
|
223
|
+
} else {
|
|
224
|
+
// Stale transition — a newer navigation has superseded this one.
|
|
225
|
+
// Reject so the caller (navigate/refresh/handlePopState) doesn't
|
|
226
|
+
// run post-transition side effects (applyHead, scroll, event
|
|
227
|
+
// dispatch) with stale data. All callers catch AbortError.
|
|
228
|
+
reject(new DOMException('Navigation superseded', 'AbortError'));
|
|
229
|
+
}
|
|
230
|
+
} catch (err) {
|
|
231
|
+
// Only clear pending if this is still the active navigation.
|
|
232
|
+
// Stale transitions must not touch state — doing so corrupts
|
|
233
|
+
// React's transition tracking and causes hook count mismatches.
|
|
234
|
+
if (counter.id === transId) {
|
|
235
|
+
setPendingUrl(null);
|
|
236
|
+
resetLinkPending(linkNavId);
|
|
237
|
+
}
|
|
238
|
+
reject(err);
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
// ─── Hard navigation guard ─────────────────────────────────
|
|
245
|
+
// When a hard navigation is in progress (500 error, version skew),
|
|
246
|
+
// suspend forever to prevent React from rendering children during
|
|
247
|
+
// page teardown. This avoids "Rendered more hooks" crashes in
|
|
248
|
+
// CHILD components whose hook counts may shift during teardown.
|
|
249
|
+
//
|
|
250
|
+
// CRITICAL: This throw MUST come AFTER all hooks (the two
|
|
251
|
+
// useState calls above). React requires the same hooks to run on
|
|
252
|
+
// every render. If we threw before hooks, React would see 0 hooks
|
|
253
|
+
// on the re-render vs 2 hooks on the initial render — triggering
|
|
254
|
+
// the exact "Rendered more hooks" error we're trying to prevent.
|
|
255
|
+
//
|
|
256
|
+
// By placing it after hooks but before the return, all hooks
|
|
257
|
+
// satisfy React's rules, but the thrown thenable prevents any
|
|
258
|
+
// children from rendering. Same pattern as Next.js app-router.tsx
|
|
259
|
+
// (pushRef.mpaNavigation — also placed after all hooks).
|
|
260
|
+
if (isHardNavigating()) {
|
|
261
|
+
throw unresolvedThenable;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Inject TopLoader alongside the element tree inside PendingNavigationProvider.
|
|
265
|
+
// The TopLoader reads pendingUrl from context to show/hide the progress bar.
|
|
266
|
+
// It is rendered only when not explicitly disabled via config.
|
|
267
|
+
const showTopLoader = topLoaderConfig?.enabled !== false;
|
|
268
|
+
const children = showTopLoader
|
|
269
|
+
? createElement(Fragment, null, createElement(TopLoader, { config: topLoaderConfig }), element)
|
|
270
|
+
: element;
|
|
271
|
+
return createElement(PendingNavigationProvider, { value: pendingUrl }, children);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ─── Public API ──────────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Trigger a transition render for non-navigation updates.
|
|
278
|
+
* React keeps the old committed tree visible while any new Suspense
|
|
279
|
+
* boundaries in the update resolve.
|
|
280
|
+
*
|
|
281
|
+
* Used for: applyRevalidation, popstate replay with cached payload.
|
|
282
|
+
*/
|
|
283
|
+
export function transitionRender(element: ReactNode): void {
|
|
284
|
+
if (_transitionRender) {
|
|
285
|
+
_transitionRender(element);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Run a full navigation inside a React transition with optimistic pending URL.
|
|
291
|
+
*
|
|
292
|
+
* The `perform` callback runs inside `startTransition` — it should fetch the
|
|
293
|
+
* RSC payload, update router state, and return the wrapped React element.
|
|
294
|
+
* The pending URL shows immediately (urgent update) and reverts
|
|
295
|
+
* to null when the transition commits (atomic with the new tree).
|
|
296
|
+
*
|
|
297
|
+
* Returns a Promise that resolves when the async work completes (note: the
|
|
298
|
+
* React transition may not have committed yet, but all state updates are done).
|
|
299
|
+
*
|
|
300
|
+
* Used for: navigate(), refresh(), popstate with fetch.
|
|
301
|
+
*/
|
|
302
|
+
export function navigateTransition(
|
|
303
|
+
pendingUrl: string,
|
|
304
|
+
perform: () => Promise<ReactNode>
|
|
305
|
+
): Promise<void> {
|
|
306
|
+
if (_navigateTransition) {
|
|
307
|
+
return _navigateTransition(pendingUrl, perform);
|
|
308
|
+
}
|
|
309
|
+
// Fallback: no NavigationRoot mounted (shouldn't happen in production)
|
|
310
|
+
return perform().then(() => {});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Check if the NavigationRoot is mounted and ready for renders.
|
|
315
|
+
* Used by browser-entry.ts to guard against renders before hydration.
|
|
316
|
+
*/
|
|
317
|
+
export function isNavigationRootReady(): boolean {
|
|
318
|
+
return _transitionRender !== null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Install one-shot deferred callbacks for the no-RSC bootstrap path (TIM-600).
|
|
323
|
+
*
|
|
324
|
+
* When there's no RSC payload, we can't create a React root immediately —
|
|
325
|
+
* `createRoot(document).render(...)` would blank the SSR HTML. Instead,
|
|
326
|
+
* this sets up `_transitionRender` and `_navigateTransition` so that the
|
|
327
|
+
* first client navigation triggers root creation via `createAndMount`.
|
|
328
|
+
*
|
|
329
|
+
* After `createAndMount` runs, NavigationRoot renders and overwrites these
|
|
330
|
+
* callbacks with its real `startTransition`-based implementations.
|
|
331
|
+
*/
|
|
332
|
+
export function installDeferredNavigation(createAndMount: (initial: ReactNode) => void): void {
|
|
333
|
+
let mounted = false;
|
|
334
|
+
const mountOnce = (element: ReactNode) => {
|
|
335
|
+
if (mounted) return;
|
|
336
|
+
mounted = true;
|
|
337
|
+
createAndMount(element);
|
|
338
|
+
};
|
|
339
|
+
_transitionRender = (element: ReactNode) => {
|
|
340
|
+
mountOnce(element);
|
|
341
|
+
};
|
|
342
|
+
_navigateTransition = async (_pendingUrl: string, perform: () => Promise<ReactNode>) => {
|
|
343
|
+
const element = await perform();
|
|
344
|
+
mountOnce(element);
|
|
345
|
+
};
|
|
346
|
+
}
|