@rangojs/router 0.0.0-experimental.c873df95 → 0.0.0-experimental.cb4b3a28

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.
Files changed (131) hide show
  1. package/README.md +76 -18
  2. package/dist/bin/rango.js +130 -47
  3. package/dist/vite/index.js +716 -237
  4. package/dist/vite/index.js.bak +5448 -0
  5. package/package.json +3 -3
  6. package/skills/cache-guide/SKILL.md +32 -0
  7. package/skills/caching/SKILL.md +8 -0
  8. package/skills/handler-use/SKILL.md +362 -0
  9. package/skills/intercept/SKILL.md +20 -0
  10. package/skills/layout/SKILL.md +22 -0
  11. package/skills/links/SKILL.md +3 -1
  12. package/skills/loader/SKILL.md +53 -43
  13. package/skills/middleware/SKILL.md +34 -3
  14. package/skills/migrate-nextjs/SKILL.md +560 -0
  15. package/skills/migrate-react-router/SKILL.md +764 -0
  16. package/skills/parallel/SKILL.md +126 -0
  17. package/skills/prerender/SKILL.md +110 -68
  18. package/skills/rango/SKILL.md +24 -22
  19. package/skills/route/SKILL.md +55 -0
  20. package/skills/router-setup/SKILL.md +87 -2
  21. package/skills/typesafety/SKILL.md +10 -0
  22. package/src/__internal.ts +1 -1
  23. package/src/browser/app-version.ts +14 -0
  24. package/src/browser/navigation-bridge.ts +37 -5
  25. package/src/browser/navigation-client.ts +98 -46
  26. package/src/browser/navigation-store.ts +43 -8
  27. package/src/browser/partial-update.ts +41 -7
  28. package/src/browser/prefetch/cache.ts +16 -6
  29. package/src/browser/prefetch/fetch.ts +68 -6
  30. package/src/browser/prefetch/queue.ts +61 -29
  31. package/src/browser/prefetch/resource-ready.ts +77 -0
  32. package/src/browser/react/Link.tsx +67 -8
  33. package/src/browser/react/NavigationProvider.tsx +13 -4
  34. package/src/browser/react/context.ts +7 -2
  35. package/src/browser/react/use-handle.ts +9 -58
  36. package/src/browser/react/use-router.ts +21 -8
  37. package/src/browser/rsc-router.tsx +26 -3
  38. package/src/browser/scroll-restoration.ts +10 -8
  39. package/src/browser/segment-reconciler.ts +36 -14
  40. package/src/browser/server-action-bridge.ts +8 -6
  41. package/src/browser/types.ts +27 -5
  42. package/src/build/generate-manifest.ts +6 -6
  43. package/src/build/generate-route-types.ts +3 -0
  44. package/src/build/route-trie.ts +50 -24
  45. package/src/build/route-types/include-resolution.ts +8 -1
  46. package/src/build/route-types/router-processing.ts +211 -72
  47. package/src/build/route-types/scan-filter.ts +8 -1
  48. package/src/cache/cache-scope.ts +46 -5
  49. package/src/cache/taint.ts +55 -0
  50. package/src/client.tsx +84 -230
  51. package/src/context-var.ts +72 -2
  52. package/src/handle.ts +40 -0
  53. package/src/index.rsc.ts +3 -1
  54. package/src/index.ts +46 -6
  55. package/src/prerender/store.ts +5 -4
  56. package/src/prerender.ts +138 -77
  57. package/src/reverse.ts +25 -1
  58. package/src/route-definition/dsl-helpers.ts +194 -32
  59. package/src/route-definition/helpers-types.ts +67 -19
  60. package/src/route-definition/index.ts +3 -0
  61. package/src/route-definition/redirect.ts +9 -1
  62. package/src/route-definition/resolve-handler-use.ts +149 -0
  63. package/src/route-types.ts +11 -0
  64. package/src/router/content-negotiation.ts +100 -1
  65. package/src/router/handler-context.ts +82 -23
  66. package/src/router/intercept-resolution.ts +9 -4
  67. package/src/router/loader-resolution.ts +156 -21
  68. package/src/router/match-api.ts +124 -189
  69. package/src/router/match-middleware/background-revalidation.ts +12 -1
  70. package/src/router/match-middleware/cache-lookup.ts +40 -13
  71. package/src/router/match-middleware/cache-store.ts +21 -4
  72. package/src/router/match-middleware/segment-resolution.ts +53 -0
  73. package/src/router/match-result.ts +102 -18
  74. package/src/router/middleware-types.ts +6 -8
  75. package/src/router/middleware.ts +2 -5
  76. package/src/router/navigation-snapshot.ts +182 -0
  77. package/src/router/prerender-match.ts +110 -10
  78. package/src/router/preview-match.ts +30 -102
  79. package/src/router/request-classification.ts +310 -0
  80. package/src/router/route-snapshot.ts +245 -0
  81. package/src/router/router-context.ts +1 -0
  82. package/src/router/router-interfaces.ts +36 -4
  83. package/src/router/router-options.ts +37 -11
  84. package/src/router/segment-resolution/fresh.ts +80 -9
  85. package/src/router/segment-resolution/helpers.ts +29 -24
  86. package/src/router/segment-resolution/revalidation.ts +91 -8
  87. package/src/router/types.ts +1 -0
  88. package/src/router.ts +54 -5
  89. package/src/rsc/handler.ts +472 -372
  90. package/src/rsc/loader-fetch.ts +23 -3
  91. package/src/rsc/manifest-init.ts +5 -1
  92. package/src/rsc/progressive-enhancement.ts +14 -2
  93. package/src/rsc/rsc-rendering.ts +10 -1
  94. package/src/rsc/server-action.ts +8 -0
  95. package/src/rsc/ssr-setup.ts +2 -2
  96. package/src/rsc/types.ts +9 -1
  97. package/src/segment-content-promise.ts +67 -0
  98. package/src/segment-loader-promise.ts +122 -0
  99. package/src/segment-system.tsx +11 -61
  100. package/src/server/context.ts +50 -1
  101. package/src/server/handle-store.ts +19 -0
  102. package/src/server/loader-registry.ts +9 -8
  103. package/src/server/request-context.ts +175 -15
  104. package/src/ssr/index.tsx +3 -0
  105. package/src/static-handler.ts +18 -6
  106. package/src/types/cache-types.ts +4 -4
  107. package/src/types/handler-context.ts +37 -19
  108. package/src/types/loader-types.ts +36 -9
  109. package/src/types/route-entry.ts +1 -1
  110. package/src/types/segments.ts +1 -1
  111. package/src/urls/include-helper.ts +20 -7
  112. package/src/urls/path-helper-types.ts +39 -6
  113. package/src/urls/path-helper.ts +47 -12
  114. package/src/urls/pattern-types.ts +12 -0
  115. package/src/urls/response-types.ts +16 -6
  116. package/src/use-loader.tsx +77 -5
  117. package/src/vite/discovery/bundle-postprocess.ts +30 -33
  118. package/src/vite/discovery/discover-routers.ts +5 -1
  119. package/src/vite/discovery/prerender-collection.ts +128 -74
  120. package/src/vite/discovery/state.ts +13 -4
  121. package/src/vite/index.ts +4 -0
  122. package/src/vite/plugin-types.ts +60 -5
  123. package/src/vite/plugins/expose-id-utils.ts +12 -0
  124. package/src/vite/plugins/expose-ids/handler-transform.ts +30 -0
  125. package/src/vite/plugins/expose-internal-ids.ts +257 -40
  126. package/src/vite/plugins/performance-tracks.ts +88 -0
  127. package/src/vite/plugins/refresh-cmd.ts +88 -26
  128. package/src/vite/rango.ts +19 -2
  129. package/src/vite/router-discovery.ts +178 -37
  130. package/src/vite/utils/prerender-utils.ts +37 -5
  131. package/src/vite/utils/shared-utils.ts +3 -2
@@ -36,7 +36,14 @@ import {
36
36
  setRequestContextPrevRouteKey,
37
37
  } from "../server/request-context.js";
38
38
  import { isAutoGeneratedRouteName } from "../route-name.js";
39
+ import type { DefaultRouteName } from "../types/global-namespace.js";
39
40
  import { debugLog, debugWarn } from "./logging.js";
41
+ import {
42
+ resolveRoute,
43
+ ensureFullRouteSnapshot,
44
+ type RouteSnapshot,
45
+ } from "./route-snapshot.js";
46
+ import { resolveNavigation } from "./navigation-snapshot.js";
40
47
 
41
48
  /**
42
49
  * Create match context for full requests (document/SSR).
@@ -52,57 +59,36 @@ export async function createMatchContextForFull<TEnv>(
52
59
 
53
60
  const metricsStore = deps.getMetricsStore();
54
61
 
55
- const routeMatchStart = metricsStore ? performance.now() : 0;
56
- const matched = deps.findMatch(pathname, metricsStore);
57
- if (metricsStore) {
58
- metricsStore.metrics.push({
59
- label: "route-matching",
60
- duration: performance.now() - routeMatchStart,
61
- startTime: routeMatchStart - metricsStore.requestStart,
62
- });
63
- }
62
+ // Full renders always resolve fresh with isSSR: true because loadManifest
63
+ // keys its cache on isSSR and stamps Store.isSSR for downstream behavior.
64
+ const result = await resolveRoute<TEnv>(pathname, {
65
+ findMatch: (p) => deps.findMatch(p, metricsStore),
66
+ metricsStore,
67
+ isSSR: true,
68
+ });
64
69
 
65
- if (!matched) {
70
+ if (!result) {
66
71
  throw new RouteNotFoundError(`No route matched for ${pathname}`, {
67
72
  cause: { pathname, method: request.method },
68
73
  });
69
74
  }
70
75
 
71
- if (matched.redirectTo) {
76
+ if (result.type === "redirect") {
72
77
  return {
73
78
  type: "redirect",
74
- redirectUrl: matched.redirectTo + url.search,
79
+ redirectUrl: result.redirectTo + url.search,
75
80
  };
76
81
  }
77
82
 
78
- const manifestStart = metricsStore ? performance.now() : 0;
79
- const manifestEntry = await loadManifest(
80
- matched.entry,
81
- matched.routeKey,
82
- pathname,
83
- metricsStore,
84
- true,
85
- );
86
- if (metricsStore) {
87
- metricsStore.metrics.push({
88
- label: "manifest-loading",
89
- duration: performance.now() - manifestStart,
90
- startTime: manifestStart - metricsStore.requestStart,
91
- });
92
- }
83
+ const snapshot = result.snapshot;
93
84
 
94
- if (
95
- manifestEntry.type === "route" &&
96
- manifestEntry.prerenderDef?.options?.passthrough === true
97
- ) {
85
+ const { matched } = snapshot;
86
+
87
+ // Backward compat: downstream middleware reads matched.pt
88
+ if (snapshot.isPassthrough) {
98
89
  matched.pt = true;
99
90
  }
100
91
 
101
- const routeMiddleware = collectRouteMiddleware(
102
- traverseBack(manifestEntry),
103
- matched.params,
104
- );
105
-
106
92
  // Clean URL without internal _rsc* params for userland access
107
93
  const cleanUrl = stripInternalParams(url);
108
94
 
@@ -134,14 +120,6 @@ export async function createMatchContextForFull<TEnv>(
134
120
  Store.metrics = metricsStore;
135
121
  }
136
122
 
137
- const entries = [...traverseBack(manifestEntry)];
138
- let cacheScope: CacheScope | null = null;
139
- for (const entry of entries) {
140
- if (entry.cache) {
141
- cacheScope = createCacheScope(entry.cache, cacheScope);
142
- }
143
- }
144
-
145
123
  return {
146
124
  request,
147
125
  url: cleanUrl,
@@ -154,12 +132,10 @@ export async function createMatchContextForFull<TEnv>(
154
132
  prevParams: {},
155
133
  prevMatch: null,
156
134
  matched,
157
- manifestEntry,
158
- entries,
135
+ manifestEntry: snapshot.manifestEntry,
136
+ entries: snapshot.entries,
159
137
  routeKey: matched.routeKey,
160
- localRouteName: matched.routeKey.includes(".")
161
- ? matched.routeKey.split(".").pop()!
162
- : matched.routeKey,
138
+ localRouteName: snapshot.localRouteName,
163
139
  handlerContext,
164
140
  loaderPromises,
165
141
  routeMap: deps.getRouteMap(),
@@ -175,16 +151,16 @@ export async function createMatchContextForFull<TEnv>(
175
151
  segments: { path: [], ids: [] },
176
152
  toRouteName:
177
153
  matched.routeKey && !isAutoGeneratedRouteName(matched.routeKey)
178
- ? matched.routeKey
154
+ ? (matched.routeKey as DefaultRouteName)
179
155
  : undefined,
180
156
  },
181
157
  isSameRouteNavigation: false,
182
158
  interceptResult: null,
183
- cacheScope,
159
+ cacheScope: snapshot.cacheScope,
184
160
  isIntercept: false,
185
161
  actionContext: undefined,
186
162
  isAction: false,
187
- routeMiddleware,
163
+ routeMiddleware: snapshot.routeMiddleware,
188
164
  isFullMatch: true,
189
165
  };
190
166
  }
@@ -204,103 +180,85 @@ export async function createMatchContextForPartial<TEnv>(
204
180
 
205
181
  const metricsStore = deps.getMetricsStore();
206
182
 
207
- const clientSegmentIds =
208
- url.searchParams.get("_rsc_segments")?.split(",").filter(Boolean) || [];
209
- const stale = url.searchParams.get("_rsc_stale") === "true";
210
- const previousUrl =
211
- request.headers.get("X-RSC-Router-Client-Path") ||
212
- request.headers.get("Referer");
213
- const interceptSourceUrl = request.headers.get(
214
- "X-RSC-Router-Intercept-Source",
215
- );
183
+ const isHmr = !!request.headers.get("X-RSC-HMR");
216
184
 
217
185
  // HMR: clear manifest cache so stale handler references are discarded
218
- if (request.headers.get("X-RSC-HMR")) {
186
+ if (isHmr) {
219
187
  clearManifestCache();
220
188
  }
221
189
 
222
- if (!previousUrl) {
223
- return null;
224
- }
225
-
226
- let prevUrl: URL;
227
- try {
228
- prevUrl = new URL(previousUrl, url.origin);
229
- } catch {
230
- return null;
231
- }
190
+ // Reuse the classified snapshot when available and not invalidated by HMR.
191
+ // classifyRequest already called resolveRoute(lite) with isSSR=false, which
192
+ // matches the partial path. On HMR, discard to pick up manifest changes.
193
+ const classifiedRoute = isHmr
194
+ ? undefined
195
+ : getRequestContext()?._classifiedRoute;
196
+
197
+ // Time route matching. On the reuse path, only nav findMatch calls are new
198
+ // (current-route findMatch and manifest-loading were already timed during
199
+ // classifyRequest via its metricsStore). On the fresh path, all findMatch
200
+ // calls are measured together.
201
+ const routeMatchStart = metricsStore ? performance.now() : 0;
232
202
 
233
- let interceptContextUrl: URL;
234
- try {
235
- interceptContextUrl = interceptSourceUrl
236
- ? new URL(interceptSourceUrl, url.origin)
237
- : prevUrl;
238
- } catch {
239
- interceptContextUrl = prevUrl;
240
- }
203
+ let snapshot: RouteSnapshot<TEnv>;
204
+ if (classifiedRoute && classifiedRoute.manifestEntry) {
205
+ snapshot = ensureFullRouteSnapshot(classifiedRoute);
206
+ } else {
207
+ const result = await resolveRoute<TEnv>(pathname, {
208
+ findMatch: (p) => deps.findMatch(p, metricsStore),
209
+ metricsStore,
210
+ isSSR: false,
211
+ skipRouteMatchMetric: true,
212
+ });
241
213
 
242
- const routeMatchStart = metricsStore ? performance.now() : 0;
243
- const prevMatch = deps.findMatch(prevUrl.pathname);
244
- const prevParams = prevMatch?.params || {};
245
- const interceptContextMatch = interceptSourceUrl
246
- ? deps.findMatch(interceptContextUrl.pathname)
247
- : prevMatch;
214
+ if (!result) {
215
+ throw new RouteNotFoundError(`No route matched for ${pathname}`, {
216
+ cause: { pathname, method: request.method },
217
+ });
218
+ }
248
219
 
249
- const matched = deps.findMatch(pathname, metricsStore);
220
+ if (result.type === "redirect") {
221
+ return null;
222
+ }
250
223
 
251
- if (metricsStore) {
252
- metricsStore.metrics.push({
253
- label: "route-matching",
254
- duration: performance.now() - routeMatchStart,
255
- startTime: routeMatchStart - metricsStore.requestStart,
256
- });
224
+ snapshot = result.snapshot;
257
225
  }
258
226
 
259
- if (!matched) {
260
- throw new RouteNotFoundError(`No route matched for ${pathname}`, {
261
- cause: { pathname, method: request.method, previousUrl },
262
- });
263
- }
227
+ const { matched } = snapshot;
264
228
 
265
- if (matched.redirectTo) {
266
- return null;
229
+ // Backward compat: downstream middleware reads matched.pt
230
+ if (snapshot.isPassthrough) {
231
+ matched.pt = true;
267
232
  }
268
233
 
269
- if (prevMatch && prevMatch.entry !== matched.entry && !matched.pr) {
270
- debugLog("matchPartial", "route group changed", {
271
- from: prevMatch.routeKey,
272
- to: matched.routeKey,
273
- });
234
+ // Navigation state (prev + intercept-source findMatch calls)
235
+ const nav = resolveNavigation(request, url, matched.routeKey, {
236
+ findMatch: deps.findMatch,
237
+ });
238
+ if (!nav) {
239
+ return null;
274
240
  }
275
241
 
276
- const manifestStart = metricsStore ? performance.now() : 0;
277
- const manifestEntry = await loadManifest(
278
- matched.entry,
279
- matched.routeKey,
280
- pathname,
281
- metricsStore,
282
- false,
283
- );
242
+ // Push route-matching metric. On the fresh path this covers all findMatch
243
+ // calls (current + prev + intercept-source). On the reuse path, current-route
244
+ // findMatch was already timed during classification, so this only covers
245
+ // the nav lookups (prev + intercept-source).
284
246
  if (metricsStore) {
247
+ const isReuse = !!classifiedRoute;
285
248
  metricsStore.metrics.push({
286
- label: "manifest-loading",
287
- duration: performance.now() - manifestStart,
288
- startTime: manifestStart - metricsStore.requestStart,
249
+ label: isReuse ? "route-matching:nav" : "route-matching",
250
+ duration: performance.now() - routeMatchStart,
251
+ startTime: routeMatchStart - metricsStore.requestStart,
289
252
  });
290
253
  }
291
254
 
292
- if (
293
- manifestEntry.type === "route" &&
294
- manifestEntry.prerenderDef?.options?.passthrough === true
295
- ) {
296
- matched.pt = true;
255
+ if (nav.prevMatch && nav.prevMatch.entry !== matched.entry && !matched.pr) {
256
+ debugLog("matchPartial", "route group changed", {
257
+ from: nav.prevMatch.routeKey,
258
+ to: matched.routeKey,
259
+ });
297
260
  }
298
261
 
299
- const routeMiddleware = collectRouteMiddleware(
300
- traverseBack(manifestEntry),
301
- matched.params,
302
- );
303
-
304
262
  // Clean URL without internal _rsc* params for userland access
305
263
  const cleanUrl = stripInternalParams(url);
306
264
 
@@ -317,9 +275,8 @@ export async function createMatchContextForPartial<TEnv>(
317
275
  matched.pt === true,
318
276
  );
319
277
 
320
- const clientSegmentSet = new Set(clientSegmentIds);
321
278
  debugLog("matchPartial", "client segments", {
322
- segments: Array.from(clientSegmentSet),
279
+ segments: Array.from(nav.clientSegmentSet),
323
280
  });
324
281
 
325
282
  const loaderPromises = new Map<string, Promise<any>>();
@@ -337,100 +294,78 @@ export async function createMatchContextForPartial<TEnv>(
337
294
  Store.metrics = metricsStore;
338
295
  }
339
296
 
340
- const isSameRouteNavigation = !!(
341
- interceptContextMatch && interceptContextMatch.routeKey === matched.routeKey
342
- );
343
-
344
- if (interceptSourceUrl) {
297
+ if (nav.hasInterceptSource) {
345
298
  debugLog("matchPartial.intercept", "intercept context detected", {
346
299
  currentUrl: pathname,
347
- interceptSource: interceptSourceUrl,
348
- contextRoute: interceptContextMatch?.routeKey,
300
+ interceptSource: nav.interceptContextUrl.href,
301
+ contextRoute: nav.interceptContextMatch?.routeKey,
349
302
  currentRoute: matched.routeKey,
350
- sameRouteNavigation: isSameRouteNavigation,
303
+ sameRouteNavigation: nav.isSameRouteNavigation,
351
304
  });
352
305
  }
353
306
 
354
- const localRouteName = matched.routeKey.includes(".")
355
- ? matched.routeKey.split(".").pop()!
356
- : matched.routeKey;
357
-
358
- const filteredSegmentIds = clientSegmentIds.filter((id) => {
359
- if (id.includes(".@")) return false;
360
- if (/D\d+\./.test(id)) return false;
361
- return true;
362
- });
363
- const effectiveFromUrl = interceptSourceUrl ? interceptContextUrl : prevUrl;
364
- const effectiveFromMatch = interceptSourceUrl
365
- ? interceptContextMatch
366
- : prevMatch;
367
-
368
307
  // Store previous route key on the request context for revalidation
369
308
  // fromRouteName. Uses effectiveFromMatch so intercept-source navigations
370
309
  // see the intercept origin route, not the plain previous URL route.
371
- setRequestContextPrevRouteKey(effectiveFromMatch?.routeKey);
310
+ setRequestContextPrevRouteKey(nav.effectiveFromMatch?.routeKey);
372
311
 
373
312
  const interceptSelectorContext: InterceptSelectorContext = {
374
- from: effectiveFromUrl,
313
+ from: nav.effectiveFromUrl,
375
314
  to: cleanUrl,
376
315
  params: matched.params,
377
316
  request,
378
317
  env,
379
318
  segments: {
380
- path: effectiveFromUrl.pathname.split("/").filter(Boolean),
381
- ids: filteredSegmentIds,
319
+ path: nav.effectiveFromUrl.pathname.split("/").filter(Boolean),
320
+ ids: nav.filteredSegmentIds,
382
321
  },
383
322
  fromRouteName:
384
- effectiveFromMatch?.routeKey &&
385
- !isAutoGeneratedRouteName(effectiveFromMatch.routeKey)
386
- ? effectiveFromMatch.routeKey
323
+ nav.effectiveFromMatch?.routeKey &&
324
+ !isAutoGeneratedRouteName(nav.effectiveFromMatch.routeKey)
325
+ ? (nav.effectiveFromMatch.routeKey as DefaultRouteName)
387
326
  : undefined,
388
327
  toRouteName:
389
328
  matched.routeKey && !isAutoGeneratedRouteName(matched.routeKey)
390
- ? matched.routeKey
329
+ ? (matched.routeKey as DefaultRouteName)
391
330
  : undefined,
392
331
  };
393
332
  const isAction = !!actionContext;
394
333
 
395
- const clientHasInterceptSegments = [...clientSegmentSet].some((id) =>
334
+ const clientHasInterceptSegments = [...nav.clientSegmentSet].some((id) =>
396
335
  id.includes(".@"),
397
336
  );
398
337
  const skipInterceptForAction = isAction && !clientHasInterceptSegments;
399
338
  const interceptResult =
400
- isSameRouteNavigation || skipInterceptForAction
339
+ nav.isSameRouteNavigation || skipInterceptForAction
401
340
  ? null
402
341
  : findInterceptForRoute(
403
342
  matched.routeKey,
404
- manifestEntry.parent,
343
+ snapshot.manifestEntry.parent,
405
344
  interceptSelectorContext,
406
345
  isAction,
407
346
  ) ||
408
- (localRouteName !== matched.routeKey
347
+ (snapshot.localRouteName !== matched.routeKey
409
348
  ? findInterceptForRoute(
410
- localRouteName,
411
- manifestEntry.parent,
349
+ snapshot.localRouteName,
350
+ snapshot.manifestEntry.parent,
412
351
  interceptSelectorContext,
413
352
  isAction,
414
353
  )
415
354
  : null);
416
355
 
356
+ // Make a mutable copy of clientSegmentSet so we can delete entries
357
+ // for same-route navigation forcing
358
+ const clientSegmentSet = new Set(nav.clientSegmentSet);
359
+
417
360
  if (
418
- isSameRouteNavigation &&
419
- manifestEntry.type === "route" &&
420
- interceptSourceUrl
361
+ nav.isSameRouteNavigation &&
362
+ snapshot.manifestEntry.type === "route" &&
363
+ nav.hasInterceptSource
421
364
  ) {
422
365
  debugLog("matchPartial.intercept", "forcing route segment render", {
423
- segmentId: manifestEntry.shortCode,
366
+ segmentId: snapshot.manifestEntry.shortCode,
424
367
  });
425
- clientSegmentSet.delete(manifestEntry.shortCode);
426
- }
427
-
428
- const entries = [...traverseBack(manifestEntry)];
429
- let cacheScope: CacheScope | null = null;
430
- for (const entry of entries) {
431
- if (entry.cache) {
432
- cacheScope = createCacheScope(entry.cache, cacheScope);
433
- }
368
+ clientSegmentSet.delete(snapshot.manifestEntry.shortCode);
434
369
  }
435
370
 
436
371
  const isIntercept = !!interceptResult;
@@ -440,31 +375,31 @@ export async function createMatchContextForPartial<TEnv>(
440
375
  url: cleanUrl,
441
376
  pathname,
442
377
  env,
443
- clientSegmentIds,
378
+ clientSegmentIds: nav.clientSegmentIds,
444
379
  clientSegmentSet,
445
- stale,
446
- prevUrl,
447
- prevParams,
448
- prevMatch,
380
+ stale: nav.stale,
381
+ prevUrl: nav.prevUrl,
382
+ prevParams: nav.prevParams,
383
+ prevMatch: nav.prevMatch,
449
384
  matched,
450
- manifestEntry,
451
- entries,
385
+ manifestEntry: snapshot.manifestEntry,
386
+ entries: snapshot.entries,
452
387
  routeKey: matched.routeKey,
453
- localRouteName,
388
+ localRouteName: snapshot.localRouteName,
454
389
  handlerContext,
455
390
  loaderPromises,
456
391
  routeMap: deps.getRouteMap(),
457
392
  metricsStore,
458
393
  Store,
459
- interceptContextMatch,
394
+ interceptContextMatch: nav.interceptContextMatch,
460
395
  interceptSelectorContext,
461
- isSameRouteNavigation,
396
+ isSameRouteNavigation: nav.isSameRouteNavigation,
462
397
  interceptResult,
463
- cacheScope,
398
+ cacheScope: snapshot.cacheScope,
464
399
  isIntercept,
465
400
  actionContext,
466
401
  isAction,
467
- routeMiddleware,
402
+ routeMiddleware: snapshot.routeMiddleware,
468
403
  isFullMatch: false,
469
404
  };
470
405
  }
@@ -149,6 +149,13 @@ export function withBackgroundRevalidation<TEnv>(
149
149
  : undefined;
150
150
 
151
151
  requestCtx?.waitUntil(async () => {
152
+ // Prevent background metrics from polluting foreground timeline.
153
+ // The foreground uses its own metricsStore reference directly (via
154
+ // appendMetric), so nulling Store.metrics only affects track() calls
155
+ // inside this background Store.run() scope.
156
+ const savedMetrics = ctx.Store.metrics;
157
+ ctx.Store.metrics = undefined;
158
+
152
159
  const start = performance.now();
153
160
  debugLog("backgroundRevalidation", "revalidating stale route", {
154
161
  pathname: ctx.pathname,
@@ -179,7 +186,9 @@ export function withBackgroundRevalidation<TEnv>(
179
186
  setupLoaderAccess(freshHandlerContext, freshLoaderPromises);
180
187
 
181
188
  // Resolve all segments fresh (without revalidation logic)
182
- // to ensure complete components for caching
189
+ // to ensure complete components for caching.
190
+ // Skip DSL loaders — they are never cached (cacheRoute filters them)
191
+ // and are always resolved fresh on each request.
183
192
  const freshSegments = await ctx.Store.run(() =>
184
193
  resolveAllSegments(
185
194
  ctx.entries,
@@ -187,6 +196,7 @@ export function withBackgroundRevalidation<TEnv>(
187
196
  ctx.matched.params,
188
197
  freshHandlerContext,
189
198
  freshLoaderPromises,
199
+ { skipLoaders: true },
190
200
  ),
191
201
  );
192
202
 
@@ -234,6 +244,7 @@ export function withBackgroundRevalidation<TEnv>(
234
244
  });
235
245
  } finally {
236
246
  requestCtx._handleStore = originalHandleStore;
247
+ ctx.Store.metrics = savedMetrics;
237
248
  }
238
249
  });
239
250
  };
@@ -96,6 +96,7 @@ import type { MatchContext, MatchPipelineState } from "../match-context.js";
96
96
  import { getRouterContext } from "../router-context.js";
97
97
  import { resolveSink, safeEmit } from "../telemetry.js";
98
98
  import { pushRevalidationTraceEntry, isTraceActive } from "../logging.js";
99
+ import { treeHasStreaming } from "./segment-resolution.js";
99
100
  import type { PrerenderStore, PrerenderEntry } from "../../prerender/store.js";
100
101
  import type { HandleStore } from "../../server/handle-store.js";
101
102
  import {
@@ -193,6 +194,16 @@ async function* yieldFromStore<TEnv>(
193
194
  state.cachedSegments = segments;
194
195
  state.cachedMatchedIds = segments.map((s) => s.id);
195
196
 
197
+ // Set streaming flag (once) and resolve render barrier.
198
+ const reqCtx = handleStoreRef ? undefined : _lazyGetRequestContext?.();
199
+ const barrierReqCtx = reqCtx ?? _getRequestContext();
200
+ if (barrierReqCtx) {
201
+ if (barrierReqCtx._treeHasStreaming === undefined) {
202
+ barrierReqCtx._treeHasStreaming = treeHasStreaming(ctx.entries);
203
+ }
204
+ barrierReqCtx._resolveRenderBarrier(segments);
205
+ }
206
+
196
207
  // For partial navigation, nullify components the client already has
197
208
  // so parent layouts stay live (client keeps its existing versions).
198
209
  // When params changed (e.g., different guide slug), the segments have
@@ -240,6 +251,7 @@ async function* yieldFromStore<TEnv>(
240
251
  ctx.url,
241
252
  ctx.routeKey,
242
253
  ctx.actionContext,
254
+ ctx.stale || undefined,
243
255
  ),
244
256
  );
245
257
  state.matchedIds = [
@@ -316,14 +328,15 @@ export function withCacheLookup<TEnv>(
316
328
 
317
329
  // Prerender lookup: check build-time cached data before runtime cache.
318
330
  // Prerender data is available regardless of runtime cache configuration.
319
- if (!ctx.isAction && ctx.matched.pr) {
331
+ // Skip for HMR requests — the dev prerender endpoint reads from a stale
332
+ // RouterRegistry snapshot; rendering fresh ensures edits are visible.
333
+ const isHmr = !!ctx.request.headers.get("X-RSC-HMR");
334
+ if (!ctx.isAction && !isHmr && ctx.matched.pr) {
320
335
  await ensurePrerenderDeps();
321
336
  if (prerenderStoreInstance) {
322
337
  const paramHash = _hashParams!(ctx.matched.params);
323
338
  const isPassthroughPrerenderRoute = ctx.entries.some(
324
- (entry) =>
325
- entry.type === "route" &&
326
- entry.prerenderDef?.options?.passthrough === true,
339
+ (entry) => entry.type === "route" && entry.isPassthrough === true,
327
340
  );
328
341
 
329
342
  if (ctx.isIntercept) {
@@ -393,9 +406,7 @@ export function withCacheLookup<TEnv>(
393
406
  if (prerenderStoreInstance) {
394
407
  const paramHash = _hashParams!(ctx.matched.params);
395
408
  const isPassthroughPrerenderRoute = ctx.entries.some(
396
- (entry) =>
397
- entry.type === "route" &&
398
- entry.prerenderDef?.options?.passthrough === true,
409
+ (entry) => entry.type === "route" && entry.isPassthrough === true,
399
410
  );
400
411
 
401
412
  if (ctx.isIntercept) {
@@ -522,18 +533,23 @@ export function withCacheLookup<TEnv>(
522
533
  const entryInfo = entryRevalidateMap?.get(segment.id);
523
534
 
524
535
  // Even without explicit revalidation rules, route segments and their
525
- // children must re-render when search params change — the handler reads
526
- // ctx.searchParams so different ?page= values produce different content.
536
+ // children must re-render when params or search params change — the
537
+ // handler reads ctx.params/ctx.searchParams so different values produce
538
+ // different content. Matches evaluateRevalidation's default logic.
527
539
  const searchChanged = ctx.prevUrl.search !== ctx.url.search;
540
+ const routeParamsChanged = !paramsEqual(
541
+ ctx.matched.params,
542
+ ctx.prevParams,
543
+ );
528
544
  const shouldDefaultRevalidate =
529
- searchChanged &&
545
+ (searchChanged || routeParamsChanged) &&
530
546
  (segment.type === "route" ||
531
547
  (segment.belongsToRoute &&
532
548
  (segment.type === "layout" || segment.type === "parallel")));
533
549
 
534
550
  if (!entryInfo || entryInfo.revalidate.length === 0) {
535
551
  if (shouldDefaultRevalidate) {
536
- // Search params changed — must re-render even without custom rules
552
+ // Params or search params changed — must re-render even without custom rules
537
553
  if (isTraceActive()) {
538
554
  pushRevalidationTraceEntry({
539
555
  segmentId: segment.id,
@@ -542,7 +558,9 @@ export function withCacheLookup<TEnv>(
542
558
  source: "cache-hit",
543
559
  defaultShouldRevalidate: true,
544
560
  finalShouldRevalidate: true,
545
- reason: "cached-search-changed",
561
+ reason: routeParamsChanged
562
+ ? "cached-params-changed"
563
+ : "cached-search-changed",
546
564
  });
547
565
  }
548
566
  yield segment;
@@ -581,7 +599,7 @@ export function withCacheLookup<TEnv>(
581
599
  routeKey: ctx.routeKey,
582
600
  context: ctx.handlerContext,
583
601
  actionContext: ctx.actionContext,
584
- stale: cacheResult.shouldRevalidate || undefined,
602
+ stale: cacheResult.shouldRevalidate || ctx.stale || undefined,
585
603
  traceSource: "cache-hit",
586
604
  });
587
605
 
@@ -608,6 +626,15 @@ export function withCacheLookup<TEnv>(
608
626
  yield segment;
609
627
  }
610
628
 
629
+ // Set streaming flag (once) and resolve render barrier.
630
+ const barrierReqCtx = _getRequestContext();
631
+ if (barrierReqCtx) {
632
+ if (barrierReqCtx._treeHasStreaming === undefined) {
633
+ barrierReqCtx._treeHasStreaming = treeHasStreaming(ctx.entries);
634
+ }
635
+ barrierReqCtx._resolveRenderBarrier(cacheResult.segments);
636
+ }
637
+
611
638
  // Resolve loaders fresh (loaders are NOT cached by default)
612
639
  // This ensures fresh data even on cache hit
613
640
  const Store = ctx.Store;