@rangojs/router 0.26.5 → 0.27.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/bin/rango.js +2 -2
- package/dist/types/browser/action-coordinator.d.ts +0 -4
- package/dist/types/browser/action-fence.d.ts +11 -3
- package/dist/types/browser/event-controller.d.ts +18 -0
- package/dist/types/browser/history-state.d.ts +4 -0
- package/dist/types/browser/intercept-utils.d.ts +23 -0
- package/dist/types/browser/rsc-router.d.ts +1 -1
- package/dist/types/prefetch-markers.d.ts +12 -5
- package/dist/types/router/match-context.d.ts +8 -0
- package/dist/types/router/match-middleware/cache-lookup.d.ts +1 -1
- package/dist/types/router/match-middleware/cache-store.d.ts +2 -2
- package/dist/types/router/revalidation.d.ts +6 -1
- package/dist/types/router/router-context.d.ts +2 -17
- package/dist/types/rsc/shell-capture-constants.d.ts +1 -1
- package/dist/types/testing/serve-shell-request.d.ts +7 -0
- package/dist/types/types/handler-context.d.ts +9 -6
- package/dist/types/vite/plugins/server-ref-hashing.d.ts +8 -1
- package/dist/vite/index.js +10 -6
- package/package.json +2 -2
- package/skills/client-urls/SKILL.md +2 -1
- package/skills/document-cache/SKILL.md +5 -0
- package/skills/intercept/SKILL.md +36 -6
- package/skills/layout/SKILL.md +11 -7
- package/skills/loader/SKILL.md +30 -13
- package/skills/middleware/SKILL.md +1 -1
- package/skills/migrate-nextjs/SKILL.md +5 -4
- package/skills/parallel/SKILL.md +5 -4
- package/skills/ppr/SKILL.md +1 -0
- package/skills/rango/SKILL.md +18 -10
- package/skills/server-actions/SKILL.md +43 -5
- package/skills/testing/predicates.md +1 -1
- package/skills/testing/serve-shell-request.md +7 -7
- package/skills/ux-architecture/SKILL.md +3 -2
- package/src/browser/action-coordinator.ts +2 -13
- package/src/browser/action-fence.ts +11 -3
- package/src/browser/event-controller.ts +37 -0
- package/src/browser/history-state.ts +9 -1
- package/src/browser/intercept-utils.ts +51 -0
- package/src/browser/navigation-bridge.ts +20 -17
- package/src/browser/navigation-client.ts +5 -10
- package/src/browser/navigation-transaction.ts +3 -10
- package/src/browser/partial-update.ts +8 -0
- package/src/browser/rsc-router.tsx +16 -2
- package/src/browser/server-action-bridge.ts +223 -78
- package/src/cache/document-cache.ts +12 -6
- package/src/prefetch-markers.ts +13 -5
- package/src/router/match-context.ts +13 -0
- package/src/router/match-middleware/cache-lookup.ts +21 -10
- package/src/router/match-middleware/cache-store.ts +10 -4
- package/src/router/match-result.ts +1 -20
- package/src/router/navigation-snapshot.ts +2 -1
- package/src/router/revalidation.ts +33 -7
- package/src/router/router-context.ts +4 -22
- package/src/rsc/rsc-rendering.ts +12 -4
- package/src/rsc/shell-capture-constants.ts +2 -0
- package/src/ssr/ssr-root.tsx +1 -0
- package/src/testing/serve-shell-request.ts +20 -2
- package/src/types/handler-context.ts +9 -6
- package/src/vite/plugins/server-ref-hashing.ts +16 -5
|
@@ -38,7 +38,7 @@ import {
|
|
|
38
38
|
type DecodedPrefetch,
|
|
39
39
|
} from "./prefetch/cache.js";
|
|
40
40
|
import { cancelAllPrefetches } from "./prefetch/loader.js";
|
|
41
|
-
import { FILL_PARAM } from "../prefetch-markers.js";
|
|
41
|
+
import { FILL_PARAM, STALE_PARAM } from "../prefetch-markers.js";
|
|
42
42
|
|
|
43
43
|
/**
|
|
44
44
|
* Create a navigation client for fetching RSC payloads
|
|
@@ -108,7 +108,7 @@ export function createNavigationClient(
|
|
|
108
108
|
fetchUrl.searchParams.set("_rsc_partial", "true");
|
|
109
109
|
fetchUrl.searchParams.set("_rsc_segments", segmentIds.join(","));
|
|
110
110
|
if (staleRevalidation) {
|
|
111
|
-
fetchUrl.searchParams.set(
|
|
111
|
+
fetchUrl.searchParams.set(STALE_PARAM, "true");
|
|
112
112
|
}
|
|
113
113
|
if (version) {
|
|
114
114
|
fetchUrl.searchParams.set("_rsc_v", version);
|
|
@@ -144,15 +144,10 @@ export function createNavigationClient(
|
|
|
144
144
|
// server-action invalidation) auto-invalidates both scopes.
|
|
145
145
|
// Skip cache for stale revalidation (needs fresh data), HMR (needs
|
|
146
146
|
// fresh modules), and intercept contexts (source-dependent responses).
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
// action's response decides whether anything changed.
|
|
147
|
+
// A prefetch served during an action's flight holds pre-write data:
|
|
148
|
+
// the action bridge's follow-up refetches the page it commits.
|
|
150
149
|
const canUsePrefetch =
|
|
151
|
-
!staleRevalidation &&
|
|
152
|
-
!hmr &&
|
|
153
|
-
!fill &&
|
|
154
|
-
!interceptSourceUrl &&
|
|
155
|
-
!isActionFenceActive();
|
|
150
|
+
!staleRevalidation && !hmr && !fill && !interceptSourceUrl;
|
|
156
151
|
const rangoState = getRangoState();
|
|
157
152
|
const wildcardKey = buildPrefetchKey(rangoState, fetchUrl);
|
|
158
153
|
const cacheKey = buildSourceKey(rangoState, previousUrl, fetchUrl);
|
|
@@ -156,7 +156,6 @@ export function createNavigationTransaction(
|
|
|
156
156
|
url: string,
|
|
157
157
|
options?: NavigateOptionsInternal & { skipLoadingState?: boolean },
|
|
158
158
|
): NavigationTransaction {
|
|
159
|
-
let committed = false;
|
|
160
159
|
const currentUrl = window.location.href;
|
|
161
160
|
|
|
162
161
|
const handle = eventController.startNavigation(url, options);
|
|
@@ -165,8 +164,6 @@ export function createNavigationTransaction(
|
|
|
165
164
|
* Commit the navigation - updates store and URL atomically
|
|
166
165
|
*/
|
|
167
166
|
function commit(opts: CommitOptions): CommitResult {
|
|
168
|
-
committed = true;
|
|
169
|
-
|
|
170
167
|
const {
|
|
171
168
|
url,
|
|
172
169
|
segmentIds,
|
|
@@ -304,14 +301,10 @@ export function createNavigationTransaction(
|
|
|
304
301
|
};
|
|
305
302
|
},
|
|
306
303
|
|
|
304
|
+
// Reaches the handle committed or not: its dispose settles the entry
|
|
305
|
+
// (NavigationEntry.settled), and resets no state once it completed.
|
|
307
306
|
[Symbol.dispose]() {
|
|
308
|
-
if (handle.signal.aborted)
|
|
309
|
-
return;
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
if (!committed) {
|
|
313
|
-
handle[Symbol.dispose]();
|
|
314
|
-
}
|
|
307
|
+
if (!handle.signal.aborted) handle[Symbol.dispose]();
|
|
315
308
|
},
|
|
316
309
|
};
|
|
317
310
|
}
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
import {
|
|
25
25
|
hasActiveIntercept as hasActiveInterceptSlots,
|
|
26
26
|
isInterceptSegment,
|
|
27
|
+
wholeInterceptMatched,
|
|
27
28
|
} from "./intercept-utils.js";
|
|
28
29
|
import type { BoundTransaction } from "./navigation-transaction.js";
|
|
29
30
|
import { ServerRedirect } from "../errors.js";
|
|
@@ -741,6 +742,13 @@ export function createPartialUpdater(
|
|
|
741
742
|
if (redirected(payload.metadata)) return;
|
|
742
743
|
|
|
743
744
|
if (payload.metadata?.isPartial) {
|
|
745
|
+
// Held nothing, answered as an intercept: the page it lands over stays
|
|
746
|
+
// under the slot, in `matched` for every reader below (the commit's
|
|
747
|
+
// handle cleanup included).
|
|
748
|
+
const kept =
|
|
749
|
+
segments.length === 0 &&
|
|
750
|
+
wholeInterceptMatched(cachedSegs, payload.metadata);
|
|
751
|
+
if (kept) payload.metadata.matched = kept;
|
|
744
752
|
const { segments: newSegments, matched, diff } = payload.metadata;
|
|
745
753
|
let serverSegments = newSegments || [];
|
|
746
754
|
let sent = diff || [];
|
|
@@ -42,6 +42,7 @@ import { setAppVersion } from "./app-version.js";
|
|
|
42
42
|
import {
|
|
43
43
|
isInterceptSegment,
|
|
44
44
|
splitInterceptSegments,
|
|
45
|
+
wholeInterceptMatched,
|
|
45
46
|
} from "./intercept-utils.js";
|
|
46
47
|
import { createAppShellRef } from "./app-shell.js";
|
|
47
48
|
import { bootLog, IS_BROWSER_DEBUG } from "./logging.js";
|
|
@@ -75,7 +76,7 @@ export interface InitBrowserAppOptions {
|
|
|
75
76
|
storeOptions?: {
|
|
76
77
|
/**
|
|
77
78
|
* Maximum number of history entries to cache
|
|
78
|
-
* @default
|
|
79
|
+
* @default 20
|
|
79
80
|
*/
|
|
80
81
|
cacheSize?: number;
|
|
81
82
|
};
|
|
@@ -531,6 +532,9 @@ export async function initBrowserApp(
|
|
|
531
532
|
const streamingToken = handle.startStreaming();
|
|
532
533
|
|
|
533
534
|
const interceptSourceUrl = store.getInterceptSourceUrl();
|
|
535
|
+
// Read before a version change below clears the history cache.
|
|
536
|
+
const onScreen =
|
|
537
|
+
store.getCachedSegments(store.getHistoryKey())?.segments ?? [];
|
|
534
538
|
|
|
535
539
|
try {
|
|
536
540
|
const { payload, streamComplete } = await client.fetchPartial({
|
|
@@ -582,7 +586,17 @@ export async function initBrowserApp(
|
|
|
582
586
|
// a paramless document GET would run the SSR path and surface the
|
|
583
587
|
// not-found page during that same transient.
|
|
584
588
|
if (payload.metadata?.isPartial) {
|
|
585
|
-
|
|
589
|
+
// Held nothing: an intercept answer keeps the page under the slot
|
|
590
|
+
// from the entry on screen; the layouts and the slot are fresh.
|
|
591
|
+
let segments = payload.metadata.segments || [];
|
|
592
|
+
const kept = wholeInterceptMatched(onScreen, payload.metadata);
|
|
593
|
+
if (kept) {
|
|
594
|
+
payload.metadata.matched = kept;
|
|
595
|
+
const byId = new Map(
|
|
596
|
+
[...onScreen, ...segments].map((s) => [s.id, s]),
|
|
597
|
+
);
|
|
598
|
+
segments = kept.flatMap((id) => byId.get(id) ?? []);
|
|
599
|
+
}
|
|
586
600
|
const matched = payload.metadata.matched || [];
|
|
587
601
|
|
|
588
602
|
// Derive intercept state from the returned payload, not the
|
|
@@ -7,7 +7,11 @@ import type {
|
|
|
7
7
|
UpdateSubscriber,
|
|
8
8
|
} from "./types.js";
|
|
9
9
|
import { addTransitionType, createPartialUpdater } from "./partial-update.js";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
enterActionFence,
|
|
12
|
+
exitActionFence,
|
|
13
|
+
isActionFenceActive,
|
|
14
|
+
} from "./action-fence.js";
|
|
11
15
|
import { KEEP_CACHE_HEADER } from "./cookie-name.js";
|
|
12
16
|
import { createNavigationTransaction } from "./navigation-transaction.js";
|
|
13
17
|
import {
|
|
@@ -16,7 +20,12 @@ import {
|
|
|
16
20
|
} from "./segment-reconciler.js";
|
|
17
21
|
import { startTransition } from "react";
|
|
18
22
|
import { liveHandleDataForCache } from "./navigation-store.js";
|
|
19
|
-
import type {
|
|
23
|
+
import type {
|
|
24
|
+
EventController,
|
|
25
|
+
ActionHandle,
|
|
26
|
+
NavigationEntry,
|
|
27
|
+
NavigationHandle,
|
|
28
|
+
} from "./event-controller.js";
|
|
20
29
|
import {
|
|
21
30
|
toNetworkError,
|
|
22
31
|
emitNetworkError,
|
|
@@ -190,30 +199,153 @@ export function createServerActionBridge(
|
|
|
190
199
|
action,
|
|
191
200
|
});
|
|
192
201
|
|
|
193
|
-
|
|
194
|
-
* Refetch current route via a navigation transaction.
|
|
195
|
-
* Encapsulates the repeated pattern of creating a navTx + fetchPartialUpdate
|
|
196
|
-
* used by navigated-away, hmr-missing, and consolidation-needed scenarios.
|
|
197
|
-
* Resolves with the error of a loader that threw on the refetch.
|
|
198
|
-
*/
|
|
199
|
-
async function refetchRoute(opts?: {
|
|
202
|
+
type RefetchOptions = {
|
|
200
203
|
segments?: string[];
|
|
201
204
|
interceptSourceUrl?: string | null;
|
|
202
205
|
actionId?: string;
|
|
203
206
|
/** transition({ when }) `action` fields for the refetch commit. */
|
|
204
207
|
action?: TransitionWhenActionInput;
|
|
205
|
-
}
|
|
206
|
-
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
/** The follow-up's transition({ when }) fields: the last owing action's. */
|
|
211
|
+
type FollowUp = Pick<RefetchOptions, "actionId" | "action">;
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* A navigation the user started (a click, Back/Forward, refresh(), an
|
|
215
|
+
* action's redirect) that a refetch may supersede: not committed yet, or
|
|
216
|
+
* committed with its transaction (a `prefetch: false` fill) or its stream
|
|
217
|
+
* still open (NavigationEntry.settled). A refetch never starts while one
|
|
218
|
+
* is open: its startNavigation would abort it, and with it the fill. A
|
|
219
|
+
* background revalidation (skipLoadingState: the follow-up, a repair, a
|
|
220
|
+
* Back/Forward entry's SWR) is not one.
|
|
221
|
+
*/
|
|
222
|
+
const unsettledUserNavigation = (): NavigationEntry | undefined => {
|
|
223
|
+
const pending = eventController.getUnsettledNavigation();
|
|
224
|
+
return pending && !pending.options?.skipLoadingState ? pending : undefined;
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* A navigation the user started that may commit data read before an
|
|
229
|
+
* action's write: it has not committed yet. One that committed and only
|
|
230
|
+
* streams does not count: the action's own response re-rendered its page.
|
|
231
|
+
*/
|
|
232
|
+
const pendingUserNavigation = (): boolean => {
|
|
233
|
+
const pending = eventController.getCurrentNavigation();
|
|
234
|
+
return !!pending && !pending.options?.skipLoadingState;
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The follow-up the page on screen is owed (finalizeAction sets it, a
|
|
239
|
+
* redirect clears it as it starts, followUp() takes it). Closure state of
|
|
240
|
+
* this bridge, as are the three below: no hook reads it, and the store
|
|
241
|
+
* stays the one source of truth for what hooks render (AGENTS.md Hard
|
|
242
|
+
* rule 8).
|
|
243
|
+
*/
|
|
244
|
+
let owed: FollowUp | undefined;
|
|
245
|
+
/** An action of the batch wrote (R1 applied); reset when the fence drops. */
|
|
246
|
+
let batchWrote = false;
|
|
247
|
+
/**
|
|
248
|
+
* Follow-ups started so far. Only the newest one runs again when it ends
|
|
249
|
+
* left: a newer one started after it, so its request read every write so
|
|
250
|
+
* far. A left follow-up's response still arrives (its request is not tied
|
|
251
|
+
* to its signal), and re-owing from it would start one that leaves the
|
|
252
|
+
* newer one, whose late answer would do the same, without end.
|
|
253
|
+
*/
|
|
254
|
+
let followUpsStarted = 0;
|
|
255
|
+
/** The newest follow-up, until it ends: leaveFollowUp() aborts it. */
|
|
256
|
+
let followUpRunning: { handle: NavigationHandle; due: FollowUp } | undefined;
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The follow-up revalidation: the rules are R1-R7 in
|
|
260
|
+
* docs/internal/execution-model.md. Once the batch's last action exited,
|
|
261
|
+
* the page on screen that missed a write is refetched once, whole (no
|
|
262
|
+
* segments held, so every loader re-runs), in the action lane (storeOnly,
|
|
263
|
+
* skipLoadingState: useNavigation() stays idle).
|
|
264
|
+
*
|
|
265
|
+
* - Never over an intercept: a request holding no segments drops the page
|
|
266
|
+
* under it (match-result.ts buildMatchResult). Checked when it would run,
|
|
267
|
+
* against the page then on screen; the debt is dropped there.
|
|
268
|
+
* - Never supersedes a navigation the user started (dc-t114): it waits for
|
|
269
|
+
* NavigationEntry.settled, then looks again. A navigation that failed
|
|
270
|
+
* keeps its error on screen, and the debt is dropped.
|
|
271
|
+
* - Runs again when it is left before its commit (a navigation, or an action
|
|
272
|
+
* that started: leaveFollowUp), for the page then on screen.
|
|
273
|
+
*/
|
|
274
|
+
function followUp(): void {
|
|
275
|
+
if (!owed || isActionFenceActive()) return;
|
|
276
|
+
const pending = unsettledUserNavigation();
|
|
277
|
+
if (pending) {
|
|
278
|
+
void pending.settled.then((committed) => {
|
|
279
|
+
if (!committed && !pending.abort.signal.aborted) owed = undefined;
|
|
280
|
+
followUp();
|
|
281
|
+
});
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
const due = owed;
|
|
285
|
+
owed = undefined;
|
|
286
|
+
if (store.getInterceptSourceUrl()) return;
|
|
287
|
+
const generation = ++followUpsStarted;
|
|
288
|
+
refetchRoute(due, {
|
|
289
|
+
started: (handle) => (followUpRunning = { handle, due }),
|
|
290
|
+
ended: (left) => {
|
|
291
|
+
if (generation !== followUpsStarted) return;
|
|
292
|
+
followUpRunning = undefined;
|
|
293
|
+
if (!left) return;
|
|
294
|
+
owed ??= due;
|
|
295
|
+
followUp();
|
|
296
|
+
},
|
|
297
|
+
}).catch((error) => {
|
|
298
|
+
if (isBackgroundSuppressible(error)) return;
|
|
299
|
+
console.error("[Browser] Background revalidation failed:", error);
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* R6: an action that starts while the follow-up has not committed leaves
|
|
305
|
+
* it, and the batch's end owes it again. Its request may predate that
|
|
306
|
+
* action's write, and its commit could land after the action's own,
|
|
307
|
+
* moving data on screen backwards (dc-t110). Through the controller, so
|
|
308
|
+
* the navigation it started stops being current.
|
|
309
|
+
*/
|
|
310
|
+
function leaveFollowUp(): void {
|
|
311
|
+
const running = followUpRunning;
|
|
312
|
+
const current = eventController.getCurrentNavigation();
|
|
313
|
+
if (!running || current?.abort !== running.handle.abort) return;
|
|
314
|
+
owed ??= running.due;
|
|
315
|
+
eventController.abortNavigation(true);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Refetch the page on screen through a navigation transaction in the
|
|
320
|
+
* action lane: the follow-up, and the hmr-missing and consolidation-needed
|
|
321
|
+
* repairs. Resolves with whether it started and the error of a loader
|
|
322
|
+
* that threw on it. Never starts while a navigation the user started may
|
|
323
|
+
* be superseded (unsettledUserNavigation): its startNavigation would
|
|
324
|
+
* abort it (dc-t114). A repair that cannot start is owed at the batch's
|
|
325
|
+
* end instead (finalizeAction). `run` (the follow-up's) is handed the
|
|
326
|
+
* refetch's navigation, and told once whether it ended left before its
|
|
327
|
+
* commit.
|
|
328
|
+
*/
|
|
329
|
+
async function refetchRoute(
|
|
330
|
+
opts: RefetchOptions = {},
|
|
331
|
+
run?: {
|
|
332
|
+
started(handle: NavigationHandle): void;
|
|
333
|
+
ended(left: boolean): void;
|
|
334
|
+
},
|
|
335
|
+
): Promise<{ started: boolean; error?: Error }> {
|
|
336
|
+
if (unsettledUserNavigation()) return { started: false };
|
|
337
|
+
const src = opts.interceptSourceUrl ?? null;
|
|
207
338
|
const navTx = createNavigationTransaction(
|
|
208
339
|
store,
|
|
209
340
|
eventController,
|
|
210
341
|
window.location.href,
|
|
211
342
|
{ replace: true, skipLoadingState: true },
|
|
212
343
|
);
|
|
344
|
+
run?.started(navTx.handle);
|
|
213
345
|
try {
|
|
214
|
-
|
|
346
|
+
const error = await fetchPartialUpdate(
|
|
215
347
|
window.location.href,
|
|
216
|
-
opts
|
|
348
|
+
opts.segments ?? [],
|
|
217
349
|
false,
|
|
218
350
|
navTx.handle.signal,
|
|
219
351
|
navTx.with({
|
|
@@ -224,12 +356,14 @@ export function createServerActionBridge(
|
|
|
224
356
|
{
|
|
225
357
|
type: "action" as const,
|
|
226
358
|
...(src ? { interceptSourceUrl: src } : {}),
|
|
227
|
-
...(opts
|
|
228
|
-
...(opts
|
|
359
|
+
...(opts.actionId !== undefined ? { actionId: opts.actionId } : {}),
|
|
360
|
+
...(opts.action ? { action: opts.action } : {}),
|
|
229
361
|
},
|
|
230
362
|
);
|
|
363
|
+
return { started: true, error };
|
|
231
364
|
} finally {
|
|
232
365
|
navTx[Symbol.dispose]();
|
|
366
|
+
run?.ended(navTx.handle.signal.aborted && !navTx.handle.completed);
|
|
233
367
|
}
|
|
234
368
|
}
|
|
235
369
|
|
|
@@ -244,7 +378,18 @@ export function createServerActionBridge(
|
|
|
244
378
|
if (tx) browserDebugLog(tx, msg, details);
|
|
245
379
|
};
|
|
246
380
|
|
|
381
|
+
leaveFollowUp();
|
|
247
382
|
const locationKey = window.history.state?.key;
|
|
383
|
+
// Capture URL pathname at action start to detect navigation during action
|
|
384
|
+
// Must use window.location (not store.path) because intercepts change URL
|
|
385
|
+
// without changing store.path (e.g., /kanban -> /kanban/card/1)
|
|
386
|
+
const actionStartPathname = window.location.pathname;
|
|
387
|
+
// The page the action was sent from is no longer on screen: a navigation
|
|
388
|
+
// committed since it started (the classifyActionOutcome rule). Its
|
|
389
|
+
// response then commits nothing, and its exit owes the follow-up.
|
|
390
|
+
const navigatedSince = (): boolean =>
|
|
391
|
+
window.location.pathname !== actionStartPathname ||
|
|
392
|
+
window.history.state?.key !== locationKey;
|
|
248
393
|
log("action start", { id, argsCount: args.length });
|
|
249
394
|
|
|
250
395
|
// Start action in event controller - handles lifecycle tracking. The
|
|
@@ -272,6 +417,23 @@ export function createServerActionBridge(
|
|
|
272
417
|
// its own latch. Latched so the finally AND the early SPA-redirect returns
|
|
273
418
|
// (whose Flight stream never settles) can both call it safely.
|
|
274
419
|
let actionFinalized = false;
|
|
420
|
+
// Set where the action navigates to its redirect's target.
|
|
421
|
+
let redirected = false;
|
|
422
|
+
// Set where its repair refetch could not start (refetchRoute).
|
|
423
|
+
let repairOwed = false;
|
|
424
|
+
// R5: the redirect fetches its page after every write so far, so the
|
|
425
|
+
// batch's debt is cleared as it starts, not at the action's exit: a
|
|
426
|
+
// sibling that lands during its navigation owes for the page it commits.
|
|
427
|
+
const startRedirect = (): void => {
|
|
428
|
+
redirected = true;
|
|
429
|
+
owed = undefined;
|
|
430
|
+
};
|
|
431
|
+
// transition({ when }) `action` for this action's commit, whichever lane
|
|
432
|
+
// applies it (its own, a repair refetch, or the follow-up).
|
|
433
|
+
let whenAction: TransitionWhenActionInput = {
|
|
434
|
+
id,
|
|
435
|
+
formData: findActionFormData(args),
|
|
436
|
+
};
|
|
275
437
|
// skipInvalidation: the version-mismatch reload terminal released nothing
|
|
276
438
|
// server-side, so it releases the fence without invalidating.
|
|
277
439
|
const finalizeAction = (skipInvalidation = false): void => {
|
|
@@ -284,9 +446,32 @@ export function createServerActionBridge(
|
|
|
284
446
|
// marking the cache stale + broadcasting cross-tab would be spurious.
|
|
285
447
|
if (responseReceived && !keepCache && !skipInvalidation) {
|
|
286
448
|
store.markCacheAsStaleAndBroadcast();
|
|
449
|
+
batchWrote = true;
|
|
450
|
+
// The write may be missing from the page on screen: that page is
|
|
451
|
+
// not the one the response re-rendered, or a navigation the user
|
|
452
|
+
// started has not committed and its request may predate the write.
|
|
453
|
+
// A navigation that committed and only streams owes nothing: this
|
|
454
|
+
// response re-rendered its page. A later land never clears this:
|
|
455
|
+
// its request was read after its own write, not necessarily after
|
|
456
|
+
// this one. A redirect owes nothing: its navigation cleared the
|
|
457
|
+
// debt (startRedirect).
|
|
458
|
+
if (!redirected && (navigatedSince() || pendingUserNavigation())) {
|
|
459
|
+
owed = { actionId: id, action: whenAction };
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
// A repair that could not start is owed at the batch's end when an
|
|
463
|
+
// action of the batch wrote. The repair runs whatever this action's
|
|
464
|
+
// keepClientCache(), so a keep action that lands last discharges a
|
|
465
|
+
// directive-free sibling's consolidation; a lone keep action owes
|
|
466
|
+
// nothing.
|
|
467
|
+
if (repairOwed && batchWrote) {
|
|
468
|
+
owed = { actionId: id, action: whenAction };
|
|
287
469
|
}
|
|
288
470
|
} finally {
|
|
289
471
|
exitActionFence();
|
|
472
|
+
if (!isActionFenceActive()) batchWrote = false;
|
|
473
|
+
// The batch's last exit runs it (followUp returns while the fence is up).
|
|
474
|
+
followUp();
|
|
290
475
|
}
|
|
291
476
|
};
|
|
292
477
|
try {
|
|
@@ -302,11 +487,6 @@ export function createServerActionBridge(
|
|
|
302
487
|
// Create temporary references for serialization
|
|
303
488
|
const temporaryReferences = deps.createTemporaryReferenceSet();
|
|
304
489
|
|
|
305
|
-
// Capture URL pathname at action start to detect navigation during action
|
|
306
|
-
// Must use window.location (not store.path) because intercepts change URL
|
|
307
|
-
// without changing store.path (e.g., /kanban -> /kanban/card/1)
|
|
308
|
-
const actionStartPathname = window.location.pathname;
|
|
309
|
-
|
|
310
490
|
// Build action request URL with current segments
|
|
311
491
|
const url = new URL(window.location.href);
|
|
312
492
|
url.searchParams.set("_rsc_action", id);
|
|
@@ -411,6 +591,7 @@ export function createServerActionBridge(
|
|
|
411
591
|
if (redirect && redirect !== "blocked" && !handle.signal.aborted) {
|
|
412
592
|
log("action simple redirect", { url: redirect.url });
|
|
413
593
|
handle.complete(undefined);
|
|
594
|
+
startRedirect();
|
|
414
595
|
// This path returns a never-settling promise, so the finally never
|
|
415
596
|
// runs: invalidate + release the fence here (the mutation committed
|
|
416
597
|
// and we're navigating away). Latched, so the finally is a no-op.
|
|
@@ -529,7 +710,10 @@ export function createServerActionBridge(
|
|
|
529
710
|
: "blocked action redirect payload",
|
|
530
711
|
{ url: redirect.url },
|
|
531
712
|
);
|
|
532
|
-
|
|
713
|
+
return returnValue?.data;
|
|
714
|
+
}
|
|
715
|
+
startRedirect();
|
|
716
|
+
if (redirect.external) {
|
|
533
717
|
log("external action redirect", { url: target });
|
|
534
718
|
window.location.assign(target);
|
|
535
719
|
} else {
|
|
@@ -582,15 +766,14 @@ export function createServerActionBridge(
|
|
|
582
766
|
|
|
583
767
|
// transition({ when }) decides the error-boundary commit too, with
|
|
584
768
|
// `action.error` set.
|
|
769
|
+
whenAction = {
|
|
770
|
+
...whenAction,
|
|
771
|
+
error: returnValue && !returnValue.ok ? returnValue.data : undefined,
|
|
772
|
+
};
|
|
585
773
|
const errorGatedOff = decideActionGatedOff(
|
|
586
774
|
errorResult.segments,
|
|
587
775
|
metadata,
|
|
588
|
-
|
|
589
|
-
id,
|
|
590
|
-
formData: findActionFormData(args),
|
|
591
|
-
error:
|
|
592
|
-
returnValue && !returnValue.ok ? returnValue.data : undefined,
|
|
593
|
-
},
|
|
776
|
+
whenAction,
|
|
594
777
|
);
|
|
595
778
|
|
|
596
779
|
// Render the full tree with error segment merged with parent layouts
|
|
@@ -603,9 +786,10 @@ export function createServerActionBridge(
|
|
|
603
786
|
: undefined,
|
|
604
787
|
});
|
|
605
788
|
|
|
606
|
-
//
|
|
607
|
-
//
|
|
608
|
-
|
|
789
|
+
// The page the action was sent from left the screen, during the
|
|
790
|
+
// flight or while the error tree rendered: the boundary is not shown,
|
|
791
|
+
// and the action's exit owes the page on screen the follow-up.
|
|
792
|
+
if (navigatedSince() || store.getHistoryKey() !== currentKey) {
|
|
609
793
|
log("user navigated during error render, skipping");
|
|
610
794
|
if (returnValue && !returnValue.ok) {
|
|
611
795
|
throw returnValue.data;
|
|
@@ -613,15 +797,6 @@ export function createServerActionBridge(
|
|
|
613
797
|
handle.complete(undefined);
|
|
614
798
|
return undefined;
|
|
615
799
|
}
|
|
616
|
-
const currentKeyNow = store.getHistoryKey();
|
|
617
|
-
if (currentKeyNow !== currentKey) {
|
|
618
|
-
log("history key changed during error render, skipping cache update");
|
|
619
|
-
if (returnValue && !returnValue.ok) {
|
|
620
|
-
throw returnValue.data;
|
|
621
|
-
}
|
|
622
|
-
handle.complete(undefined);
|
|
623
|
-
return undefined;
|
|
624
|
-
}
|
|
625
800
|
|
|
626
801
|
// Update segment tracking to exclude error segment IDs
|
|
627
802
|
const errorSegmentIds = new Set(diff);
|
|
@@ -709,6 +884,7 @@ export function createServerActionBridge(
|
|
|
709
884
|
let revalidationError: Error | undefined;
|
|
710
885
|
|
|
711
886
|
if (returnValue && !returnValue.ok) {
|
|
887
|
+
whenAction = { ...whenAction, error: returnValue.data };
|
|
712
888
|
handle.fail(returnValue.data);
|
|
713
889
|
throw returnValue.data;
|
|
714
890
|
}
|
|
@@ -725,7 +901,6 @@ export function createServerActionBridge(
|
|
|
725
901
|
currentLocationKey: window.history.state?.key,
|
|
726
902
|
reconciledSegmentCount: fullSegments.length,
|
|
727
903
|
matchedCount: matched.length,
|
|
728
|
-
currentInterceptSource: store.getInterceptSourceUrl(),
|
|
729
904
|
});
|
|
730
905
|
|
|
731
906
|
// Apply server-set location state exhaustively here, as a successful-
|
|
@@ -745,51 +920,17 @@ export function createServerActionBridge(
|
|
|
745
920
|
eventController,
|
|
746
921
|
);
|
|
747
922
|
}
|
|
748
|
-
|
|
749
|
-
// lane applies it (normal, or a refetch below).
|
|
750
|
-
const whenAction: TransitionWhenActionInput = {
|
|
751
|
-
id,
|
|
752
|
-
formData: findActionFormData(args),
|
|
753
|
-
result: returnData,
|
|
754
|
-
};
|
|
923
|
+
whenAction = { ...whenAction, result: returnData };
|
|
755
924
|
|
|
756
925
|
switch (scenario.type) {
|
|
757
926
|
case "navigated-away": {
|
|
758
927
|
log("user navigated away during action", {
|
|
759
928
|
from: actionStartPathname,
|
|
760
929
|
to: window.location.pathname,
|
|
761
|
-
historyKeyChanged: scenario.historyKeyChanged,
|
|
762
930
|
});
|
|
763
931
|
// Clear concurrent action tracking - don't consolidate for old route's segments
|
|
764
932
|
handle.clearConsolidation();
|
|
765
|
-
|
|
766
|
-
if (scenario.historyKeyChanged) {
|
|
767
|
-
// Invalidation is deferred to finalizeAction(); here we only trigger
|
|
768
|
-
// the revalidation refetch of the new route (suppressed on keep).
|
|
769
|
-
if (!scenario.onInterceptRoute && !keepCache) {
|
|
770
|
-
refetchRoute({ actionId: id, action: whenAction }).catch(
|
|
771
|
-
(error) => {
|
|
772
|
-
if (isBackgroundSuppressible(error)) return;
|
|
773
|
-
console.error(
|
|
774
|
-
"[Browser] Background revalidation failed:",
|
|
775
|
-
error,
|
|
776
|
-
);
|
|
777
|
-
},
|
|
778
|
-
);
|
|
779
|
-
}
|
|
780
|
-
break;
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
// Same history key but different pathname - safe to refetch current
|
|
784
|
-
// route. Invalidation is deferred to finalizeAction(); here we only
|
|
785
|
-
// trigger the revalidation refetch (suppressed on keep).
|
|
786
|
-
if (!keepCache) {
|
|
787
|
-
revalidationError = await refetchRoute({
|
|
788
|
-
interceptSourceUrl: store.getInterceptSourceUrl(),
|
|
789
|
-
actionId: id,
|
|
790
|
-
action: whenAction,
|
|
791
|
-
});
|
|
792
|
-
}
|
|
933
|
+
// finalizeAction() owes the page on screen the follow-up.
|
|
793
934
|
break;
|
|
794
935
|
}
|
|
795
936
|
|
|
@@ -801,11 +942,13 @@ export function createServerActionBridge(
|
|
|
801
942
|
// resolving last must discharge a directive-free sibling's repair.
|
|
802
943
|
// See the keep row in docs/design/rango-state-cookie.md (the all-keep
|
|
803
944
|
// edge, and the benign re-mark-stale-after-refetch end-state delta).
|
|
804
|
-
|
|
945
|
+
const repair = await refetchRoute({
|
|
805
946
|
interceptSourceUrl,
|
|
806
947
|
actionId: id,
|
|
807
948
|
action: whenAction,
|
|
808
949
|
});
|
|
950
|
+
revalidationError = repair.error;
|
|
951
|
+
repairOwed = !repair.started;
|
|
809
952
|
break;
|
|
810
953
|
}
|
|
811
954
|
|
|
@@ -824,12 +967,14 @@ export function createServerActionBridge(
|
|
|
824
967
|
handle.clearConsolidation();
|
|
825
968
|
|
|
826
969
|
// Ungated on keepCache, same as hmr-missing above (see the keep row).
|
|
827
|
-
|
|
970
|
+
const repair = await refetchRoute({
|
|
828
971
|
segments: segmentsToSend,
|
|
829
972
|
interceptSourceUrl,
|
|
830
973
|
actionId: id,
|
|
831
974
|
action: whenAction,
|
|
832
975
|
});
|
|
976
|
+
revalidationError = repair.error;
|
|
977
|
+
repairOwed = !repair.started;
|
|
833
978
|
break;
|
|
834
979
|
}
|
|
835
980
|
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { requestHeaders } from "../server/request-headers.js";
|
|
17
|
-
import { FILL_PARAM } from "../prefetch-markers.js";
|
|
17
|
+
import { FILL_PARAM, STALE_PARAM } from "../prefetch-markers.js";
|
|
18
18
|
import type { MiddlewareFn, MiddlewareContext } from "../router/middleware.js";
|
|
19
19
|
import {
|
|
20
20
|
documentCacheStoresRender,
|
|
@@ -238,11 +238,17 @@ export function createDocumentCacheMiddleware<TEnv = any>(
|
|
|
238
238
|
return next();
|
|
239
239
|
}
|
|
240
240
|
|
|
241
|
-
// Skip fill
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
|
|
241
|
+
// Skip fill and stale partial requests: never served from a stored body,
|
|
242
|
+
// never stored. A fill renders against what one client holds. A stale
|
|
243
|
+
// request (the follow-up revalidation after an action, a Back/Forward
|
|
244
|
+
// entry's revalidation, or one holding no segments) may follow a write
|
|
245
|
+
// the key cannot see. Partial only: _rsc params leave the key, so a
|
|
246
|
+
// document GET carrying them must not force an origin render.
|
|
247
|
+
if (
|
|
248
|
+
rawUrl.searchParams.has("_rsc_partial") &&
|
|
249
|
+
(rawUrl.searchParams.has(FILL_PARAM) ||
|
|
250
|
+
rawUrl.searchParams.get(STALE_PARAM) === "true")
|
|
251
|
+
) {
|
|
246
252
|
return next();
|
|
247
253
|
}
|
|
248
254
|
|
package/src/prefetch-markers.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The wire markers of prefetches
|
|
3
|
-
* (docs/design/prefetch-false.md): shared by the browser
|
|
4
|
-
* (router/segment-resolution/prefetch-deferral.ts requestKind,
|
|
5
|
-
* rsc/prefetch-render-error.ts
|
|
6
|
-
*
|
|
2
|
+
* The wire markers of prefetches, of `prefetch: false`
|
|
3
|
+
* (docs/design/prefetch-false.md) and of stale requests: shared by the browser
|
|
4
|
+
* and the server (router/segment-resolution/prefetch-deferral.ts requestKind,
|
|
5
|
+
* rsc/prefetch-render-error.ts, rsc/rsc-rendering.ts,
|
|
6
|
+
* cache/document-cache.ts, router/navigation-snapshot.ts). Dependency free,
|
|
7
|
+
* like segment-fragments.ts: the browser must not import the server modules
|
|
8
|
+
* that use them.
|
|
7
9
|
*/
|
|
8
10
|
|
|
9
11
|
/** Request header of a `<Link>` prefetch (browser/prefetch/fetch.ts). */
|
|
@@ -12,6 +14,12 @@ export const PREFETCH_HEADER = "X-Rango-Prefetch";
|
|
|
12
14
|
/** Query param of a fill request (browser/navigation-client.ts). */
|
|
13
15
|
export const FILL_PARAM = "_rsc_fill";
|
|
14
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Query param of a stale request (browser/navigation-client.ts): `revalidate()`
|
|
19
|
+
* sees `stale: true` (router/navigation-snapshot.ts).
|
|
20
|
+
*/
|
|
21
|
+
export const STALE_PARAM = "_rsc_stale";
|
|
22
|
+
|
|
15
23
|
/**
|
|
16
24
|
* The last row of a prefetch whose render reported an error
|
|
17
25
|
* (rsc/prefetch-render-error.ts): a Flight hint with a code React does not
|