@rangojs/router 0.0.0-experimental.b9cb8739 → 0.0.0-experimental.bd6e11bc
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 +196 -43
- package/dist/bin/rango.js +277 -99
- package/dist/testing/vitest.js +48 -0
- package/dist/vite/index.js +2779 -1064
- package/dist/vite/index.js.bak +5448 -0
- package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
- package/package.json +57 -11
- package/skills/breadcrumbs/SKILL.md +3 -1
- package/skills/bundle-analysis/SKILL.md +159 -0
- package/skills/cache-guide/SKILL.md +243 -21
- package/skills/caching/SKILL.md +155 -6
- package/skills/composability/SKILL.md +27 -2
- package/skills/document-cache/SKILL.md +78 -55
- package/skills/handler-use/SKILL.md +364 -0
- package/skills/hooks/SKILL.md +229 -20
- package/skills/host-router/SKILL.md +45 -20
- package/skills/i18n/SKILL.md +276 -0
- package/skills/intercept/SKILL.md +46 -4
- package/skills/layout/SKILL.md +28 -7
- package/skills/links/SKILL.md +249 -17
- package/skills/loader/SKILL.md +273 -53
- package/skills/middleware/SKILL.md +49 -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 +197 -6
- package/skills/prerender/SKILL.md +123 -100
- package/skills/rango/SKILL.md +242 -22
- package/skills/react-compiler/SKILL.md +168 -0
- package/skills/response-routes/SKILL.md +66 -9
- package/skills/route/SKILL.md +88 -4
- package/skills/router-setup/SKILL.md +90 -5
- package/skills/server-actions/SKILL.md +751 -0
- package/skills/streams-and-websockets/SKILL.md +283 -0
- package/skills/testing/SKILL.md +716 -0
- package/skills/typesafety/SKILL.md +329 -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 +117 -0
- package/src/__internal.ts +1 -1
- package/src/browser/action-coordinator.ts +53 -36
- package/src/browser/app-shell.ts +52 -0
- package/src/browser/app-version.ts +14 -0
- package/src/browser/event-controller.ts +91 -70
- package/src/browser/history-state.ts +21 -0
- package/src/browser/index.ts +3 -3
- package/src/browser/navigation-bridge.ts +102 -16
- package/src/browser/navigation-client.ts +164 -59
- package/src/browser/navigation-store.ts +75 -17
- package/src/browser/navigation-transaction.ts +21 -37
- package/src/browser/partial-update.ts +139 -38
- package/src/browser/prefetch/cache.ts +175 -15
- package/src/browser/prefetch/fetch.ts +180 -33
- package/src/browser/prefetch/queue.ts +123 -20
- package/src/browser/prefetch/resource-ready.ts +77 -0
- package/src/browser/rango-state.ts +53 -13
- package/src/browser/react/Link.tsx +81 -9
- package/src/browser/react/NavigationProvider.tsx +110 -33
- package/src/browser/react/context.ts +7 -2
- 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 +23 -64
- 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 +43 -10
- package/src/browser/react/use-segments.ts +11 -8
- package/src/browser/response-adapter.ts +25 -0
- package/src/browser/rsc-router.tsx +191 -74
- package/src/browser/scroll-restoration.ts +41 -14
- package/src/browser/segment-reconciler.ts +36 -9
- package/src/browser/segment-structure-assert.ts +2 -2
- package/src/browser/server-action-bridge.ts +31 -36
- package/src/browser/types.ts +57 -5
- package/src/build/collect-fallback-refs.ts +107 -0
- package/src/build/generate-manifest.ts +65 -40
- package/src/build/generate-route-types.ts +5 -0
- package/src/build/index.ts +2 -0
- package/src/build/route-trie.ts +52 -25
- package/src/build/route-types/codegen.ts +4 -4
- package/src/build/route-types/include-resolution.ts +9 -2
- package/src/build/route-types/per-module-writer.ts +7 -4
- package/src/build/route-types/router-processing.ts +278 -88
- package/src/build/route-types/scan-filter.ts +9 -2
- package/src/build/route-types/source-scan.ts +118 -0
- package/src/build/runtime-discovery.ts +9 -20
- package/src/cache/cache-runtime.ts +15 -11
- package/src/cache/cache-scope.ts +76 -49
- package/src/cache/cf/cf-cache-store.ts +501 -18
- package/src/cache/cf/index.ts +5 -1
- package/src/cache/document-cache.ts +17 -7
- package/src/cache/index.ts +1 -0
- package/src/cache/taint.ts +55 -0
- package/src/client.rsc.tsx +3 -0
- package/src/client.tsx +94 -238
- package/src/context-var.ts +72 -2
- package/src/debug.ts +2 -2
- package/src/decode-loader-results.ts +36 -0
- package/src/errors.ts +30 -1
- package/src/handle.ts +65 -12
- 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 -20
- package/src/index.rsc.ts +12 -5
- package/src/index.ts +61 -11
- 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 +5 -4
- package/src/prerender.ts +141 -80
- package/src/response-utils.ts +37 -0
- package/src/reverse.ts +65 -15
- package/src/route-content-wrapper.tsx +6 -28
- package/src/route-definition/dsl-helpers.ts +435 -260
- package/src/route-definition/helper-factories.ts +29 -139
- package/src/route-definition/helpers-types.ts +110 -34
- package/src/route-definition/index.ts +3 -0
- package/src/route-definition/redirect.ts +11 -3
- package/src/route-definition/resolve-handler-use.ts +155 -0
- package/src/route-definition/use-item-types.ts +32 -0
- package/src/route-map-builder.ts +7 -1
- package/src/route-types.ts +37 -41
- package/src/router/basename.ts +14 -0
- package/src/router/content-negotiation.ts +113 -1
- package/src/router/error-handling.ts +1 -1
- package/src/router/find-match.ts +4 -2
- package/src/router/handler-context.ts +77 -38
- package/src/router/intercept-resolution.ts +15 -22
- package/src/router/lazy-includes.ts +12 -9
- package/src/router/loader-resolution.ts +174 -22
- package/src/router/logging.ts +5 -2
- package/src/router/manifest.ts +31 -16
- package/src/router/match-api.ts +128 -192
- package/src/router/match-handlers.ts +63 -20
- package/src/router/match-middleware/background-revalidation.ts +30 -2
- package/src/router/match-middleware/cache-lookup.ts +136 -106
- package/src/router/match-middleware/cache-store.ts +54 -10
- package/src/router/match-middleware/intercept-resolution.ts +9 -7
- package/src/router/match-middleware/segment-resolution.ts +61 -5
- package/src/router/match-result.ts +125 -10
- package/src/router/metrics.ts +7 -2
- package/src/router/middleware-types.ts +21 -34
- package/src/router/middleware.ts +103 -90
- package/src/router/navigation-snapshot.ts +182 -0
- package/src/router/pattern-matching.ts +101 -17
- package/src/router/prerender-match.ts +110 -10
- package/src/router/preview-match.ts +32 -102
- package/src/router/request-classification.ts +286 -0
- package/src/router/revalidation.ts +58 -2
- package/src/router/route-snapshot.ts +245 -0
- package/src/router/router-context.ts +6 -1
- package/src/router/router-interfaces.ts +77 -28
- package/src/router/router-options.ts +76 -11
- package/src/router/router-registry.ts +2 -5
- package/src/router/segment-resolution/fresh.ts +223 -24
- package/src/router/segment-resolution/helpers.ts +29 -24
- package/src/router/segment-resolution/loader-cache.ts +1 -0
- package/src/router/segment-resolution/revalidation.ts +466 -285
- package/src/router/segment-resolution/view-transition-default.ts +36 -0
- package/src/router/segment-wrappers.ts +2 -0
- package/src/router/substitute-pattern-params.ts +56 -0
- package/src/router/telemetry.ts +99 -0
- package/src/router/trie-matching.ts +18 -13
- package/src/router/types.ts +9 -0
- package/src/router/url-params.ts +49 -0
- package/src/router.ts +91 -23
- package/src/rsc/handler-context.ts +2 -2
- package/src/rsc/handler.ts +440 -381
- package/src/rsc/helpers.ts +91 -43
- package/src/rsc/index.ts +1 -1
- package/src/rsc/loader-fetch.ts +23 -3
- package/src/rsc/manifest-init.ts +5 -1
- package/src/rsc/origin-guard.ts +28 -10
- package/src/rsc/progressive-enhancement.ts +18 -2
- package/src/rsc/response-route-handler.ts +46 -53
- package/src/rsc/rsc-rendering.ts +41 -48
- package/src/rsc/runtime-warnings.ts +9 -10
- package/src/rsc/server-action.ts +25 -37
- package/src/rsc/ssr-setup.ts +18 -2
- package/src/rsc/types.ts +17 -3
- 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 +219 -67
- package/src/serialize.ts +243 -0
- package/src/server/context.ts +277 -61
- package/src/server/cookie-store.ts +28 -4
- package/src/server/handle-store.ts +19 -0
- package/src/server/loader-registry.ts +9 -8
- package/src/server/request-context.ts +204 -60
- package/src/ssr/index.tsx +9 -1
- package/src/static-handler.ts +19 -7
- package/src/testing/cache-status.ts +166 -0
- package/src/testing/collect-handle.ts +63 -0
- package/src/testing/dispatch.ts +440 -0
- package/src/testing/dom.entry.ts +22 -0
- package/src/testing/e2e/fixture.ts +154 -0
- package/src/testing/e2e/index.ts +149 -0
- package/src/testing/e2e/matchers.ts +51 -0
- package/src/testing/e2e/page-helpers.ts +272 -0
- package/src/testing/e2e/parity.ts +306 -0
- package/src/testing/e2e/server.ts +183 -0
- package/src/testing/flight-matchers.ts +104 -0
- package/src/testing/flight-runtime.d.ts +21 -0
- package/src/testing/flight.entry.ts +22 -0
- package/src/testing/flight.ts +182 -0
- package/src/testing/generated-routes.ts +223 -0
- package/src/testing/index.ts +106 -0
- package/src/testing/internal/context.ts +255 -0
- package/src/testing/render-route.tsx +565 -0
- package/src/testing/run-loader.ts +296 -0
- package/src/testing/run-middleware.ts +179 -0
- package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
- package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
- package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
- package/src/testing/vitest-stubs/version.ts +5 -0
- package/src/testing/vitest.ts +183 -0
- package/src/types/cache-types.ts +4 -4
- package/src/types/global-namespace.ts +39 -26
- package/src/types/handler-context.ts +194 -72
- package/src/types/index.ts +1 -0
- package/src/types/loader-types.ts +41 -15
- package/src/types/request-scope.ts +126 -0
- package/src/types/route-entry.ts +19 -1
- package/src/types/segments.ts +37 -1
- package/src/urls/include-helper.ts +34 -67
- package/src/urls/index.ts +0 -3
- package/src/urls/path-helper-types.ts +50 -9
- package/src/urls/path-helper.ts +63 -63
- package/src/urls/pattern-types.ts +48 -19
- package/src/urls/response-types.ts +25 -22
- package/src/urls/type-extraction.ts +26 -116
- package/src/urls/urls-function.ts +1 -5
- package/src/use-loader.tsx +487 -44
- package/src/vite/debug.ts +185 -0
- package/src/vite/discovery/bundle-postprocess.ts +34 -37
- package/src/vite/discovery/discover-routers.ts +105 -51
- 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 +188 -93
- 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 +46 -6
- package/src/vite/discovery/virtual-module-codegen.ts +13 -23
- package/src/vite/index.ts +6 -0
- package/src/vite/plugin-types.ts +111 -72
- 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 +55 -33
- package/src/vite/plugins/expose-id-utils.ts +24 -8
- package/src/vite/plugins/expose-ids/export-analysis.ts +100 -20
- package/src/vite/plugins/expose-ids/handler-transform.ts +12 -35
- 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 +544 -317
- package/src/vite/plugins/performance-tracks.ts +92 -0
- package/src/vite/plugins/refresh-cmd.ts +88 -26
- 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 +72 -3
- package/src/vite/plugins/virtual-entries.ts +2 -2
- package/src/vite/rango.ts +265 -226
- package/src/vite/router-discovery.ts +920 -137
- package/src/vite/utils/ast-handler-extract.ts +15 -15
- package/src/vite/utils/banner.ts +4 -4
- 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 +21 -5
- package/src/vite/utils/package-resolution.ts +41 -1
- package/src/vite/utils/prerender-utils.ts +38 -5
- package/src/vite/utils/shared-utils.ts +109 -27
- package/src/browser/action-response-classifier.ts +0 -99
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { ComponentType, ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* App-shell metadata: the set of per-router fields that describe the
|
|
5
|
+
* "envelope" around the current app's segment tree. These fields are set
|
|
6
|
+
* from the initial RSC payload and must be replaced atomically when the
|
|
7
|
+
* client navigates into a different router (app switch).
|
|
8
|
+
*
|
|
9
|
+
* Intentionally NOT part of the shell (all document-lifetime):
|
|
10
|
+
* - themeConfig / initialTheme: ThemeProvider is mounted above the segment
|
|
11
|
+
* tree and must not remount on smooth transitions.
|
|
12
|
+
* - warmupEnabled: attached to the NavigationProvider's lifetime effect;
|
|
13
|
+
* toggling it mid-session would tear down and restart idle listeners.
|
|
14
|
+
* Also not serialized on every full-render path (e.g. the not-found
|
|
15
|
+
* fallback), so carrying it here would be unreliable.
|
|
16
|
+
* - prefetchCacheTTL: the not-found full-render payload does not serialize
|
|
17
|
+
* it, so a cross-app nav into a 404 would silently erase the setting.
|
|
18
|
+
* Mutable shell fields must be serialized on EVERY full-render path,
|
|
19
|
+
* otherwise absent fields are indistinguishable from "new app has no
|
|
20
|
+
* value" and the old app's value is dropped.
|
|
21
|
+
*
|
|
22
|
+
* A new document navigation (hard reload) applies these fields from the
|
|
23
|
+
* target app's initial payload.
|
|
24
|
+
*/
|
|
25
|
+
export interface AppShell {
|
|
26
|
+
/** Router identity. Used to namespace per-app client state (e.g. the
|
|
27
|
+
* rango-state localStorage key) so sibling apps on the same origin
|
|
28
|
+
* cannot observe each other's cache invalidations. */
|
|
29
|
+
routerId?: string;
|
|
30
|
+
rootLayout?: ComponentType<{ children: ReactNode }>;
|
|
31
|
+
basename?: string;
|
|
32
|
+
version?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Mutable container for the active app shell. Read-through via `get()` so
|
|
37
|
+
* closures capture the ref, not the shell, and pick up updates at call time.
|
|
38
|
+
*/
|
|
39
|
+
export interface AppShellRef {
|
|
40
|
+
get(): AppShell;
|
|
41
|
+
update(next: AppShell): void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function createAppShellRef(initial: AppShell): AppShellRef {
|
|
45
|
+
let current = initial;
|
|
46
|
+
return {
|
|
47
|
+
get: () => current,
|
|
48
|
+
update: (next) => {
|
|
49
|
+
current = next;
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mutable app version — updated after HMR revalidation.
|
|
3
|
+
* Read by prefetch, navigation, and context code.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
let currentVersion: string | undefined;
|
|
7
|
+
|
|
8
|
+
export function getAppVersion(): string | undefined {
|
|
9
|
+
return currentVersion;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function setAppVersion(version: string | undefined): void {
|
|
13
|
+
currentVersion = version;
|
|
14
|
+
}
|
|
@@ -79,6 +79,8 @@ export interface DerivedNavigationState {
|
|
|
79
79
|
state: "idle" | "loading";
|
|
80
80
|
/** Whether any operation is streaming */
|
|
81
81
|
isStreaming: boolean;
|
|
82
|
+
/** Whether a navigation is active (fetching or streaming, before commit) */
|
|
83
|
+
isNavigating: boolean;
|
|
82
84
|
/** Current committed location */
|
|
83
85
|
location: NavigationLocation;
|
|
84
86
|
/** URL being navigated to (null if idle) */
|
|
@@ -111,11 +113,24 @@ export type ActionStateListener = (state: TrackedActionState) => void;
|
|
|
111
113
|
export type HandleListener = () => void;
|
|
112
114
|
|
|
113
115
|
/**
|
|
114
|
-
* 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.
|
|
115
129
|
*/
|
|
116
130
|
export interface HandleState {
|
|
117
131
|
data: HandleData;
|
|
118
132
|
segmentOrder: string[];
|
|
133
|
+
routeSegmentIds: string[];
|
|
119
134
|
}
|
|
120
135
|
|
|
121
136
|
/**
|
|
@@ -200,6 +215,14 @@ export interface EventController {
|
|
|
200
215
|
data: HandleData,
|
|
201
216
|
matched?: string[],
|
|
202
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[],
|
|
203
226
|
): void;
|
|
204
227
|
getHandleState(): HandleState;
|
|
205
228
|
|
|
@@ -245,6 +268,20 @@ function matchesActionId(
|
|
|
245
268
|
return entryActionId.endsWith(`#${subscriptionId}`);
|
|
246
269
|
}
|
|
247
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
|
+
|
|
248
285
|
// ============================================================================
|
|
249
286
|
// Implementation
|
|
250
287
|
// ============================================================================
|
|
@@ -298,6 +335,7 @@ export function createEventController(
|
|
|
298
335
|
// Handle data from RSC payload
|
|
299
336
|
let handleData: HandleData = {};
|
|
300
337
|
let handleSegmentOrder: string[] = [];
|
|
338
|
+
let routeSegmentIds: string[] = [];
|
|
301
339
|
|
|
302
340
|
// Merged route params from current match
|
|
303
341
|
let routeParams: Record<string, string> = {};
|
|
@@ -310,18 +348,7 @@ export function createEventController(
|
|
|
310
348
|
const actionListeners = new Map<string, Set<ActionStateListener>>();
|
|
311
349
|
const handleListeners = new Set<HandleListener>();
|
|
312
350
|
|
|
313
|
-
|
|
314
|
-
let notifyTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
315
|
-
|
|
316
|
-
function notify() {
|
|
317
|
-
if (notifyTimeout !== null) {
|
|
318
|
-
clearTimeout(notifyTimeout);
|
|
319
|
-
}
|
|
320
|
-
notifyTimeout = setTimeout(() => {
|
|
321
|
-
notifyTimeout = null;
|
|
322
|
-
stateListeners.forEach((listener) => listener());
|
|
323
|
-
}, 0);
|
|
324
|
-
}
|
|
351
|
+
const notify = makeDebouncedNotifier(stateListeners);
|
|
325
352
|
|
|
326
353
|
// Debounce per-action notifications
|
|
327
354
|
const actionNotifyTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
|
@@ -347,18 +374,7 @@ export function createEventController(
|
|
|
347
374
|
);
|
|
348
375
|
}
|
|
349
376
|
|
|
350
|
-
|
|
351
|
-
let handleNotifyTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
352
|
-
|
|
353
|
-
function notifyHandles() {
|
|
354
|
-
if (handleNotifyTimeout !== null) {
|
|
355
|
-
clearTimeout(handleNotifyTimeout);
|
|
356
|
-
}
|
|
357
|
-
handleNotifyTimeout = setTimeout(() => {
|
|
358
|
-
handleNotifyTimeout = null;
|
|
359
|
-
handleListeners.forEach((listener) => listener());
|
|
360
|
-
}, 0);
|
|
361
|
-
}
|
|
377
|
+
const notifyHandles = makeDebouncedNotifier(handleListeners);
|
|
362
378
|
|
|
363
379
|
// ========================================================================
|
|
364
380
|
// Derived State
|
|
@@ -389,6 +405,9 @@ export function createEventController(
|
|
|
389
405
|
return {
|
|
390
406
|
state,
|
|
391
407
|
isStreaming,
|
|
408
|
+
// True when a navigation is active (fetching or streaming, before
|
|
409
|
+
// commit). Broader than pendingUrl which clears during streaming.
|
|
410
|
+
isNavigating: currentNavigation !== null,
|
|
392
411
|
location,
|
|
393
412
|
// pendingUrl only during fetching phase - once streaming starts (URL changed), not pending.
|
|
394
413
|
// Background revalidations (skipLoadingState) don't expose a pending URL.
|
|
@@ -402,22 +421,17 @@ export function createEventController(
|
|
|
402
421
|
}
|
|
403
422
|
|
|
404
423
|
function getActionState(actionId: string): TrackedActionState {
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
const
|
|
408
|
-
.filter(
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
(a) => matchesActionId(actionId, a.actionId) && a.phase === "settling",
|
|
417
|
-
)
|
|
418
|
-
.sort((a, b) => b.startedAt - a.startedAt)[0];
|
|
419
|
-
|
|
420
|
-
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);
|
|
421
435
|
|
|
422
436
|
if (!entry) {
|
|
423
437
|
return { ...DEFAULT_ACTION_STATE };
|
|
@@ -605,6 +619,19 @@ export function createEventController(
|
|
|
605
619
|
doSettle();
|
|
606
620
|
}
|
|
607
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
|
+
|
|
608
635
|
return {
|
|
609
636
|
id,
|
|
610
637
|
abort,
|
|
@@ -641,35 +668,11 @@ export function createEventController(
|
|
|
641
668
|
},
|
|
642
669
|
|
|
643
670
|
complete(result?: unknown) {
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
actionCompleted = true;
|
|
647
|
-
entry.completed = true;
|
|
648
|
-
pendingResult = { type: "success", value: result };
|
|
649
|
-
|
|
650
|
-
// If streaming never started or already ended, finalize immediately
|
|
651
|
-
// Otherwise wait for streaming to end
|
|
652
|
-
if (entry.phase === "fetching" || streamingEnded) {
|
|
653
|
-
streamingEnded = true; // Mark as ended if never started
|
|
654
|
-
tryFinalize();
|
|
655
|
-
}
|
|
656
|
-
// If streaming is in progress, tryFinalize() will be called when streaming ends
|
|
671
|
+
settleWith({ type: "success", value: result });
|
|
657
672
|
},
|
|
658
673
|
|
|
659
674
|
fail(error: unknown) {
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
actionCompleted = true;
|
|
663
|
-
entry.completed = true;
|
|
664
|
-
pendingResult = { type: "error", value: error };
|
|
665
|
-
|
|
666
|
-
// If streaming never started or already ended, finalize immediately
|
|
667
|
-
// Otherwise wait for streaming to end
|
|
668
|
-
if (entry.phase === "fetching" || streamingEnded) {
|
|
669
|
-
streamingEnded = true; // Mark as ended if never started
|
|
670
|
-
tryFinalize();
|
|
671
|
-
}
|
|
672
|
-
// If streaming is in progress, tryFinalize() will be called when streaming ends
|
|
675
|
+
settleWith({ type: "error", value: error });
|
|
673
676
|
},
|
|
674
677
|
|
|
675
678
|
getRevalidatedSegments(): Set<string> {
|
|
@@ -739,8 +742,15 @@ export function createEventController(
|
|
|
739
742
|
data: HandleData,
|
|
740
743
|
matched?: string[],
|
|
741
744
|
isPartial?: boolean,
|
|
745
|
+
resolvedIds?: string[],
|
|
742
746
|
): void {
|
|
743
|
-
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
|
+
);
|
|
744
754
|
|
|
745
755
|
if (isPartial && newSegmentOrder.length > 0) {
|
|
746
756
|
// Partial update: merge new data with existing
|
|
@@ -752,10 +762,19 @@ export function createEventController(
|
|
|
752
762
|
handleData[handleName][segmentId] = data[handleName][segmentId];
|
|
753
763
|
}
|
|
754
764
|
}
|
|
755
|
-
|
|
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.
|
|
756
772
|
for (const handleName of Object.keys(handleData)) {
|
|
757
773
|
for (const segmentId of Object.keys(handleData[handleName])) {
|
|
758
|
-
|
|
774
|
+
const droppedFromMatch = !newSegmentOrder.includes(segmentId);
|
|
775
|
+
const reresolvedWithoutPush =
|
|
776
|
+
resolvedIdSet?.has(segmentId) && !data[handleName]?.[segmentId];
|
|
777
|
+
if (droppedFromMatch || reresolvedWithoutPush) {
|
|
759
778
|
delete handleData[handleName][segmentId];
|
|
760
779
|
}
|
|
761
780
|
}
|
|
@@ -765,6 +784,7 @@ export function createEventController(
|
|
|
765
784
|
handleData = data;
|
|
766
785
|
}
|
|
767
786
|
handleSegmentOrder = newSegmentOrder;
|
|
787
|
+
routeSegmentIds = newRouteSegmentIds;
|
|
768
788
|
|
|
769
789
|
notifyHandles();
|
|
770
790
|
}
|
|
@@ -773,6 +793,7 @@ export function createEventController(
|
|
|
773
793
|
return {
|
|
774
794
|
data: handleData,
|
|
775
795
|
segmentOrder: handleSegmentOrder,
|
|
796
|
+
routeSegmentIds,
|
|
776
797
|
};
|
|
777
798
|
}
|
|
778
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";
|
|
@@ -4,13 +4,16 @@ import type {
|
|
|
4
4
|
NavigateOptionsInternal,
|
|
5
5
|
ResolvedSegment,
|
|
6
6
|
} from "./types.js";
|
|
7
|
+
import { setAppVersion } from "./app-version.js";
|
|
8
|
+
import { setRangoStateLocal } from "./rango-state.js";
|
|
9
|
+
import type { AppShell, AppShellRef } from "./app-shell.js";
|
|
7
10
|
import * as React from "react";
|
|
8
11
|
import { startTransition } from "react";
|
|
9
12
|
import {
|
|
10
13
|
createNavigationTransaction,
|
|
11
14
|
resolveNavigationState,
|
|
12
15
|
} from "./navigation-transaction.js";
|
|
13
|
-
import { buildHistoryState } from "./history-state.js";
|
|
16
|
+
import { buildHistoryState, pushHistoryWithIdx } from "./history-state.js";
|
|
14
17
|
import {
|
|
15
18
|
handleNavigationStart,
|
|
16
19
|
handleNavigationEnd,
|
|
@@ -47,8 +50,13 @@ export { createNavigationTransaction };
|
|
|
47
50
|
*/
|
|
48
51
|
export interface NavigationBridgeConfigWithController extends NavigationBridgeConfig {
|
|
49
52
|
eventController: EventController;
|
|
50
|
-
/** RSC version from initial payload metadata */
|
|
53
|
+
/** RSC version from initial payload metadata (fallback when appShellRef is not provided) */
|
|
51
54
|
version?: string;
|
|
55
|
+
/**
|
|
56
|
+
* Live app-shell ref. When supplied, the bridge reads version/basename
|
|
57
|
+
* from this ref so cross-app navigations propagate correctly.
|
|
58
|
+
*/
|
|
59
|
+
appShellRef?: AppShellRef;
|
|
52
60
|
}
|
|
53
61
|
|
|
54
62
|
/**
|
|
@@ -67,8 +75,45 @@ export interface NavigationBridgeConfigWithController extends NavigationBridgeCo
|
|
|
67
75
|
export function createNavigationBridge(
|
|
68
76
|
config: NavigationBridgeConfigWithController,
|
|
69
77
|
): NavigationBridge {
|
|
70
|
-
const {
|
|
71
|
-
|
|
78
|
+
const {
|
|
79
|
+
store,
|
|
80
|
+
client,
|
|
81
|
+
eventController,
|
|
82
|
+
onUpdate,
|
|
83
|
+
renderSegments,
|
|
84
|
+
appShellRef,
|
|
85
|
+
} = config;
|
|
86
|
+
let version = config.version;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Replace the active app-shell snapshot atomically. Called by the partial
|
|
90
|
+
* updater when a response's routerId indicates the navigation crossed
|
|
91
|
+
* into a different app. Runs the local-only side-effects tied to
|
|
92
|
+
* app-shell fields (app version, rango-state namespace) so the new app
|
|
93
|
+
* owns them after the swap. Theme, warmup, and prefetch TTL are
|
|
94
|
+
* document-lifetime and are NOT touched here.
|
|
95
|
+
*/
|
|
96
|
+
function applyAppShell(next: AppShell): void {
|
|
97
|
+
if (appShellRef) {
|
|
98
|
+
appShellRef.update(next);
|
|
99
|
+
}
|
|
100
|
+
if (next.version !== undefined) {
|
|
101
|
+
version = next.version;
|
|
102
|
+
setAppVersion(next.version);
|
|
103
|
+
// Use the local-only setter — initRangoState writes the shared
|
|
104
|
+
// localStorage key and fires a storage event in other tabs still in
|
|
105
|
+
// the old app. setRangoStateLocal only mutates this tab's in-memory
|
|
106
|
+
// cache and rebinds it to the target app's routerId-scoped key,
|
|
107
|
+
// preserving the "local-only, no broadcast/rotation" contract for
|
|
108
|
+
// smooth app-switch transitions.
|
|
109
|
+
setRangoStateLocal(next.version, next.routerId);
|
|
110
|
+
}
|
|
111
|
+
// Cross-app: prior cache entries belong to a different app's segments.
|
|
112
|
+
// Drop them locally only — do NOT broadcast invalidation or rotate the
|
|
113
|
+
// shared X-Rango-State token, since other tabs still in the old app are
|
|
114
|
+
// unaffected by this tab's transition.
|
|
115
|
+
store.clearHistoryCacheLocal();
|
|
116
|
+
}
|
|
72
117
|
|
|
73
118
|
// Create shared partial updater
|
|
74
119
|
const fetchPartialUpdate = createPartialUpdater({
|
|
@@ -76,7 +121,8 @@ export function createNavigationBridge(
|
|
|
76
121
|
client,
|
|
77
122
|
onUpdate,
|
|
78
123
|
renderSegments,
|
|
79
|
-
version,
|
|
124
|
+
getVersion: () => version,
|
|
125
|
+
applyAppShell,
|
|
80
126
|
});
|
|
81
127
|
|
|
82
128
|
return {
|
|
@@ -158,11 +204,7 @@ export function createNavigationBridge(
|
|
|
158
204
|
},
|
|
159
205
|
{},
|
|
160
206
|
);
|
|
161
|
-
|
|
162
|
-
window.history.replaceState(historyState, "", url);
|
|
163
|
-
} else {
|
|
164
|
-
window.history.pushState(historyState, "", url);
|
|
165
|
-
}
|
|
207
|
+
pushHistoryWithIdx(historyState, url, options?.replace ?? false);
|
|
166
208
|
|
|
167
209
|
// Ensure new history entry has a scroll restoration key
|
|
168
210
|
ensureHistoryKey();
|
|
@@ -260,18 +302,24 @@ export function createNavigationBridge(
|
|
|
260
302
|
// 2. routes that CAN be intercepted - we don't know if this navigation will intercept
|
|
261
303
|
// 3. when leaving intercept - we need fresh non-intercept segments from server
|
|
262
304
|
// 4. redirect-with-state - force re-render so hooks read fresh state
|
|
305
|
+
// 5. stale cache - server action invalidated it, need fresh data with loading state
|
|
263
306
|
const hasUsableCache =
|
|
264
307
|
cachedSegments &&
|
|
265
308
|
cachedSegments.length > 0 &&
|
|
266
309
|
!isInterceptOnlyCache(cachedSegments) &&
|
|
267
310
|
!hasInterceptCache &&
|
|
268
311
|
!isLeavingIntercept &&
|
|
312
|
+
!cached?.stale &&
|
|
269
313
|
!options?._skipCache;
|
|
270
314
|
|
|
315
|
+
// Forward navigations always await fetchPartialUpdate before rendering,
|
|
316
|
+
// so useNavigation should always report "loading". skipLoadingState is
|
|
317
|
+
// only used for popstate background revalidation (line ~526) where
|
|
318
|
+
// cached content renders instantly without a network wait.
|
|
271
319
|
const tx = createNavigationTransaction(store, eventController, url, {
|
|
272
320
|
...options,
|
|
273
321
|
state: resolvedState,
|
|
274
|
-
skipLoadingState:
|
|
322
|
+
skipLoadingState: false,
|
|
275
323
|
});
|
|
276
324
|
|
|
277
325
|
// REVALIDATE: Fetch fresh data from server
|
|
@@ -411,6 +459,15 @@ export function createNavigationBridge(
|
|
|
411
459
|
eventController.abortAllActions();
|
|
412
460
|
}
|
|
413
461
|
|
|
462
|
+
// Popstate that exits an intercept to a non-intercept destination. The
|
|
463
|
+
// fallback fetch path below needs `leave-intercept` mode so it filters
|
|
464
|
+
// the cached @modal segment from the request and forces a re-render —
|
|
465
|
+
// otherwise a cache-miss popstate whose server response has an empty
|
|
466
|
+
// diff hits the "no changes" branch in partial-update and the modal
|
|
467
|
+
// stays on screen.
|
|
468
|
+
const isLeavingIntercept =
|
|
469
|
+
!isIntercept && currentInterceptSource !== null;
|
|
470
|
+
|
|
414
471
|
// Compute history key from URL (with intercept suffix if applicable)
|
|
415
472
|
const historyKey = generateHistoryKey(url, { intercept: isIntercept });
|
|
416
473
|
|
|
@@ -447,6 +504,12 @@ export function createNavigationBridge(
|
|
|
447
504
|
store.setCurrentUrl(url);
|
|
448
505
|
store.setPath(new URL(url).pathname);
|
|
449
506
|
|
|
507
|
+
// Restore router identity from cache so subsequent navigations
|
|
508
|
+
// don't falsely detect an app switch.
|
|
509
|
+
if (cached?.routerId) {
|
|
510
|
+
store.setRouterId?.(cached.routerId);
|
|
511
|
+
}
|
|
512
|
+
|
|
450
513
|
// Render from cache - force await to skip loading fallbacks
|
|
451
514
|
try {
|
|
452
515
|
const root = await renderSegments(cachedSegments, {
|
|
@@ -472,8 +535,16 @@ export function createNavigationBridge(
|
|
|
472
535
|
cachedHandleData,
|
|
473
536
|
params: cachedParams,
|
|
474
537
|
},
|
|
538
|
+
scroll: { restore: true, isStreaming },
|
|
475
539
|
};
|
|
476
|
-
|
|
540
|
+
// Intercept-driven popstate (entering OR leaving an intercept) only
|
|
541
|
+
// mutates the parallel slot; the main outlet shows the same content.
|
|
542
|
+
// Skip startViewTransition in those cases — same rationale as the
|
|
543
|
+
// intercept guard in partial-update.ts's hasTransition computation.
|
|
544
|
+
const hasTransition =
|
|
545
|
+
!isIntercept &&
|
|
546
|
+
!isLeavingIntercept &&
|
|
547
|
+
cachedSegments.some((s) => s.transition);
|
|
477
548
|
if (hasTransition) {
|
|
478
549
|
startTransition(() => {
|
|
479
550
|
if (addTransitionType) {
|
|
@@ -485,9 +556,6 @@ export function createNavigationBridge(
|
|
|
485
556
|
onUpdate(popstateUpdate);
|
|
486
557
|
}
|
|
487
558
|
|
|
488
|
-
// Restore scroll position for back/forward navigation
|
|
489
|
-
handleNavigationEnd({ restore: true, isStreaming });
|
|
490
|
-
|
|
491
559
|
// SWR: If stale, trigger background revalidation
|
|
492
560
|
if (isStale) {
|
|
493
561
|
debugLog("[Browser] Cache is stale, background revalidating...");
|
|
@@ -557,7 +625,11 @@ export function createNavigationBridge(
|
|
|
557
625
|
intercept: isIntercept,
|
|
558
626
|
interceptSourceUrl,
|
|
559
627
|
}),
|
|
560
|
-
isIntercept
|
|
628
|
+
isIntercept
|
|
629
|
+
? { type: "navigate", interceptSourceUrl }
|
|
630
|
+
: isLeavingIntercept
|
|
631
|
+
? { type: "leave-intercept" }
|
|
632
|
+
: undefined,
|
|
561
633
|
);
|
|
562
634
|
// Restore scroll position after fetch completes
|
|
563
635
|
handleNavigationEnd({ restore: true, isStreaming });
|
|
@@ -634,6 +706,20 @@ export function createNavigationBridge(
|
|
|
634
706
|
window.removeEventListener("pageshow", handlePageShow);
|
|
635
707
|
};
|
|
636
708
|
},
|
|
709
|
+
|
|
710
|
+
getVersion(): string | undefined {
|
|
711
|
+
return version;
|
|
712
|
+
},
|
|
713
|
+
|
|
714
|
+
updateVersion(newVersion: string): void {
|
|
715
|
+
version = newVersion;
|
|
716
|
+
setAppVersion(newVersion);
|
|
717
|
+
store.clearHistoryCache();
|
|
718
|
+
},
|
|
719
|
+
|
|
720
|
+
updateAppShell(next: AppShell): void {
|
|
721
|
+
applyAppShell(next);
|
|
722
|
+
},
|
|
637
723
|
};
|
|
638
724
|
}
|
|
639
725
|
|