@rangojs/router 0.0.0-experimental.19 → 0.0.0-experimental.1fa245e2
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/{CLAUDE.md → AGENTS.md} +4 -0
- package/README.md +122 -30
- package/dist/bin/rango.js +245 -63
- package/dist/vite/index.js +859 -418
- package/package.json +3 -3
- package/skills/breadcrumbs/SKILL.md +250 -0
- package/skills/cache-guide/SKILL.md +32 -0
- package/skills/caching/SKILL.md +49 -8
- package/skills/document-cache/SKILL.md +2 -2
- package/skills/hooks/SKILL.md +33 -31
- package/skills/host-router/SKILL.md +218 -0
- package/skills/links/SKILL.md +3 -1
- package/skills/loader/SKILL.md +72 -22
- package/skills/middleware/SKILL.md +2 -0
- package/skills/parallel/SKILL.md +126 -0
- package/skills/prerender/SKILL.md +112 -70
- package/skills/rango/SKILL.md +0 -1
- package/skills/route/SKILL.md +34 -4
- package/skills/router-setup/SKILL.md +95 -5
- package/skills/typesafety/SKILL.md +35 -23
- package/src/__internal.ts +92 -0
- package/src/bin/rango.ts +18 -0
- package/src/browser/app-version.ts +14 -0
- package/src/browser/event-controller.ts +5 -0
- package/src/browser/link-interceptor.ts +4 -0
- package/src/browser/navigation-bridge.ts +114 -18
- package/src/browser/navigation-client.ts +126 -44
- package/src/browser/navigation-store.ts +43 -8
- package/src/browser/navigation-transaction.ts +11 -9
- package/src/browser/partial-update.ts +80 -15
- package/src/browser/prefetch/cache.ts +166 -27
- package/src/browser/prefetch/fetch.ts +52 -39
- package/src/browser/prefetch/policy.ts +6 -0
- package/src/browser/prefetch/queue.ts +92 -20
- package/src/browser/prefetch/resource-ready.ts +77 -0
- package/src/browser/react/Link.tsx +70 -14
- package/src/browser/react/NavigationProvider.tsx +40 -4
- package/src/browser/react/context.ts +7 -2
- package/src/browser/react/use-handle.ts +9 -58
- package/src/browser/react/use-router.ts +21 -8
- package/src/browser/rsc-router.tsx +143 -59
- package/src/browser/scroll-restoration.ts +41 -42
- package/src/browser/segment-reconciler.ts +6 -1
- package/src/browser/server-action-bridge.ts +454 -436
- package/src/browser/types.ts +60 -5
- package/src/build/generate-manifest.ts +6 -6
- package/src/build/generate-route-types.ts +5 -0
- package/src/build/route-trie.ts +19 -3
- package/src/build/route-types/include-resolution.ts +8 -1
- package/src/build/route-types/router-processing.ts +346 -87
- package/src/build/route-types/scan-filter.ts +8 -1
- package/src/cache/cache-runtime.ts +15 -11
- package/src/cache/cache-scope.ts +48 -7
- 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/cache/taint.ts +55 -0
- package/src/client.rsc.tsx +2 -1
- package/src/client.tsx +3 -102
- package/src/context-var.ts +72 -2
- package/src/debug.ts +2 -2
- package/src/handle.ts +40 -0
- package/src/handles/breadcrumbs.ts +66 -0
- package/src/handles/index.ts +1 -0
- package/src/host/index.ts +0 -3
- package/src/index.rsc.ts +8 -37
- package/src/index.ts +40 -66
- package/src/prerender/store.ts +57 -15
- package/src/prerender.ts +138 -77
- package/src/reverse.ts +22 -1
- package/src/route-definition/dsl-helpers.ts +73 -25
- package/src/route-definition/helpers-types.ts +10 -6
- package/src/route-definition/index.ts +3 -3
- package/src/route-definition/redirect.ts +11 -3
- package/src/route-definition/resolve-handler-use.ts +149 -0
- package/src/route-map-builder.ts +7 -1
- package/src/route-types.ts +11 -0
- package/src/router/content-negotiation.ts +100 -1
- package/src/router/find-match.ts +4 -2
- package/src/router/handler-context.ts +108 -25
- package/src/router/intercept-resolution.ts +11 -4
- package/src/router/lazy-includes.ts +4 -1
- package/src/router/loader-resolution.ts +123 -11
- package/src/router/logging.ts +5 -2
- package/src/router/manifest.ts +9 -3
- package/src/router/match-api.ts +125 -190
- package/src/router/match-middleware/background-revalidation.ts +30 -2
- package/src/router/match-middleware/cache-lookup.ts +88 -16
- 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 +61 -5
- package/src/router/match-result.ts +22 -15
- package/src/router/metrics.ts +238 -13
- package/src/router/middleware-types.ts +53 -12
- package/src/router/middleware.ts +172 -85
- package/src/router/navigation-snapshot.ts +182 -0
- package/src/router/pattern-matching.ts +20 -5
- package/src/router/prerender-match.ts +114 -10
- package/src/router/preview-match.ts +30 -102
- package/src/router/request-classification.ts +310 -0
- package/src/router/revalidation.ts +27 -7
- package/src/router/route-snapshot.ts +245 -0
- package/src/router/router-context.ts +6 -1
- package/src/router/router-interfaces.ts +50 -5
- package/src/router/router-options.ts +50 -19
- package/src/router/segment-resolution/fresh.ts +200 -19
- package/src/router/segment-resolution/helpers.ts +30 -25
- package/src/router/segment-resolution/loader-cache.ts +1 -0
- package/src/router/segment-resolution/revalidation.ts +429 -301
- package/src/router/segment-wrappers.ts +2 -0
- package/src/router/trie-matching.ts +20 -2
- package/src/router/types.ts +1 -0
- package/src/router.ts +88 -15
- package/src/rsc/handler.ts +546 -359
- package/src/rsc/index.ts +0 -20
- package/src/rsc/manifest-init.ts +5 -1
- package/src/rsc/progressive-enhancement.ts +25 -8
- package/src/rsc/rsc-rendering.ts +35 -43
- package/src/rsc/server-action.ts +16 -10
- package/src/rsc/ssr-setup.ts +128 -0
- package/src/rsc/types.ts +10 -1
- package/src/search-params.ts +16 -13
- package/src/segment-system.tsx +140 -4
- package/src/server/context.ts +148 -16
- package/src/server/loader-registry.ts +9 -8
- package/src/server/request-context.ts +182 -34
- package/src/server.ts +6 -0
- package/src/ssr/index.tsx +4 -0
- package/src/static-handler.ts +18 -6
- package/src/theme/index.ts +4 -13
- package/src/types/cache-types.ts +4 -4
- package/src/types/handler-context.ts +149 -49
- package/src/types/loader-types.ts +36 -9
- package/src/types/route-config.ts +17 -8
- package/src/types/route-entry.ts +8 -1
- package/src/types/segments.ts +2 -5
- package/src/urls/path-helper-types.ts +9 -2
- package/src/urls/path-helper.ts +48 -13
- package/src/urls/pattern-types.ts +12 -0
- package/src/urls/response-types.ts +16 -6
- package/src/use-loader.tsx +73 -4
- package/src/vite/discovery/bundle-postprocess.ts +61 -89
- package/src/vite/discovery/discover-routers.ts +23 -5
- package/src/vite/discovery/prerender-collection.ts +48 -15
- package/src/vite/discovery/state.ts +17 -13
- package/src/vite/index.ts +8 -3
- package/src/vite/plugin-types.ts +51 -79
- package/src/vite/plugins/client-ref-dedup.ts +115 -0
- package/src/vite/plugins/expose-action-id.ts +1 -3
- package/src/vite/plugins/performance-tracks.ts +88 -0
- package/src/vite/plugins/refresh-cmd.ts +127 -0
- package/src/vite/plugins/version-plugin.ts +13 -1
- package/src/vite/rango.ts +174 -211
- package/src/vite/router-discovery.ts +169 -42
- package/src/vite/utils/banner.ts +3 -3
- package/src/vite/utils/prerender-utils.ts +78 -0
- package/src/vite/utils/shared-utils.ts +3 -2
- package/skills/testing/SKILL.md +0 -226
- package/src/route-definition/route-function.ts +0 -119
|
@@ -22,6 +22,8 @@ import type {
|
|
|
22
22
|
import type { EventController } from "./event-controller.js";
|
|
23
23
|
import type { ResolvedThemeConfig, Theme } from "../theme/types.js";
|
|
24
24
|
import { initRangoState } from "./rango-state.js";
|
|
25
|
+
import { initPrefetchCache } from "./prefetch/cache.js";
|
|
26
|
+
import { setAppVersion } from "./app-version.js";
|
|
25
27
|
import {
|
|
26
28
|
isInterceptSegment,
|
|
27
29
|
splitInterceptSegments,
|
|
@@ -138,7 +140,6 @@ export async function initBrowserApp(
|
|
|
138
140
|
initialTheme,
|
|
139
141
|
} = options;
|
|
140
142
|
|
|
141
|
-
// Load initial payload from SSR-injected __FLIGHT_DATA__
|
|
142
143
|
const initialPayload =
|
|
143
144
|
await deps.createFromReadableStream<RscPayload>(rscStream);
|
|
144
145
|
|
|
@@ -163,6 +164,12 @@ export async function initBrowserApp(
|
|
|
163
164
|
...(storeOptions?.cacheSize && { cacheSize: storeOptions.cacheSize }),
|
|
164
165
|
});
|
|
165
166
|
|
|
167
|
+
// Seed router identity from the initial SSR payload so the first
|
|
168
|
+
// cross-app SPA navigation can detect the app switch.
|
|
169
|
+
if (initialPayload.metadata?.routerId) {
|
|
170
|
+
store.setRouterId?.(initialPayload.metadata.routerId);
|
|
171
|
+
}
|
|
172
|
+
|
|
166
173
|
// Create event controller for reactive state management
|
|
167
174
|
const eventController = createEventController({
|
|
168
175
|
initialLocation: new URL(window.location.href),
|
|
@@ -201,9 +208,17 @@ export async function initBrowserApp(
|
|
|
201
208
|
const rootLayout = initialPayload.metadata?.rootLayout;
|
|
202
209
|
const version = initialPayload.metadata?.version;
|
|
203
210
|
|
|
204
|
-
// Initialize the localStorage state key for
|
|
211
|
+
// Initialize the localStorage state key for cache invalidation.
|
|
205
212
|
// Uses the build version so a new deploy automatically busts all cached prefetches.
|
|
206
213
|
initRangoState(version ?? "0");
|
|
214
|
+
setAppVersion(version);
|
|
215
|
+
|
|
216
|
+
// Initialize the in-memory prefetch cache TTL from server config.
|
|
217
|
+
// A value of 0 disables the cache; undefined falls back to the module default.
|
|
218
|
+
const prefetchCacheTTL = initialPayload.metadata?.prefetchCacheTTL;
|
|
219
|
+
if (prefetchCacheTTL !== undefined) {
|
|
220
|
+
initPrefetchCache(prefetchCacheTTL);
|
|
221
|
+
}
|
|
207
222
|
|
|
208
223
|
// Create a bound renderSegments that includes rootLayout
|
|
209
224
|
const renderSegments = (
|
|
@@ -223,7 +238,6 @@ export async function initBrowserApp(
|
|
|
223
238
|
deps,
|
|
224
239
|
onUpdate: (update) => store.emitUpdate(update),
|
|
225
240
|
renderSegments,
|
|
226
|
-
version,
|
|
227
241
|
onNavigate: (url, options) => {
|
|
228
242
|
if (!navigateFn) {
|
|
229
243
|
window.location.href = url;
|
|
@@ -241,7 +255,7 @@ export async function initBrowserApp(
|
|
|
241
255
|
client,
|
|
242
256
|
onUpdate: (update) => store.emitUpdate(update),
|
|
243
257
|
renderSegments,
|
|
244
|
-
version,
|
|
258
|
+
version: version,
|
|
245
259
|
});
|
|
246
260
|
|
|
247
261
|
// Connect action redirect → navigation bridge (now that both are initialized)
|
|
@@ -255,70 +269,139 @@ export async function initBrowserApp(
|
|
|
255
269
|
// Build initial tree with rootLayout
|
|
256
270
|
const initialTree = renderSegments(initialPayload.metadata!.segments);
|
|
257
271
|
|
|
258
|
-
// Setup HMR
|
|
272
|
+
// Setup HMR with debounce — burst saves (format-on-save, rapid edits)
|
|
273
|
+
// fire many rsc:update events in quick succession. Without debouncing,
|
|
274
|
+
// each event triggers a fetchPartial() which on slow routes can pile up
|
|
275
|
+
// and overwhelm the worker (cross-request promise issues, 500s).
|
|
259
276
|
if (import.meta.hot) {
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
+
let hmrTimer: ReturnType<typeof setTimeout> | null = null;
|
|
278
|
+
let hmrAbort: AbortController | null = null;
|
|
279
|
+
|
|
280
|
+
import.meta.hot.on("rsc:update", () => {
|
|
281
|
+
// Cancel any pending debounce timer
|
|
282
|
+
if (hmrTimer !== null) {
|
|
283
|
+
clearTimeout(hmrTimer);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Abort any in-flight HMR fetch so it doesn't race with the next one
|
|
287
|
+
if (hmrAbort) {
|
|
288
|
+
hmrAbort.abort();
|
|
289
|
+
hmrAbort = null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Debounce: wait 200ms of quiet before fetching
|
|
293
|
+
hmrTimer = setTimeout(async () => {
|
|
294
|
+
hmrTimer = null;
|
|
295
|
+
|
|
296
|
+
// Don't interrupt an active user navigation — startNavigation()
|
|
297
|
+
// would abort it and refetch the old URL (window.location.href
|
|
298
|
+
// hasn't updated yet). The user's navigation will pick up the
|
|
299
|
+
// new server code when it completes. isNavigating covers the
|
|
300
|
+
// full lifecycle (fetching + streaming, before commit) without
|
|
301
|
+
// blocking on server actions.
|
|
302
|
+
if (eventController.getState().isNavigating) {
|
|
303
|
+
console.log("[RSCRouter] HMR: Skipping — navigation in progress");
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
console.log("[RSCRouter] HMR: Server update, refetching RSC");
|
|
308
|
+
|
|
309
|
+
const abort = new AbortController();
|
|
310
|
+
hmrAbort = abort;
|
|
311
|
+
|
|
312
|
+
const handle = eventController.startNavigation(window.location.href, {
|
|
313
|
+
replace: true,
|
|
277
314
|
});
|
|
315
|
+
const streamingToken = handle.startStreaming();
|
|
316
|
+
|
|
317
|
+
const interceptSourceUrl = store.getInterceptSourceUrl();
|
|
318
|
+
|
|
319
|
+
try {
|
|
320
|
+
const { payload, streamComplete } = await client.fetchPartial({
|
|
321
|
+
targetUrl: window.location.href,
|
|
322
|
+
segmentIds: [],
|
|
323
|
+
previousUrl: store.getSegmentState().currentUrl,
|
|
324
|
+
interceptSourceUrl: interceptSourceUrl || undefined,
|
|
325
|
+
routerId: store.getRouterId?.(),
|
|
326
|
+
hmr: true,
|
|
327
|
+
signal: abort.signal,
|
|
328
|
+
});
|
|
278
329
|
|
|
279
|
-
|
|
280
|
-
const segments = payload.metadata.segments || [];
|
|
281
|
-
const matched = payload.metadata.matched || [];
|
|
330
|
+
if (abort.signal.aborted) return;
|
|
282
331
|
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
|
|
332
|
+
// If the server returned a non-RSC response (404, 500 without
|
|
333
|
+
// error boundary), the payload won't have valid metadata.
|
|
334
|
+
// Reload to recover rather than leaving the page stale.
|
|
335
|
+
if (!payload.metadata) {
|
|
336
|
+
throw new Error("HMR refetch returned invalid payload");
|
|
337
|
+
}
|
|
287
338
|
|
|
288
|
-
//
|
|
289
|
-
|
|
290
|
-
|
|
339
|
+
// Update version BEFORE rebuilding state so that
|
|
340
|
+
// clearHistoryCache() runs first, then the fresh segment
|
|
341
|
+
// cache entry we create below survives.
|
|
342
|
+
const newVersion = payload.metadata.version;
|
|
343
|
+
if (newVersion && newVersion !== version) {
|
|
344
|
+
console.log(
|
|
345
|
+
"[RSCRouter] HMR: version changed",
|
|
346
|
+
version,
|
|
347
|
+
"→",
|
|
348
|
+
newVersion,
|
|
349
|
+
"clearing caches",
|
|
350
|
+
);
|
|
351
|
+
navigationBridge.updateVersion(newVersion);
|
|
291
352
|
}
|
|
292
353
|
|
|
293
|
-
|
|
294
|
-
|
|
354
|
+
if (payload.metadata?.isPartial) {
|
|
355
|
+
const segments = payload.metadata.segments || [];
|
|
356
|
+
const matched = payload.metadata.matched || [];
|
|
357
|
+
|
|
358
|
+
// Derive intercept state from the returned payload, not the
|
|
359
|
+
// pre-fetch store snapshot. If the HMR edit removed intercept
|
|
360
|
+
// behavior, the response won't contain intercept segments.
|
|
361
|
+
const responseIsIntercept = segments.some(isInterceptSegment);
|
|
362
|
+
|
|
363
|
+
// Sync store intercept state with what the server returned
|
|
364
|
+
if (!responseIsIntercept && interceptSourceUrl) {
|
|
365
|
+
store.setInterceptSourceUrl(null);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
store.setSegmentIds(matched);
|
|
369
|
+
store.setCurrentUrl(window.location.href);
|
|
370
|
+
|
|
371
|
+
const historyKey = generateHistoryKey(window.location.href, {
|
|
372
|
+
intercept: responseIsIntercept,
|
|
373
|
+
});
|
|
374
|
+
store.setHistoryKey(historyKey);
|
|
375
|
+
const currentHandleData = eventController.getHandleState().data;
|
|
376
|
+
store.cacheSegmentsForHistory(
|
|
377
|
+
historyKey,
|
|
378
|
+
segments,
|
|
379
|
+
currentHandleData,
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
const { main, intercept } = splitInterceptSegments(segments);
|
|
383
|
+
store.emitUpdate({
|
|
384
|
+
root: renderSegments(main, {
|
|
385
|
+
interceptSegments: intercept.length > 0 ? intercept : undefined,
|
|
386
|
+
}),
|
|
387
|
+
metadata: payload.metadata,
|
|
388
|
+
});
|
|
389
|
+
}
|
|
295
390
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
);
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
store.emitUpdate({
|
|
309
|
-
root: renderSegments(main, {
|
|
310
|
-
interceptSegments: intercept.length > 0 ? intercept : undefined,
|
|
311
|
-
}),
|
|
312
|
-
metadata: payload.metadata,
|
|
313
|
-
});
|
|
391
|
+
await streamComplete;
|
|
392
|
+
handle.complete(new URL(window.location.href));
|
|
393
|
+
console.log("[RSCRouter] HMR: RSC stream complete");
|
|
394
|
+
} catch (err) {
|
|
395
|
+
if (abort.signal.aborted) return;
|
|
396
|
+
console.warn("[RSCRouter] HMR: Refetch failed, reloading page", err);
|
|
397
|
+
window.location.reload();
|
|
398
|
+
return;
|
|
399
|
+
} finally {
|
|
400
|
+
if (hmrAbort === abort) hmrAbort = null;
|
|
401
|
+
streamingToken.end();
|
|
402
|
+
handle[Symbol.dispose]();
|
|
314
403
|
}
|
|
315
|
-
|
|
316
|
-
await streamComplete;
|
|
317
|
-
handle.complete(new URL(window.location.href));
|
|
318
|
-
console.log("[RSCRouter] HMR: RSC stream complete");
|
|
319
|
-
} finally {
|
|
320
|
-
streamingToken.end();
|
|
321
|
-
}
|
|
404
|
+
}, 200);
|
|
322
405
|
});
|
|
323
406
|
}
|
|
324
407
|
|
|
@@ -417,6 +500,7 @@ export function RSCRouter(_props: RSCRouterProps): React.ReactElement {
|
|
|
417
500
|
initialTheme={initialTheme}
|
|
418
501
|
warmupEnabled={warmupEnabled}
|
|
419
502
|
version={version}
|
|
503
|
+
basename={initialPayload.metadata?.basename}
|
|
420
504
|
/>
|
|
421
505
|
);
|
|
422
506
|
}
|
|
@@ -10,6 +10,15 @@
|
|
|
10
10
|
|
|
11
11
|
import { debugLog } from "./logging.js";
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Defers a callback to the next animation frame.
|
|
15
|
+
* Falls back to setTimeout(0) in environments without requestAnimationFrame.
|
|
16
|
+
*/
|
|
17
|
+
const deferToNextPaint: (fn: () => void) => void =
|
|
18
|
+
typeof requestAnimationFrame === "function"
|
|
19
|
+
? requestAnimationFrame
|
|
20
|
+
: (fn) => setTimeout(fn, 0);
|
|
21
|
+
|
|
13
22
|
const SCROLL_STORAGE_KEY = "rsc-router-scroll-positions";
|
|
14
23
|
|
|
15
24
|
/**
|
|
@@ -264,51 +273,35 @@ export function restoreScrollPosition(options?: {
|
|
|
264
273
|
return false;
|
|
265
274
|
}
|
|
266
275
|
|
|
267
|
-
//
|
|
268
|
-
const maxScrollY = document.documentElement.scrollHeight - window.innerHeight;
|
|
269
|
-
const canScrollToPosition = savedY <= maxScrollY;
|
|
270
|
-
|
|
271
|
-
if (canScrollToPosition) {
|
|
272
|
-
window.scrollTo(0, savedY);
|
|
273
|
-
debugLog("[Scroll] Restored position:", savedY, "for key:", key);
|
|
274
|
-
return true;
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
// Scroll as far as we can for now
|
|
278
|
-
window.scrollTo(0, maxScrollY);
|
|
279
|
-
debugLog("[Scroll] Partial restore to:", maxScrollY, "target:", savedY);
|
|
280
|
-
|
|
281
|
-
// Poll while streaming until we can scroll to target position
|
|
276
|
+
// If streaming, poll until streaming ends then scroll to saved position
|
|
282
277
|
if (options?.retryIfStreaming && options?.isStreaming?.()) {
|
|
283
278
|
const startTime = Date.now();
|
|
284
279
|
|
|
285
280
|
pendingPollInterval = setInterval(() => {
|
|
286
|
-
// Stop if we've exceeded the timeout
|
|
287
281
|
if (Date.now() - startTime > SCROLL_POLL_TIMEOUT_MS) {
|
|
288
282
|
debugLog("[Scroll] Polling timeout, giving up");
|
|
289
283
|
cancelScrollRestorationPolling();
|
|
290
284
|
return;
|
|
291
285
|
}
|
|
292
286
|
|
|
293
|
-
// Stop if streaming ended
|
|
294
287
|
if (!options.isStreaming?.()) {
|
|
295
|
-
debugLog("[Scroll] Streaming ended, stopping poll");
|
|
296
|
-
cancelScrollRestorationPolling();
|
|
297
|
-
return;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
// Check if we can now scroll to the target position
|
|
301
|
-
const currentMaxScrollY =
|
|
302
|
-
document.documentElement.scrollHeight - window.innerHeight;
|
|
303
|
-
if (savedY <= currentMaxScrollY) {
|
|
304
288
|
window.scrollTo(0, savedY);
|
|
305
|
-
debugLog("[Scroll]
|
|
289
|
+
debugLog("[Scroll] Restored after streaming:", savedY);
|
|
306
290
|
cancelScrollRestorationPolling();
|
|
307
291
|
}
|
|
308
292
|
}, SCROLL_POLL_INTERVAL_MS);
|
|
293
|
+
|
|
294
|
+
return true;
|
|
309
295
|
}
|
|
310
296
|
|
|
311
|
-
|
|
297
|
+
// Not streaming — scroll after React commits and browser paints.
|
|
298
|
+
// startTransition defers the DOM commit, so scrolling synchronously
|
|
299
|
+
// would be overwritten when React replaces the content.
|
|
300
|
+
deferToNextPaint(() => {
|
|
301
|
+
window.scrollTo(0, savedY);
|
|
302
|
+
debugLog("[Scroll] Restored position:", savedY, "for key:", key);
|
|
303
|
+
});
|
|
304
|
+
return true;
|
|
312
305
|
}
|
|
313
306
|
|
|
314
307
|
/**
|
|
@@ -363,32 +356,38 @@ export function handleNavigationEnd(options: {
|
|
|
363
356
|
scroll?: boolean;
|
|
364
357
|
isStreaming?: () => boolean;
|
|
365
358
|
}): void {
|
|
366
|
-
if (!initialized) {
|
|
367
|
-
return;
|
|
368
|
-
}
|
|
369
|
-
|
|
370
359
|
const { restore = false, scroll = true, isStreaming } = options;
|
|
371
360
|
|
|
372
|
-
// Don't scroll if explicitly disabled
|
|
373
|
-
if (scroll === false) {
|
|
361
|
+
// Don't scroll if explicitly disabled or not in a browser
|
|
362
|
+
if (scroll === false || typeof window === "undefined") {
|
|
374
363
|
return;
|
|
375
364
|
}
|
|
376
365
|
|
|
377
|
-
//
|
|
378
|
-
|
|
366
|
+
// Save/restore requires initialization (sessionStorage, history state).
|
|
367
|
+
// But basic scroll-to-top and hash scrolling work without it — this
|
|
368
|
+
// matters during cross-app navigation where ScrollRestoration unmounts
|
|
369
|
+
// and remounts, creating a brief window where initialized is false.
|
|
370
|
+
if (restore && initialized) {
|
|
379
371
|
if (restoreScrollPosition({ retryIfStreaming: true, isStreaming })) {
|
|
380
372
|
return;
|
|
381
373
|
}
|
|
382
374
|
// Fall through to hash or top if no saved position
|
|
383
375
|
}
|
|
384
376
|
|
|
385
|
-
//
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
377
|
+
// Defer hash and scroll-to-top to after React paints the new content,
|
|
378
|
+
// so the user doesn't see the current page jump before the new route appears.
|
|
379
|
+
deferToNextPaint(() => {
|
|
380
|
+
// Re-check: the deferred callback may fire after environment teardown
|
|
381
|
+
if (typeof window === "undefined") return;
|
|
382
|
+
|
|
383
|
+
// Try hash scrolling first
|
|
384
|
+
if (scrollToHash()) {
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
389
387
|
|
|
390
|
-
|
|
391
|
-
|
|
388
|
+
// Default: scroll to top
|
|
389
|
+
scrollToTop();
|
|
390
|
+
});
|
|
392
391
|
}
|
|
393
392
|
|
|
394
393
|
/**
|
|
@@ -160,8 +160,13 @@ export function reconcileSegments(input: ReconcileInput): ReconcileResult {
|
|
|
160
160
|
|
|
161
161
|
// For non-action actors: cached segments the server decided not to re-render.
|
|
162
162
|
// - Preserve loading=false (suppressed boundary) to maintain tree structure
|
|
163
|
-
// -
|
|
163
|
+
// - Preserve parallel segment loading so renderSegments can reconstruct
|
|
164
|
+
// parallel-owned loader markers from the cached slot metadata
|
|
165
|
+
// - Clear other truthy loading values to prevent suspense on cached content
|
|
164
166
|
if (actor !== "action") {
|
|
167
|
+
if (fromCache.type === "parallel" && fromCache.loading !== undefined) {
|
|
168
|
+
return fromCache;
|
|
169
|
+
}
|
|
165
170
|
if (fromCache.loading !== undefined && fromCache.loading !== false) {
|
|
166
171
|
return { ...fromCache, loading: undefined };
|
|
167
172
|
}
|