@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.
@@ -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;AAMzD;;;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,CA6R7B"}
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;AAOH,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,CA+FpC"}
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
- * TransitionRoot → PendingNavigationProvider → Fragment(TopLoader, ...) →
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
- * TransitionRoot
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
- * SsrTransitionRoot
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.68",
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
- TransitionRoot,
62
+ NavigationRoot,
63
63
  transitionRender,
64
64
  navigateTransition,
65
65
  installDeferredNavigation,
66
- } from './transition-root.js';
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 TransitionRoot (for
420
+ // TimberNuqsAdapter (for nuqs context), and NavigationRoot (for
417
421
  // transition-based rendering during client navigation).
418
422
  //
419
- // TransitionRoot holds the element in React state and updates via
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(TransitionRoot, {
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
- // TransitionRoot's real startTransition-based callbacks take over.
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 TransitionRoot is mounted before the first
480
+ // deferred callbacks ensure NavigationRoot is mounted before the first
477
481
  // navigation completes.
478
482
  installDeferredNavigation((initial) => {
479
- const rootElement = createElement(TransitionRoot, {
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 TransitionRoot component during hydration.
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 TransitionRoot's state-based mechanism.
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. TransitionRoot startTransition: captures navId, does async work
18
- * 3. TransitionRoot commit: resetLinkPending(capturedNavId) →
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 TransitionRoot's startTransition after the async
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
  *
@@ -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: TransitionRoot calls resetLinkPending(navId) inside
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 TransitionRoot can
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 TransitionRoot (index chunk) would use
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
- * TransitionRoot (urgent useState), consumed by useNavigationPending
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
+ }