@rangojs/router 0.0.0-experimental.cb54cbba → 0.0.0-experimental.debug-cache-2383ca26
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/AGENTS.md +4 -0
- package/dist/bin/rango.js +8 -3
- package/dist/vite/index.js +139 -200
- package/package.json +15 -14
- package/skills/caching/SKILL.md +37 -4
- package/skills/parallel/SKILL.md +126 -0
- package/src/browser/event-controller.ts +5 -0
- package/src/browser/navigation-bridge.ts +1 -3
- package/src/browser/navigation-client.ts +60 -27
- package/src/browser/navigation-transaction.ts +11 -9
- package/src/browser/partial-update.ts +50 -9
- package/src/browser/prefetch/cache.ts +57 -5
- package/src/browser/prefetch/fetch.ts +30 -21
- package/src/browser/prefetch/queue.ts +53 -13
- package/src/browser/react/Link.tsx +9 -1
- package/src/browser/react/NavigationProvider.tsx +27 -0
- package/src/browser/rsc-router.tsx +109 -57
- package/src/browser/scroll-restoration.ts +31 -34
- package/src/browser/segment-reconciler.ts +6 -1
- package/src/browser/types.ts +9 -0
- package/src/build/route-types/router-processing.ts +12 -2
- package/src/cache/cache-runtime.ts +15 -11
- package/src/cache/cache-scope.ts +43 -3
- package/src/cache/cf/cf-cache-store.ts +453 -11
- 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/debug.ts +2 -2
- package/src/route-definition/dsl-helpers.ts +32 -7
- package/src/route-definition/redirect.ts +2 -2
- package/src/route-map-builder.ts +7 -1
- package/src/router/find-match.ts +4 -2
- package/src/router/intercept-resolution.ts +2 -0
- package/src/router/lazy-includes.ts +4 -1
- package/src/router/logging.ts +5 -2
- package/src/router/manifest.ts +9 -3
- package/src/router/match-middleware/background-revalidation.ts +30 -2
- package/src/router/match-middleware/cache-lookup.ts +66 -9
- package/src/router/match-middleware/cache-store.ts +53 -10
- package/src/router/match-middleware/intercept-resolution.ts +9 -7
- package/src/router/match-middleware/segment-resolution.ts +8 -5
- package/src/router/match-result.ts +22 -6
- package/src/router/metrics.ts +6 -1
- package/src/router/middleware.ts +2 -1
- package/src/router/router-context.ts +6 -1
- package/src/router/segment-resolution/fresh.ts +122 -15
- package/src/router/segment-resolution/loader-cache.ts +1 -0
- package/src/router/segment-resolution/revalidation.ts +347 -290
- package/src/router/segment-wrappers.ts +2 -0
- package/src/router.ts +5 -1
- package/src/segment-system.tsx +140 -4
- package/src/server/context.ts +90 -13
- package/src/server/request-context.ts +10 -4
- package/src/ssr/index.tsx +1 -0
- package/src/types/handler-context.ts +103 -17
- package/src/types/route-entry.ts +7 -0
- package/src/types/segments.ts +2 -0
- package/src/urls/path-helper.ts +1 -1
- package/src/vite/discovery/state.ts +0 -2
- package/src/vite/plugin-types.ts +0 -83
- package/src/vite/plugins/expose-action-id.ts +1 -3
- package/src/vite/plugins/version-plugin.ts +13 -1
- package/src/vite/rango.ts +144 -209
- package/src/vite/router-discovery.ts +0 -8
- package/src/vite/utils/banner.ts +3 -3
package/skills/caching/SKILL.md
CHANGED
|
@@ -120,9 +120,9 @@ const store = new MemorySegmentCacheStore({
|
|
|
120
120
|
});
|
|
121
121
|
```
|
|
122
122
|
|
|
123
|
-
### Cloudflare
|
|
123
|
+
### Cloudflare Edge Cache Store
|
|
124
124
|
|
|
125
|
-
For distributed caching on Cloudflare Workers:
|
|
125
|
+
For distributed caching on Cloudflare Workers using the Cache API:
|
|
126
126
|
|
|
127
127
|
```typescript
|
|
128
128
|
import { CFCacheStore } from "@rangojs/router/cache";
|
|
@@ -132,14 +132,47 @@ const router = createRouter<AppBindings>({
|
|
|
132
132
|
urls: urlpatterns,
|
|
133
133
|
cache: (env, ctx) => ({
|
|
134
134
|
store: new CFCacheStore({
|
|
135
|
-
|
|
136
|
-
|
|
135
|
+
ctx,
|
|
136
|
+
defaults: { ttl: 60, swr: 300 },
|
|
137
137
|
}),
|
|
138
138
|
enabled: true,
|
|
139
139
|
}),
|
|
140
140
|
});
|
|
141
141
|
```
|
|
142
142
|
|
|
143
|
+
### With KV L2 Persistence
|
|
144
|
+
|
|
145
|
+
Add a KV namespace for global cross-colo persistence. On Cache API miss, KV is
|
|
146
|
+
checked and hits are promoted back to L1. Writes go to both layers.
|
|
147
|
+
|
|
148
|
+
```typescript
|
|
149
|
+
import { CFCacheStore } from "@rangojs/router/cache";
|
|
150
|
+
|
|
151
|
+
const router = createRouter<AppBindings>({
|
|
152
|
+
document: Document,
|
|
153
|
+
urls: urlpatterns,
|
|
154
|
+
cache: (env, ctx) => ({
|
|
155
|
+
store: new CFCacheStore({
|
|
156
|
+
ctx,
|
|
157
|
+
kv: env.CACHE_KV, // optional KV namespace binding
|
|
158
|
+
defaults: { ttl: 60, swr: 300 },
|
|
159
|
+
}),
|
|
160
|
+
enabled: true,
|
|
161
|
+
}),
|
|
162
|
+
});
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
**How the two layers work:**
|
|
166
|
+
|
|
167
|
+
| Scenario | L1 (Cache API) | L2 (KV) | Result |
|
|
168
|
+
| ------------ | -------------- | ------- | ----------------------------- |
|
|
169
|
+
| Hot request | HIT | — | Serve from L1 (fast) |
|
|
170
|
+
| Cold colo | MISS | HIT | Serve from KV, promote to L1 |
|
|
171
|
+
| First render | MISS | MISS | Render, write to both L1 + KV |
|
|
172
|
+
|
|
173
|
+
KV entries require `expirationTtl >= 60s`. Short-lived entries (< 60s total TTL)
|
|
174
|
+
are only cached in L1.
|
|
175
|
+
|
|
143
176
|
## Nested Cache Boundaries
|
|
144
177
|
|
|
145
178
|
Override cache settings for specific sections:
|
package/skills/parallel/SKILL.md
CHANGED
|
@@ -92,6 +92,73 @@ path("/dashboard/:id", (ctx) => {
|
|
|
92
92
|
])
|
|
93
93
|
```
|
|
94
94
|
|
|
95
|
+
## Setting Handles (Meta, Breadcrumbs)
|
|
96
|
+
|
|
97
|
+
Parallel slot handlers can call `ctx.use(Meta)` or `ctx.use(Breadcrumbs)` to
|
|
98
|
+
push handle data. The data is associated with the **parent** layout or route
|
|
99
|
+
segment, not the parallel segment itself. This is because parallels execute
|
|
100
|
+
after their parent handler and inherit its segment scope.
|
|
101
|
+
|
|
102
|
+
This works well for document-level metadata — the handle data follows the
|
|
103
|
+
parent's lifecycle (appears when the parent is mounted, removed when it
|
|
104
|
+
unmounts).
|
|
105
|
+
|
|
106
|
+
```typescript
|
|
107
|
+
parallel({
|
|
108
|
+
"@meta": (ctx) => {
|
|
109
|
+
const meta = ctx.use(Meta);
|
|
110
|
+
meta({ title: "Product Detail" });
|
|
111
|
+
meta({ name: "description", content: "..." });
|
|
112
|
+
return null; // UI-less slot, only sets metadata
|
|
113
|
+
},
|
|
114
|
+
"@sidebar": (ctx) => <Sidebar />,
|
|
115
|
+
})
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Multiple parallels on the same parent can each push handle data — they all
|
|
119
|
+
accumulate under the parent segment ID.
|
|
120
|
+
|
|
121
|
+
### Pattern: `@meta` slot for per-route metadata overrides
|
|
122
|
+
|
|
123
|
+
A dedicated `@meta` parallel slot lets routes define metadata separately from
|
|
124
|
+
their handler logic. The layout sets defaults via a title template, and each
|
|
125
|
+
route overrides via its own `@meta` slot. Since child segments push after
|
|
126
|
+
parents and `collectMeta` uses last-wins deduplication, overrides work
|
|
127
|
+
naturally.
|
|
128
|
+
|
|
129
|
+
```typescript
|
|
130
|
+
// Layout sets defaults
|
|
131
|
+
layout((ctx) => {
|
|
132
|
+
ctx.use(Meta)({ title: { template: "%s | Store", default: "Store" } });
|
|
133
|
+
return <StoreLayout />;
|
|
134
|
+
}, () => [
|
|
135
|
+
// Route with @meta override — decoupled from handler rendering
|
|
136
|
+
path("/:slug", ProductPage, { name: "product" }, () => [
|
|
137
|
+
parallel({
|
|
138
|
+
"@meta": async (ctx) => {
|
|
139
|
+
const product = await ctx.use(ProductLoader);
|
|
140
|
+
const meta = ctx.use(Meta);
|
|
141
|
+
meta({ title: product.name });
|
|
142
|
+
meta({ name: "description", content: product.description });
|
|
143
|
+
meta({
|
|
144
|
+
"script:ld+json": {
|
|
145
|
+
"@context": "https://schema.org",
|
|
146
|
+
"@type": "Product",
|
|
147
|
+
name: product.name,
|
|
148
|
+
description: product.description,
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
return null; // UI-less slot
|
|
152
|
+
},
|
|
153
|
+
}),
|
|
154
|
+
]),
|
|
155
|
+
])
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
This keeps the route handler focused on rendering UI while metadata
|
|
159
|
+
(title, description, Open Graph, JSON-LD) lives in a composable slot that
|
|
160
|
+
can be added, removed, or swapped per route without touching the handler.
|
|
161
|
+
|
|
95
162
|
## Parallel Routes with Loaders
|
|
96
163
|
|
|
97
164
|
Add loaders and loading states to parallel routes:
|
|
@@ -109,6 +176,65 @@ parallel(
|
|
|
109
176
|
)
|
|
110
177
|
```
|
|
111
178
|
|
|
179
|
+
### Streaming Behavior
|
|
180
|
+
|
|
181
|
+
Parallels with `loading()` are **independent streaming units**. They don't
|
|
182
|
+
block the parent layout or sibling routes during SSR:
|
|
183
|
+
|
|
184
|
+
- **With `loading()`**: The skeleton renders immediately. The loader runs
|
|
185
|
+
in the background and streams data to the client when ready. The rest
|
|
186
|
+
of the page (layout, route content, other parallels) renders without
|
|
187
|
+
waiting.
|
|
188
|
+
- **Without `loading()`**: The parallel's loaders block the parent layout's
|
|
189
|
+
rendering. Use this when the data must be available before the page
|
|
190
|
+
paints (e.g., critical above-the-fold content).
|
|
191
|
+
- **SPA navigation**: Parallel loaders resolve in the background. The
|
|
192
|
+
existing parallel UI stays visible — no skeleton flash on route changes
|
|
193
|
+
within the same layout.
|
|
194
|
+
|
|
195
|
+
```typescript
|
|
196
|
+
// Sidebar streams independently — page renders immediately
|
|
197
|
+
parallel(
|
|
198
|
+
{ "@sidebar": () => <Sidebar /> },
|
|
199
|
+
() => [loader(SlowSidebarLoader), loading(<SidebarSkeleton />)]
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
// Cart data blocks layout — must be ready before paint
|
|
203
|
+
parallel(
|
|
204
|
+
{ "@cartBadge": () => <CartBadge /> },
|
|
205
|
+
() => [loader(CartCountLoader)] // No loading() = awaited
|
|
206
|
+
)
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
## Slot Override Semantics
|
|
210
|
+
|
|
211
|
+
When multiple `parallel()` calls define the same slot name, **the last
|
|
212
|
+
definition wins**. Earlier definitions of that slot are removed. Other
|
|
213
|
+
slots from the earlier call are preserved.
|
|
214
|
+
|
|
215
|
+
This enables composition patterns where included routes override
|
|
216
|
+
parent-defined slots:
|
|
217
|
+
|
|
218
|
+
```typescript
|
|
219
|
+
layout(DashboardLayout, () => [
|
|
220
|
+
// Base slots
|
|
221
|
+
parallel({
|
|
222
|
+
"@sidebar": () => <DefaultSidebar />,
|
|
223
|
+
"@footer": () => <Footer />,
|
|
224
|
+
}),
|
|
225
|
+
|
|
226
|
+
// Override just @sidebar — @footer is preserved
|
|
227
|
+
parallel({ "@sidebar": () => <CustomSidebar /> }),
|
|
228
|
+
|
|
229
|
+
path("/", DashboardIndex, { name: "index" }),
|
|
230
|
+
])
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
After resolution, the layout has two parallel entries:
|
|
234
|
+
|
|
235
|
+
- `{ "@footer": () => <Footer /> }` (first call, `@sidebar` removed)
|
|
236
|
+
- `{ "@sidebar": () => <CustomSidebar /> }` (second call, wins)
|
|
237
|
+
|
|
112
238
|
## Multiple Parallel Slots
|
|
113
239
|
|
|
114
240
|
```typescript
|
|
@@ -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) */
|
|
@@ -389,6 +391,9 @@ export function createEventController(
|
|
|
389
391
|
return {
|
|
390
392
|
state,
|
|
391
393
|
isStreaming,
|
|
394
|
+
// True when a navigation is active (fetching or streaming, before
|
|
395
|
+
// commit). Broader than pendingUrl which clears during streaming.
|
|
396
|
+
isNavigating: currentNavigation !== null,
|
|
392
397
|
location,
|
|
393
398
|
// pendingUrl only during fetching phase - once streaming starts (URL changed), not pending.
|
|
394
399
|
// Background revalidations (skipLoadingState) don't expose a pending URL.
|
|
@@ -472,6 +472,7 @@ export function createNavigationBridge(
|
|
|
472
472
|
cachedHandleData,
|
|
473
473
|
params: cachedParams,
|
|
474
474
|
},
|
|
475
|
+
scroll: { restore: true, isStreaming },
|
|
475
476
|
};
|
|
476
477
|
const hasTransition = cachedSegments.some((s) => s.transition);
|
|
477
478
|
if (hasTransition) {
|
|
@@ -485,9 +486,6 @@ export function createNavigationBridge(
|
|
|
485
486
|
onUpdate(popstateUpdate);
|
|
486
487
|
}
|
|
487
488
|
|
|
488
|
-
// Restore scroll position for back/forward navigation
|
|
489
|
-
handleNavigationEnd({ restore: true, isStreaming });
|
|
490
|
-
|
|
491
489
|
// SWR: If stale, trigger background revalidation
|
|
492
490
|
if (isStale) {
|
|
493
491
|
debugLog("[Browser] Cache is stale, background revalidating...");
|
|
@@ -17,7 +17,11 @@ import {
|
|
|
17
17
|
emptyResponse,
|
|
18
18
|
teeWithCompletion,
|
|
19
19
|
} from "./response-adapter.js";
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
buildPrefetchKey,
|
|
22
|
+
consumeInflightPrefetch,
|
|
23
|
+
consumePrefetch,
|
|
24
|
+
} from "./prefetch/cache.js";
|
|
21
25
|
|
|
22
26
|
/**
|
|
23
27
|
* Create a navigation client for fetching RSC payloads
|
|
@@ -85,49 +89,33 @@ export function createNavigationClient(
|
|
|
85
89
|
fetchUrl.searchParams.set("_rsc_v", version);
|
|
86
90
|
}
|
|
87
91
|
|
|
88
|
-
// Check in-memory prefetch cache before making a network request.
|
|
92
|
+
// Check completed in-memory prefetch cache before making a network request.
|
|
89
93
|
// The cache key includes the source URL (previousUrl) because the
|
|
90
94
|
// server's diff response depends on the source page context.
|
|
91
95
|
// Skip cache for stale revalidation (needs fresh data), HMR (needs
|
|
92
96
|
// fresh modules), and intercept contexts (source-dependent responses).
|
|
97
|
+
//
|
|
98
|
+
const canUsePrefetch = !staleRevalidation && !hmr && !interceptSourceUrl;
|
|
93
99
|
const cacheKey = buildPrefetchKey(previousUrl, fetchUrl);
|
|
94
|
-
const cachedResponse =
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
100
|
+
const cachedResponse = canUsePrefetch ? consumePrefetch(cacheKey) : null;
|
|
101
|
+
const inflightResponsePromise = canUsePrefetch
|
|
102
|
+
? consumeInflightPrefetch(cacheKey)
|
|
103
|
+
: null;
|
|
99
104
|
// Track when the stream completes
|
|
100
105
|
let resolveStreamComplete: () => void;
|
|
101
106
|
const streamComplete = new Promise<void>((resolve) => {
|
|
102
107
|
resolveStreamComplete = resolve;
|
|
103
108
|
});
|
|
104
109
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
if (cachedResponse) {
|
|
108
|
-
if (tx) {
|
|
109
|
-
browserDebugLog(tx, "prefetch cache hit", { key: cacheKey });
|
|
110
|
-
}
|
|
111
|
-
// Cached response body is already fully buffered (arrayBuffer),
|
|
112
|
-
// so stream completion is immediate.
|
|
113
|
-
responsePromise = Promise.resolve(cachedResponse).then((response) => {
|
|
114
|
-
return teeWithCompletion(
|
|
115
|
-
response,
|
|
116
|
-
() => {
|
|
117
|
-
if (tx) browserDebugLog(tx, "stream complete (from cache)");
|
|
118
|
-
resolveStreamComplete();
|
|
119
|
-
},
|
|
120
|
-
signal,
|
|
121
|
-
);
|
|
122
|
-
});
|
|
123
|
-
} else {
|
|
110
|
+
/** Start a fresh navigation fetch (no cache / inflight hit). */
|
|
111
|
+
const doFreshFetch = (): Promise<Response> => {
|
|
124
112
|
if (tx) {
|
|
125
113
|
browserDebugLog(tx, "fetching", {
|
|
126
114
|
path: `${fetchUrl.pathname}${fetchUrl.search}`,
|
|
127
115
|
});
|
|
128
116
|
}
|
|
129
117
|
|
|
130
|
-
|
|
118
|
+
return fetch(fetchUrl, {
|
|
131
119
|
headers: {
|
|
132
120
|
"X-RSC-Router-Client-Path": previousUrl,
|
|
133
121
|
"X-Rango-State": getRangoState(),
|
|
@@ -183,6 +171,51 @@ export function createNavigationClient(
|
|
|
183
171
|
signal,
|
|
184
172
|
);
|
|
185
173
|
});
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
let responsePromise: Promise<Response>;
|
|
177
|
+
|
|
178
|
+
if (cachedResponse) {
|
|
179
|
+
if (tx) {
|
|
180
|
+
browserDebugLog(tx, "prefetch cache hit", { key: cacheKey });
|
|
181
|
+
}
|
|
182
|
+
// Cached response body is already fully buffered (arrayBuffer),
|
|
183
|
+
// so stream completion is immediate.
|
|
184
|
+
responsePromise = Promise.resolve(cachedResponse).then((response) => {
|
|
185
|
+
return teeWithCompletion(
|
|
186
|
+
response,
|
|
187
|
+
() => {
|
|
188
|
+
if (tx) browserDebugLog(tx, "stream complete (from cache)");
|
|
189
|
+
resolveStreamComplete();
|
|
190
|
+
},
|
|
191
|
+
signal,
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
} else if (inflightResponsePromise) {
|
|
195
|
+
if (tx) {
|
|
196
|
+
browserDebugLog(tx, "reusing inflight prefetch", { key: cacheKey });
|
|
197
|
+
}
|
|
198
|
+
responsePromise = inflightResponsePromise.then(async (response) => {
|
|
199
|
+
if (!response) {
|
|
200
|
+
if (tx) {
|
|
201
|
+
browserDebugLog(tx, "inflight prefetch unavailable, refetching");
|
|
202
|
+
}
|
|
203
|
+
return doFreshFetch();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return teeWithCompletion(
|
|
207
|
+
response,
|
|
208
|
+
() => {
|
|
209
|
+
if (tx) {
|
|
210
|
+
browserDebugLog(tx, "stream complete (from inflight prefetch)");
|
|
211
|
+
}
|
|
212
|
+
resolveStreamComplete();
|
|
213
|
+
},
|
|
214
|
+
signal,
|
|
215
|
+
);
|
|
216
|
+
});
|
|
217
|
+
} else {
|
|
218
|
+
responsePromise = doFreshFetch();
|
|
186
219
|
}
|
|
187
220
|
|
|
188
221
|
try {
|
|
@@ -7,7 +7,6 @@ import type {
|
|
|
7
7
|
import { generateHistoryKey } from "./navigation-store.js";
|
|
8
8
|
import {
|
|
9
9
|
handleNavigationStart,
|
|
10
|
-
handleNavigationEnd,
|
|
11
10
|
ensureHistoryKey,
|
|
12
11
|
} from "./scroll-restoration.js";
|
|
13
12
|
import type { EventController, NavigationHandle } from "./event-controller.js";
|
|
@@ -81,11 +80,12 @@ export interface BoundTransaction {
|
|
|
81
80
|
readonly currentUrl: string;
|
|
82
81
|
/** Start streaming and get a token to end it when the stream completes */
|
|
83
82
|
startStreaming(): StreamingToken;
|
|
83
|
+
/** Commit the navigation. Returns the effective scroll option for the caller to handle. */
|
|
84
84
|
commit(
|
|
85
85
|
segmentIds: string[],
|
|
86
86
|
segments: ResolvedSegment[],
|
|
87
87
|
overrides?: BoundCommitOverrides,
|
|
88
|
-
):
|
|
88
|
+
): { scroll?: boolean };
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
/**
|
|
@@ -93,7 +93,7 @@ export interface BoundTransaction {
|
|
|
93
93
|
* Uses the event controller handle for lifecycle management
|
|
94
94
|
*/
|
|
95
95
|
interface NavigationTransaction extends Disposable {
|
|
96
|
-
commit(options: CommitOptions):
|
|
96
|
+
commit(options: CommitOptions): { scroll?: boolean };
|
|
97
97
|
with(
|
|
98
98
|
options: Omit<CommitOptions, "segmentIds" | "segments">,
|
|
99
99
|
): BoundTransaction;
|
|
@@ -120,7 +120,7 @@ export function createNavigationTransaction(
|
|
|
120
120
|
/**
|
|
121
121
|
* Commit the navigation - updates store and URL atomically
|
|
122
122
|
*/
|
|
123
|
-
function commit(opts: CommitOptions):
|
|
123
|
+
function commit(opts: CommitOptions): { scroll?: boolean } {
|
|
124
124
|
committed = true;
|
|
125
125
|
|
|
126
126
|
const {
|
|
@@ -150,7 +150,7 @@ export function createNavigationTransaction(
|
|
|
150
150
|
// Without this, the entry lingers and weakens state-machine invariants.
|
|
151
151
|
handle.complete(parsedUrl);
|
|
152
152
|
debugLog("[Browser] Cache-only commit, historyKey:", historyKey);
|
|
153
|
-
return;
|
|
153
|
+
return { scroll: false };
|
|
154
154
|
}
|
|
155
155
|
|
|
156
156
|
// Save current scroll position before navigating
|
|
@@ -172,7 +172,7 @@ export function createNavigationTransaction(
|
|
|
172
172
|
debugLog("[Browser] Store updated (action)");
|
|
173
173
|
// Complete navigation to clear loading state
|
|
174
174
|
handle.complete(parsedUrl);
|
|
175
|
-
return;
|
|
175
|
+
return { scroll: false };
|
|
176
176
|
}
|
|
177
177
|
|
|
178
178
|
// Build history state - include user state, intercept info, and server-set state
|
|
@@ -205,14 +205,16 @@ export function createNavigationTransaction(
|
|
|
205
205
|
// Complete the navigation in event controller (sets idle state, updates location)
|
|
206
206
|
handle.complete(parsedUrl);
|
|
207
207
|
|
|
208
|
-
//
|
|
209
|
-
|
|
208
|
+
// NOTE: Scroll is NOT handled here. The caller (partial-update.ts) handles
|
|
209
|
+
// scroll AFTER onUpdate() so React has the new content before we scroll.
|
|
210
210
|
|
|
211
211
|
debugLog(
|
|
212
212
|
"[Browser] Navigation committed, historyKey:",
|
|
213
213
|
historyKey,
|
|
214
214
|
intercept ? "(intercept)" : "",
|
|
215
215
|
);
|
|
216
|
+
|
|
217
|
+
return { scroll };
|
|
216
218
|
}
|
|
217
219
|
|
|
218
220
|
return {
|
|
@@ -263,7 +265,7 @@ export function createNavigationTransaction(
|
|
|
263
265
|
overrides?.state !== undefined ? overrides.state : opts.state;
|
|
264
266
|
// Server-set location state: only from overrides (set by partial-update)
|
|
265
267
|
const serverState = overrides?.serverState;
|
|
266
|
-
commit({
|
|
268
|
+
return commit({
|
|
267
269
|
...opts,
|
|
268
270
|
segmentIds,
|
|
269
271
|
segments,
|
|
@@ -19,6 +19,14 @@ import type { BoundTransaction } from "./navigation-transaction.js";
|
|
|
19
19
|
import { ServerRedirect } from "../errors.js";
|
|
20
20
|
import { debugLog } from "./logging.js";
|
|
21
21
|
import { validateRedirectOrigin } from "./validate-redirect-origin.js";
|
|
22
|
+
import type { NavigationUpdate } from "./types.js";
|
|
23
|
+
|
|
24
|
+
/** Build a scroll payload from the commit's scroll option */
|
|
25
|
+
function toScrollPayload(
|
|
26
|
+
scroll: boolean | undefined,
|
|
27
|
+
): NonNullable<NavigationUpdate["scroll"]> {
|
|
28
|
+
return { enabled: scroll !== false ? scroll : false };
|
|
29
|
+
}
|
|
22
30
|
|
|
23
31
|
/**
|
|
24
32
|
* Configuration for creating a partial updater
|
|
@@ -246,7 +254,21 @@ export function createPartialUpdater(
|
|
|
246
254
|
forceAwait: true,
|
|
247
255
|
});
|
|
248
256
|
|
|
249
|
-
tx.commit(
|
|
257
|
+
const { scroll: commitScroll } = tx.commit(
|
|
258
|
+
matchedIds,
|
|
259
|
+
existingSegments,
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
// Fix: tx.commit() cached the source page's handleData because
|
|
263
|
+
// eventController hasn't been updated yet. Overwrite with the
|
|
264
|
+
// correct cached handleData to prevent cache corruption on
|
|
265
|
+
// subsequent navigations to this same URL.
|
|
266
|
+
if (mode.targetCacheHandleData) {
|
|
267
|
+
store.updateCacheHandleData(
|
|
268
|
+
store.getHistoryKey(),
|
|
269
|
+
mode.targetCacheHandleData,
|
|
270
|
+
);
|
|
271
|
+
}
|
|
250
272
|
|
|
251
273
|
// Include cachedHandleData in metadata so NavigationProvider can restore
|
|
252
274
|
// breadcrumbs and other handle data from cache.
|
|
@@ -260,6 +282,7 @@ export function createPartialUpdater(
|
|
|
260
282
|
...metadataWithoutHandles,
|
|
261
283
|
cachedHandleData: mode.targetCacheHandleData,
|
|
262
284
|
},
|
|
285
|
+
scroll: toScrollPayload(commitScroll),
|
|
263
286
|
};
|
|
264
287
|
|
|
265
288
|
const cachedHasTransition = existingSegments.some(
|
|
@@ -290,11 +313,15 @@ export function createPartialUpdater(
|
|
|
290
313
|
forceAwait: true,
|
|
291
314
|
});
|
|
292
315
|
|
|
293
|
-
tx.commit(
|
|
316
|
+
const { scroll: leaveScroll } = tx.commit(
|
|
317
|
+
matchedIds,
|
|
318
|
+
existingSegments,
|
|
319
|
+
);
|
|
294
320
|
|
|
295
321
|
onUpdate({
|
|
296
322
|
root: newTree,
|
|
297
323
|
metadata: payload.metadata,
|
|
324
|
+
scroll: toScrollPayload(leaveScroll),
|
|
298
325
|
});
|
|
299
326
|
|
|
300
327
|
debugLog("[Browser] Navigation complete (left intercept)");
|
|
@@ -426,7 +453,11 @@ export function createPartialUpdater(
|
|
|
426
453
|
: serverLocationState
|
|
427
454
|
? { serverState: serverLocationState }
|
|
428
455
|
: undefined;
|
|
429
|
-
tx.commit(
|
|
456
|
+
const { scroll: navScroll } = tx.commit(
|
|
457
|
+
allSegmentIds,
|
|
458
|
+
reconciled.segments,
|
|
459
|
+
overrides,
|
|
460
|
+
);
|
|
430
461
|
|
|
431
462
|
// For stale revalidation: verify history key hasn't changed before updating UI
|
|
432
463
|
if (mode.type === "stale-revalidation") {
|
|
@@ -441,8 +472,10 @@ export function createPartialUpdater(
|
|
|
441
472
|
|
|
442
473
|
debugLog("[partial-update] updating document");
|
|
443
474
|
|
|
444
|
-
// Emit update to trigger React render
|
|
475
|
+
// Emit update to trigger React render.
|
|
476
|
+
// Scroll info is included so NavigationProvider applies it after React commits.
|
|
445
477
|
const hasTransition = reconciled.mainSegments.some((s) => s.transition);
|
|
478
|
+
const scrollPayload = toScrollPayload(navScroll);
|
|
446
479
|
|
|
447
480
|
if (mode.type === "action" || mode.type === "stale-revalidation") {
|
|
448
481
|
startTransition(() => {
|
|
@@ -452,6 +485,7 @@ export function createPartialUpdater(
|
|
|
452
485
|
onUpdate({
|
|
453
486
|
root: newTree,
|
|
454
487
|
metadata: payload.metadata!,
|
|
488
|
+
scroll: scrollPayload,
|
|
455
489
|
});
|
|
456
490
|
});
|
|
457
491
|
} else if (hasTransition) {
|
|
@@ -462,12 +496,14 @@ export function createPartialUpdater(
|
|
|
462
496
|
onUpdate({
|
|
463
497
|
root: newTree,
|
|
464
498
|
metadata: payload.metadata!,
|
|
499
|
+
scroll: scrollPayload,
|
|
465
500
|
});
|
|
466
501
|
});
|
|
467
502
|
} else {
|
|
468
503
|
onUpdate({
|
|
469
504
|
root: newTree,
|
|
470
505
|
metadata: payload.metadata!,
|
|
506
|
+
scroll: scrollPayload,
|
|
471
507
|
});
|
|
472
508
|
}
|
|
473
509
|
|
|
@@ -494,15 +530,16 @@ export function createPartialUpdater(
|
|
|
494
530
|
}
|
|
495
531
|
|
|
496
532
|
const fullUpdateServerState = payload.metadata?.locationState;
|
|
497
|
-
|
|
498
|
-
tx.commit(segmentIds, segments, {
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
533
|
+
const { scroll: fullScroll } = fullUpdateServerState
|
|
534
|
+
? tx.commit(segmentIds, segments, {
|
|
535
|
+
serverState: fullUpdateServerState,
|
|
536
|
+
})
|
|
537
|
+
: tx.commit(segmentIds, segments);
|
|
502
538
|
|
|
503
539
|
const fullHasTransition = segments.some(
|
|
504
540
|
(s: ResolvedSegment) => s.transition,
|
|
505
541
|
);
|
|
542
|
+
const fullScrollPayload = toScrollPayload(fullScroll);
|
|
506
543
|
|
|
507
544
|
if (mode.type === "stale-revalidation") {
|
|
508
545
|
await rawStreamComplete;
|
|
@@ -513,6 +550,7 @@ export function createPartialUpdater(
|
|
|
513
550
|
onUpdate({
|
|
514
551
|
root: newTree,
|
|
515
552
|
metadata: payload.metadata!,
|
|
553
|
+
scroll: fullScrollPayload,
|
|
516
554
|
});
|
|
517
555
|
});
|
|
518
556
|
} else if (mode.type === "action") {
|
|
@@ -523,6 +561,7 @@ export function createPartialUpdater(
|
|
|
523
561
|
onUpdate({
|
|
524
562
|
root: newTree,
|
|
525
563
|
metadata: payload.metadata!,
|
|
564
|
+
scroll: fullScrollPayload,
|
|
526
565
|
});
|
|
527
566
|
});
|
|
528
567
|
} else if (fullHasTransition) {
|
|
@@ -533,12 +572,14 @@ export function createPartialUpdater(
|
|
|
533
572
|
onUpdate({
|
|
534
573
|
root: newTree,
|
|
535
574
|
metadata: payload.metadata!,
|
|
575
|
+
scroll: fullScrollPayload,
|
|
536
576
|
});
|
|
537
577
|
});
|
|
538
578
|
} else {
|
|
539
579
|
onUpdate({
|
|
540
580
|
root: newTree,
|
|
541
581
|
metadata: payload.metadata!,
|
|
582
|
+
scroll: fullScrollPayload,
|
|
542
583
|
});
|
|
543
584
|
}
|
|
544
585
|
|