@rangojs/router 0.22.1 → 0.23.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 +8 -3
- package/dist/testing/vitest.js +31 -1
- package/dist/types/browser/defer.d.ts +48 -0
- package/dist/types/browser/document-revealed.d.ts +17 -0
- package/dist/types/browser/event-controller.d.ts +7 -1
- package/dist/types/browser/navigation-transaction.d.ts +21 -7
- package/dist/types/browser/partial-update.d.ts +34 -5
- package/dist/types/browser/react/NavigationProvider.d.ts +13 -3
- package/dist/types/browser/react/filter-segment-order.d.ts +7 -0
- package/dist/types/browser/response-adapter.d.ts +7 -0
- package/dist/types/browser/types.d.ts +19 -9
- package/dist/types/browser/validate-redirect-origin.d.ts +11 -0
- package/dist/types/build/generate-route-types.d.ts +1 -1
- package/dist/types/build/route-types/router-processing.d.ts +1 -1
- package/dist/types/build/route-types/scan-filter.d.ts +16 -2
- package/dist/types/cache/cf/cf-cache-store.d.ts +16 -1
- package/dist/types/cache/cf/cf-cache-types.d.ts +23 -2
- package/dist/types/client-urls/client-urls.d.ts +1 -1
- package/dist/types/client-urls/hoisted-revalidate.d.ts +13 -0
- package/dist/types/client-urls/navigation.d.ts +0 -37
- package/dist/types/client-urls/revalidate-registry.d.ts +4 -0
- package/dist/types/client-urls/server-projection.d.ts +29 -1
- package/dist/types/client-urls/types.d.ts +34 -69
- package/dist/types/client.d.ts +0 -1
- package/dist/types/errors.d.ts +5 -0
- package/dist/types/handles/deferred-resolution.d.ts +2 -1
- package/dist/types/handles/pushed-by.d.ts +23 -0
- package/dist/types/loader-store.d.ts +8 -0
- package/dist/types/prefetch-markers.d.ts +11 -0
- package/dist/types/route-definition/helpers-types.d.ts +27 -0
- package/dist/types/router/is-action.d.ts +2 -1
- package/dist/types/router/segment-resolution/loader-mask.d.ts +3 -2
- package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +241 -0
- package/dist/types/server/context.d.ts +21 -1
- package/dist/types/server/handle-store.d.ts +9 -1
- package/dist/types/server/request-context.d.ts +14 -1
- package/dist/types/server.d.ts +1 -0
- package/dist/types/testing/flight.entry.d.ts +1 -1
- package/dist/types/testing/index.d.ts +3 -3
- package/dist/types/testing/internal/action-id.d.ts +10 -1
- package/dist/types/testing/internal/client-urls-projection.d.ts +16 -0
- package/dist/types/testing/internal/test-action.d.ts +8 -0
- package/dist/types/testing/run-loader.d.ts +2 -1
- package/dist/types/testing/run-revalidate.d.ts +74 -0
- package/dist/types/testing/run-transition-when.d.ts +2 -3
- package/dist/types/testing/serve-shell-request.d.ts +38 -0
- package/dist/types/testing/vitest.d.ts +11 -0
- package/dist/types/types/boundaries.d.ts +9 -0
- package/dist/types/types/handler-context.d.ts +14 -0
- package/dist/types/types/loader-types.d.ts +30 -0
- package/dist/types/types/segments.d.ts +21 -4
- package/dist/types/urls/path-helper-types.d.ts +25 -4
- package/dist/types/urls/type-extraction.d.ts +6 -2
- package/dist/types/vite/discovery/discover-routers.d.ts +9 -0
- package/dist/types/vite/discovery/state.d.ts +11 -4
- package/dist/types/vite/plugins/client-urls-revalidate-hoist.d.ts +54 -0
- package/dist/types/vite/plugins/hoist/literal.d.ts +83 -0
- package/dist/types/vite/plugins/hoist/scope.d.ts +40 -0
- package/dist/types/vite/plugins/hoist/source.d.ts +41 -0
- package/dist/types/vite/plugins/transition-when-hoist.d.ts +8 -11
- package/dist/types/vite/router-discovery.d.ts +10 -0
- package/dist/vite/index.js +842 -308
- package/package.json +1 -1
- package/skills/caching/SKILL.md +42 -5
- package/skills/catalog.json +2 -2
- package/skills/client-urls/SKILL.md +113 -24
- package/skills/cloudflare/SKILL.md +14 -9
- package/skills/hooks/SKILL.md +11 -3
- package/skills/links/SKILL.md +24 -0
- package/skills/loader/SKILL.md +192 -1
- package/skills/migrate-nextjs/SKILL.md +2 -2
- package/skills/migrate-react-router/SKILL.md +10 -7
- package/skills/migrate-react-router/data-and-actions.md +1 -1
- package/skills/ppr/SKILL.md +13 -0
- package/skills/prerender/SKILL.md +22 -8
- package/skills/rango/SKILL.md +12 -6
- package/skills/route/SKILL.md +139 -0
- package/skills/testing/SKILL.md +3 -2
- package/skills/testing/client-components.md +1 -1
- package/skills/testing/predicates.md +67 -32
- package/skills/testing/serve-shell-request.md +88 -18
- package/skills/testing/setup.md +2 -2
- package/skills/typesafety/route-types.md +2 -2
- package/skills/view-transitions/SKILL.md +26 -0
- package/src/browser/defer.ts +82 -0
- package/src/browser/document-revealed.ts +52 -0
- package/src/browser/event-controller.ts +43 -32
- package/src/browser/navigation-bridge.ts +64 -14
- package/src/browser/navigation-client.ts +48 -36
- package/src/browser/navigation-store.ts +13 -0
- package/src/browser/navigation-transaction.ts +25 -8
- package/src/browser/partial-update.ts +528 -135
- package/src/browser/prefetch/fetch.ts +2 -1
- package/src/browser/react/NavigationProvider.tsx +47 -28
- package/src/browser/react/filter-segment-order.ts +13 -2
- package/src/browser/response-adapter.ts +20 -0
- package/src/browser/rsc-router.tsx +17 -45
- package/src/browser/server-action-bridge.ts +19 -63
- package/src/browser/types.ts +19 -9
- package/src/browser/validate-redirect-origin.ts +16 -0
- package/src/build/generate-route-types.ts +1 -0
- package/src/build/route-types/router-processing.ts +3 -6
- package/src/build/route-types/scan-filter.ts +37 -10
- package/src/cache/cf/cf-cache-store.ts +59 -21
- package/src/cache/cf/cf-cache-types.ts +24 -2
- package/src/cache/document-cache.ts +22 -1
- package/src/client-urls/client-urls.ts +22 -6
- package/src/client-urls/hoisted-revalidate.ts +25 -0
- package/src/client-urls/navigation.ts +0 -96
- package/src/client-urls/revalidate-registry.ts +30 -0
- package/src/client-urls/server-projection.ts +151 -65
- package/src/client-urls/types.ts +39 -71
- package/src/client.tsx +9 -5
- package/src/errors.ts +15 -0
- package/src/handles/deferred-resolution.ts +28 -5
- package/src/handles/pushed-by.ts +122 -0
- package/src/loader-store.ts +18 -7
- package/src/prefetch-markers.ts +13 -0
- package/src/route-content-wrapper.tsx +18 -4
- package/src/route-definition/dsl-helpers.ts +27 -4
- package/src/route-definition/helpers-types.ts +27 -1
- package/src/router/intercept-resolution.ts +6 -4
- package/src/router/is-action.ts +2 -1
- package/src/router/loader-resolution.ts +83 -4
- package/src/router/match-api.ts +36 -1
- package/src/router/match-middleware/background-revalidation.ts +11 -1
- package/src/router/match-middleware/cache-lookup.ts +29 -6
- package/src/router/match-middleware/cache-store.ts +17 -1
- package/src/router/match-result.ts +14 -2
- package/src/router/navigation-snapshot.ts +2 -1
- package/src/router/segment-resolution/fresh.ts +22 -18
- package/src/router/segment-resolution/loader-cache.ts +2 -1
- package/src/router/segment-resolution/loader-mask.ts +5 -3
- package/src/router/segment-resolution/prefetch-deferral.ts +543 -0
- package/src/router/segment-resolution/revalidation.ts +169 -60
- package/src/rsc/rsc-rendering.ts +50 -7
- package/src/rsc/shell-capture.ts +2 -1
- package/src/segment-loader-promise.ts +23 -20
- package/src/segment-system.tsx +60 -26
- package/src/server/context.ts +39 -5
- package/src/server/handle-store.ts +104 -8
- package/src/server/request-context.ts +21 -1
- package/src/server.ts +4 -0
- package/src/testing/action-form.ts +2 -11
- package/src/testing/e2e/page-helpers.ts +3 -1
- package/src/testing/flight.entry.ts +1 -0
- package/src/testing/index.ts +6 -3
- package/src/testing/internal/action-id.ts +16 -1
- package/src/testing/internal/client-urls-projection.ts +70 -0
- package/src/testing/internal/test-action.ts +25 -0
- package/src/testing/run-loader.ts +2 -1
- package/src/testing/run-revalidate.ts +142 -0
- package/src/testing/run-transition-when.ts +7 -8
- package/src/testing/serve-shell-request.ts +116 -6
- package/src/testing/vitest.ts +67 -1
- package/src/types/boundaries.ts +12 -0
- package/src/types/handler-context.ts +14 -0
- package/src/types/loader-types.ts +30 -0
- package/src/types/segments.ts +26 -4
- package/src/urls/path-helper-types.ts +27 -5
- package/src/urls/type-extraction.ts +30 -10
- package/src/use-loader.tsx +18 -13
- package/src/vite/discovery/client-urls-projection.ts +20 -21
- package/src/vite/discovery/discover-routers.ts +94 -26
- package/src/vite/discovery/state.ts +20 -4
- package/src/vite/discovery/virtual-module-codegen.ts +22 -17
- package/src/vite/plugins/client-urls-revalidate-hoist.ts +564 -0
- package/src/vite/plugins/hoist/literal.ts +417 -0
- package/src/vite/plugins/hoist/scope.ts +370 -0
- package/src/vite/plugins/hoist/source.ts +84 -0
- package/src/vite/plugins/transition-when-hoist.ts +113 -779
- package/src/vite/rango.ts +2 -0
- package/src/vite/router-discovery.ts +93 -32
- package/dist/types/client-urls/revalidate-chain.d.ts +0 -33
- package/dist/types/client-urls/revalidation-protocol.d.ts +0 -25
- package/dist/types/testing/run-client-revalidate.d.ts +0 -43
- package/src/client-urls/revalidate-chain.ts +0 -83
- package/src/client-urls/revalidation-protocol.ts +0 -56
- package/src/testing/run-client-revalidate.ts +0 -92
|
@@ -35,6 +35,7 @@ import { shouldPrefetch } from "./policy.js";
|
|
|
35
35
|
import { debugLog, IS_BROWSER_DEBUG } from "../logging.js";
|
|
36
36
|
import { teeWithCompletion, isForeignRouterId } from "../response-adapter.js";
|
|
37
37
|
import { SEGMENT_FRAGMENT_CAPABILITY_HEADER } from "../../segment-fragments.js";
|
|
38
|
+
import { PREFETCH_HEADER } from "../../prefetch-markers.js";
|
|
38
39
|
import type { RscPayload } from "../types.js";
|
|
39
40
|
|
|
40
41
|
/**
|
|
@@ -263,7 +264,7 @@ function executePrefetchFetch(
|
|
|
263
264
|
headers: {
|
|
264
265
|
"X-Rango-State": rangoState,
|
|
265
266
|
"X-RSC-Router-Client-Path": window.location.href,
|
|
266
|
-
|
|
267
|
+
[PREFETCH_HEADER]: "1",
|
|
267
268
|
...(isFragmentPassthroughEnabled() && {
|
|
268
269
|
[SEGMENT_FRAGMENT_CAPABILITY_HEADER]: "1",
|
|
269
270
|
}),
|
|
@@ -22,6 +22,7 @@ import type {
|
|
|
22
22
|
NavigationUpdate,
|
|
23
23
|
NavigateOptionsInternal,
|
|
24
24
|
NavigationBridge,
|
|
25
|
+
RscMetadata,
|
|
25
26
|
} from "../types.js";
|
|
26
27
|
import type { EventController } from "../event-controller.js";
|
|
27
28
|
import { RootErrorBoundary } from "../../root-error-boundary.js";
|
|
@@ -211,6 +212,30 @@ async function processHandles(
|
|
|
211
212
|
}
|
|
212
213
|
}
|
|
213
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Read the handle stream of `metadata` into the event controller and the
|
|
217
|
+
* entry on screen. The provider's own call for every payload it is handed,
|
|
218
|
+
* and the one write for a response that hands React nothing (a fill,
|
|
219
|
+
* browser/partial-update.ts fillHoles).
|
|
220
|
+
*/
|
|
221
|
+
export function consumeHandles(
|
|
222
|
+
metadata: RscMetadata,
|
|
223
|
+
eventController: EventController,
|
|
224
|
+
store: NavigationStore,
|
|
225
|
+
): void {
|
|
226
|
+
processHandles(metadata.handles!, {
|
|
227
|
+
eventController,
|
|
228
|
+
store,
|
|
229
|
+
matched: metadata.matched,
|
|
230
|
+
isPartial: metadata.isPartial,
|
|
231
|
+
resolvedIds: metadata.resolvedIds,
|
|
232
|
+
// Now: by the time the stream ends the user may be elsewhere.
|
|
233
|
+
historyKey: store.getHistoryKey(),
|
|
234
|
+
}).catch((err) =>
|
|
235
|
+
console.error("[NavigationProvider] Error consuming handles:", err),
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
214
239
|
/**
|
|
215
240
|
* Props for NavigationProvider
|
|
216
241
|
*/
|
|
@@ -288,8 +313,11 @@ export interface NavigationProviderProps {
|
|
|
288
313
|
* `settled` resolves once the stream has ended and React has revealed every
|
|
289
314
|
* boundary. Until then location state is `undefined`, as SSR rendered it;
|
|
290
315
|
* then one transition gives the entry's state and changes
|
|
291
|
-
* HydrationBarrierContext, and the store is released after that commit
|
|
292
|
-
*
|
|
316
|
+
* HydrationBarrierContext, and the store is released after that commit
|
|
317
|
+
* (handle data lands in that release notification). A navigation committed
|
|
318
|
+
* earlier releases the store before its update, for the whole hold when the
|
|
319
|
+
* navigation is held: an old-page boundary hydrating meanwhile reads
|
|
320
|
+
* the destination's values and can mismatch.
|
|
293
321
|
* Absent for a provider mounted on the client.
|
|
294
322
|
*/
|
|
295
323
|
hydration?: { settled: Promise<void> };
|
|
@@ -368,6 +396,11 @@ export function NavigationProvider({
|
|
|
368
396
|
}
|
|
369
397
|
const shellRef = appShellRef ?? fallbackShellRef.current;
|
|
370
398
|
|
|
399
|
+
// Fixed for the document: read once on mount.
|
|
400
|
+
const [origin] = useState(
|
|
401
|
+
() => (eventController.getState().location as URL | undefined)?.origin,
|
|
402
|
+
);
|
|
403
|
+
|
|
371
404
|
const contextValue = useMemo<NavigationStoreContextValue>(() => {
|
|
372
405
|
const value = {
|
|
373
406
|
store,
|
|
@@ -375,8 +408,7 @@ export function NavigationProvider({
|
|
|
375
408
|
navigate,
|
|
376
409
|
refresh,
|
|
377
410
|
defaultPrefetch: initialPayload.metadata.defaultPrefetch,
|
|
378
|
-
|
|
379
|
-
origin: (eventController.getState().location as URL | undefined)?.origin,
|
|
411
|
+
origin,
|
|
380
412
|
} as NavigationStoreContextValue;
|
|
381
413
|
Object.defineProperty(value, "basename", {
|
|
382
414
|
configurable: true,
|
|
@@ -456,7 +488,7 @@ export function NavigationProvider({
|
|
|
456
488
|
return unsub;
|
|
457
489
|
}, [eventController]);
|
|
458
490
|
|
|
459
|
-
//
|
|
491
|
+
// The last navigation's scroll decision, until React commits its update.
|
|
460
492
|
const pendingScrollRef = useRef<NavigationUpdate["scroll"]>(undefined);
|
|
461
493
|
|
|
462
494
|
// Apply scroll after React commits the new content to the DOM
|
|
@@ -478,11 +510,14 @@ export function NavigationProvider({
|
|
|
478
510
|
useEffect(() => {
|
|
479
511
|
const unsubscribe = store.onUpdate((update) => {
|
|
480
512
|
if (process.env.NODE_ENV !== "production") auditTreeUpdate(update.root);
|
|
481
|
-
//
|
|
482
|
-
//
|
|
483
|
-
//
|
|
484
|
-
//
|
|
485
|
-
|
|
513
|
+
// Scroll belongs to the navigation transaction: only an update that
|
|
514
|
+
// carries one's decision sets the pending action, and the layout effect
|
|
515
|
+
// above consumes it once, after the commit that follows. An update with
|
|
516
|
+
// no decision (server action, error, prefetch: false fill) leaves a
|
|
517
|
+
// pending one alone. React can hold a navigation's commit, and whatever
|
|
518
|
+
// lands meanwhile must not cost the navigation its scroll. Nothing
|
|
519
|
+
// stale is replayed: every update commits, and that commit consumes.
|
|
520
|
+
if (update.scroll) pendingScrollRef.current = update.scroll;
|
|
486
521
|
|
|
487
522
|
// Committed before the document finished hydrating: end the window
|
|
488
523
|
// first, so the page this update mounts reads the live store. A
|
|
@@ -494,10 +529,7 @@ export function NavigationProvider({
|
|
|
494
529
|
eventController.releaseHydrationLock();
|
|
495
530
|
}
|
|
496
531
|
|
|
497
|
-
setPayload({
|
|
498
|
-
root: update.root,
|
|
499
|
-
metadata: update.metadata,
|
|
500
|
-
});
|
|
532
|
+
setPayload({ root: update.root, metadata: update.metadata });
|
|
501
533
|
// The state of the entry this payload renders, as its commit recorded
|
|
502
534
|
// it: one update, so one lane, so one React commit with the tree.
|
|
503
535
|
setLocationState(eventController.getLocationState());
|
|
@@ -511,20 +543,7 @@ export function NavigationProvider({
|
|
|
511
543
|
|
|
512
544
|
// Update handle data progressively as it streams in
|
|
513
545
|
if (update.metadata.handles) {
|
|
514
|
-
|
|
515
|
-
// the user might have navigated elsewhere
|
|
516
|
-
const historyKey = store.getHistoryKey();
|
|
517
|
-
|
|
518
|
-
processHandles(update.metadata.handles, {
|
|
519
|
-
eventController,
|
|
520
|
-
store,
|
|
521
|
-
matched: update.metadata.matched,
|
|
522
|
-
isPartial: update.metadata.isPartial,
|
|
523
|
-
resolvedIds: update.metadata.resolvedIds,
|
|
524
|
-
historyKey,
|
|
525
|
-
}).catch((err) =>
|
|
526
|
-
console.error("[NavigationProvider] Error consuming handles:", err),
|
|
527
|
-
);
|
|
546
|
+
consumeHandles(update.metadata, eventController, store);
|
|
528
547
|
} else if (update.metadata.matched) {
|
|
529
548
|
// cachedHandleData present -> full restore (back/forward); absent ->
|
|
530
549
|
// partial cleanup of segments no longer matched.
|
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A loader sub-id carries a `D${index}.` marker ("M0L0D1.user"). Non-global,
|
|
3
|
+
* so `.test` and `.replace` are stateless.
|
|
4
|
+
*/
|
|
5
|
+
export const LOADER_SEGMENT_ID: RegExp = /D\d+\./;
|
|
6
|
+
|
|
7
|
+
/** True for a loader sub-id ("M0L0D1.user"), false for a layout/route/slot id. */
|
|
8
|
+
export function isLoaderSegmentId(id: string): boolean {
|
|
9
|
+
return LOADER_SEGMENT_ID.test(id);
|
|
10
|
+
}
|
|
11
|
+
|
|
1
12
|
/**
|
|
2
13
|
* Build the handle-collection segment order from a raw `matched` list.
|
|
3
14
|
*
|
|
@@ -24,7 +35,7 @@ export function filterSegmentOrder(matched: string[]): string[] {
|
|
|
24
35
|
const nonSlotSet = new Set<string>();
|
|
25
36
|
|
|
26
37
|
for (const id of matched) {
|
|
27
|
-
if (
|
|
38
|
+
if (isLoaderSegmentId(id)) continue;
|
|
28
39
|
const slotIdx = id.indexOf(".@");
|
|
29
40
|
if (slotIdx >= 0) {
|
|
30
41
|
const parent = id.slice(0, slotIdx);
|
|
@@ -66,5 +77,5 @@ export function filterSegmentOrder(matched: string[]): string[] {
|
|
|
66
77
|
* and React reports a hydration mismatch.
|
|
67
78
|
*/
|
|
68
79
|
export function filterRouteSegmentIds(matched: string[]): string[] {
|
|
69
|
-
return matched.filter((id) => !id.includes(".@") &&
|
|
80
|
+
return matched.filter((id) => !id.includes(".@") && !isLoaderSegmentId(id));
|
|
70
81
|
}
|
|
@@ -26,6 +26,26 @@ export function extractRscHeaderUrl(
|
|
|
26
26
|
return url ? { url } : "blocked";
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Run `request` with a signal that `signal` aborts until the request settles,
|
|
31
|
+
* and only until then. A response that has arrived streams to its end:
|
|
32
|
+
* aborting a Flight body mid-read rejects every chunk a mounted tree may be
|
|
33
|
+
* reading. Aborted, the request rejects with `signal.reason`.
|
|
34
|
+
*/
|
|
35
|
+
export function abortUntilResponse<T>(
|
|
36
|
+
signal: AbortSignal | undefined,
|
|
37
|
+
request: (signal: AbortSignal | undefined) => Promise<T>,
|
|
38
|
+
): Promise<T> {
|
|
39
|
+
if (!signal) return request(undefined);
|
|
40
|
+
const controller = new AbortController();
|
|
41
|
+
const cancel = (): void => controller.abort(signal.reason);
|
|
42
|
+
if (signal.aborted) cancel();
|
|
43
|
+
else signal.addEventListener("abort", cancel, { once: true });
|
|
44
|
+
return request(controller.signal).finally(() =>
|
|
45
|
+
signal.removeEventListener("abort", cancel),
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
29
49
|
/**
|
|
30
50
|
* Empty 200 response that won't choke Flight parsing.
|
|
31
51
|
* Used when a header URL is blocked by origin validation.
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
import { createEventController } from "./event-controller.js";
|
|
12
12
|
import { resolveDeferredHandleValues } from "../handles/deferred-resolution.js";
|
|
13
13
|
import { validateRedirectOrigin } from "./validate-redirect-origin.js";
|
|
14
|
+
import { awaitDocumentRevealed } from "./document-revealed.js";
|
|
14
15
|
import { createNavigationClient } from "./navigation-client.js";
|
|
15
16
|
import { createServerActionBridge } from "./server-action-bridge.js";
|
|
16
17
|
import { createNavigationBridge } from "./navigation-bridge.js";
|
|
@@ -331,14 +332,23 @@ export async function initBrowserApp(
|
|
|
331
332
|
// deferred values here exactly like the nav lane's processHandles
|
|
332
333
|
// does before applying.
|
|
333
334
|
const lateData = await resolveDeferredHandleValues(rawLateData);
|
|
334
|
-
|
|
335
|
+
// Live store only while the document's page is on screen: a full
|
|
336
|
+
// replace with its `matched` would overwrite another page's handles.
|
|
337
|
+
const pageKeys = store.getPageHistoryKeys(initialHistoryKey);
|
|
338
|
+
const onScreen = pageKeys.includes(store.getHistoryKey());
|
|
339
|
+
bootLog("handles: late push", {
|
|
335
340
|
segments: Object.keys(lateData),
|
|
341
|
+
onScreen,
|
|
336
342
|
});
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
343
|
+
if (onScreen) {
|
|
344
|
+
eventController.setHandleData(
|
|
345
|
+
lateData,
|
|
346
|
+
initialPayload.metadata?.matched,
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
for (const key of pageKeys) {
|
|
350
|
+
store.updateCacheHandleData(key, lateData);
|
|
351
|
+
}
|
|
342
352
|
}
|
|
343
353
|
} catch (err) {
|
|
344
354
|
console.error("[rsc-router] Error consuming late handles:", err);
|
|
@@ -641,28 +651,7 @@ export async function initBrowserApp(
|
|
|
641
651
|
linkInterceptionEnabled: linkInterception,
|
|
642
652
|
version,
|
|
643
653
|
appShellRef,
|
|
644
|
-
documentRevealed:
|
|
645
|
-
// Parsing ends with the stream, but React reveals a streamed boundary
|
|
646
|
-
// in batches up to 300 ms later (Fizz's $RC marks it `$~` and reveals
|
|
647
|
-
// it from a timer), so the boundary is still pending at
|
|
648
|
-
// DOMContentLoaded. A transition then would make React client-render
|
|
649
|
-
// it over its server HTML.
|
|
650
|
-
const start = (): void => {
|
|
651
|
-
// A reveal rewrites the same comment's data, or React removes the
|
|
652
|
-
// node; a boundary nested in revealed content joins the document
|
|
653
|
-
// only then, so resolve only once a full walk finds none.
|
|
654
|
-
let pending = unrevealedBoundaries();
|
|
655
|
-
const check = (): void => {
|
|
656
|
-
pending = pending.filter(isUnrevealed);
|
|
657
|
-
if (pending.length === 0) pending = unrevealedBoundaries();
|
|
658
|
-
if (pending.length > 0) requestAnimationFrame(check);
|
|
659
|
-
else resolve();
|
|
660
|
-
};
|
|
661
|
-
check();
|
|
662
|
-
};
|
|
663
|
-
if (document.readyState !== "loading") start();
|
|
664
|
-
else document.addEventListener("DOMContentLoaded", start, { once: true });
|
|
665
|
-
}),
|
|
654
|
+
documentRevealed: awaitDocumentRevealed(),
|
|
666
655
|
};
|
|
667
656
|
browserAppContext = context;
|
|
668
657
|
|
|
@@ -670,23 +659,6 @@ export async function initBrowserApp(
|
|
|
670
659
|
return context;
|
|
671
660
|
}
|
|
672
661
|
|
|
673
|
-
/**
|
|
674
|
-
* A Suspense boundary of the document React has not revealed yet: Fizz marks
|
|
675
|
-
* one `$?` while its content streams and `$~` once its reveal is queued.
|
|
676
|
-
*/
|
|
677
|
-
function isUnrevealed(node: Comment): boolean {
|
|
678
|
-
return node.isConnected && (node.data === "$?" || node.data === "$~");
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
function unrevealedBoundaries(): Comment[] {
|
|
682
|
-
const found: Comment[] = [];
|
|
683
|
-
const walker = document.createTreeWalker(document, NodeFilter.SHOW_COMMENT);
|
|
684
|
-
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
685
|
-
if (isUnrevealed(node as Comment)) found.push(node as Comment);
|
|
686
|
-
}
|
|
687
|
-
return found;
|
|
688
|
-
}
|
|
689
|
-
|
|
690
662
|
// Once-flag so the hydration-commit boot log fires a single time (StrictMode
|
|
691
663
|
// re-runs the root effect; the second flush is not a second hydration).
|
|
692
664
|
let hydrationCommitLogged = false;
|
|
@@ -26,8 +26,8 @@ import {
|
|
|
26
26
|
startBrowserTransaction,
|
|
27
27
|
} from "./logging.js";
|
|
28
28
|
import {
|
|
29
|
+
validatePayloadRedirect,
|
|
29
30
|
validateRedirectOrigin,
|
|
30
|
-
validateExternalRedirect,
|
|
31
31
|
} from "./validate-redirect-origin.js";
|
|
32
32
|
import {
|
|
33
33
|
extractRscHeaderUrl,
|
|
@@ -40,13 +40,11 @@ import { mergeLocationState } from "./history-state.js";
|
|
|
40
40
|
import { stampLocationState } from "./react/location-state-shared.js";
|
|
41
41
|
import { classifyActionOutcome } from "./action-coordinator.js";
|
|
42
42
|
import { getAppVersion } from "./app-version.js";
|
|
43
|
-
import { collectClientRevalidationDecisions } from "../client-urls/navigation.js";
|
|
44
43
|
import {
|
|
45
44
|
decideCommitGatedOff,
|
|
46
45
|
findActionFormData,
|
|
47
46
|
type TransitionWhenActionInput,
|
|
48
47
|
} from "./transition-when.js";
|
|
49
|
-
import { CLIENT_REVALIDATION_HEADER } from "../client-urls/revalidation-protocol.js";
|
|
50
48
|
import { auditTreeCause } from "../suspense-audit.js";
|
|
51
49
|
|
|
52
50
|
// Polyfill Symbol.dispose/asyncDispose for Safari and older browsers
|
|
@@ -335,39 +333,12 @@ export function createServerActionBridge(
|
|
|
335
333
|
const onHandleAbort = () => fetchAbort.abort();
|
|
336
334
|
handle.signal.addEventListener("abort", onHandleAbort, { once: true });
|
|
337
335
|
|
|
338
|
-
// Client-run per-loader revalidation for the action follow-up render:
|
|
339
|
-
// run the held clientUrls route's revalidate() predicates with the
|
|
340
|
-
// action context and ship their decisions. Fails soft to null (locked
|
|
341
|
-
// server defaults).
|
|
342
|
-
let clientRevalidation: string | null = null;
|
|
343
|
-
try {
|
|
344
|
-
const actionPageUrl = new URL(
|
|
345
|
-
segmentState.currentUrl,
|
|
346
|
-
window.location.origin,
|
|
347
|
-
);
|
|
348
|
-
clientRevalidation = collectClientRevalidationDecisions({
|
|
349
|
-
currentUrl: actionPageUrl,
|
|
350
|
-
nextUrl: actionPageUrl,
|
|
351
|
-
// Decisions ride the action POST itself — the server evaluates it
|
|
352
|
-
// with actionContext, so the locked default is the action default.
|
|
353
|
-
actionRequest: true,
|
|
354
|
-
isAction: true,
|
|
355
|
-
actionId: id,
|
|
356
|
-
stale: false,
|
|
357
|
-
});
|
|
358
|
-
} catch {
|
|
359
|
-
clientRevalidation = null;
|
|
360
|
-
}
|
|
361
|
-
|
|
362
336
|
// Send action request with stream tracking
|
|
363
337
|
const responsePromise = fetch(url, {
|
|
364
338
|
method: "POST",
|
|
365
339
|
headers: {
|
|
366
340
|
"rsc-action": id,
|
|
367
341
|
"X-RSC-Router-Client-Path": segmentState.currentUrl,
|
|
368
|
-
...(clientRevalidation && {
|
|
369
|
-
[CLIENT_REVALIDATION_HEADER]: clientRevalidation,
|
|
370
|
-
}),
|
|
371
342
|
...(tx && { "X-RSC-Router-Request-Id": tx.requestId }),
|
|
372
343
|
...(interceptSourceUrl && {
|
|
373
344
|
"X-RSC-Router-Intercept-Source": interceptSourceUrl,
|
|
@@ -518,41 +489,26 @@ export function createServerActionBridge(
|
|
|
518
489
|
// Check handle.signal.aborted to avoid redirecting from a stale action
|
|
519
490
|
// when the user has already navigated away.
|
|
520
491
|
if (metadata?.redirect && !handle.signal.aborted) {
|
|
521
|
-
//
|
|
522
|
-
//
|
|
523
|
-
//
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
492
|
+
// An explicit off-host redirect is a hard navigation, still
|
|
493
|
+
// scheme-validated: a forged payload carrying a javascript:/data: URL
|
|
494
|
+
// cannot script via location.assign (validatePayloadRedirect).
|
|
495
|
+
const { redirect } = metadata;
|
|
496
|
+
const target = validatePayloadRedirect(redirect);
|
|
497
|
+
handle.complete(returnValue?.data);
|
|
498
|
+
if (!target) {
|
|
499
|
+
log(
|
|
500
|
+
redirect.external
|
|
501
|
+
? "blocked external action redirect payload"
|
|
502
|
+
: "blocked action redirect payload",
|
|
503
|
+
{ url: redirect.url },
|
|
529
504
|
);
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
}
|
|
537
|
-
log("external action redirect", { url: externalUrl });
|
|
538
|
-
handle.complete(returnValue?.data);
|
|
539
|
-
window.location.assign(externalUrl);
|
|
540
|
-
return returnValue?.data;
|
|
505
|
+
} else if (redirect.external) {
|
|
506
|
+
log("external action redirect", { url: target });
|
|
507
|
+
window.location.assign(target);
|
|
508
|
+
} else {
|
|
509
|
+
log("action redirect", { url: target });
|
|
510
|
+
await dispatchRedirect(target, metadata.locationState);
|
|
541
511
|
}
|
|
542
|
-
const redirectUrl = validateRedirectOrigin(
|
|
543
|
-
metadata.redirect.url,
|
|
544
|
-
window.location.origin,
|
|
545
|
-
);
|
|
546
|
-
if (!redirectUrl) {
|
|
547
|
-
log("blocked action redirect payload", {
|
|
548
|
-
url: metadata.redirect.url,
|
|
549
|
-
});
|
|
550
|
-
handle.complete(returnValue?.data);
|
|
551
|
-
return returnValue?.data;
|
|
552
|
-
}
|
|
553
|
-
log("action redirect", { url: redirectUrl });
|
|
554
|
-
handle.complete(returnValue?.data);
|
|
555
|
-
await dispatchRedirect(redirectUrl, metadata.locationState);
|
|
556
512
|
return returnValue?.data;
|
|
557
513
|
}
|
|
558
514
|
|
package/src/browser/types.ts
CHANGED
|
@@ -308,7 +308,12 @@ export interface HistoryEntryMemory {
|
|
|
308
308
|
export interface NavigationUpdate {
|
|
309
309
|
root: ReactNode | Promise<ReactNode>;
|
|
310
310
|
metadata: RscMetadata;
|
|
311
|
-
/**
|
|
311
|
+
/**
|
|
312
|
+
* The scroll decision of the navigation transaction this update commits,
|
|
313
|
+
* applied after React commits it. Absent on every update that is not a
|
|
314
|
+
* navigation's (server action, error, `prefetch: false` fill): such an
|
|
315
|
+
* update says nothing about scroll, and leaves a pending decision alone.
|
|
316
|
+
*/
|
|
312
317
|
scroll?: {
|
|
313
318
|
/** For back/forward: restore saved position */
|
|
314
319
|
restore?: boolean;
|
|
@@ -637,6 +642,12 @@ export interface NavigationStore {
|
|
|
637
642
|
stale?: boolean,
|
|
638
643
|
handlesPending?: boolean,
|
|
639
644
|
): void;
|
|
645
|
+
/**
|
|
646
|
+
* The history keys whose entries show the page `historyKey`'s entry shows:
|
|
647
|
+
* that entry and the copies a shallow navigation made of it, which share its
|
|
648
|
+
* segments array. `[historyKey]` when it has no entry.
|
|
649
|
+
*/
|
|
650
|
+
getPageHistoryKeys(historyKey: string): string[];
|
|
640
651
|
markHistoryCacheStale(): void;
|
|
641
652
|
markCacheAsStaleAndBroadcast(): void;
|
|
642
653
|
clearHistoryCache(): void;
|
|
@@ -671,13 +682,6 @@ export interface FetchPartialOptions {
|
|
|
671
682
|
signal?: AbortSignal;
|
|
672
683
|
/** If true, this is a stale cache revalidation request - server should force revalidators */
|
|
673
684
|
staleRevalidation?: boolean;
|
|
674
|
-
/**
|
|
675
|
-
* Encoded client-run per-loader revalidation decisions
|
|
676
|
-
* (clientUrls revalidate() predicates executed in the browser); sent as
|
|
677
|
-
* X-Rango-Client-Reval and honored only by materialized client-urls loader
|
|
678
|
-
* stubs. Null/absent = locked server defaults.
|
|
679
|
-
*/
|
|
680
|
-
clientRevalidation?: string | null;
|
|
681
685
|
interceptSourceUrl?: string;
|
|
682
686
|
/** RSC version for cache invalidation detection */
|
|
683
687
|
version?: string;
|
|
@@ -685,6 +689,12 @@ export interface FetchPartialOptions {
|
|
|
685
689
|
routerId?: string;
|
|
686
690
|
/** If true, this is an HMR refetch - server should invalidate manifest cache */
|
|
687
691
|
hmr?: boolean;
|
|
692
|
+
/**
|
|
693
|
+
* The fill request of an adopted prefetch that carried deferred segments
|
|
694
|
+
* (`prefetch: false`): marked `_rsc_fill`, never answered from or written
|
|
695
|
+
* to a prefetch cache or the HTTP cache, and it cancels no prefetch.
|
|
696
|
+
*/
|
|
697
|
+
fill?: boolean;
|
|
688
698
|
}
|
|
689
699
|
|
|
690
700
|
/**
|
|
@@ -785,7 +795,7 @@ export type { ResolvedSegment };
|
|
|
785
795
|
|
|
786
796
|
/**
|
|
787
797
|
* Token for tracking an active stream.
|
|
788
|
-
* Call end() when the stream completes.
|
|
798
|
+
* Call end() when the stream completes. Only the first call counts.
|
|
789
799
|
*/
|
|
790
800
|
export interface StreamingToken {
|
|
791
801
|
end(): void;
|
|
@@ -54,3 +54,19 @@ export function validateExternalRedirect(
|
|
|
54
54
|
}
|
|
55
55
|
return resolved;
|
|
56
56
|
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The target of a redirect a Flight payload carries (`metadata.redirect`), or
|
|
60
|
+
* null when the client refuses it. An explicit off-host one
|
|
61
|
+
* (`redirect(url, { external: true })`) is scheme-validated only:
|
|
62
|
+
* `external` waives the same-origin check the app opted out of, not scheme
|
|
63
|
+
* safety. Every other one must stay on this origin.
|
|
64
|
+
*/
|
|
65
|
+
export function validatePayloadRedirect(redirect: {
|
|
66
|
+
url: string;
|
|
67
|
+
external?: boolean;
|
|
68
|
+
}): string | null {
|
|
69
|
+
return redirect.external
|
|
70
|
+
? validateExternalRedirect(redirect.url, window.location.origin)
|
|
71
|
+
: validateRedirectOrigin(redirect.url, window.location.origin);
|
|
72
|
+
}
|
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
} from "node:path";
|
|
15
15
|
import ts from "typescript";
|
|
16
16
|
import { generateRouteTypesSource } from "./codegen.js";
|
|
17
|
-
import type
|
|
17
|
+
import { isUniversallySkippedScanDir, type ScanFilter } from "./scan-filter.js";
|
|
18
18
|
import { firstCodeMatchIndex } from "./source-scan.js";
|
|
19
19
|
import {
|
|
20
20
|
resolveImportedVariable,
|
|
@@ -58,12 +58,9 @@ function isRoutableSourceFile(name: string): boolean {
|
|
|
58
58
|
|
|
59
59
|
function isExcludedScanDir(name: string): boolean {
|
|
60
60
|
return (
|
|
61
|
-
name
|
|
62
|
-
name === "dist" ||
|
|
63
|
-
name === "coverage" ||
|
|
61
|
+
isUniversallySkippedScanDir(name) ||
|
|
64
62
|
name === "__tests__" ||
|
|
65
|
-
name === "__mocks__"
|
|
66
|
-
name.startsWith(".")
|
|
63
|
+
name === "__mocks__"
|
|
67
64
|
);
|
|
68
65
|
}
|
|
69
66
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { join, relative } from "node:path";
|
|
1
|
+
import { isAbsolute, join, relative } from "node:path";
|
|
2
2
|
import { readdirSync } from "node:fs";
|
|
3
3
|
// @ts-ignore -- picomatch ships no .d.ts; types are trivial
|
|
4
4
|
import picomatch from "picomatch";
|
|
@@ -44,8 +44,40 @@ export function createScanFilter(
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
/**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
47
|
+
* Directory names no discovery scan enters: findTsFiles() and the router scans
|
|
48
|
+
* (findRouterFiles/findHostRouterFiles, router-processing.ts) all build their
|
|
49
|
+
* skip rule on this, so a file below one is never discovered. Dot-directories
|
|
50
|
+
* cover a nested git worktree, a tool cache, or a test's working copy of
|
|
51
|
+
* another app.
|
|
52
|
+
*/
|
|
53
|
+
export function isUniversallySkippedScanDir(name: string): boolean {
|
|
54
|
+
return (
|
|
55
|
+
name === "node_modules" ||
|
|
56
|
+
name === "dist" ||
|
|
57
|
+
name === "coverage" ||
|
|
58
|
+
name.startsWith(".")
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Whether a file under `root` sits in a directory every discovery scan skips
|
|
64
|
+
* (isUniversallySkippedScanDir). The dev route-file watcher uses it to ignore
|
|
65
|
+
* files discovery would never find. A path outside `root` is false.
|
|
66
|
+
*/
|
|
67
|
+
export function isInSkippedScanDir(
|
|
68
|
+
root: string,
|
|
69
|
+
absolutePath: string,
|
|
70
|
+
): boolean {
|
|
71
|
+
const rel = relative(root, absolutePath);
|
|
72
|
+
if (isAbsolute(rel)) return false;
|
|
73
|
+
const dirs = rel.split(/[\\/]/).slice(0, -1);
|
|
74
|
+
if (dirs[0] === "..") return false;
|
|
75
|
+
return dirs.some(isUniversallySkippedScanDir);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Recursively find .ts/.tsx files under a directory, skipping the directories
|
|
80
|
+
* isUniversallySkippedScanDir() names, `build`, and .gen. files.
|
|
49
81
|
*/
|
|
50
82
|
export function findTsFiles(dir: string, filter?: ScanFilter): string[] {
|
|
51
83
|
const results: string[] = [];
|
|
@@ -61,14 +93,9 @@ export function findTsFiles(dir: string, filter?: ScanFilter): string[] {
|
|
|
61
93
|
for (const entry of entries) {
|
|
62
94
|
const fullPath = join(dir, entry.name);
|
|
63
95
|
if (entry.isDirectory()) {
|
|
64
|
-
if (
|
|
65
|
-
entry.name === "node_modules" ||
|
|
66
|
-
entry.name.startsWith(".") ||
|
|
67
|
-
entry.name === "dist" ||
|
|
68
|
-
entry.name === "build" ||
|
|
69
|
-
entry.name === "coverage"
|
|
70
|
-
)
|
|
96
|
+
if (isUniversallySkippedScanDir(entry.name) || entry.name === "build") {
|
|
71
97
|
continue;
|
|
98
|
+
}
|
|
72
99
|
results.push(...findTsFiles(fullPath, filter));
|
|
73
100
|
} else if (
|
|
74
101
|
(entry.name.endsWith(".ts") ||
|