@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.
- package/dist/types/decode-loader-results.d.ts +4 -1
- package/dist/types/loader-store.d.ts +49 -0
- package/dist/types/route-definition/helpers-types.d.ts +4 -2
- package/dist/types/route-types.d.ts +2 -0
- package/dist/types/rsc/shell-capture.d.ts +0 -1
- package/dist/types/thenable-status.d.ts +9 -0
- package/dist/vite/index.js +89 -8
- package/package.json +3 -3
- package/skills/hooks/data.md +23 -1
- package/skills/loader/SKILL.md +9 -0
- package/skills/view-transitions/SKILL.md +1 -1
- package/src/browser/partial-update.ts +64 -79
- package/src/decode-loader-results.ts +4 -1
- package/src/loader-store.ts +103 -0
- package/src/route-definition/dsl-helpers.ts +17 -12
- package/src/route-definition/helpers-types.ts +4 -2
- package/src/route-types.ts +2 -0
- package/src/rsc/shell-capture.ts +22 -0
- package/src/segment-system.tsx +57 -8
- package/src/ssr-suspension-warning.ts +2 -13
- package/src/theme/ThemeProvider.tsx +14 -12
- package/src/thenable-status.ts +16 -0
- package/src/use-loader.tsx +12 -1
|
@@ -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).
|
|
347
|
-
*
|
|
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;
|
package/dist/vite/index.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|
package/skills/hooks/data.md
CHANGED
|
@@ -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
|
|
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:
|
package/skills/loader/SKILL.md
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
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
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
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
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
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
|
-
|
|
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",
|
package/src/loader-store.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
61
|
-
|
|
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 {
|
|
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).
|
|
461
|
-
*
|
|
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
|
package/src/route-types.ts
CHANGED
package/src/rsc/shell-capture.ts
CHANGED
|
@@ -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;
|
package/src/segment-system.tsx
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
|
-
import {
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
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
|
-
|
|
618
|
-
|
|
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
|
-
|
|
21
|
+
import { unwrapsSynchronously } from "./thenable-status.js";
|
|
22
22
|
|
|
23
|
-
|
|
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
|
|
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
|
|
259
|
+
systemTheme,
|
|
258
260
|
themes,
|
|
259
261
|
config,
|
|
260
262
|
}),
|
|
261
|
-
[theme, setTheme, resolvedTheme, systemTheme, themes, config
|
|
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
|
+
}
|
package/src/use-loader.tsx
CHANGED
|
@@ -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;
|