@rangojs/router 0.15.0 → 0.16.0

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.
@@ -14,7 +14,10 @@ import type { ReactNode } from "react";
14
14
  * decodeLoaderResults runs during the SERVER tree build on forceAwait/action
15
15
  * lanes, where a throw would collapse the whole payload — it routes signals
16
16
  * through the errorFallback slot instead (same visual: the slot replaces the
17
- * children under OutletProvider).
17
+ * children under OutletProvider). The document lane's `{ ssr: false }`
18
+ * results take the same non-throwing route (segment-system
19
+ * buildLoaderStreams): they are settled before the tree is built, and the
20
+ * read-site throw would land in the Fizz shell with no boundary to absorb it.
18
21
  */
19
22
  export declare const LOADER_NOT_FOUND_FALLBACK: unique symbol;
20
23
  export declare const LOADER_REDIRECT: unique symbol;
@@ -33,6 +33,12 @@
33
33
  * realm, so there is no cross-request pollution. Server renders never mutate
34
34
  * the store — the hook falls back to `OutletContext.loaderData`.
35
35
  */
36
+ /** The loader-segment shape announcePendingStreams reads (ResolvedSegment). */
37
+ export interface PendingStreamSource {
38
+ type: string;
39
+ loaderId?: string;
40
+ loaderData?: unknown;
41
+ }
36
42
  export interface LoaderEntry<T = unknown> {
37
43
  readonly value: T | undefined;
38
44
  /**
@@ -77,6 +83,14 @@ export interface SubscribeOptions {
77
83
  * Refreshes this bucket in place (no params/body) and rejects on failure.
78
84
  */
79
85
  refetch?: () => Promise<void>;
86
+ /**
87
+ * Called by `announcePendingStreams` (inside a transition commit) when a
88
+ * navigation stream for this bucket's loader is still pending. The hook pins
89
+ * `isLoading: true` with a useOptimistic update; React reverts it when that
90
+ * transition commits. Route-context readers only — an ephemeral reader owns
91
+ * its own lifecycle.
92
+ */
93
+ onStreamPending?: () => void;
80
94
  }
81
95
  export declare class LoaderStore {
82
96
  private readonly entries;
@@ -84,6 +98,18 @@ export declare class LoaderStore {
84
98
  private readonly families;
85
99
  /** refresh group name -> set of bucket keys, for refreshGroups(). */
86
100
  private readonly groups;
101
+ /**
102
+ * loader.$$id -> per-loader streams a committed navigation is still
103
+ * receiving. Registered and announced by announcePendingStreams(), which
104
+ * browser/partial-update.ts calls INSIDE every transition commit, so a
105
+ * reader whose content is held on screen by that transition pins
106
+ * `isLoading: true` for the data it is still showing until the transition
107
+ * lands (use-loader.tsx). The new tree's own read of the loader suspends at
108
+ * the read site and never observes the flag. A Set, not a flag: overlapping
109
+ * navigations (second click before the first stream lands) keep the family
110
+ * pending until the LAST stream settles.
111
+ */
112
+ private readonly pendingStreams;
87
113
  private getOrCreate;
88
114
  private registerFamily;
89
115
  /**
@@ -178,6 +204,29 @@ export declare class LoaderStore {
178
204
  */
179
205
  clearFamily(loaderId: string): void;
180
206
  private notify;
207
+ /**
208
+ * Register a still-streaming per-loader result for `loaderId`. No-op for a
209
+ * settled Flight chunk (unwrapsSynchronously / "rejected"): the forceAwait
210
+ * and action lanes commit settled chunks, and a { ssr: false } loader is
211
+ * awaited before flush. Released as the stream settles, either way.
212
+ */
213
+ trackPendingStream(loaderId: string, stream: Promise<unknown>): void;
214
+ /** True while any navigation stream for `loaderId` is still pending. */
215
+ isStreamPending(loaderId: string): boolean;
216
+ /**
217
+ * Register the loader segments of a tree being committed whose data is
218
+ * still a pending promise, then fire `onStreamPending` on every subscriber
219
+ * of every family with a pending stream. MUST run inside the startTransition
220
+ * that commits the tree: the hook answers with a useOptimistic update, which
221
+ * React reverts exactly when that transition commits — so the held reader
222
+ * reports isLoading:true for as long as its data stays on screen and not a
223
+ * frame longer. Outside a transition React reverts an optimistic update on
224
+ * the next render, pinning nothing useful. Registering here rather than in
225
+ * the tree build keeps register + announce in one call; settled chunks
226
+ * (cached/reused segments, forceAwait lanes) are skipped by
227
+ * trackPendingStream.
228
+ */
229
+ announcePendingStreams(segments: readonly PendingStreamSource[]): void;
181
230
  /**
182
231
  * Test-only escape hatch. Drops every entry. Production code should never
183
232
  * call this; the store is process-scoped and lives for the tab's lifetime.
@@ -343,8 +343,10 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
343
343
  * through React's startTransition, so a same-route nav (same route,
344
344
  * different params, e.g. /product/1 -> /product/2) holds the previous
345
345
  * content while the new loader resolves instead of flashing the route's
346
- * loading() skeleton (see segment-system.tsx inTransitionScope). This is
347
- * also the precondition for any view-transition animation.
346
+ * loading() skeleton (see segment-system.tsx inTransitionScope). Held
347
+ * useLoader readers of the re-run loaders report isLoading: true until
348
+ * the new data commits (use-loader.tsx). This is also the precondition
349
+ * for any view-transition animation.
348
350
  * 2. <ViewTransition> (experimental React only): the segment content is also
349
351
  * wrapped in React's <ViewTransition>, so the held swap cross-fades/morphs.
350
352
  * Layered on by default; pass { viewTransition: false } to keep #1 without
@@ -89,6 +89,8 @@ export type CacheItem = {
89
89
  export type TransitionItem = {
90
90
  name: string;
91
91
  type: "transition";
92
+ /** Wrapper form only: the children it wraps (see hasRoutesInItem). */
93
+ uses?: AllUseItems[];
92
94
  };
93
95
  /**
94
96
  * Typed transition item that carries child routes as phantom type
@@ -78,7 +78,6 @@ declare const REFUSED_CAPTURE_MAX_MS = 60000;
78
78
  * docs/design/ppr-shell-resume.md ("Refused-capture backoff").
79
79
  */
80
80
  declare const REFUSED_CAPTURE_DEV_MAX_MS = 2000;
81
- /** True iff `key` is still inside its (exponential) backoff window. */
82
81
  declare function isCaptureBackedOff(key: string): boolean;
83
82
  /**
84
83
  * Record a refused/failed capture, escalating the backoff window exponentially up
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Thenable statuses React's `use()` unwraps without suspending: React's
3
+ * re-check after `.then()` handles "fulfilled", and Flight chunks in the
4
+ * resolved_* states initialize synchronously inside that same call. Anything
5
+ * else (pending, blocked, an unstamped native promise) suspends at least once.
6
+ * Shared by the dev SSR-suspension diagnostic and the held-navigation
7
+ * isLoading registration (loader-store.ts), which must agree on "settled".
8
+ */
9
+ export declare function unwrapsSynchronously(stream: Promise<unknown>): boolean;
@@ -3795,7 +3795,7 @@ import { resolve } from "node:path";
3795
3795
  // package.json
3796
3796
  var package_default = {
3797
3797
  name: "@rangojs/router",
3798
- version: "0.15.0",
3798
+ version: "0.16.0",
3799
3799
  description: "Django-inspired RSC router with composable URL patterns",
3800
3800
  keywords: [
3801
3801
  "react",
@@ -3993,7 +3993,7 @@ var package_default = {
3993
3993
  },
3994
3994
  dependencies: {
3995
3995
  "@types/debug": "^4.1.12",
3996
- "@vitejs/plugin-rsc": "^0.5.34",
3996
+ "@vitejs/plugin-rsc": "^0.5.35",
3997
3997
  debug: "^4.4.1",
3998
3998
  "magic-string": "^0.30.17",
3999
3999
  picomatch: "^4.0.4",
@@ -4026,7 +4026,7 @@ var package_default = {
4026
4026
  "@playwright/test": "^1.49.1",
4027
4027
  "@testing-library/react": ">=16",
4028
4028
  "@vercel/functions": "^3.0.0",
4029
- "@vitejs/plugin-rsc": "^0.5.34",
4029
+ "@vitejs/plugin-rsc": "^0.5.35",
4030
4030
  react: ">=19.2.8 <20",
4031
4031
  "react-dom": ">=19.2.8 <20",
4032
4032
  vite: "^8.0.16",
@@ -7766,10 +7766,17 @@ import {
7766
7766
  useContext as useContext4,
7767
7767
  useEffect as useEffect2,
7768
7768
  useMemo as useMemo3,
7769
+ useOptimistic,
7769
7770
  useRef,
7770
7771
  useState
7771
7772
  } from "react";
7772
7773
 
7774
+ // src/thenable-status.ts
7775
+ function unwrapsSynchronously(stream) {
7776
+ const status = stream.status;
7777
+ return status === "fulfilled" || status === "resolved_model" || status === "resolved_module";
7778
+ }
7779
+
7773
7780
  // src/loader-store.ts
7774
7781
  var EMPTY_SNAPSHOT = Object.freeze({
7775
7782
  value: void 0,
@@ -7790,6 +7797,18 @@ var LoaderStore = class {
7790
7797
  this.families = /* @__PURE__ */ new Map();
7791
7798
  /** refresh group name -> set of bucket keys, for refreshGroups(). */
7792
7799
  this.groups = /* @__PURE__ */ new Map();
7800
+ /**
7801
+ * loader.$$id -> per-loader streams a committed navigation is still
7802
+ * receiving. Registered and announced by announcePendingStreams(), which
7803
+ * browser/partial-update.ts calls INSIDE every transition commit, so a
7804
+ * reader whose content is held on screen by that transition pins
7805
+ * `isLoading: true` for the data it is still showing until the transition
7806
+ * lands (use-loader.tsx). The new tree's own read of the loader suspends at
7807
+ * the read site and never observes the flag. A Set, not a flag: overlapping
7808
+ * navigations (second click before the first stream lands) keep the family
7809
+ * pending until the LAST stream settles.
7810
+ */
7811
+ this.pendingStreams = /* @__PURE__ */ new Map();
7793
7812
  }
7794
7813
  getOrCreate(bucketKey) {
7795
7814
  let e = this.entries.get(bucketKey);
@@ -7803,7 +7822,8 @@ var LoaderStore = class {
7803
7822
  pendingClear: false,
7804
7823
  clearWhenSettled: false,
7805
7824
  groups: /* @__PURE__ */ new Map(),
7806
- refetch: void 0
7825
+ refetch: void 0,
7826
+ streamPendingListeners: /* @__PURE__ */ new Set()
7807
7827
  };
7808
7828
  this.entries.set(bucketKey, e);
7809
7829
  }
@@ -7837,8 +7857,11 @@ var LoaderStore = class {
7837
7857
  e.pendingClear = false;
7838
7858
  e.clearWhenSettled = false;
7839
7859
  e.listeners.add(cb);
7860
+ const onStreamPending = options?.onStreamPending;
7861
+ if (onStreamPending) e.streamPendingListeners.add(onStreamPending);
7840
7862
  return () => {
7841
7863
  e.listeners.delete(cb);
7864
+ if (onStreamPending) e.streamPendingListeners.delete(onStreamPending);
7842
7865
  for (const group of groups) this.releaseGroup(group, bucketKey, e);
7843
7866
  this.maybeScheduleRefcountClear(bucketKey, e);
7844
7867
  };
@@ -8080,6 +8103,63 @@ var LoaderStore = class {
8080
8103
  notify(e) {
8081
8104
  for (const cb of e.listeners) cb();
8082
8105
  }
8106
+ /**
8107
+ * Register a still-streaming per-loader result for `loaderId`. No-op for a
8108
+ * settled Flight chunk (unwrapsSynchronously / "rejected"): the forceAwait
8109
+ * and action lanes commit settled chunks, and a { ssr: false } loader is
8110
+ * awaited before flush. Released as the stream settles, either way.
8111
+ */
8112
+ trackPendingStream(loaderId, stream) {
8113
+ if (unwrapsSynchronously(stream) || stream.status === "rejected") {
8114
+ return;
8115
+ }
8116
+ let set = this.pendingStreams.get(loaderId);
8117
+ if (set?.has(stream)) return;
8118
+ if (!set) {
8119
+ set = /* @__PURE__ */ new Set();
8120
+ this.pendingStreams.set(loaderId, set);
8121
+ }
8122
+ set.add(stream);
8123
+ const release = () => {
8124
+ const current = this.pendingStreams.get(loaderId);
8125
+ if (!current || !current.delete(stream)) return;
8126
+ if (current.size === 0) this.pendingStreams.delete(loaderId);
8127
+ };
8128
+ stream.then(release, release);
8129
+ }
8130
+ /** True while any navigation stream for `loaderId` is still pending. */
8131
+ isStreamPending(loaderId) {
8132
+ return this.pendingStreams.has(loaderId);
8133
+ }
8134
+ /**
8135
+ * Register the loader segments of a tree being committed whose data is
8136
+ * still a pending promise, then fire `onStreamPending` on every subscriber
8137
+ * of every family with a pending stream. MUST run inside the startTransition
8138
+ * that commits the tree: the hook answers with a useOptimistic update, which
8139
+ * React reverts exactly when that transition commits — so the held reader
8140
+ * reports isLoading:true for as long as its data stays on screen and not a
8141
+ * frame longer. Outside a transition React reverts an optimistic update on
8142
+ * the next render, pinning nothing useful. Registering here rather than in
8143
+ * the tree build keeps register + announce in one call; settled chunks
8144
+ * (cached/reused segments, forceAwait lanes) are skipped by
8145
+ * trackPendingStream.
8146
+ */
8147
+ announcePendingStreams(segments) {
8148
+ for (const segment of segments) {
8149
+ if (segment.type === "loader" && segment.loaderId && segment.loaderData instanceof Promise) {
8150
+ this.trackPendingStream(segment.loaderId, segment.loaderData);
8151
+ }
8152
+ }
8153
+ for (const loaderId of this.pendingStreams.keys()) {
8154
+ const fam = this.families.get(loaderId);
8155
+ if (!fam) continue;
8156
+ for (const bucketKey of fam) {
8157
+ const e = this.entries.get(bucketKey);
8158
+ if (!e) continue;
8159
+ for (const cb of e.streamPendingListeners) cb();
8160
+ }
8161
+ }
8162
+ }
8083
8163
  /**
8084
8164
  * Test-only escape hatch. Drops every entry. Production code should never
8085
8165
  * call this; the store is process-scoped and lives for the tab's lifetime.
@@ -8089,6 +8169,7 @@ var LoaderStore = class {
8089
8169
  this.entries.clear();
8090
8170
  this.families.clear();
8091
8171
  this.groups.clear();
8172
+ this.pendingStreams.clear();
8092
8173
  }
8093
8174
  };
8094
8175
  var loaderStore = new LoaderStore();
@@ -8098,7 +8179,7 @@ import {
8098
8179
  useContext as useContext5,
8099
8180
  useState as useState2,
8100
8181
  useEffect as useEffect3,
8101
- useOptimistic,
8182
+ useOptimistic as useOptimistic2,
8102
8183
  startTransition as startTransition2,
8103
8184
  useRef as useRef2
8104
8185
  } from "react";
@@ -8134,7 +8215,7 @@ import {
8134
8215
  useState as useState6,
8135
8216
  useEffect as useEffect7,
8136
8217
  useRef as useRef6,
8137
- useOptimistic as useOptimistic2,
8218
+ useOptimistic as useOptimistic3,
8138
8219
  startTransition as startTransition3
8139
8220
  } from "react";
8140
8221
 
@@ -8213,7 +8294,7 @@ import {
8213
8294
  useState as useState11,
8214
8295
  useEffect as useEffect11,
8215
8296
  useRef as useRef10,
8216
- useOptimistic as useOptimistic3,
8297
+ useOptimistic as useOptimistic4,
8217
8298
  startTransition as startTransition4
8218
8299
  } from "react";
8219
8300
  var LinkContext = createContext7(
@@ -8677,7 +8758,7 @@ import {
8677
8758
  useState as useState13,
8678
8759
  useEffect as useEffect15,
8679
8760
  useRef as useRef13,
8680
- useOptimistic as useOptimistic4,
8761
+ useOptimistic as useOptimistic5,
8681
8762
  startTransition as startTransition5
8682
8763
  } from "react";
8683
8764
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -184,7 +184,7 @@
184
184
  },
185
185
  "dependencies": {
186
186
  "@types/debug": "^4.1.12",
187
- "@vitejs/plugin-rsc": "^0.5.34",
187
+ "@vitejs/plugin-rsc": "^0.5.35",
188
188
  "debug": "^4.4.1",
189
189
  "magic-string": "^0.30.17",
190
190
  "picomatch": "^4.0.4",
@@ -217,7 +217,7 @@
217
217
  "@playwright/test": "^1.49.1",
218
218
  "@testing-library/react": ">=16",
219
219
  "@vercel/functions": "^3.0.0",
220
- "@vitejs/plugin-rsc": "^0.5.34",
220
+ "@vitejs/plugin-rsc": "^0.5.35",
221
221
  "react": ">=19.2.8 <20",
222
222
  "react-dom": ">=19.2.8 <20",
223
223
  "vite": "^8.0.16",
@@ -13,7 +13,8 @@ function ProductPrice() {
13
13
  const { data, isLoading, error } = useLoader(ProductLoader);
14
14
 
15
15
  // data: T (guaranteed - throws if not in context)
16
- // isLoading: boolean (refetch/load() states — NOT the initial streamed read)
16
+ // isLoading: boolean (refetch/load() states + a held navigation replacing
17
+ // this data — NOT the initial streamed read)
17
18
  // error: Error | null
18
19
 
19
20
  return <span>${data.price}</span>;
@@ -29,6 +30,27 @@ component renders, `data` is present. (On document loads a loader registered
29
30
  with `{ ssr: false }` is already settled at first paint, so its
30
31
  reads never suspend there — see `/loader`.)
31
32
 
33
+ **A held navigation flags the data it keeps on screen.** When a navigation
34
+ keeps the current content visible while the loader re-runs (a `transition()`
35
+ same-route nav, a same-structure search/filter nav), the reader that is still
36
+ showing the OLD data reports `isLoading: true` from
37
+ the moment the new tree is committed until the new data lands — render your
38
+ stale indicator from it. The flag never flashes back to `false` on the old
39
+ data: it flips in the same commit that swaps `data`. Only loaders the
40
+ navigation actually re-runs are flagged: a layout loader the server does not
41
+ re-execute keeps its data and stays `isLoading: false`. It never applies to a
42
+ navigation that remounts the route (that reader is gone; the skeleton shows),
43
+ to a fully-prefetched nav (its data is already settled when it commits, so
44
+ nothing is pending to flag), or to an ephemeral `useFetchLoader` read outside
45
+ route context.
46
+
47
+ ```tsx
48
+ function ProductPrice() {
49
+ const { data, isLoading } = useLoader(ProductLoader);
50
+ return <span style={{ opacity: isLoading ? 0.5 : 1 }}>${data.price}</span>; // dims while /products/1 -> /products/2 streams the next price
51
+ }
52
+ ```
53
+
32
54
  **Precondition**: Loader must be registered on route via `loader()` helper.
33
55
 
34
56
  Loaders can also be passed as props from server to client components:
@@ -695,6 +695,15 @@ Semantics by lane:
695
695
  | `notFound()` | Not-found UI resolves server-side (nearest `notFoundBoundary` → router option → default) and rides the envelope; the 404 STATUS is **opportunistic** — real only if the rejection beats Response construction. `ssr: false` (below) makes it deterministic. | 404 UI swaps in, URL preserved, payload stays 200 |
696
696
  | `redirect()` | 200 document, then a client-side replace to the target — **no document-lane 302 from loaders**; pre-stream redirect authority belongs to middleware | Redirect envelope navigates to the target |
697
697
 
698
+ With `loader(Def, { ssr: false })` the result is settled BEFORE the document
699
+ flushes, so both signals are resolved server-side while the tree is built:
700
+ `redirect()` replaces the whole page with the redirect carrier (still a 200
701
+ document, the client replaces to the target on hydration) and `notFound()`
702
+ renders the not-found UI at the owning segment with a real 404. No read site
703
+ runs, so a `useLoader` in a layout above every Suspense boundary is safe.
704
+ (Before this was fixed, the flagged read threw inside the Fizz shell and
705
+ 500ed the document.)
706
+
698
707
  Session/auth gates belong in middleware (they are request-shaped, not
699
708
  data-shaped, and middleware CAN emit a real pre-stream 302). Data-dependent
700
709
  "this slug moved / does not exist" belongs in the loader.
@@ -8,7 +8,7 @@ argument-hint: [layout|route|parallel|intercept]
8
8
 
9
9
  `transition()` opts a route (or group of routes) into transition-driven navigation. It does two things, and you choose how far to go:
10
10
 
11
- 1. **`startTransition` (the foundation).** The navigation commit is driven through React's `startTransition`. That holds the previous content across a same-route navigation (stale-while-revalidate — no loading-skeleton flash) and is the **precondition** for any view-transition animation. Works on **all** React versions.
11
+ 1. **`startTransition` (the foundation).** The navigation commit is driven through React's `startTransition`. That holds the previous content across a same-route navigation (stale-while-revalidate — no loading-skeleton flash) and is the **precondition** for any view-transition animation. Works on **all** React versions. While the hold lasts, `useLoader` readers of the loaders being re-run report `isLoading: true` — render your stale indicator from it (see the hooks skill, `data.md`).
12
12
  2. **`<ViewTransition>` (the animation, layered on top).** On React 19.3+ (or an experimental build), rango also wraps the segment content in React's `<ViewTransition>` so the swap cross-fades/morphs. This is the only part that needs 19.3+; pass `viewTransition: false` to keep #1 without it (and place your own `<ViewTransition>` where you want it).
13
13
 
14
14
  > The `<ViewTransition>` layer requires a React that exports `<ViewTransition>` / `addTransitionType`: stable 19.3+ or an experimental build. Rango feature-detects it; on React 19.2 that layer is a no-op — but the `startTransition` driving (content hold) still applies.
@@ -31,6 +31,7 @@ import {
31
31
  } from "./validate-redirect-origin.js";
32
32
  import type { NavigationUpdate } from "./types.js";
33
33
  import { OPTIMISTIC_COMMIT_TRANSITION_TYPE } from "./optimistic-commit.js";
34
+ import { loaderStore } from "../loader-store.js";
34
35
  import {
35
36
  collectClientRevalidationDecisions,
36
37
  setActiveInterceptTargets,
@@ -140,6 +141,28 @@ export function createPartialUpdater(
140
141
  return cached?.segments || [];
141
142
  }
142
143
 
144
+ /**
145
+ * Transition commit. Every held commit announces the loader streams the
146
+ * committed tree is still receiving (loader-store.ts announcePendingStreams)
147
+ * so held useLoader readers pin isLoading:true until this transition lands;
148
+ * settled/cached segments make it a no-op. Urgent commits call onUpdate
149
+ * directly: nothing is held there, a reader of a pending stream suspends to
150
+ * its fallback.
151
+ */
152
+ function commitInTransition(
153
+ segments: readonly ResolvedSegment[],
154
+ update: NavigationUpdate,
155
+ transitionTypes: readonly string[],
156
+ ): void {
157
+ startTransition(() => {
158
+ loaderStore.announcePendingStreams(segments);
159
+ if (addTransitionType) {
160
+ for (const type of transitionTypes) addTransitionType(type);
161
+ }
162
+ onUpdate(update);
163
+ });
164
+ }
165
+
143
166
  async function fetchPartialUpdate(
144
167
  targetUrl: string,
145
168
  segmentIds: string[] | undefined,
@@ -351,12 +374,7 @@ export function createPartialUpdater(
351
374
  };
352
375
 
353
376
  if (shouldStartViewTransition(existingSegments)) {
354
- startTransition(() => {
355
- if (addTransitionType) {
356
- addTransitionType("navigation");
357
- }
358
- onUpdate(cachedUpdate);
359
- });
377
+ commitInTransition(existingSegments, cachedUpdate, ["navigation"]);
360
378
  } else {
361
379
  onUpdate(cachedUpdate);
362
380
  }
@@ -589,31 +607,25 @@ export function createPartialUpdater(
589
607
 
590
608
  const scrollPayload = toScrollPayload(navScroll);
591
609
 
610
+ const update: NavigationUpdate = {
611
+ root: newTree,
612
+ metadata: payload.metadata!,
613
+ scroll: scrollPayload,
614
+ };
592
615
  if (mode.type === "action" || mode.type === "stale-revalidation") {
593
- startTransition(() => {
594
- if (hasTransition && addTransitionType) {
595
- addTransitionType("action");
596
- }
597
- onUpdate({
598
- root: newTree,
599
- metadata: payload.metadata!,
600
- scroll: scrollPayload,
601
- });
602
- });
616
+ commitInTransition(
617
+ reconciled.mainSegments,
618
+ update,
619
+ hasTransition ? ["action"] : [],
620
+ );
603
621
  } else if (hasTransition) {
604
- startTransition(() => {
605
- if (addTransitionType) {
606
- addTransitionType("navigation");
607
- if (optimisticPresented) {
608
- addTransitionType(OPTIMISTIC_COMMIT_TRANSITION_TYPE);
609
- }
610
- }
611
- onUpdate({
612
- root: newTree,
613
- metadata: payload.metadata!,
614
- scroll: scrollPayload,
615
- });
616
- });
622
+ commitInTransition(
623
+ reconciled.mainSegments,
624
+ update,
625
+ optimisticPresented
626
+ ? ["navigation", OPTIMISTIC_COMMIT_TRANSITION_TYPE]
627
+ : ["navigation"],
628
+ );
617
629
  } else if (fullyPrefetched || isSameStructureNav || optimisticPresented) {
618
630
  // Content-hold commit, two triggers. Fully-prefetched nav: the payload
619
631
  // is fully resolved (forceAwait above), so the transition commits
@@ -637,26 +649,17 @@ export function createPartialUpdater(
637
649
  // optimistic clientUrls() presentation commits here too: the group
638
650
  // segment reconciles in place (clientGroup key), so a read that still
639
651
  // suspends must hold the presented content, not flash a fallback.
640
- startTransition(() => {
641
- if (optimisticPresented && addTransitionType) {
642
- addTransitionType(OPTIMISTIC_COMMIT_TRANSITION_TYPE);
643
- }
644
- onUpdate({
645
- root: newTree,
646
- metadata: payload.metadata!,
647
- scroll: scrollPayload,
648
- });
649
- });
652
+ commitInTransition(
653
+ reconciled.mainSegments,
654
+ update,
655
+ optimisticPresented ? [OPTIMISTIC_COMMIT_TRANSITION_TYPE] : [],
656
+ );
650
657
  } else {
651
658
  // Cold/partially-prefetched nav that mounts NEW segments: normal
652
659
  // commit so fallbacks stream like a first load and the click has
653
660
  // visible feedback. Explicit transition() routes keep the
654
661
  // content-hold via the hasTransition branch above (the opt-in).
655
- onUpdate({
656
- root: newTree,
657
- metadata: payload.metadata!,
658
- scroll: scrollPayload,
659
- });
662
+ onUpdate(update);
660
663
  }
661
664
 
662
665
  debugLog("[Browser] Navigation complete");
@@ -689,6 +692,11 @@ export function createPartialUpdater(
689
692
 
690
693
  const fullHasTransition = shouldStartViewTransition(segments);
691
694
  const fullScrollPayload = toScrollPayload(fullScroll);
695
+ const fullUpdate: NavigationUpdate = {
696
+ root: newTree,
697
+ metadata: payload.metadata!,
698
+ scroll: fullScrollPayload,
699
+ };
692
700
 
693
701
  if (mode.type === "stale-revalidation") {
694
702
  await rawStreamComplete;
@@ -704,44 +712,21 @@ export function createPartialUpdater(
704
712
  );
705
713
  return;
706
714
  }
707
- startTransition(() => {
708
- if (fullHasTransition && addTransitionType) {
709
- addTransitionType("action");
710
- }
711
- onUpdate({
712
- root: newTree,
713
- metadata: payload.metadata!,
714
- scroll: fullScrollPayload,
715
- });
716
- });
715
+ commitInTransition(
716
+ segments,
717
+ fullUpdate,
718
+ fullHasTransition ? ["action"] : [],
719
+ );
717
720
  } else if (mode.type === "action") {
718
- startTransition(() => {
719
- if (fullHasTransition && addTransitionType) {
720
- addTransitionType("action");
721
- }
722
- onUpdate({
723
- root: newTree,
724
- metadata: payload.metadata!,
725
- scroll: fullScrollPayload,
726
- });
727
- });
721
+ commitInTransition(
722
+ segments,
723
+ fullUpdate,
724
+ fullHasTransition ? ["action"] : [],
725
+ );
728
726
  } else if (fullHasTransition) {
729
- startTransition(() => {
730
- if (addTransitionType) {
731
- addTransitionType("navigation");
732
- }
733
- onUpdate({
734
- root: newTree,
735
- metadata: payload.metadata!,
736
- scroll: fullScrollPayload,
737
- });
738
- });
727
+ commitInTransition(segments, fullUpdate, ["navigation"]);
739
728
  } else {
740
- onUpdate({
741
- root: newTree,
742
- metadata: payload.metadata!,
743
- scroll: fullScrollPayload,
744
- });
729
+ onUpdate(fullUpdate);
745
730
  }
746
731
 
747
732
  return;
@@ -18,7 +18,10 @@ import { LoaderRedirect } from "./loader-redirect.js";
18
18
  * decodeLoaderResults runs during the SERVER tree build on forceAwait/action
19
19
  * lanes, where a throw would collapse the whole payload — it routes signals
20
20
  * through the errorFallback slot instead (same visual: the slot replaces the
21
- * children under OutletProvider).
21
+ * children under OutletProvider). The document lane's `{ ssr: false }`
22
+ * results take the same non-throwing route (segment-system
23
+ * buildLoaderStreams): they are settled before the tree is built, and the
24
+ * read-site throw would land in the Fizz shell with no boundary to absorb it.
22
25
  */
23
26
  export const LOADER_NOT_FOUND_FALLBACK: unique symbol = Symbol(
24
27
  "rango.loaderNotFound",
@@ -34,6 +34,15 @@
34
34
  * the store — the hook falls back to `OutletContext.loaderData`.
35
35
  */
36
36
 
37
+ import { unwrapsSynchronously } from "./thenable-status.js";
38
+
39
+ /** The loader-segment shape announcePendingStreams reads (ResolvedSegment). */
40
+ export interface PendingStreamSource {
41
+ type: string;
42
+ loaderId?: string;
43
+ loaderData?: unknown;
44
+ }
45
+
37
46
  export interface LoaderEntry<T = unknown> {
38
47
  readonly value: T | undefined;
39
48
  /**
@@ -87,6 +96,14 @@ export interface SubscribeOptions {
87
96
  * Refreshes this bucket in place (no params/body) and rejects on failure.
88
97
  */
89
98
  refetch?: () => Promise<void>;
99
+ /**
100
+ * Called by `announcePendingStreams` (inside a transition commit) when a
101
+ * navigation stream for this bucket's loader is still pending. The hook pins
102
+ * `isLoading: true` with a useOptimistic update; React reverts it when that
103
+ * transition commits. Route-context readers only — an ephemeral reader owns
104
+ * its own lifecycle.
105
+ */
106
+ onStreamPending?: () => void;
90
107
  }
91
108
 
92
109
  interface InternalEntry {
@@ -118,6 +135,8 @@ interface InternalEntry {
118
135
  groups: Map<string, number>;
119
136
  /** Plain-GET refresh thunk for `refreshGroups`, set while in any group. */
120
137
  refetch: (() => Promise<void>) | undefined;
138
+ /** Subscribers' `onStreamPending` callbacks (see SubscribeOptions). */
139
+ streamPendingListeners: Set<() => void>;
121
140
  }
122
141
 
123
142
  /**
@@ -137,6 +156,18 @@ export class LoaderStore {
137
156
  private readonly families = new Map<string, Set<string>>();
138
157
  /** refresh group name -> set of bucket keys, for refreshGroups(). */
139
158
  private readonly groups = new Map<string, Set<string>>();
159
+ /**
160
+ * loader.$$id -> per-loader streams a committed navigation is still
161
+ * receiving. Registered and announced by announcePendingStreams(), which
162
+ * browser/partial-update.ts calls INSIDE every transition commit, so a
163
+ * reader whose content is held on screen by that transition pins
164
+ * `isLoading: true` for the data it is still showing until the transition
165
+ * lands (use-loader.tsx). The new tree's own read of the loader suspends at
166
+ * the read site and never observes the flag. A Set, not a flag: overlapping
167
+ * navigations (second click before the first stream lands) keep the family
168
+ * pending until the LAST stream settles.
169
+ */
170
+ private readonly pendingStreams = new Map<string, Set<Promise<unknown>>>();
140
171
 
141
172
  private getOrCreate(bucketKey: string): InternalEntry {
142
173
  let e = this.entries.get(bucketKey);
@@ -151,6 +182,7 @@ export class LoaderStore {
151
182
  clearWhenSettled: false,
152
183
  groups: new Map(),
153
184
  refetch: undefined,
185
+ streamPendingListeners: new Set(),
154
186
  };
155
187
  this.entries.set(bucketKey, e);
156
188
  }
@@ -195,8 +227,11 @@ export class LoaderStore {
195
227
  e.pendingClear = false;
196
228
  e.clearWhenSettled = false;
197
229
  e.listeners.add(cb);
230
+ const onStreamPending = options?.onStreamPending;
231
+ if (onStreamPending) e.streamPendingListeners.add(onStreamPending);
198
232
  return () => {
199
233
  e.listeners.delete(cb);
234
+ if (onStreamPending) e.streamPendingListeners.delete(onStreamPending);
200
235
  // Group membership is refcounted per subscriber so refreshGroups() never
201
236
  // fetches for an unmounted reader, and a bucket shared by subscribers in
202
237
  // different groups stays in each group until ALL of that group's
@@ -479,6 +514,73 @@ export class LoaderStore {
479
514
  for (const cb of e.listeners) cb();
480
515
  }
481
516
 
517
+ /**
518
+ * Register a still-streaming per-loader result for `loaderId`. No-op for a
519
+ * settled Flight chunk (unwrapsSynchronously / "rejected"): the forceAwait
520
+ * and action lanes commit settled chunks, and a { ssr: false } loader is
521
+ * awaited before flush. Released as the stream settles, either way.
522
+ */
523
+ trackPendingStream(loaderId: string, stream: Promise<unknown>): void {
524
+ if (
525
+ unwrapsSynchronously(stream) ||
526
+ (stream as { status?: string }).status === "rejected"
527
+ ) {
528
+ return;
529
+ }
530
+ let set = this.pendingStreams.get(loaderId);
531
+ if (set?.has(stream)) return;
532
+ if (!set) {
533
+ set = new Set();
534
+ this.pendingStreams.set(loaderId, set);
535
+ }
536
+ set.add(stream);
537
+ const release = () => {
538
+ const current = this.pendingStreams.get(loaderId);
539
+ if (!current || !current.delete(stream)) return;
540
+ if (current.size === 0) this.pendingStreams.delete(loaderId);
541
+ };
542
+ stream.then(release, release);
543
+ }
544
+
545
+ /** True while any navigation stream for `loaderId` is still pending. */
546
+ isStreamPending(loaderId: string): boolean {
547
+ return this.pendingStreams.has(loaderId);
548
+ }
549
+
550
+ /**
551
+ * Register the loader segments of a tree being committed whose data is
552
+ * still a pending promise, then fire `onStreamPending` on every subscriber
553
+ * of every family with a pending stream. MUST run inside the startTransition
554
+ * that commits the tree: the hook answers with a useOptimistic update, which
555
+ * React reverts exactly when that transition commits — so the held reader
556
+ * reports isLoading:true for as long as its data stays on screen and not a
557
+ * frame longer. Outside a transition React reverts an optimistic update on
558
+ * the next render, pinning nothing useful. Registering here rather than in
559
+ * the tree build keeps register + announce in one call; settled chunks
560
+ * (cached/reused segments, forceAwait lanes) are skipped by
561
+ * trackPendingStream.
562
+ */
563
+ announcePendingStreams(segments: readonly PendingStreamSource[]): void {
564
+ for (const segment of segments) {
565
+ if (
566
+ segment.type === "loader" &&
567
+ segment.loaderId &&
568
+ segment.loaderData instanceof Promise
569
+ ) {
570
+ this.trackPendingStream(segment.loaderId, segment.loaderData);
571
+ }
572
+ }
573
+ for (const loaderId of this.pendingStreams.keys()) {
574
+ const fam = this.families.get(loaderId);
575
+ if (!fam) continue;
576
+ for (const bucketKey of fam) {
577
+ const e = this.entries.get(bucketKey);
578
+ if (!e) continue;
579
+ for (const cb of e.streamPendingListeners) cb();
580
+ }
581
+ }
582
+ }
583
+
482
584
  /**
483
585
  * Test-only escape hatch. Drops every entry. Production code should never
484
586
  * call this; the store is process-scoped and lives for the tab's lifetime.
@@ -488,6 +590,7 @@ export class LoaderStore {
488
590
  this.entries.clear();
489
591
  this.families.clear();
490
592
  this.groups.clear();
593
+ this.pendingStreams.clear();
491
594
  }
492
595
  }
493
596
 
@@ -48,8 +48,17 @@ import { resolveHandlerUse, mergeHandlerUse } from "./resolve-handler-use.js";
48
48
  import { ALL_USE_ITEM_TYPES } from "./use-item-types.js";
49
49
 
50
50
  /**
51
- * Check if an item contains routes (directly or inside nested structures like cache).
51
+ * Check if an item contains routes (directly or inside nested wrappers).
52
52
  * Used to determine if a layout or cache should be treated as an orphan.
53
+ *
54
+ * Recurses through ANY item carrying `uses` (cache, layout, middleware,
55
+ * wrapper-form transition) rather than an enumerated list: a wrapper helper
56
+ * that returns `uses` is covered the moment it exists. Scar tissue: the list
57
+ * form missed `transition`, so `layout(Shell, () => [transition(cfg, () =>
58
+ * [routes])])` was classified orphan and pushed onto its PARENT's layout[] —
59
+ * rendering around every sibling route (test-app TxBlockShell wrapped "/").
60
+ * parallel()/intercept() register their children by side effect and return
61
+ * no `uses`, so a layout whose only children are those still reads as orphan.
53
62
  */
54
63
  const hasRoutesInItem = (item: AllUseItems): boolean => {
55
64
  if (item.type === "route") return true;
@@ -57,16 +66,8 @@ const hasRoutesInItem = (item: AllUseItems): boolean => {
57
66
  // to prevent the parent layout from being misclassified as orphan,
58
67
  // which would clear its parent pointer and break the middleware chain.
59
68
  if (item.type === "include") return true;
60
- if (item.type === "cache" && item.uses) {
61
- return item.uses.some((child) => hasRoutesInItem(child));
62
- }
63
- if (item.type === "layout" && item.uses) {
64
- return item.uses.some((child) => hasRoutesInItem(child));
65
- }
66
- if (item.type === "middleware" && item.uses) {
67
- return item.uses.some((child) => hasRoutesInItem(child));
68
- }
69
- return false;
69
+ const uses = (item as { uses?: AllUseItems[] }).uses;
70
+ return uses ? uses.some(hasRoutesInItem) : false;
70
71
  };
71
72
 
72
73
  /**
@@ -958,7 +959,11 @@ const transition = (
958
959
 
959
960
  if (isOrphan(result)) attachOrphanSibling(ctx.parent, entry);
960
961
 
961
- return { name: namespace, type: "transition" } as TransitionItem;
962
+ return {
963
+ name: namespace,
964
+ type: "transition",
965
+ uses: result,
966
+ } as TransitionItem;
962
967
  };
963
968
 
964
969
  const route: RouteHelpers<any, any>["route"] = (name, handler, use) => {
@@ -457,8 +457,10 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
457
457
  * through React's startTransition, so a same-route nav (same route,
458
458
  * different params, e.g. /product/1 -> /product/2) holds the previous
459
459
  * content while the new loader resolves instead of flashing the route's
460
- * loading() skeleton (see segment-system.tsx inTransitionScope). This is
461
- * also the precondition for any view-transition animation.
460
+ * loading() skeleton (see segment-system.tsx inTransitionScope). Held
461
+ * useLoader readers of the re-run loaders report isLoading: true until
462
+ * the new data commits (use-loader.tsx). This is also the precondition
463
+ * for any view-transition animation.
462
464
  * 2. <ViewTransition> (experimental React only): the segment content is also
463
465
  * wrapped in React's <ViewTransition>, so the held swap cross-fades/morphs.
464
466
  * Layered on by default; pass { viewTransition: false } to keep #1 without
@@ -106,6 +106,8 @@ export type CacheItem = {
106
106
  export type TransitionItem = {
107
107
  name: string;
108
108
  type: "transition";
109
+ /** Wrapper form only: the children it wraps (see hasRoutesInItem). */
110
+ uses?: AllUseItems[];
109
111
  };
110
112
 
111
113
  /**
@@ -17,6 +17,7 @@
17
17
  */
18
18
 
19
19
  import React from "react";
20
+ import { isLoaderDataResult } from "../types.js";
20
21
  import { bufferToBase64 } from "../cache/cf/cf-base64.js";
21
22
  import { reportCacheError } from "../cache/cache-error.js";
22
23
  import { runBackground } from "../cache/background-task.js";
@@ -311,6 +312,14 @@ function refusedCaptureCeilingMs(): number {
311
312
  const refusedCaptures = new Map<string, { failures: number; until: number }>();
312
313
 
313
314
  /** True iff `key` is still inside its (exponential) backoff window. */
315
+ function isSettledLoaderSignal(value: unknown): boolean {
316
+ return (
317
+ isLoaderDataResult(value) &&
318
+ !value.ok &&
319
+ (value.redirect !== undefined || value.notFound === true)
320
+ );
321
+ }
322
+
314
323
  function isCaptureBackedOff(key: string): boolean {
315
324
  const entry = refusedCaptures.get(key);
316
325
  if (entry === undefined) return false;
@@ -1936,6 +1945,19 @@ async function captureAndStoreShell(
1936
1945
  // The container itself never settled: it is a hole (under an ancestor
1937
1946
  // boundary) or the trivial-prelude gate already fired. Omit — no pin.
1938
1947
  if (isLoaderHoleMarker(elided.value)) continue;
1948
+ // redirect()/notFound() RESOLVE as ok:false envelopes
1949
+ // (wrapLoaderWithErrorHandling), so the rejection check above never
1950
+ // sees them; the tree builder resolves them to LoaderRedirect / the
1951
+ // not-found UI (segment-system buildLoaderStreams), which must not
1952
+ // bake into a shell every visitor shares.
1953
+ if (isSettledLoaderSignal(elided.value)) {
1954
+ warnCaptureRefusedOnce(
1955
+ capture.key,
1956
+ `the loader for segment "${segmentKey}" settled with redirect()/notFound() during capture; a request-specific signal must not bake into the shared shell. ` +
1957
+ "Give its entry a loading() boundary so it stays on the live lane, or move the decision into middleware.",
1958
+ );
1959
+ return "refused";
1960
+ }
1939
1961
  // Past the hole check: this container settled with real material that
1940
1962
  // bakes into the shell prelude (independent of the snapshot pin below).
1941
1963
  bakedLoaderMaterial = true;
@@ -1,10 +1,16 @@
1
1
  import * as React from "react";
2
- import { createElement, type ReactNode, type ComponentType } from "react";
2
+ import {
3
+ createElement,
4
+ type ReactNode,
5
+ type ComponentType,
6
+ isValidElement,
7
+ } from "react";
3
8
  import { OutletProvider } from "./outlet-provider.js";
4
9
  import { withOptimisticCommitNone } from "./browser/optimistic-commit.js";
5
10
  import { MountContextProvider } from "./browser/react/mount-context.js";
6
11
  import type { ResolvedSegment, RootLayoutProps } from "./types.js";
7
12
  import { decodeLoaderResults } from "./decode-loader-results.js";
13
+ import { LoaderRedirect } from "./loader-redirect.js";
8
14
  import { invariant } from "./errors.js";
9
15
  import {
10
16
  RouteContentWrapper,
@@ -203,20 +209,43 @@ function wrapDefaultOutletContent(
203
209
  * SSR-completeness contract must not depend on. Unflagged siblings keep the
204
210
  * promise (deliberate streaming). Flagged ids are collected as input for the
205
211
  * dev SSR-suspension diagnostic (ssr-suspension-warning.ts).
212
+ *
213
+ * The settled results also go through decodeLoaderResults here: a settled
214
+ * redirect()/notFound() (or an error with an errorBoundary() fallback) must
215
+ * be resolved to its node while the tree is built, exactly as the
216
+ * forceAwait/action lanes do, because the read-site throw has no boundary on
217
+ * the document lane — flagged content renders without Suspense and a layout
218
+ * reader sits above every boundary, so the throw is a Fizz shell error (500).
219
+ * `settledFallback` is that node; the caller plants it and, for a redirect,
220
+ * replaces the whole page (an ancestor reading the loader would still throw).
206
221
  */
207
222
  async function buildLoaderStreams(loaders: ResolvedSegment[]): Promise<{
208
223
  streams: Record<string, unknown>;
209
224
  awaitedIds: string[] | undefined;
225
+ settledFallback: ReactNode | undefined;
210
226
  }> {
211
227
  const streams: Record<string, unknown> = {};
212
228
  let awaitedIds: string[] | undefined;
229
+ let awaitedValues: unknown[] | undefined;
213
230
  for (const l of loaders) {
214
- streams[l.loaderId!] = l.awaitBeforeFlush
215
- ? await l.loaderData
216
- : l.loaderData;
217
- if (l.awaitBeforeFlush) (awaitedIds ??= []).push(l.loaderId!);
231
+ if (l.awaitBeforeFlush) {
232
+ const value = await l.loaderData;
233
+ streams[l.loaderId!] = value;
234
+ (awaitedIds ??= []).push(l.loaderId!);
235
+ (awaitedValues ??= []).push(value);
236
+ } else {
237
+ streams[l.loaderId!] = l.loaderData;
238
+ }
218
239
  }
219
- return { streams, awaitedIds };
240
+ const settledFallback = awaitedIds
241
+ ? (decodeLoaderResults(awaitedValues!, awaitedIds).errorFallback ??
242
+ undefined)
243
+ : undefined;
244
+ return { streams, awaitedIds, settledFallback };
245
+ }
246
+
247
+ function isLoaderRedirectNode(node: ReactNode): boolean {
248
+ return isValidElement(node) && node.type === LoaderRedirect;
220
249
  }
221
250
 
222
251
  /**
@@ -539,10 +568,19 @@ export async function renderSegments(
539
568
  });
540
569
  }
541
570
  } else if (loaderEntries.length > 0) {
571
+ let settledFallback: ReactNode | undefined;
542
572
  ({
543
573
  streams: boundaryLoaderStreams,
544
574
  awaitedIds: boundaryAwaitedLoaderIds,
575
+ settledFallback,
545
576
  } = await buildLoaderStreams(loaderEntries));
577
+ if (settledFallback !== undefined) {
578
+ if (isLoaderRedirectNode(settledFallback)) {
579
+ content = settledFallback;
580
+ break;
581
+ }
582
+ nodeContent = settledFallback;
583
+ }
546
584
  if (segDebug) {
547
585
  segDebugLog(
548
586
  `segment ${id}: per-loader streams via LoaderBoundary (read-site suspense)`,
@@ -614,8 +652,19 @@ export async function renderSegments(
614
652
  }
615
653
  }
616
654
  } else if (layoutLoaders.length > 0) {
617
- ({ streams: loaderStreams, awaitedIds: awaitedLoaderIds } =
618
- await buildLoaderStreams(layoutLoaders));
655
+ let settledFallback: ReactNode | undefined;
656
+ ({
657
+ streams: loaderStreams,
658
+ awaitedIds: awaitedLoaderIds,
659
+ settledFallback,
660
+ } = await buildLoaderStreams(layoutLoaders));
661
+ if (settledFallback !== undefined) {
662
+ if (isLoaderRedirectNode(settledFallback)) {
663
+ content = settledFallback;
664
+ break;
665
+ }
666
+ errorFallback = settledFallback;
667
+ }
619
668
  if (segDebug) {
620
669
  segDebugLog(
621
670
  `segment ${id}: layout loaders streaming to read sites (no loading())`,
@@ -18,20 +18,9 @@
18
18
  * render's warnings through the once-per-id set).
19
19
  */
20
20
 
21
- const warned = new Set<string>();
21
+ import { unwrapsSynchronously } from "./thenable-status.js";
22
22
 
23
- /** Thenable statuses `use()` unwraps without suspending: React's re-check
24
- * after `.then()` handles "fulfilled", and Flight chunks in the resolved_*
25
- * states initialize synchronously inside that same call. Anything else
26
- * (pending, blocked, an unstamped native promise) suspends at least once. */
27
- function unwrapsSynchronously(stream: Promise<unknown>): boolean {
28
- const status = (stream as { status?: string }).status;
29
- return (
30
- status === "fulfilled" ||
31
- status === "resolved_model" ||
32
- status === "resolved_module"
33
- );
34
- }
23
+ const warned = new Set<string>();
35
24
 
36
25
  export function warnAwaitedSsrSuspension(
37
26
  loaderId: string,
@@ -114,8 +114,6 @@ export function ThemeProvider({
114
114
  initialTheme,
115
115
  children,
116
116
  }: ThemeProviderProps): React.ReactNode {
117
- const [mounted, setMounted] = useState(false);
118
-
119
117
  // HYDRATION PARITY: this initializer is the server (SSR/resume) render AND
120
118
  // the client's hydration render — both must produce the same value. It must
121
119
  // NEVER read cookie/localStorage: whenever the payload's initialTheme
@@ -132,7 +130,6 @@ export function ThemeProvider({
132
130
  const [systemTheme, setSystemTheme] = useState<ResolvedTheme>("light");
133
131
 
134
132
  useEffect(() => {
135
- setMounted(true);
136
133
  setSystemTheme(getSystemTheme());
137
134
  // Re-sync state from an EXPLICITLY stored theme after mount. initialTheme
138
135
  // comes from the payload and can legitimately differ from the visitor's
@@ -229,18 +226,16 @@ export function ThemeProvider({
229
226
  };
230
227
  }, [config]);
231
228
 
229
+ // No pre-mount special case: `theme` and `systemTheme` ("light" until the
230
+ // mount effect) are identical on the server and in the hydration render, so
231
+ // this is hydration-safe, and a concrete defaultTheme without an initialTheme
232
+ // no longer flips at mount.
232
233
  const resolvedTheme: ResolvedTheme = useMemo(() => {
233
- if (!mounted) {
234
- if (initialTheme && initialTheme !== "system") {
235
- return initialTheme as ResolvedTheme;
236
- }
237
- return "light";
238
- }
239
234
  if (theme === "system" && config.enableSystem) {
240
235
  return systemTheme;
241
236
  }
242
237
  return theme as ResolvedTheme;
243
- }, [theme, systemTheme, config.enableSystem, mounted, initialTheme]);
238
+ }, [theme, systemTheme, config.enableSystem]);
244
239
 
245
240
  const themes = useMemo(() => {
246
241
  if (config.enableSystem) {
@@ -249,16 +244,23 @@ export function ThemeProvider({
249
244
  return config.themes;
250
245
  }, [config.themes, config.enableSystem]);
251
246
 
247
+ // Keyed on the exposed field values only: a NEW context object with
248
+ // unchanged fields propagates into every dehydrated Suspense boundary still
249
+ // streaming in (React cannot see their consumers) and makes React
250
+ // client-render those boundaries instead of adopting the server HTML their
251
+ // $RC script delivers. A field that genuinely changes at mount (dark system
252
+ // theme, stored override) must still publish; that case stays open
253
+ // (CHANGELOG).
252
254
  const contextValue: ThemeContextValue = useMemo(
253
255
  () => ({
254
256
  theme,
255
257
  setTheme,
256
258
  resolvedTheme,
257
- systemTheme: mounted ? systemTheme : "light",
259
+ systemTheme,
258
260
  themes,
259
261
  config,
260
262
  }),
261
- [theme, setTheme, resolvedTheme, systemTheme, themes, config, mounted],
263
+ [theme, setTheme, resolvedTheme, systemTheme, themes, config],
262
264
  );
263
265
 
264
266
  return (
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Thenable statuses React's `use()` unwraps without suspending: React's
3
+ * re-check after `.then()` handles "fulfilled", and Flight chunks in the
4
+ * resolved_* states initialize synchronously inside that same call. Anything
5
+ * else (pending, blocked, an unstamped native promise) suspends at least once.
6
+ * Shared by the dev SSR-suspension diagnostic and the held-navigation
7
+ * isLoading registration (loader-store.ts), which must agree on "settled".
8
+ */
9
+ export function unwrapsSynchronously(stream: Promise<unknown>): boolean {
10
+ const status = (stream as { status?: string }).status;
11
+ return (
12
+ status === "fulfilled" ||
13
+ status === "resolved_model" ||
14
+ status === "resolved_module"
15
+ );
16
+ }
@@ -8,6 +8,7 @@ import {
8
8
  useContext,
9
9
  useEffect,
10
10
  useMemo,
11
+ useOptimistic,
11
12
  useRef,
12
13
  useState,
13
14
  type ReactNode,
@@ -261,6 +262,13 @@ function useLoaderInternal<T>(
261
262
  }
262
263
  }, [loaderId, bucketKey]);
263
264
 
265
+ // Held-navigation pin: a transition commit whose stream for this loader is
266
+ // still pending announces it (loader-store.ts announcePendingStreams, which
267
+ // documents the lane discipline); this optimistic update renders urgently
268
+ // on the held content and React reverts it when that transition commits,
269
+ // i.e. together with the new data.
270
+ const [streamPending, setStreamPending] = useOptimistic(false);
271
+
264
272
  const [sharedState, setSharedState] = useState<{
265
273
  bucketKey: string;
266
274
  snapshot: LoaderEntry;
@@ -295,6 +303,9 @@ function useLoaderInternal<T>(
295
303
  ephemeral: !hasContextData,
296
304
  group: hasGroups ? groupList : undefined,
297
305
  refetch: hasGroups ? groupRefetch : undefined,
306
+ onStreamPending: hasContextData
307
+ ? () => startTransition(() => setStreamPending(true))
308
+ : undefined,
298
309
  },
299
310
  );
300
311
  // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional:
@@ -362,7 +373,7 @@ function useLoaderInternal<T>(
362
373
  : sharedSnapshot.hasValue
363
374
  ? (sharedSnapshot.value as T | undefined)
364
375
  : contextData;
365
- const isLoading = localIsLoading || sharedSnapshot.isLoading;
376
+ const isLoading = localIsLoading || sharedSnapshot.isLoading || streamPending;
366
377
  const error = localError ?? sharedSnapshot.error;
367
378
 
368
379
  const throwOnError = options?.throwOnError ?? true;