@rangojs/router 0.0.0-experimental.77 → 0.0.0-experimental.77ed8945
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 +120 -25
- package/dist/bin/rango.js +147 -57
- package/dist/vite/index.js +2103 -861
- package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
- package/package.json +13 -8
- package/skills/api-client/SKILL.md +211 -0
- package/skills/breadcrumbs/SKILL.md +3 -1
- package/skills/bundle-analysis/SKILL.md +159 -0
- package/skills/cache-guide/SKILL.md +220 -30
- package/skills/caching/SKILL.md +116 -8
- package/skills/composability/SKILL.md +27 -2
- package/skills/css/SKILL.md +76 -0
- package/skills/document-cache/SKILL.md +78 -55
- package/skills/handler-use/SKILL.md +3 -1
- package/skills/hooks/SKILL.md +229 -20
- package/skills/host-router/SKILL.md +66 -20
- package/skills/i18n/SKILL.md +276 -0
- package/skills/intercept/SKILL.md +26 -4
- package/skills/layout/SKILL.md +6 -7
- package/skills/links/SKILL.md +247 -17
- package/skills/loader/SKILL.md +219 -9
- package/skills/middleware/SKILL.md +47 -12
- package/skills/migrate-nextjs/SKILL.md +562 -0
- package/skills/migrate-react-router/SKILL.md +769 -0
- package/skills/mime-routes/SKILL.md +27 -0
- package/skills/observability/SKILL.md +137 -0
- package/skills/parallel/SKILL.md +12 -6
- package/skills/prerender/SKILL.md +14 -33
- package/skills/rango/SKILL.md +238 -22
- package/skills/react-compiler/SKILL.md +168 -0
- package/skills/response-routes/SKILL.md +122 -47
- package/skills/route/SKILL.md +33 -4
- package/skills/router-setup/SKILL.md +3 -3
- package/skills/server-actions/SKILL.md +751 -0
- package/skills/streams-and-websockets/SKILL.md +283 -0
- package/skills/tailwind/SKILL.md +27 -3
- package/skills/typesafety/SKILL.md +319 -27
- package/skills/use-cache/SKILL.md +34 -5
- package/skills/view-transitions/SKILL.md +294 -0
- package/src/__augment-tests__/augment.ts +81 -0
- package/src/__augment-tests__/augmented.check.ts +116 -0
- package/src/browser/action-coordinator.ts +53 -36
- package/src/browser/app-shell.ts +39 -0
- package/src/browser/event-controller.ts +86 -70
- package/src/browser/history-state.ts +21 -0
- package/src/browser/index.ts +3 -3
- package/src/browser/navigation-bridge.ts +29 -9
- package/src/browser/navigation-client.ts +99 -77
- package/src/browser/navigation-store.ts +7 -8
- package/src/browser/navigation-transaction.ts +10 -28
- package/src/browser/partial-update.ts +60 -40
- package/src/browser/prefetch/cache.ts +196 -49
- package/src/browser/prefetch/fetch.ts +203 -59
- package/src/browser/prefetch/queue.ts +36 -5
- package/src/browser/rango-state.ts +37 -13
- package/src/browser/react/Link.tsx +18 -13
- package/src/browser/react/NavigationProvider.tsx +75 -31
- package/src/browser/react/filter-segment-order.ts +51 -7
- package/src/browser/react/index.ts +3 -0
- package/src/browser/react/location-state-shared.ts +175 -4
- package/src/browser/react/location-state.ts +39 -13
- package/src/browser/react/use-handle.ts +17 -9
- package/src/browser/react/use-navigation.ts +22 -2
- package/src/browser/react/use-params.ts +20 -8
- package/src/browser/react/use-reverse.ts +106 -0
- package/src/browser/react/use-router.ts +23 -2
- package/src/browser/react/use-segments.ts +11 -8
- package/src/browser/response-adapter.ts +52 -1
- package/src/browser/rsc-router.tsx +71 -22
- package/src/browser/scroll-restoration.ts +22 -14
- package/src/browser/segment-reconciler.ts +10 -14
- package/src/browser/segment-structure-assert.ts +2 -2
- package/src/browser/server-action-bridge.ts +44 -30
- package/src/browser/types.ts +12 -2
- package/src/build/collect-fallback-refs.ts +107 -0
- package/src/build/generate-manifest.ts +60 -35
- package/src/build/generate-route-types.ts +2 -0
- package/src/build/index.ts +8 -1
- package/src/build/prefix-tree-utils.ts +123 -0
- package/src/build/route-trie.ts +45 -1
- package/src/build/route-types/codegen.ts +4 -4
- package/src/build/route-types/include-resolution.ts +1 -1
- package/src/build/route-types/per-module-writer.ts +7 -4
- package/src/build/route-types/router-processing.ts +55 -14
- package/src/build/route-types/scan-filter.ts +1 -1
- package/src/build/route-types/source-scan.ts +118 -0
- package/src/build/runtime-discovery.ts +9 -20
- package/src/cache/cache-runtime.ts +17 -5
- package/src/cache/cache-scope.ts +51 -49
- package/src/cache/cf/cf-cache-store.ts +502 -32
- package/src/cache/cf/index.ts +3 -0
- package/src/cache/handle-snapshot.ts +103 -0
- package/src/cache/index.ts +3 -0
- package/src/cache/memory-segment-store.ts +3 -2
- package/src/cache/types.ts +10 -6
- package/src/client.rsc.tsx +3 -0
- package/src/client.tsx +96 -205
- package/src/context-var.ts +5 -5
- package/src/decode-loader-results.ts +36 -0
- package/src/errors.ts +30 -4
- package/src/handle.ts +4 -6
- package/src/host/index.ts +2 -2
- package/src/host/router.ts +129 -57
- package/src/host/types.ts +31 -2
- package/src/host/utils.ts +1 -1
- package/src/href-client.ts +140 -21
- package/src/index.rsc.ts +10 -6
- package/src/index.ts +17 -8
- package/src/loader-store.ts +500 -0
- package/src/loader.rsc.ts +2 -5
- package/src/loader.ts +3 -10
- package/src/missing-id-error.ts +68 -0
- package/src/outlet-context.ts +1 -1
- package/src/prerender/store.ts +9 -7
- package/src/prerender.ts +4 -4
- package/src/response-utils.ts +37 -0
- package/src/reverse.ts +65 -39
- package/src/route-content-wrapper.tsx +6 -28
- package/src/route-definition/dsl-helpers.ts +253 -265
- package/src/route-definition/helper-factories.ts +29 -139
- package/src/route-definition/helpers-types.ts +43 -15
- package/src/route-definition/resolve-handler-use.ts +6 -0
- package/src/route-definition/use-item-types.ts +32 -0
- package/src/route-types.ts +26 -41
- package/src/router/content-negotiation.ts +15 -2
- package/src/router/error-handling.ts +1 -1
- package/src/router/find-match.ts +54 -6
- package/src/router/handler-context.ts +21 -41
- package/src/router/intercept-resolution.ts +4 -18
- package/src/router/lazy-includes.ts +41 -22
- package/src/router/loader-resolution.ts +82 -36
- package/src/router/manifest.ts +41 -19
- package/src/router/match-api.ts +4 -3
- package/src/router/match-handlers.ts +1 -0
- package/src/router/match-middleware/cache-lookup.ts +57 -95
- package/src/router/match-middleware/cache-store.ts +3 -2
- package/src/router/match-result.ts +53 -32
- package/src/router/metrics.ts +1 -1
- package/src/router/middleware-types.ts +15 -26
- package/src/router/middleware.ts +99 -84
- package/src/router/pattern-matching.ts +116 -19
- package/src/router/prerender-match.ts +40 -15
- package/src/router/preview-match.ts +3 -1
- package/src/router/request-classification.ts +40 -37
- package/src/router/revalidation.ts +58 -2
- package/src/router/router-interfaces.ts +51 -35
- package/src/router/router-options.ts +25 -1
- package/src/router/router-registry.ts +2 -5
- package/src/router/segment-resolution/fresh.ts +27 -6
- package/src/router/segment-resolution/revalidation.ts +147 -106
- package/src/router/segment-resolution/static-store.ts +19 -5
- package/src/router/segment-resolution/view-transition-default.ts +36 -0
- package/src/router/substitute-pattern-params.ts +56 -0
- package/src/router/trie-matching.ts +40 -16
- package/src/router/types.ts +8 -0
- package/src/router/url-params.ts +49 -0
- package/src/router.ts +37 -25
- package/src/rsc/handler-context.ts +2 -2
- package/src/rsc/handler.ts +58 -77
- package/src/rsc/helpers.ts +72 -43
- package/src/rsc/index.ts +1 -1
- package/src/rsc/manifest-init.ts +28 -41
- package/src/rsc/origin-guard.ts +30 -10
- package/src/rsc/progressive-enhancement.ts +4 -0
- package/src/rsc/response-error.ts +79 -12
- package/src/rsc/response-route-handler.ts +76 -61
- package/src/rsc/rsc-rendering.ts +45 -51
- package/src/rsc/runtime-warnings.ts +9 -10
- package/src/rsc/server-action.ts +33 -39
- package/src/rsc/ssr-setup.ts +16 -0
- package/src/rsc/types.ts +8 -2
- package/src/search-params.ts +4 -4
- package/src/segment-content-promise.ts +67 -0
- package/src/segment-loader-promise.ts +122 -0
- package/src/segment-system.tsx +132 -116
- package/src/serialize.ts +243 -0
- package/src/server/context.ts +175 -53
- package/src/server/cookie-store.ts +28 -4
- package/src/server/request-context.ts +57 -51
- package/src/ssr/index.tsx +5 -1
- package/src/static-handler.ts +1 -1
- package/src/types/global-namespace.ts +39 -26
- package/src/types/handler-context.ts +68 -50
- package/src/types/index.ts +1 -0
- package/src/types/loader-types.ts +11 -9
- package/src/types/request-scope.ts +126 -0
- package/src/types/route-entry.ts +11 -0
- package/src/types/segments.ts +35 -2
- package/src/urls/include-helper.ts +34 -67
- package/src/urls/index.ts +1 -5
- package/src/urls/path-helper-types.ts +17 -3
- package/src/urls/path-helper.ts +17 -52
- package/src/urls/pattern-types.ts +36 -19
- package/src/urls/response-types.ts +22 -29
- package/src/urls/type-extraction.ts +58 -139
- package/src/urls/urls-function.ts +1 -5
- package/src/use-loader.tsx +413 -42
- package/src/vite/debug.ts +185 -0
- package/src/vite/discovery/bundle-postprocess.ts +6 -6
- package/src/vite/discovery/discover-routers.ts +106 -75
- package/src/vite/discovery/discovery-errors.ts +194 -0
- package/src/vite/discovery/gate-state.ts +171 -0
- package/src/vite/discovery/prerender-collection.ts +72 -31
- package/src/vite/discovery/route-types-writer.ts +40 -84
- package/src/vite/discovery/self-gen-tracking.ts +27 -1
- package/src/vite/discovery/state.ts +33 -0
- package/src/vite/discovery/virtual-module-codegen.ts +13 -23
- package/src/vite/index.ts +2 -0
- package/src/vite/plugin-types.ts +67 -0
- package/src/vite/plugins/cjs-to-esm.ts +8 -7
- package/src/vite/plugins/client-ref-dedup.ts +16 -0
- package/src/vite/plugins/client-ref-hashing.ts +28 -5
- package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
- package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
- package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
- package/src/vite/plugins/expose-action-id.ts +54 -30
- package/src/vite/plugins/expose-id-utils.ts +12 -8
- package/src/vite/plugins/expose-ids/export-analysis.ts +100 -20
- package/src/vite/plugins/expose-ids/handler-transform.ts +8 -61
- package/src/vite/plugins/expose-ids/loader-transform.ts +3 -5
- package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
- package/src/vite/plugins/expose-internal-ids.ts +496 -486
- package/src/vite/plugins/performance-tracks.ts +29 -25
- package/src/vite/plugins/use-cache-transform.ts +65 -50
- package/src/vite/plugins/version-injector.ts +39 -23
- package/src/vite/plugins/version-plugin.ts +59 -2
- package/src/vite/plugins/virtual-entries.ts +2 -2
- package/src/vite/rango.ts +116 -29
- package/src/vite/router-discovery.ts +753 -104
- package/src/vite/utils/ast-handler-extract.ts +15 -15
- package/src/vite/utils/banner.ts +1 -1
- package/src/vite/utils/bundle-analysis.ts +4 -2
- package/src/vite/utils/client-chunks.ts +190 -0
- package/src/vite/utils/forward-user-plugins.ts +193 -0
- package/src/vite/utils/manifest-utils.ts +8 -59
- package/src/vite/utils/package-resolution.ts +41 -1
- package/src/vite/utils/prerender-utils.ts +5 -4
- package/src/vite/utils/shared-utils.ts +107 -26
- package/src/browser/action-response-classifier.ts +0 -99
|
@@ -113,11 +113,24 @@ export type ActionStateListener = (state: TrackedActionState) => void;
|
|
|
113
113
|
export type HandleListener = () => void;
|
|
114
114
|
|
|
115
115
|
/**
|
|
116
|
-
* Internal handle state stored in controller
|
|
116
|
+
* Internal handle state stored in controller.
|
|
117
|
+
*
|
|
118
|
+
* Two segment lists are exposed because they serve different consumers:
|
|
119
|
+
*
|
|
120
|
+
* - `segmentOrder` drives handle collection (collectHandleData). Includes
|
|
121
|
+
* parallel slot ids and reorders them after their parent so later-wins
|
|
122
|
+
* collect functions (e.g. Meta) get the right precedence.
|
|
123
|
+
* - `routeSegmentIds` is the layouts-and-routes-only list documented by
|
|
124
|
+
* `useSegments().segmentIds`. Parallels and loader sub-ids are stripped;
|
|
125
|
+
* raw matched order is preserved.
|
|
126
|
+
*
|
|
127
|
+
* Both are derived from the same `matched` input on each setHandleData call
|
|
128
|
+
* so they stay in sync.
|
|
117
129
|
*/
|
|
118
130
|
export interface HandleState {
|
|
119
131
|
data: HandleData;
|
|
120
132
|
segmentOrder: string[];
|
|
133
|
+
routeSegmentIds: string[];
|
|
121
134
|
}
|
|
122
135
|
|
|
123
136
|
/**
|
|
@@ -202,6 +215,14 @@ export interface EventController {
|
|
|
202
215
|
data: HandleData,
|
|
203
216
|
matched?: string[],
|
|
204
217
|
isPartial?: boolean,
|
|
218
|
+
/**
|
|
219
|
+
* Segment ids that were re-resolved on the server this request (the
|
|
220
|
+
* partial response's `diff`). On a partial update, any existing bucket
|
|
221
|
+
* keyed under one of these ids that has no incoming entry is treated as
|
|
222
|
+
* stale and cleared. Without this, a parallel slot that revalidates but
|
|
223
|
+
* pushes nothing leaves its previous bucket in place forever.
|
|
224
|
+
*/
|
|
225
|
+
resolvedIds?: string[],
|
|
205
226
|
): void;
|
|
206
227
|
getHandleState(): HandleState;
|
|
207
228
|
|
|
@@ -247,6 +268,20 @@ function matchesActionId(
|
|
|
247
268
|
return entryActionId.endsWith(`#${subscriptionId}`);
|
|
248
269
|
}
|
|
249
270
|
|
|
271
|
+
// Coalesce rapid notifications into one microtask-deferred fan-out; the
|
|
272
|
+
// setTimeout(0) batching prevents render storms. Each notifier owns its timer
|
|
273
|
+
// so listener kinds coalesce independently.
|
|
274
|
+
function makeDebouncedNotifier(listeners: Set<() => void>): () => void {
|
|
275
|
+
let timeout: ReturnType<typeof setTimeout> | null = null;
|
|
276
|
+
return () => {
|
|
277
|
+
if (timeout !== null) clearTimeout(timeout);
|
|
278
|
+
timeout = setTimeout(() => {
|
|
279
|
+
timeout = null;
|
|
280
|
+
listeners.forEach((listener) => listener());
|
|
281
|
+
}, 0);
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
250
285
|
// ============================================================================
|
|
251
286
|
// Implementation
|
|
252
287
|
// ============================================================================
|
|
@@ -300,6 +335,7 @@ export function createEventController(
|
|
|
300
335
|
// Handle data from RSC payload
|
|
301
336
|
let handleData: HandleData = {};
|
|
302
337
|
let handleSegmentOrder: string[] = [];
|
|
338
|
+
let routeSegmentIds: string[] = [];
|
|
303
339
|
|
|
304
340
|
// Merged route params from current match
|
|
305
341
|
let routeParams: Record<string, string> = {};
|
|
@@ -312,18 +348,7 @@ export function createEventController(
|
|
|
312
348
|
const actionListeners = new Map<string, Set<ActionStateListener>>();
|
|
313
349
|
const handleListeners = new Set<HandleListener>();
|
|
314
350
|
|
|
315
|
-
|
|
316
|
-
let notifyTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
317
|
-
|
|
318
|
-
function notify() {
|
|
319
|
-
if (notifyTimeout !== null) {
|
|
320
|
-
clearTimeout(notifyTimeout);
|
|
321
|
-
}
|
|
322
|
-
notifyTimeout = setTimeout(() => {
|
|
323
|
-
notifyTimeout = null;
|
|
324
|
-
stateListeners.forEach((listener) => listener());
|
|
325
|
-
}, 0);
|
|
326
|
-
}
|
|
351
|
+
const notify = makeDebouncedNotifier(stateListeners);
|
|
327
352
|
|
|
328
353
|
// Debounce per-action notifications
|
|
329
354
|
const actionNotifyTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
|
@@ -349,18 +374,7 @@ export function createEventController(
|
|
|
349
374
|
);
|
|
350
375
|
}
|
|
351
376
|
|
|
352
|
-
|
|
353
|
-
let handleNotifyTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
354
|
-
|
|
355
|
-
function notifyHandles() {
|
|
356
|
-
if (handleNotifyTimeout !== null) {
|
|
357
|
-
clearTimeout(handleNotifyTimeout);
|
|
358
|
-
}
|
|
359
|
-
handleNotifyTimeout = setTimeout(() => {
|
|
360
|
-
handleNotifyTimeout = null;
|
|
361
|
-
handleListeners.forEach((listener) => listener());
|
|
362
|
-
}, 0);
|
|
363
|
-
}
|
|
377
|
+
const notifyHandles = makeDebouncedNotifier(handleListeners);
|
|
364
378
|
|
|
365
379
|
// ========================================================================
|
|
366
380
|
// Derived State
|
|
@@ -407,22 +421,17 @@ export function createEventController(
|
|
|
407
421
|
}
|
|
408
422
|
|
|
409
423
|
function getActionState(actionId: string): TrackedActionState {
|
|
410
|
-
//
|
|
411
|
-
//
|
|
412
|
-
const
|
|
413
|
-
.filter(
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
(a) => matchesActionId(actionId, a.actionId) && a.phase === "settling",
|
|
422
|
-
)
|
|
423
|
-
.sort((a, b) => b.startedAt - a.startedAt)[0];
|
|
424
|
-
|
|
425
|
-
const entry = activeEntry || settlingEntry;
|
|
424
|
+
// Prefer the most-recent non-settling entry; fall back to most-recent
|
|
425
|
+
// settling so a just-settled action's result/error stays readable.
|
|
426
|
+
const entry = [...inflightActions.values()]
|
|
427
|
+
.filter((a) => matchesActionId(actionId, a.actionId))
|
|
428
|
+
.reduce<ActionEntry | undefined>((best, a) => {
|
|
429
|
+
if (!best) return a;
|
|
430
|
+
const aActive = a.phase !== "settling";
|
|
431
|
+
const bActive = best.phase !== "settling";
|
|
432
|
+
if (aActive !== bActive) return aActive ? a : best;
|
|
433
|
+
return a.startedAt > best.startedAt ? a : best;
|
|
434
|
+
}, undefined);
|
|
426
435
|
|
|
427
436
|
if (!entry) {
|
|
428
437
|
return { ...DEFAULT_ACTION_STATE };
|
|
@@ -610,6 +619,19 @@ export function createEventController(
|
|
|
610
619
|
doSettle();
|
|
611
620
|
}
|
|
612
621
|
|
|
622
|
+
// streamingEnded is forced here for the "streaming never started" case so
|
|
623
|
+
// tryFinalize can run; otherwise the streaming token's end() finalizes.
|
|
624
|
+
function settleWith(result: NonNullable<typeof pendingResult>) {
|
|
625
|
+
if (!inflightActions.has(id) || settled) return;
|
|
626
|
+
actionCompleted = true;
|
|
627
|
+
entry.completed = true;
|
|
628
|
+
pendingResult = result;
|
|
629
|
+
if (entry.phase === "fetching" || streamingEnded) {
|
|
630
|
+
streamingEnded = true;
|
|
631
|
+
tryFinalize();
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
613
635
|
return {
|
|
614
636
|
id,
|
|
615
637
|
abort,
|
|
@@ -646,35 +668,11 @@ export function createEventController(
|
|
|
646
668
|
},
|
|
647
669
|
|
|
648
670
|
complete(result?: unknown) {
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
actionCompleted = true;
|
|
652
|
-
entry.completed = true;
|
|
653
|
-
pendingResult = { type: "success", value: result };
|
|
654
|
-
|
|
655
|
-
// If streaming never started or already ended, finalize immediately
|
|
656
|
-
// Otherwise wait for streaming to end
|
|
657
|
-
if (entry.phase === "fetching" || streamingEnded) {
|
|
658
|
-
streamingEnded = true; // Mark as ended if never started
|
|
659
|
-
tryFinalize();
|
|
660
|
-
}
|
|
661
|
-
// If streaming is in progress, tryFinalize() will be called when streaming ends
|
|
671
|
+
settleWith({ type: "success", value: result });
|
|
662
672
|
},
|
|
663
673
|
|
|
664
674
|
fail(error: unknown) {
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
actionCompleted = true;
|
|
668
|
-
entry.completed = true;
|
|
669
|
-
pendingResult = { type: "error", value: error };
|
|
670
|
-
|
|
671
|
-
// If streaming never started or already ended, finalize immediately
|
|
672
|
-
// Otherwise wait for streaming to end
|
|
673
|
-
if (entry.phase === "fetching" || streamingEnded) {
|
|
674
|
-
streamingEnded = true; // Mark as ended if never started
|
|
675
|
-
tryFinalize();
|
|
676
|
-
}
|
|
677
|
-
// If streaming is in progress, tryFinalize() will be called when streaming ends
|
|
675
|
+
settleWith({ type: "error", value: error });
|
|
678
676
|
},
|
|
679
677
|
|
|
680
678
|
getRevalidatedSegments(): Set<string> {
|
|
@@ -744,8 +742,15 @@ export function createEventController(
|
|
|
744
742
|
data: HandleData,
|
|
745
743
|
matched?: string[],
|
|
746
744
|
isPartial?: boolean,
|
|
745
|
+
resolvedIds?: string[],
|
|
747
746
|
): void {
|
|
748
|
-
const
|
|
747
|
+
const rawMatched = matched ?? [];
|
|
748
|
+
const newSegmentOrder = filterSegmentOrder(rawMatched);
|
|
749
|
+
// Separate list for useSegments(): "layouts and routes only" — strip
|
|
750
|
+
// parallels (".@") and loader sub-ids (D digit) without reordering.
|
|
751
|
+
const newRouteSegmentIds = rawMatched.filter(
|
|
752
|
+
(id) => !id.includes(".@") && !/D\d+\./.test(id),
|
|
753
|
+
);
|
|
749
754
|
|
|
750
755
|
if (isPartial && newSegmentOrder.length > 0) {
|
|
751
756
|
// Partial update: merge new data with existing
|
|
@@ -757,10 +762,19 @@ export function createEventController(
|
|
|
757
762
|
handleData[handleName][segmentId] = data[handleName][segmentId];
|
|
758
763
|
}
|
|
759
764
|
}
|
|
760
|
-
|
|
765
|
+
const resolvedIdSet =
|
|
766
|
+
resolvedIds && resolvedIds.length > 0 ? new Set(resolvedIds) : null;
|
|
767
|
+
// Cleanup pass:
|
|
768
|
+
// a) segment dropped from the match list — delete its bucket.
|
|
769
|
+
// b) segment was re-resolved this request but pushed nothing for
|
|
770
|
+
// this handle — its previous bucket is stale.
|
|
771
|
+
// (a) is the existing behavior; (b) requires resolvedIds.
|
|
761
772
|
for (const handleName of Object.keys(handleData)) {
|
|
762
773
|
for (const segmentId of Object.keys(handleData[handleName])) {
|
|
763
|
-
|
|
774
|
+
const droppedFromMatch = !newSegmentOrder.includes(segmentId);
|
|
775
|
+
const reresolvedWithoutPush =
|
|
776
|
+
resolvedIdSet?.has(segmentId) && !data[handleName]?.[segmentId];
|
|
777
|
+
if (droppedFromMatch || reresolvedWithoutPush) {
|
|
764
778
|
delete handleData[handleName][segmentId];
|
|
765
779
|
}
|
|
766
780
|
}
|
|
@@ -770,6 +784,7 @@ export function createEventController(
|
|
|
770
784
|
handleData = data;
|
|
771
785
|
}
|
|
772
786
|
handleSegmentOrder = newSegmentOrder;
|
|
787
|
+
routeSegmentIds = newRouteSegmentIds;
|
|
773
788
|
|
|
774
789
|
notifyHandles();
|
|
775
790
|
}
|
|
@@ -778,6 +793,7 @@ export function createEventController(
|
|
|
778
793
|
return {
|
|
779
794
|
data: handleData,
|
|
780
795
|
segmentOrder: handleSegmentOrder,
|
|
796
|
+
routeSegmentIds,
|
|
781
797
|
};
|
|
782
798
|
}
|
|
783
799
|
|
|
@@ -61,6 +61,27 @@ export function buildHistoryState(
|
|
|
61
61
|
return Object.keys(result).length > 0 ? result : null;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Stamp an `idx` on the next history entry's state and call push/replaceState.
|
|
66
|
+
* Push increments the current idx; replace keeps it. Initial entry idx is 0.
|
|
67
|
+
* Used by useRouter().back() to detect "first entry in this session" without
|
|
68
|
+
* relying on the Navigation API.
|
|
69
|
+
*/
|
|
70
|
+
export function pushHistoryWithIdx(
|
|
71
|
+
state: Record<string, unknown> | null,
|
|
72
|
+
url: string,
|
|
73
|
+
replace: boolean,
|
|
74
|
+
): void {
|
|
75
|
+
const oldIdx = (window.history.state as { idx?: number } | null)?.idx ?? 0;
|
|
76
|
+
const newIdx = replace ? oldIdx : oldIdx + 1;
|
|
77
|
+
const finalState = { ...(state ?? {}), idx: newIdx };
|
|
78
|
+
if (replace) {
|
|
79
|
+
window.history.replaceState(finalState, "", url);
|
|
80
|
+
} else {
|
|
81
|
+
window.history.pushState(finalState, "", url);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
64
85
|
/**
|
|
65
86
|
* Merge server-set location state into the current history entry.
|
|
66
87
|
* Replaces the current history state and dispatches notification event
|
package/src/browser/index.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// ============================================================================
|
|
2
|
-
// Browser Module - Browser entry point for
|
|
2
|
+
// Browser Module - Browser entry point for Rango
|
|
3
3
|
// ============================================================================
|
|
4
4
|
//
|
|
5
5
|
// Usage:
|
|
6
|
-
// import { initBrowserApp,
|
|
6
|
+
// import { initBrowserApp, Rango } from "rsc-router/browser";
|
|
7
7
|
//
|
|
8
8
|
// For React components (Link, useNavigation, etc.):
|
|
9
9
|
// import { Link, useNavigation, useAction, href } from "rsc-router/client";
|
|
@@ -13,6 +13,6 @@
|
|
|
13
13
|
// Browser app initialization
|
|
14
14
|
export {
|
|
15
15
|
initBrowserApp,
|
|
16
|
-
|
|
16
|
+
Rango,
|
|
17
17
|
type InitBrowserAppOptions,
|
|
18
18
|
} from "./rsc-router.js";
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
createNavigationTransaction,
|
|
12
12
|
resolveNavigationState,
|
|
13
13
|
} from "./navigation-transaction.js";
|
|
14
|
-
import { buildHistoryState } from "./history-state.js";
|
|
14
|
+
import { buildHistoryState, pushHistoryWithIdx } from "./history-state.js";
|
|
15
15
|
import {
|
|
16
16
|
handleNavigationStart,
|
|
17
17
|
handleNavigationEnd,
|
|
@@ -48,7 +48,7 @@ export { createNavigationTransaction };
|
|
|
48
48
|
*/
|
|
49
49
|
export interface NavigationBridgeConfigWithController extends NavigationBridgeConfig {
|
|
50
50
|
eventController: EventController;
|
|
51
|
-
/** RSC version from initial payload metadata */
|
|
51
|
+
/** RSC version from initial payload metadata. */
|
|
52
52
|
version?: string;
|
|
53
53
|
}
|
|
54
54
|
|
|
@@ -159,11 +159,7 @@ export function createNavigationBridge(
|
|
|
159
159
|
},
|
|
160
160
|
{},
|
|
161
161
|
);
|
|
162
|
-
|
|
163
|
-
window.history.replaceState(historyState, "", url);
|
|
164
|
-
} else {
|
|
165
|
-
window.history.pushState(historyState, "", url);
|
|
166
|
-
}
|
|
162
|
+
pushHistoryWithIdx(historyState, url, options?.replace ?? false);
|
|
167
163
|
|
|
168
164
|
// Ensure new history entry has a scroll restoration key
|
|
169
165
|
ensureHistoryKey();
|
|
@@ -418,6 +414,15 @@ export function createNavigationBridge(
|
|
|
418
414
|
eventController.abortAllActions();
|
|
419
415
|
}
|
|
420
416
|
|
|
417
|
+
// Popstate that exits an intercept to a non-intercept destination. The
|
|
418
|
+
// fallback fetch path below needs `leave-intercept` mode so it filters
|
|
419
|
+
// the cached @modal segment from the request and forces a re-render —
|
|
420
|
+
// otherwise a cache-miss popstate whose server response has an empty
|
|
421
|
+
// diff hits the "no changes" branch in partial-update and the modal
|
|
422
|
+
// stays on screen.
|
|
423
|
+
const isLeavingIntercept =
|
|
424
|
+
!isIntercept && currentInterceptSource !== null;
|
|
425
|
+
|
|
421
426
|
// Compute history key from URL (with intercept suffix if applicable)
|
|
422
427
|
const historyKey = generateHistoryKey(url, { intercept: isIntercept });
|
|
423
428
|
|
|
@@ -487,7 +492,14 @@ export function createNavigationBridge(
|
|
|
487
492
|
},
|
|
488
493
|
scroll: { restore: true, isStreaming },
|
|
489
494
|
};
|
|
490
|
-
|
|
495
|
+
// Intercept-driven popstate (entering OR leaving an intercept) only
|
|
496
|
+
// mutates the parallel slot; the main outlet shows the same content.
|
|
497
|
+
// Skip startViewTransition in those cases — same rationale as the
|
|
498
|
+
// intercept guard in partial-update.ts's hasTransition computation.
|
|
499
|
+
const hasTransition =
|
|
500
|
+
!isIntercept &&
|
|
501
|
+
!isLeavingIntercept &&
|
|
502
|
+
cachedSegments.some((s) => s.transition);
|
|
491
503
|
if (hasTransition) {
|
|
492
504
|
startTransition(() => {
|
|
493
505
|
if (addTransitionType) {
|
|
@@ -568,7 +580,11 @@ export function createNavigationBridge(
|
|
|
568
580
|
intercept: isIntercept,
|
|
569
581
|
interceptSourceUrl,
|
|
570
582
|
}),
|
|
571
|
-
isIntercept
|
|
583
|
+
isIntercept
|
|
584
|
+
? { type: "navigate", interceptSourceUrl }
|
|
585
|
+
: isLeavingIntercept
|
|
586
|
+
? { type: "leave-intercept" }
|
|
587
|
+
: undefined,
|
|
572
588
|
);
|
|
573
589
|
// Restore scroll position after fetch completes
|
|
574
590
|
handleNavigationEnd({ restore: true, isStreaming });
|
|
@@ -646,6 +662,10 @@ export function createNavigationBridge(
|
|
|
646
662
|
};
|
|
647
663
|
},
|
|
648
664
|
|
|
665
|
+
getVersion(): string | undefined {
|
|
666
|
+
return version;
|
|
667
|
+
},
|
|
668
|
+
|
|
649
669
|
updateVersion(newVersion: string): void {
|
|
650
670
|
version = newVersion;
|
|
651
671
|
setAppVersion(newVersion);
|
|
@@ -15,12 +15,16 @@ import { getRangoState } from "./rango-state.js";
|
|
|
15
15
|
import {
|
|
16
16
|
extractRscHeaderUrl,
|
|
17
17
|
emptyResponse,
|
|
18
|
+
handleReloadHeader,
|
|
18
19
|
teeWithCompletion,
|
|
20
|
+
isForeignRouterId,
|
|
19
21
|
} from "./response-adapter.js";
|
|
20
22
|
import {
|
|
21
23
|
buildPrefetchKey,
|
|
24
|
+
buildSourceKey,
|
|
22
25
|
consumeInflightPrefetch,
|
|
23
26
|
consumePrefetch,
|
|
27
|
+
type DecodedPrefetch,
|
|
24
28
|
} from "./prefetch/cache.js";
|
|
25
29
|
|
|
26
30
|
/**
|
|
@@ -30,8 +34,10 @@ import {
|
|
|
30
34
|
* deserializing the response using the RSC runtime.
|
|
31
35
|
*
|
|
32
36
|
* Checks the in-memory prefetch cache before making a network request.
|
|
33
|
-
*
|
|
34
|
-
*
|
|
37
|
+
* Tries the source-scoped key first (populated when the server tagged
|
|
38
|
+
* the response as source-sensitive via `X-RSC-Prefetch-Scope: source`)
|
|
39
|
+
* and falls back to the Rango-state-keyed wildcard slot used for the
|
|
40
|
+
* common source-agnostic case.
|
|
35
41
|
*
|
|
36
42
|
* @param deps - RSC browser dependencies (createFromFetch)
|
|
37
43
|
* @returns NavigationClient instance
|
|
@@ -93,38 +99,40 @@ export function createNavigationClient(
|
|
|
93
99
|
fetchUrl.searchParams.set("_rsc_rid", routerId);
|
|
94
100
|
}
|
|
95
101
|
|
|
96
|
-
// Check completed in-memory prefetch cache before making a network
|
|
97
|
-
//
|
|
98
|
-
//
|
|
102
|
+
// Check completed in-memory prefetch cache before making a network
|
|
103
|
+
// request. Try the source-scoped key first (populated when the server
|
|
104
|
+
// tagged the prefetch response as source-sensitive, e.g. intercepts,
|
|
105
|
+
// or when a Link opted in with `prefetchKey=":source"`), then fall
|
|
106
|
+
// back to the wildcard slot shared across source pages.
|
|
107
|
+
// Both keys embed the Rango state, so state rotation (deploy or
|
|
108
|
+
// server-action invalidation) auto-invalidates both scopes.
|
|
99
109
|
// Skip cache for stale revalidation (needs fresh data), HMR (needs
|
|
100
110
|
// fresh modules), and intercept contexts (source-dependent responses).
|
|
101
|
-
//
|
|
102
111
|
const canUsePrefetch = !staleRevalidation && !hmr && !interceptSourceUrl;
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const wildcardKey = "*\0" + fetchUrl.pathname + fetchUrl.search;
|
|
112
|
+
const rangoState = getRangoState();
|
|
113
|
+
const wildcardKey = buildPrefetchKey(rangoState, fetchUrl);
|
|
114
|
+
const cacheKey = buildSourceKey(rangoState, previousUrl, fetchUrl);
|
|
107
115
|
|
|
108
|
-
let
|
|
116
|
+
let cachedEntry: DecodedPrefetch | null = null;
|
|
109
117
|
let hitKey: string | null = null;
|
|
110
118
|
if (canUsePrefetch) {
|
|
111
|
-
|
|
112
|
-
if (
|
|
119
|
+
cachedEntry = consumePrefetch(cacheKey);
|
|
120
|
+
if (cachedEntry) {
|
|
113
121
|
hitKey = cacheKey;
|
|
114
122
|
} else {
|
|
115
|
-
|
|
116
|
-
if (
|
|
123
|
+
cachedEntry = consumePrefetch(wildcardKey);
|
|
124
|
+
if (cachedEntry) hitKey = wildcardKey;
|
|
117
125
|
}
|
|
118
126
|
}
|
|
119
127
|
|
|
120
|
-
let
|
|
121
|
-
if (canUsePrefetch && !
|
|
122
|
-
|
|
123
|
-
if (
|
|
128
|
+
let inflightEntryPromise: Promise<DecodedPrefetch | null> | null = null;
|
|
129
|
+
if (canUsePrefetch && !cachedEntry) {
|
|
130
|
+
inflightEntryPromise = consumeInflightPrefetch(cacheKey);
|
|
131
|
+
if (inflightEntryPromise) {
|
|
124
132
|
hitKey = cacheKey;
|
|
125
133
|
} else {
|
|
126
|
-
|
|
127
|
-
if (
|
|
134
|
+
inflightEntryPromise = consumeInflightPrefetch(wildcardKey);
|
|
135
|
+
if (inflightEntryPromise) hitKey = wildcardKey;
|
|
128
136
|
}
|
|
129
137
|
}
|
|
130
138
|
// Track when the stream completes
|
|
@@ -143,21 +151,17 @@ export function createNavigationClient(
|
|
|
143
151
|
source: string,
|
|
144
152
|
): Response | Promise<Response> => {
|
|
145
153
|
// Version mismatch — server wants a full page reload
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
window.location.href = reload.url;
|
|
158
|
-
// Block further processing — page is reloading
|
|
159
|
-
return new Promise<Response>(() => {});
|
|
160
|
-
}
|
|
154
|
+
const reloadResult = handleReloadHeader(response, {
|
|
155
|
+
onBlocked: resolveStreamComplete,
|
|
156
|
+
onReload: (url) => {
|
|
157
|
+
if (tx) {
|
|
158
|
+
browserDebugLog(tx, `version mismatch, reloading (${source})`, {
|
|
159
|
+
reloadUrl: url,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
if (reloadResult) return reloadResult;
|
|
161
165
|
|
|
162
166
|
// Server-side redirect without state: the server returned 204 with
|
|
163
167
|
// X-RSC-Redirect instead of a 3xx (which fetch would auto-follow
|
|
@@ -178,6 +182,19 @@ export function createNavigationClient(
|
|
|
178
182
|
throw new ServerRedirect(redirect.url, undefined);
|
|
179
183
|
}
|
|
180
184
|
|
|
185
|
+
// Integrity check (pre-decode): refuse a foreign app's content response
|
|
186
|
+
// before createFromFetch imports its chunks. Ordered AFTER the reload
|
|
187
|
+
// and redirect handlers — control responses are never stamped with
|
|
188
|
+
// X-RSC-Router-Id, so they are steered first and never reach here.
|
|
189
|
+
if (isForeignRouterId(response, routerId)) {
|
|
190
|
+
if (tx) {
|
|
191
|
+
browserDebugLog(tx, `router id mismatch, reloading (${source})`);
|
|
192
|
+
}
|
|
193
|
+
resolveStreamComplete();
|
|
194
|
+
window.location.href = targetUrl;
|
|
195
|
+
return new Promise<Response>(() => {});
|
|
196
|
+
}
|
|
197
|
+
|
|
181
198
|
return response;
|
|
182
199
|
};
|
|
183
200
|
|
|
@@ -215,63 +232,68 @@ export function createNavigationClient(
|
|
|
215
232
|
});
|
|
216
233
|
};
|
|
217
234
|
|
|
218
|
-
|
|
235
|
+
// A warm prefetch hit returns its eagerly-decoded payload directly: the
|
|
236
|
+
// route's chunks were imported during the prefetch, so this click runs
|
|
237
|
+
// no decode and no network. Only the fresh path runs createFromFetch and
|
|
238
|
+
// resolves the local streamComplete (via doFreshFetch's teeWithCompletion
|
|
239
|
+
// and the control-header short-circuits in validateRscHeaders).
|
|
240
|
+
const freshResult = (): {
|
|
241
|
+
payload: Promise<RscPayload>;
|
|
242
|
+
streamComplete: Promise<void>;
|
|
243
|
+
} => ({
|
|
244
|
+
payload: deps.createFromFetch<RscPayload>(doFreshFetch()),
|
|
245
|
+
streamComplete,
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
let payloadPromise: Promise<RscPayload>;
|
|
249
|
+
let streamCompletePromise: Promise<void>;
|
|
219
250
|
|
|
220
|
-
if (
|
|
251
|
+
if (cachedEntry) {
|
|
221
252
|
if (tx) {
|
|
222
|
-
browserDebugLog(tx, "prefetch cache hit", {
|
|
253
|
+
browserDebugLog(tx, "prefetch cache hit (warm)", {
|
|
223
254
|
key: hitKey,
|
|
224
255
|
wildcard: hitKey === wildcardKey,
|
|
225
256
|
});
|
|
226
257
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
return teeWithCompletion(
|
|
232
|
-
validated,
|
|
233
|
-
() => {
|
|
234
|
-
if (tx) browserDebugLog(tx, "stream complete (from cache)");
|
|
235
|
-
resolveStreamComplete();
|
|
236
|
-
},
|
|
237
|
-
signal,
|
|
238
|
-
);
|
|
239
|
-
});
|
|
240
|
-
} else if (inflightResponsePromise) {
|
|
258
|
+
payloadPromise = cachedEntry.payload;
|
|
259
|
+
streamCompletePromise = cachedEntry.streamComplete;
|
|
260
|
+
} else if (inflightEntryPromise) {
|
|
241
261
|
if (tx) {
|
|
242
262
|
browserDebugLog(tx, "reusing inflight prefetch", {
|
|
243
263
|
key: hitKey,
|
|
244
264
|
wildcard: hitKey === wildcardKey,
|
|
245
265
|
});
|
|
246
266
|
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
return doFreshFetch();
|
|
267
|
+
const adoptedViaWildcard = hitKey === wildcardKey;
|
|
268
|
+
const entry = await inflightEntryPromise;
|
|
269
|
+
if (!entry) {
|
|
270
|
+
if (tx) {
|
|
271
|
+
browserDebugLog(tx, "inflight prefetch unavailable, refetching");
|
|
253
272
|
}
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
(
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
273
|
+
({ payload: payloadPromise, streamComplete: streamCompletePromise } =
|
|
274
|
+
freshResult());
|
|
275
|
+
} else if (adoptedViaWildcard && entry.scope === "source") {
|
|
276
|
+
// A wildcard-adopted inflight that turned out source-scoped was
|
|
277
|
+
// built for a different source page. Discard and refetch.
|
|
278
|
+
if (tx) {
|
|
279
|
+
browserDebugLog(
|
|
280
|
+
tx,
|
|
281
|
+
"wildcard inflight turned out source-scoped, refetching",
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
({ payload: payloadPromise, streamComplete: streamCompletePromise } =
|
|
285
|
+
freshResult());
|
|
286
|
+
} else {
|
|
287
|
+
payloadPromise = entry.payload;
|
|
288
|
+
streamCompletePromise = entry.streamComplete;
|
|
289
|
+
}
|
|
269
290
|
} else {
|
|
270
|
-
|
|
291
|
+
({ payload: payloadPromise, streamComplete: streamCompletePromise } =
|
|
292
|
+
freshResult());
|
|
271
293
|
}
|
|
272
294
|
|
|
273
295
|
try {
|
|
274
|
-
const payload = await
|
|
296
|
+
const payload = await payloadPromise;
|
|
275
297
|
|
|
276
298
|
if (tx) {
|
|
277
299
|
browserDebugLog(tx, "response received", {
|
|
@@ -280,7 +302,7 @@ export function createNavigationClient(
|
|
|
280
302
|
diffCount: payload.metadata?.diff?.length ?? 0,
|
|
281
303
|
});
|
|
282
304
|
}
|
|
283
|
-
return { payload, streamComplete };
|
|
305
|
+
return { payload, streamComplete: streamCompletePromise };
|
|
284
306
|
} catch (error) {
|
|
285
307
|
// Convert network-level errors to NetworkError for proper handling
|
|
286
308
|
if (isNetworkError(error)) {
|
|
@@ -280,18 +280,17 @@ export function createNavigationStore(
|
|
|
280
280
|
/**
|
|
281
281
|
* Create a debounced function that batches rapid calls
|
|
282
282
|
*/
|
|
283
|
+
// A non-keyed notifier is the keyed one restricted to a single constant key;
|
|
284
|
+
// its own keyed instance means the "" key never collides with action keys.
|
|
283
285
|
function createDebouncedNotifier<T extends (...args: any[]) => void>(
|
|
284
286
|
fn: T,
|
|
285
287
|
ms: number = 20,
|
|
286
288
|
): T {
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
fn(...args);
|
|
293
|
-
}, ms);
|
|
294
|
-
}) as T;
|
|
289
|
+
const keyed = createKeyedDebouncedNotifier(
|
|
290
|
+
(_key: string, ...args: any[]) => fn(...args),
|
|
291
|
+
ms,
|
|
292
|
+
);
|
|
293
|
+
return ((...args: Parameters<T>) => keyed("", ...args)) as T;
|
|
295
294
|
}
|
|
296
295
|
|
|
297
296
|
/**
|