@rangojs/router 0.0.0-experimental.18 → 0.0.0-experimental.19
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/README.md +46 -8
- package/dist/bin/rango.js +105 -18
- package/dist/vite/index.js +227 -93
- package/package.json +15 -14
- package/skills/hooks/SKILL.md +1 -1
- package/skills/intercept/SKILL.md +79 -0
- package/skills/layout/SKILL.md +62 -2
- package/skills/loader/SKILL.md +94 -1
- package/skills/middleware/SKILL.md +81 -0
- package/skills/parallel/SKILL.md +57 -2
- package/skills/prerender/SKILL.md +187 -17
- package/skills/route/SKILL.md +42 -1
- package/skills/router-setup/SKILL.md +77 -0
- package/src/__internal.ts +1 -1
- package/src/bin/rango.ts +38 -19
- package/src/browser/action-coordinator.ts +97 -0
- package/src/browser/event-controller.ts +25 -27
- package/src/browser/history-state.ts +80 -0
- package/src/browser/intercept-utils.ts +1 -1
- package/src/browser/link-interceptor.ts +0 -3
- package/src/browser/merge-segment-loaders.ts +9 -2
- package/src/browser/navigation-bridge.ts +46 -13
- package/src/browser/navigation-client.ts +32 -61
- package/src/browser/navigation-store.ts +1 -31
- package/src/browser/navigation-transaction.ts +46 -207
- package/src/browser/partial-update.ts +102 -150
- package/src/browser/{prefetch-cache.ts → prefetch/cache.ts} +23 -4
- package/src/browser/{prefetch-fetch.ts → prefetch/fetch.ts} +36 -8
- package/src/browser/prefetch/policy.ts +42 -0
- package/src/browser/{prefetch-queue.ts → prefetch/queue.ts} +10 -3
- package/src/browser/react/Link.tsx +28 -23
- package/src/browser/react/NavigationProvider.tsx +9 -1
- package/src/browser/react/index.ts +2 -6
- package/src/browser/react/location-state-shared.ts +1 -1
- package/src/browser/react/location-state.ts +2 -0
- package/src/browser/react/nonce-context.ts +23 -0
- package/src/browser/react/use-action.ts +9 -1
- package/src/browser/react/use-handle.ts +3 -25
- package/src/browser/react/use-params.ts +2 -4
- package/src/browser/react/use-pathname.ts +2 -3
- package/src/browser/react/use-router.ts +1 -1
- package/src/browser/react/use-search-params.ts +2 -1
- package/src/browser/react/use-segments.ts +7 -60
- package/src/browser/response-adapter.ts +73 -0
- package/src/browser/rsc-router.tsx +29 -23
- package/src/browser/scroll-restoration.ts +10 -7
- package/src/browser/server-action-bridge.ts +115 -96
- package/src/browser/types.ts +1 -31
- package/src/browser/validate-redirect-origin.ts +29 -0
- package/src/build/generate-manifest.ts +5 -0
- package/src/build/generate-route-types.ts +2 -0
- package/src/build/route-types/codegen.ts +13 -4
- package/src/build/route-types/include-resolution.ts +13 -0
- package/src/build/route-types/per-module-writer.ts +15 -3
- package/src/build/route-types/router-processing.ts +45 -3
- package/src/build/runtime-discovery.ts +13 -1
- package/src/cache/background-task.ts +34 -0
- package/src/cache/cache-key-utils.ts +44 -0
- package/src/cache/cache-policy.ts +125 -0
- package/src/cache/cache-runtime.ts +132 -96
- package/src/cache/cache-scope.ts +71 -73
- package/src/cache/cf/cf-cache-store.ts +9 -4
- package/src/cache/document-cache.ts +72 -47
- package/src/cache/handle-capture.ts +81 -0
- package/src/cache/memory-segment-store.ts +18 -7
- package/src/cache/profile-registry.ts +43 -8
- package/src/cache/read-through-swr.ts +134 -0
- package/src/cache/segment-codec.ts +101 -112
- package/src/cache/taint.ts +26 -0
- package/src/client.tsx +53 -30
- package/src/errors.ts +6 -1
- package/src/handle.ts +1 -1
- package/src/handles/MetaTags.tsx +5 -2
- package/src/host/cookie-handler.ts +8 -3
- package/src/host/router.ts +14 -1
- package/src/href-client.ts +3 -1
- package/src/index.rsc.ts +33 -1
- package/src/index.ts +27 -0
- package/src/loader.rsc.ts +12 -4
- package/src/loader.ts +8 -0
- package/src/prerender/store.ts +4 -3
- package/src/prerender.ts +76 -18
- package/src/reverse.ts +11 -7
- package/src/root-error-boundary.tsx +30 -26
- package/src/route-definition/dsl-helpers.ts +9 -6
- package/src/route-definition/redirect.ts +15 -3
- package/src/route-map-builder.ts +38 -2
- package/src/route-name.ts +53 -0
- package/src/route-types.ts +7 -0
- package/src/router/content-negotiation.ts +1 -1
- package/src/router/debug-manifest.ts +16 -3
- package/src/router/handler-context.ts +94 -15
- package/src/router/intercept-resolution.ts +6 -4
- package/src/router/lazy-includes.ts +4 -0
- package/src/router/loader-resolution.ts +1 -0
- package/src/router/logging.ts +100 -3
- package/src/router/manifest.ts +32 -3
- package/src/router/match-api.ts +61 -7
- package/src/router/match-context.ts +3 -0
- package/src/router/match-handlers.ts +185 -11
- package/src/router/match-middleware/background-revalidation.ts +65 -85
- package/src/router/match-middleware/cache-lookup.ts +69 -4
- package/src/router/match-middleware/cache-store.ts +2 -0
- package/src/router/match-pipelines.ts +8 -43
- package/src/router/middleware-types.ts +7 -0
- package/src/router/middleware.ts +93 -8
- package/src/router/pattern-matching.ts +41 -5
- package/src/router/prerender-match.ts +34 -6
- package/src/router/preview-match.ts +7 -1
- package/src/router/revalidation.ts +61 -2
- package/src/router/router-context.ts +15 -0
- package/src/router/router-interfaces.ts +34 -0
- package/src/router/router-options.ts +200 -0
- package/src/router/segment-resolution/fresh.ts +123 -30
- package/src/router/segment-resolution/helpers.ts +19 -0
- package/src/router/segment-resolution/loader-cache.ts +37 -146
- package/src/router/segment-resolution/revalidation.ts +358 -94
- package/src/router/segment-wrappers.ts +3 -0
- package/src/router/telemetry-otel.ts +299 -0
- package/src/router/telemetry.ts +300 -0
- package/src/router/timeout.ts +148 -0
- package/src/router/types.ts +7 -1
- package/src/router.ts +155 -11
- package/src/rsc/handler-context.ts +11 -0
- package/src/rsc/handler.ts +380 -88
- package/src/rsc/helpers.ts +25 -16
- package/src/rsc/loader-fetch.ts +84 -42
- package/src/rsc/origin-guard.ts +141 -0
- package/src/rsc/progressive-enhancement.ts +232 -19
- package/src/rsc/response-route-handler.ts +37 -26
- package/src/rsc/rsc-rendering.ts +12 -5
- package/src/rsc/runtime-warnings.ts +42 -0
- package/src/rsc/server-action.ts +134 -58
- package/src/rsc/types.ts +8 -0
- package/src/search-params.ts +22 -10
- package/src/server/context.ts +53 -5
- package/src/server/fetchable-loader-store.ts +11 -6
- package/src/server/handle-store.ts +66 -9
- package/src/server/loader-registry.ts +11 -46
- package/src/server/request-context.ts +90 -9
- package/src/ssr/index.tsx +63 -27
- package/src/static-handler.ts +7 -0
- package/src/theme/ThemeProvider.tsx +6 -1
- package/src/theme/index.ts +1 -6
- package/src/theme/theme-context.ts +1 -28
- package/src/theme/theme-script.ts +2 -1
- package/src/types/cache-types.ts +5 -0
- package/src/types/error-types.ts +3 -0
- package/src/types/global-namespace.ts +9 -0
- package/src/types/handler-context.ts +35 -13
- package/src/types/loader-types.ts +7 -0
- package/src/types/route-entry.ts +28 -0
- package/src/urls/include-helper.ts +49 -8
- package/src/urls/index.ts +1 -0
- package/src/urls/path-helper-types.ts +30 -12
- package/src/urls/path-helper.ts +17 -2
- package/src/urls/pattern-types.ts +21 -1
- package/src/urls/response-types.ts +27 -2
- package/src/urls/type-extraction.ts +23 -15
- package/src/use-loader.tsx +12 -4
- package/src/vite/discovery/bundle-postprocess.ts +12 -7
- package/src/vite/discovery/discover-routers.ts +30 -18
- package/src/vite/discovery/prerender-collection.ts +24 -27
- package/src/vite/discovery/route-types-writer.ts +7 -7
- package/src/vite/discovery/virtual-module-codegen.ts +5 -2
- package/src/vite/plugins/client-ref-hashing.ts +3 -3
- package/src/vite/plugins/use-cache-transform.ts +91 -3
- package/src/vite/rango.ts +3 -3
- package/src/vite/router-discovery.ts +99 -36
- package/src/vite/utils/prerender-utils.ts +21 -0
- package/src/vite/utils/shared-utils.ts +3 -1
- package/src/browser/request-controller.ts +0 -164
- package/src/href-context.ts +0 -33
- package/src/router.gen.ts +0 -6
- package/src/static-handler.gen.ts +0 -5
- package/src/urls.gen.ts +0 -8
- /package/src/browser/{prefetch-observer.ts → prefetch/observer.ts} +0 -0
|
@@ -11,12 +11,7 @@ import { createEventController } from "./event-controller.js";
|
|
|
11
11
|
import { createNavigationClient } from "./navigation-client.js";
|
|
12
12
|
import { createServerActionBridge } from "./server-action-bridge.js";
|
|
13
13
|
import { createNavigationBridge } from "./navigation-bridge.js";
|
|
14
|
-
import {
|
|
15
|
-
NavigationProvider,
|
|
16
|
-
initHandleDataSync,
|
|
17
|
-
initSegmentsSync,
|
|
18
|
-
} from "./react/index.js";
|
|
19
|
-
import { initThemeConfigSync } from "../theme/theme-context.js";
|
|
14
|
+
import { NavigationProvider } from "./react/index.js";
|
|
20
15
|
import type {
|
|
21
16
|
RscPayload,
|
|
22
17
|
RscBrowserDependencies,
|
|
@@ -27,6 +22,10 @@ import type {
|
|
|
27
22
|
import type { EventController } from "./event-controller.js";
|
|
28
23
|
import type { ResolvedThemeConfig, Theme } from "../theme/types.js";
|
|
29
24
|
import { initRangoState } from "./rango-state.js";
|
|
25
|
+
import {
|
|
26
|
+
isInterceptSegment,
|
|
27
|
+
splitInterceptSegments,
|
|
28
|
+
} from "./intercept-utils.js";
|
|
30
29
|
|
|
31
30
|
// Vite HMR types are provided by vite/client
|
|
32
31
|
|
|
@@ -169,16 +168,6 @@ export async function initBrowserApp(
|
|
|
169
168
|
initialLocation: new URL(window.location.href),
|
|
170
169
|
});
|
|
171
170
|
|
|
172
|
-
// Initialize segments state BEFORE hydration to avoid mismatch
|
|
173
|
-
initSegmentsSync(
|
|
174
|
-
initialPayload.metadata?.matched,
|
|
175
|
-
initialPayload.metadata?.pathname,
|
|
176
|
-
initialPayload.metadata?.params,
|
|
177
|
-
);
|
|
178
|
-
|
|
179
|
-
// Initialize theme config for MetaTags (must match SSR state)
|
|
180
|
-
initThemeConfigSync(effectiveThemeConfig);
|
|
181
|
-
|
|
182
171
|
// Initialize event controller with segment order (even without handles)
|
|
183
172
|
eventController.setHandleData({}, initialPayload.metadata?.matched);
|
|
184
173
|
|
|
@@ -194,12 +183,11 @@ export async function initBrowserApp(
|
|
|
194
183
|
for await (const handleData of handlesGenerator) {
|
|
195
184
|
lastHandleData = handleData;
|
|
196
185
|
}
|
|
197
|
-
// Initialize
|
|
186
|
+
// Initialize event controller with initial handle state before hydration.
|
|
198
187
|
eventController.setHandleData(
|
|
199
188
|
lastHandleData,
|
|
200
189
|
initialPayload.metadata?.matched,
|
|
201
190
|
);
|
|
202
|
-
initHandleDataSync(lastHandleData, initialPayload.metadata?.matched);
|
|
203
191
|
|
|
204
192
|
// Update the initial cache entry with the processed handleData
|
|
205
193
|
// The cache entry was created by createNavigationStore but without handleData
|
|
@@ -272,16 +260,19 @@ export async function initBrowserApp(
|
|
|
272
260
|
import.meta.hot.on("rsc:update", async () => {
|
|
273
261
|
console.log("[RSCRouter] HMR: Server update, refetching RSC");
|
|
274
262
|
|
|
275
|
-
|
|
263
|
+
using handle = eventController.startNavigation(window.location.href, {
|
|
276
264
|
replace: true,
|
|
277
265
|
});
|
|
278
266
|
const streamingToken = handle.startStreaming();
|
|
279
267
|
|
|
268
|
+
const interceptSourceUrl = store.getInterceptSourceUrl();
|
|
269
|
+
|
|
280
270
|
try {
|
|
281
271
|
const { payload, streamComplete } = await client.fetchPartial({
|
|
282
272
|
targetUrl: window.location.href,
|
|
283
273
|
segmentIds: [],
|
|
284
274
|
previousUrl: store.getSegmentState().currentUrl,
|
|
275
|
+
interceptSourceUrl: interceptSourceUrl || undefined,
|
|
285
276
|
hmr: true,
|
|
286
277
|
});
|
|
287
278
|
|
|
@@ -289,10 +280,22 @@ export async function initBrowserApp(
|
|
|
289
280
|
const segments = payload.metadata.segments || [];
|
|
290
281
|
const matched = payload.metadata.matched || [];
|
|
291
282
|
|
|
283
|
+
// Derive intercept state from the returned payload, not the
|
|
284
|
+
// pre-fetch store snapshot. If the HMR edit removed intercept
|
|
285
|
+
// behavior, the response won't contain intercept segments.
|
|
286
|
+
const responseIsIntercept = segments.some(isInterceptSegment);
|
|
287
|
+
|
|
288
|
+
// Sync store intercept state with what the server returned
|
|
289
|
+
if (!responseIsIntercept && interceptSourceUrl) {
|
|
290
|
+
store.setInterceptSourceUrl(null);
|
|
291
|
+
}
|
|
292
|
+
|
|
292
293
|
store.setSegmentIds(matched);
|
|
293
294
|
store.setCurrentUrl(window.location.href);
|
|
294
295
|
|
|
295
|
-
const historyKey = generateHistoryKey(window.location.href
|
|
296
|
+
const historyKey = generateHistoryKey(window.location.href, {
|
|
297
|
+
intercept: responseIsIntercept,
|
|
298
|
+
});
|
|
296
299
|
store.setHistoryKey(historyKey);
|
|
297
300
|
const currentHandleData = eventController.getHandleState().data;
|
|
298
301
|
store.cacheSegmentsForHistory(
|
|
@@ -301,18 +304,21 @@ export async function initBrowserApp(
|
|
|
301
304
|
currentHandleData,
|
|
302
305
|
);
|
|
303
306
|
|
|
307
|
+
const { main, intercept } = splitInterceptSegments(segments);
|
|
304
308
|
store.emitUpdate({
|
|
305
|
-
root: renderSegments(
|
|
309
|
+
root: renderSegments(main, {
|
|
310
|
+
interceptSegments: intercept.length > 0 ? intercept : undefined,
|
|
311
|
+
}),
|
|
306
312
|
metadata: payload.metadata,
|
|
307
313
|
});
|
|
308
314
|
}
|
|
309
315
|
|
|
310
316
|
await streamComplete;
|
|
317
|
+
handle.complete(new URL(window.location.href));
|
|
318
|
+
console.log("[RSCRouter] HMR: RSC stream complete");
|
|
311
319
|
} finally {
|
|
312
320
|
streamingToken.end();
|
|
313
321
|
}
|
|
314
|
-
handle.complete(new URL(window.location.href));
|
|
315
|
-
console.log("[RSCRouter] HMR: RSC stream complete");
|
|
316
322
|
});
|
|
317
323
|
}
|
|
318
324
|
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* - Supports hash link scrolling
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { debugLog } from "./logging.js";
|
|
12
|
+
|
|
11
13
|
const SCROLL_STORAGE_KEY = "rsc-router-scroll-positions";
|
|
12
14
|
|
|
13
15
|
/**
|
|
@@ -139,12 +141,13 @@ export function initScrollRestoration(options?: {
|
|
|
139
141
|
|
|
140
142
|
window.addEventListener("pagehide", handlePageHide);
|
|
141
143
|
|
|
142
|
-
|
|
144
|
+
debugLog(
|
|
143
145
|
"[Scroll] Initialized, loaded positions:",
|
|
144
146
|
Object.keys(savedScrollPositions).length,
|
|
145
147
|
);
|
|
146
148
|
|
|
147
149
|
return () => {
|
|
150
|
+
cancelScrollRestorationPolling();
|
|
148
151
|
window.removeEventListener("pagehide", handlePageHide);
|
|
149
152
|
window.history.scrollRestoration = "auto";
|
|
150
153
|
initialized = false;
|
|
@@ -267,13 +270,13 @@ export function restoreScrollPosition(options?: {
|
|
|
267
270
|
|
|
268
271
|
if (canScrollToPosition) {
|
|
269
272
|
window.scrollTo(0, savedY);
|
|
270
|
-
|
|
273
|
+
debugLog("[Scroll] Restored position:", savedY, "for key:", key);
|
|
271
274
|
return true;
|
|
272
275
|
}
|
|
273
276
|
|
|
274
277
|
// Scroll as far as we can for now
|
|
275
278
|
window.scrollTo(0, maxScrollY);
|
|
276
|
-
|
|
279
|
+
debugLog("[Scroll] Partial restore to:", maxScrollY, "target:", savedY);
|
|
277
280
|
|
|
278
281
|
// Poll while streaming until we can scroll to target position
|
|
279
282
|
if (options?.retryIfStreaming && options?.isStreaming?.()) {
|
|
@@ -282,14 +285,14 @@ export function restoreScrollPosition(options?: {
|
|
|
282
285
|
pendingPollInterval = setInterval(() => {
|
|
283
286
|
// Stop if we've exceeded the timeout
|
|
284
287
|
if (Date.now() - startTime > SCROLL_POLL_TIMEOUT_MS) {
|
|
285
|
-
|
|
288
|
+
debugLog("[Scroll] Polling timeout, giving up");
|
|
286
289
|
cancelScrollRestorationPolling();
|
|
287
290
|
return;
|
|
288
291
|
}
|
|
289
292
|
|
|
290
293
|
// Stop if streaming ended
|
|
291
294
|
if (!options.isStreaming?.()) {
|
|
292
|
-
|
|
295
|
+
debugLog("[Scroll] Streaming ended, stopping poll");
|
|
293
296
|
cancelScrollRestorationPolling();
|
|
294
297
|
return;
|
|
295
298
|
}
|
|
@@ -299,7 +302,7 @@ export function restoreScrollPosition(options?: {
|
|
|
299
302
|
document.documentElement.scrollHeight - window.innerHeight;
|
|
300
303
|
if (savedY <= currentMaxScrollY) {
|
|
301
304
|
window.scrollTo(0, savedY);
|
|
302
|
-
|
|
305
|
+
debugLog("[Scroll] Poll restored position:", savedY);
|
|
303
306
|
cancelScrollRestorationPolling();
|
|
304
307
|
}
|
|
305
308
|
}, SCROLL_POLL_INTERVAL_MS);
|
|
@@ -322,7 +325,7 @@ export function scrollToHash(): boolean {
|
|
|
322
325
|
const element = document.getElementById(id);
|
|
323
326
|
if (element) {
|
|
324
327
|
element.scrollIntoView();
|
|
325
|
-
|
|
328
|
+
debugLog("[Scroll] Scrolled to hash element:", id);
|
|
326
329
|
return true;
|
|
327
330
|
}
|
|
328
331
|
} catch (e) {
|
|
@@ -9,7 +9,6 @@ import {
|
|
|
9
9
|
reconcileSegments,
|
|
10
10
|
reconcileErrorSegments,
|
|
11
11
|
} from "./segment-reconciler.js";
|
|
12
|
-
import { classifyActionResponse } from "./action-response-classifier.js";
|
|
13
12
|
import { startTransition } from "react";
|
|
14
13
|
import type { EventController } from "./event-controller.js";
|
|
15
14
|
import {
|
|
@@ -22,6 +21,14 @@ import {
|
|
|
22
21
|
isBrowserDebugEnabled,
|
|
23
22
|
startBrowserTransaction,
|
|
24
23
|
} from "./logging.js";
|
|
24
|
+
import { validateRedirectOrigin } from "./validate-redirect-origin.js";
|
|
25
|
+
import {
|
|
26
|
+
extractRscHeaderUrl,
|
|
27
|
+
emptyResponse,
|
|
28
|
+
teeWithCompletion,
|
|
29
|
+
} from "./response-adapter.js";
|
|
30
|
+
import { mergeLocationState } from "./history-state.js";
|
|
31
|
+
import { classifyActionOutcome } from "./action-coordinator.js";
|
|
25
32
|
|
|
26
33
|
// Polyfill Symbol.dispose/asyncDispose for Safari and older browsers
|
|
27
34
|
if (typeof Symbol.dispose === "undefined") {
|
|
@@ -109,7 +116,7 @@ export function createServerActionBridge(
|
|
|
109
116
|
...(src ? { intercept: true, interceptSourceUrl: src } : {}),
|
|
110
117
|
}),
|
|
111
118
|
{
|
|
112
|
-
|
|
119
|
+
type: "action" as const,
|
|
113
120
|
...(src ? { interceptSourceUrl: src } : {}),
|
|
114
121
|
},
|
|
115
122
|
);
|
|
@@ -180,6 +187,14 @@ export function createServerActionBridge(
|
|
|
180
187
|
// Track streaming token - will be set when response arrives
|
|
181
188
|
let streamingToken: { end(): void } | null = null;
|
|
182
189
|
|
|
190
|
+
// Use a dedicated abort controller for the fetch so we can cancel network
|
|
191
|
+
// I/O without disrupting the Flight stream once the response has arrived.
|
|
192
|
+
// Aborting a response mid-stream causes React's Flight decoder to throw
|
|
193
|
+
// asynchronous unhandled errors (BodyStreamBuffer was aborted).
|
|
194
|
+
const fetchAbort = new AbortController();
|
|
195
|
+
const onHandleAbort = () => fetchAbort.abort();
|
|
196
|
+
handle.signal.addEventListener("abort", onHandleAbort, { once: true });
|
|
197
|
+
|
|
183
198
|
// Send action request with stream tracking
|
|
184
199
|
const responsePromise = fetch(url, {
|
|
185
200
|
method: "POST",
|
|
@@ -193,25 +208,21 @@ export function createServerActionBridge(
|
|
|
193
208
|
}),
|
|
194
209
|
},
|
|
195
210
|
body: encodedBody,
|
|
211
|
+
signal: fetchAbort.signal,
|
|
196
212
|
}).then(async (response) => {
|
|
213
|
+
// Response arrived — disconnect fetch abort from handle abort so
|
|
214
|
+
// abortAllActions() doesn't disrupt the in-progress Flight stream.
|
|
215
|
+
handle.signal.removeEventListener("abort", onHandleAbort);
|
|
216
|
+
|
|
197
217
|
// Check for version mismatch - server wants us to reload
|
|
198
|
-
const
|
|
199
|
-
if (
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
);
|
|
207
|
-
}
|
|
208
|
-
} catch (e) {
|
|
209
|
-
console.error("[rango]", e);
|
|
210
|
-
return response;
|
|
211
|
-
}
|
|
212
|
-
log("version mismatch on action, reloading", { reloadUrl });
|
|
213
|
-
window.location.href = reloadUrl;
|
|
214
|
-
// Return a never-resolving promise to prevent further processing
|
|
218
|
+
const reload = extractRscHeaderUrl(response, "X-RSC-Reload");
|
|
219
|
+
if (reload === "blocked") {
|
|
220
|
+
resolveStreamComplete();
|
|
221
|
+
return emptyResponse();
|
|
222
|
+
}
|
|
223
|
+
if (reload) {
|
|
224
|
+
log("version mismatch on action, reloading", { reloadUrl: reload.url });
|
|
225
|
+
window.location.href = reload.url;
|
|
215
226
|
return new Promise<Response>(() => {});
|
|
216
227
|
}
|
|
217
228
|
|
|
@@ -219,64 +230,34 @@ export function createServerActionBridge(
|
|
|
219
230
|
// Short-circuits before createFromFetch — no Flight deserialization needed.
|
|
220
231
|
// Check handle.signal.aborted to avoid redirecting from a stale action
|
|
221
232
|
// when the user has already navigated away.
|
|
222
|
-
const
|
|
223
|
-
if (
|
|
224
|
-
|
|
225
|
-
browserDebugLog(tx, "action simple redirect", {
|
|
226
|
-
url: simpleRedirectUrl,
|
|
227
|
-
});
|
|
228
|
-
}
|
|
233
|
+
const redirect = extractRscHeaderUrl(response, "X-RSC-Redirect");
|
|
234
|
+
if (redirect && redirect !== "blocked" && !handle.signal.aborted) {
|
|
235
|
+
log("action simple redirect", { url: redirect.url });
|
|
229
236
|
handle.complete(undefined);
|
|
230
237
|
if (onNavigate) {
|
|
231
|
-
await onNavigate(
|
|
238
|
+
await onNavigate(redirect.url, {
|
|
232
239
|
replace: true,
|
|
233
240
|
_skipCache: true,
|
|
234
241
|
});
|
|
235
242
|
} else {
|
|
236
|
-
window.location.href =
|
|
243
|
+
window.location.href = redirect.url;
|
|
237
244
|
}
|
|
238
245
|
return new Promise<Response>(() => {});
|
|
239
246
|
}
|
|
247
|
+
if (redirect === "blocked") {
|
|
248
|
+
resolveStreamComplete();
|
|
249
|
+
return emptyResponse();
|
|
250
|
+
}
|
|
240
251
|
|
|
241
252
|
// Start streaming immediately when response arrives
|
|
242
253
|
if (!handle.signal.aborted) {
|
|
243
254
|
streamingToken = handle.startStreaming();
|
|
244
255
|
}
|
|
245
256
|
|
|
246
|
-
|
|
247
|
-
|
|
257
|
+
return teeWithCompletion(response, () => {
|
|
258
|
+
log("stream complete");
|
|
248
259
|
streamingToken?.end();
|
|
249
260
|
resolveStreamComplete();
|
|
250
|
-
return response;
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
// Tee the stream: one for RSC runtime, one for tracking completion
|
|
254
|
-
const [rscStream, trackingStream] = response.body.tee();
|
|
255
|
-
|
|
256
|
-
// Consume the tracking stream to detect when it closes
|
|
257
|
-
(async () => {
|
|
258
|
-
const reader = trackingStream.getReader();
|
|
259
|
-
try {
|
|
260
|
-
while (true) {
|
|
261
|
-
const { done } = await reader.read();
|
|
262
|
-
if (done) break;
|
|
263
|
-
}
|
|
264
|
-
} finally {
|
|
265
|
-
reader.releaseLock();
|
|
266
|
-
log("stream complete");
|
|
267
|
-
streamingToken?.end();
|
|
268
|
-
resolveStreamComplete();
|
|
269
|
-
}
|
|
270
|
-
})().catch((error) => {
|
|
271
|
-
console.error("[STREAMING] Error reading tracking stream:", error);
|
|
272
|
-
streamingToken?.end();
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
// Return response with the RSC stream
|
|
276
|
-
return new Response(rscStream, {
|
|
277
|
-
headers: response.headers,
|
|
278
|
-
status: response.status,
|
|
279
|
-
statusText: response.statusText,
|
|
280
261
|
});
|
|
281
262
|
});
|
|
282
263
|
|
|
@@ -294,6 +275,15 @@ export function createServerActionBridge(
|
|
|
294
275
|
// resolveStreamComplete is assigned in the Promise constructor so it's safe to call
|
|
295
276
|
resolveStreamComplete!();
|
|
296
277
|
|
|
278
|
+
// Silently swallow abort errors — the action was intentionally cancelled
|
|
279
|
+
// (e.g., user navigated away or abortAllActions was called).
|
|
280
|
+
// Return undefined instead of throwing to avoid surfacing as a page error.
|
|
281
|
+
// Check both DOMException AbortError and stream-level abort messages
|
|
282
|
+
// (BodyStreamBuffer was aborted) that propagate from the aborted fetch.
|
|
283
|
+
if (handle.signal.aborted) {
|
|
284
|
+
return undefined;
|
|
285
|
+
}
|
|
286
|
+
|
|
297
287
|
// Convert network-level errors to NetworkError for proper handling
|
|
298
288
|
const networkError = toNetworkError(error, {
|
|
299
289
|
url: url.toString(),
|
|
@@ -314,6 +304,14 @@ export function createServerActionBridge(
|
|
|
314
304
|
diffCount: payload.metadata?.diff?.length ?? 0,
|
|
315
305
|
});
|
|
316
306
|
|
|
307
|
+
// Guard: if the action was aborted while streaming (e.g., user navigated
|
|
308
|
+
// away or abortAllActions fired), bail out before any reconcile/render/cache
|
|
309
|
+
// writes to avoid overwriting the current UI with stale action results.
|
|
310
|
+
if (handle.signal.aborted) {
|
|
311
|
+
log("action aborted after response, skipping reconciliation");
|
|
312
|
+
return undefined;
|
|
313
|
+
}
|
|
314
|
+
|
|
317
315
|
// Process response
|
|
318
316
|
const { metadata, returnValue } = payload;
|
|
319
317
|
|
|
@@ -322,9 +320,17 @@ export function createServerActionBridge(
|
|
|
322
320
|
// Check handle.signal.aborted to avoid redirecting from a stale action
|
|
323
321
|
// when the user has already navigated away.
|
|
324
322
|
if (metadata?.redirect && !handle.signal.aborted) {
|
|
325
|
-
const
|
|
323
|
+
const redirectUrl = validateRedirectOrigin(
|
|
324
|
+
metadata.redirect.url,
|
|
325
|
+
window.location.origin,
|
|
326
|
+
);
|
|
327
|
+
if (!redirectUrl) {
|
|
328
|
+
log("blocked action redirect payload", { url: metadata.redirect.url });
|
|
329
|
+
handle.complete(returnValue?.data);
|
|
330
|
+
return returnValue?.data;
|
|
331
|
+
}
|
|
326
332
|
const redirectState = metadata.locationState;
|
|
327
|
-
|
|
333
|
+
log("action redirect", { url: redirectUrl });
|
|
328
334
|
handle.complete(returnValue?.data);
|
|
329
335
|
if (onNavigate) {
|
|
330
336
|
await onNavigate(redirectUrl, {
|
|
@@ -338,6 +344,15 @@ export function createServerActionBridge(
|
|
|
338
344
|
return returnValue?.data;
|
|
339
345
|
}
|
|
340
346
|
|
|
347
|
+
// Bail out if the action was aborted after deserialization (e.g. user
|
|
348
|
+
// navigated away or abortAllActions was called while the Flight stream
|
|
349
|
+
// was being consumed). Without this check the code below would mutate
|
|
350
|
+
// the store / UI for a stale action.
|
|
351
|
+
if (handle.signal.aborted) {
|
|
352
|
+
log("action aborted after deserialization, skipping mutations");
|
|
353
|
+
return returnValue?.data;
|
|
354
|
+
}
|
|
355
|
+
|
|
341
356
|
const { matched, diff, segments, isPartial, isError } = metadata || {};
|
|
342
357
|
|
|
343
358
|
// Log action result
|
|
@@ -349,6 +364,12 @@ export function createServerActionBridge(
|
|
|
349
364
|
if (isError && isPartial && segments && diff) {
|
|
350
365
|
log("processing error boundary response");
|
|
351
366
|
|
|
367
|
+
// Fail current handle BEFORE aborting all actions so the event controller
|
|
368
|
+
// records the error state (abortAllActions clears inflight entries)
|
|
369
|
+
if (returnValue && !returnValue.ok) {
|
|
370
|
+
handle.fail(returnValue.data);
|
|
371
|
+
}
|
|
372
|
+
|
|
352
373
|
// Abort all other pending action requests - error takes precedence
|
|
353
374
|
// This prevents other actions from completing and overwriting the error UI
|
|
354
375
|
eventController.abortAllActions();
|
|
@@ -373,6 +394,26 @@ export function createServerActionBridge(
|
|
|
373
394
|
: undefined,
|
|
374
395
|
});
|
|
375
396
|
|
|
397
|
+
// Re-check route stability after async renderSegments — user may have
|
|
398
|
+
// navigated away while the error tree was being prepared.
|
|
399
|
+
if (window.location.pathname !== actionStartPathname) {
|
|
400
|
+
log("user navigated during error render, skipping");
|
|
401
|
+
if (returnValue && !returnValue.ok) {
|
|
402
|
+
throw returnValue.data;
|
|
403
|
+
}
|
|
404
|
+
handle.complete(undefined);
|
|
405
|
+
return undefined;
|
|
406
|
+
}
|
|
407
|
+
const currentKeyNow = store.getHistoryKey();
|
|
408
|
+
if (currentKeyNow !== currentKey) {
|
|
409
|
+
log("history key changed during error render, skipping cache update");
|
|
410
|
+
if (returnValue && !returnValue.ok) {
|
|
411
|
+
throw returnValue.data;
|
|
412
|
+
}
|
|
413
|
+
handle.complete(undefined);
|
|
414
|
+
return undefined;
|
|
415
|
+
}
|
|
416
|
+
|
|
376
417
|
// Update UI with error boundary
|
|
377
418
|
startTransition(() => {
|
|
378
419
|
onUpdate({ root: errorTree, metadata: metadata! });
|
|
@@ -395,7 +436,6 @@ export function createServerActionBridge(
|
|
|
395
436
|
|
|
396
437
|
// Throw the error so the action promise rejects
|
|
397
438
|
if (returnValue && !returnValue.ok) {
|
|
398
|
-
handle.fail(returnValue.data);
|
|
399
439
|
throw returnValue.data;
|
|
400
440
|
}
|
|
401
441
|
|
|
@@ -405,9 +445,13 @@ export function createServerActionBridge(
|
|
|
405
445
|
}
|
|
406
446
|
|
|
407
447
|
if (!isPartial) {
|
|
408
|
-
//
|
|
448
|
+
// Protocol invariant: action revalidation responses MUST be partial.
|
|
449
|
+
// The server always sends isPartial: true for successful revalidation
|
|
450
|
+
// and isPartial: true + isError: true for error boundary responses.
|
|
451
|
+
// A non-partial payload here indicates a server-side bug.
|
|
409
452
|
throw new Error(
|
|
410
|
-
`[Browser]
|
|
453
|
+
`[Browser] Action response missing isPartial — the server must ` +
|
|
454
|
+
`always send partial payloads for action revalidation.`,
|
|
411
455
|
);
|
|
412
456
|
}
|
|
413
457
|
|
|
@@ -449,20 +493,17 @@ export function createServerActionBridge(
|
|
|
449
493
|
}
|
|
450
494
|
|
|
451
495
|
// Classify the post-reconciliation scenario
|
|
452
|
-
const
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
const scenario = classifyActionResponse({
|
|
496
|
+
const scenario = classifyActionOutcome({
|
|
497
|
+
handleId: handle.id,
|
|
498
|
+
inflightActions: eventController.getInflightActions(),
|
|
499
|
+
hadAnyConcurrentActions: eventController.hadAnyConcurrentActions(),
|
|
500
|
+
revalidatedSegments: handle.getRevalidatedSegments(),
|
|
458
501
|
actionStartPathname,
|
|
459
502
|
currentPathname: window.location.pathname,
|
|
460
503
|
actionStartLocationKey: locationKey,
|
|
461
504
|
currentLocationKey: window.history.state?.key,
|
|
462
505
|
reconciledSegmentCount: fullSegments.length,
|
|
463
506
|
matchedCount: matched.length,
|
|
464
|
-
consolidationSegments: consolidationSegments || null,
|
|
465
|
-
otherFetchingActionCount: otherFetchingActions.length,
|
|
466
507
|
currentInterceptSource: store.getInterceptSourceUrl(),
|
|
467
508
|
});
|
|
468
509
|
|
|
@@ -572,19 +613,7 @@ export function createServerActionBridge(
|
|
|
572
613
|
// Apply server-set location state to history.state (non-redirect flow)
|
|
573
614
|
const actionLocationState = metadata?.locationState;
|
|
574
615
|
if (actionLocationState) {
|
|
575
|
-
|
|
576
|
-
...window.history.state,
|
|
577
|
-
...actionLocationState,
|
|
578
|
-
};
|
|
579
|
-
window.history.replaceState(merged, "", window.location.href);
|
|
580
|
-
// Notify useLocationState hooks so they re-read from history.state
|
|
581
|
-
if (
|
|
582
|
-
Object.keys(actionLocationState).some((k) =>
|
|
583
|
-
k.startsWith("__rsc_ls_"),
|
|
584
|
-
)
|
|
585
|
-
) {
|
|
586
|
-
window.dispatchEvent(new Event("__rsc_locationstate"));
|
|
587
|
-
}
|
|
616
|
+
mergeLocationState(actionLocationState);
|
|
588
617
|
}
|
|
589
618
|
|
|
590
619
|
// Update store state
|
|
@@ -616,16 +645,6 @@ export function createServerActionBridge(
|
|
|
616
645
|
deps.setServerCallback(handleServerAction);
|
|
617
646
|
isRegistered = true;
|
|
618
647
|
},
|
|
619
|
-
|
|
620
|
-
/**
|
|
621
|
-
* Unregister the server action callback
|
|
622
|
-
*/
|
|
623
|
-
unregister(): void {
|
|
624
|
-
if (!isRegistered) {
|
|
625
|
-
return;
|
|
626
|
-
}
|
|
627
|
-
isRegistered = false;
|
|
628
|
-
},
|
|
629
648
|
};
|
|
630
649
|
}
|
|
631
650
|
|
package/src/browser/types.ts
CHANGED
|
@@ -121,7 +121,7 @@ export interface NavigationState {
|
|
|
121
121
|
/** Whether RSC data is currently streaming (initial load or navigation) */
|
|
122
122
|
isStreaming: boolean;
|
|
123
123
|
|
|
124
|
-
/** Current location
|
|
124
|
+
/** Current location */
|
|
125
125
|
location: NavigationLocation;
|
|
126
126
|
|
|
127
127
|
/** URL being navigated to (null when idle) */
|
|
@@ -402,35 +402,6 @@ export interface NavigationStore {
|
|
|
402
402
|
): () => void;
|
|
403
403
|
}
|
|
404
404
|
|
|
405
|
-
// ============================================================================
|
|
406
|
-
// Request Controller Types
|
|
407
|
-
// ============================================================================
|
|
408
|
-
|
|
409
|
-
/**
|
|
410
|
-
* Disposable abort controller with automatic cleanup
|
|
411
|
-
*/
|
|
412
|
-
export interface DisposableAbortController extends Disposable {
|
|
413
|
-
controller: AbortController;
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
/**
|
|
417
|
-
* Request controller for managing concurrent requests
|
|
418
|
-
*
|
|
419
|
-
* Separates navigation requests (aborted on new navigation) from
|
|
420
|
-
* action requests (complete independently of navigation).
|
|
421
|
-
*/
|
|
422
|
-
export interface RequestController {
|
|
423
|
-
create(): AbortController;
|
|
424
|
-
createDisposable(): DisposableAbortController;
|
|
425
|
-
/** Create a disposable controller for actions (not aborted by navigation) */
|
|
426
|
-
createActionDisposable(): DisposableAbortController;
|
|
427
|
-
/** Abort all navigation requests (not actions) */
|
|
428
|
-
abortAll(): void;
|
|
429
|
-
/** Abort all action requests (used for error handling) */
|
|
430
|
-
abortAllActions(): void;
|
|
431
|
-
remove(controller: AbortController): void;
|
|
432
|
-
}
|
|
433
|
-
|
|
434
405
|
// ============================================================================
|
|
435
406
|
// Navigation Client Types
|
|
436
407
|
// ============================================================================
|
|
@@ -488,7 +459,6 @@ export interface LinkInterceptorOptions {
|
|
|
488
459
|
*/
|
|
489
460
|
export interface ServerActionBridge {
|
|
490
461
|
register(): void;
|
|
491
|
-
unregister(): void;
|
|
492
462
|
}
|
|
493
463
|
|
|
494
464
|
/**
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validate that a client-consumed redirect URL (from headers or Flight payload)
|
|
3
|
+
* targets the same origin as the current page. Prevents open-redirect attacks
|
|
4
|
+
* via crafted responses.
|
|
5
|
+
*
|
|
6
|
+
* @returns The canonical (normalized) URL string on success, or null if blocked.
|
|
7
|
+
*/
|
|
8
|
+
export function validateRedirectOrigin(
|
|
9
|
+
url: string,
|
|
10
|
+
currentOrigin: string,
|
|
11
|
+
): string | null {
|
|
12
|
+
try {
|
|
13
|
+
const target = new URL(url, currentOrigin);
|
|
14
|
+
if (target.origin !== currentOrigin) {
|
|
15
|
+
console.error(
|
|
16
|
+
`[rango] Redirect blocked: origin mismatch (${target.origin})`,
|
|
17
|
+
);
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
// Return pathname+search+hash for relative inputs, full href for absolute.
|
|
21
|
+
// This normalizes protocol-relative and other ambiguous forms.
|
|
22
|
+
return target.href.startsWith(currentOrigin)
|
|
23
|
+
? target.href
|
|
24
|
+
: target.pathname + target.search + target.hash;
|
|
25
|
+
} catch {
|
|
26
|
+
console.error(`[rango] Redirect blocked: invalid URL "${url}"`);
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -200,6 +200,11 @@ function buildPrefixTreeNode(
|
|
|
200
200
|
}
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
+
// Remove from visited so sibling branches can reuse the same patterns
|
|
204
|
+
// without false circular-include detection. Only ancestors in the current
|
|
205
|
+
// recursion path should trigger the cycle guard.
|
|
206
|
+
visited.delete(patterns);
|
|
207
|
+
|
|
203
208
|
return {
|
|
204
209
|
staticPrefix: extractStaticPrefix(urlPrefix),
|
|
205
210
|
fullPrefix: urlPrefix,
|
|
@@ -27,6 +27,8 @@ export {
|
|
|
27
27
|
extractUrlsVariableFromRouter,
|
|
28
28
|
buildCombinedRouteMapForRouterFile,
|
|
29
29
|
detectUnresolvableIncludes,
|
|
30
|
+
detectUnresolvableIncludesForUrlsFile,
|
|
30
31
|
findRouterFiles,
|
|
31
32
|
writeCombinedRouteTypes,
|
|
32
33
|
} from "./route-types/router-processing.js";
|
|
34
|
+
export { findUrlsVariableNames } from "./route-types/per-module-writer.js";
|