@rangojs/router 0.31.0 → 0.32.0

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 (52) hide show
  1. package/dist/bin/rango.js +37 -7
  2. package/dist/types/browser/history-state.d.ts +13 -2
  3. package/dist/types/browser/react/Link.d.ts +10 -13
  4. package/dist/types/browser/react/location-state-shared.d.ts +2 -1
  5. package/dist/types/cache/document-cache-policy.d.ts +1 -1
  6. package/dist/types/route-definition/redirect.d.ts +5 -0
  7. package/dist/types/router/middleware-types.d.ts +6 -0
  8. package/dist/types/rsc/helpers.d.ts +5 -3
  9. package/dist/types/rsc/request-stages.d.ts +2 -0
  10. package/dist/types/server/document-location-state.d.ts +56 -0
  11. package/dist/types/server/request-context.d.ts +23 -6
  12. package/dist/types/testing/render-route.d.ts +17 -0
  13. package/dist/types/testing/serve-shell-request.d.ts +9 -0
  14. package/dist/types/types/handler-context.d.ts +18 -3
  15. package/dist/types/types/segments.d.ts +6 -0
  16. package/dist/types/vite/utils/forward-user-plugins.d.ts +32 -2
  17. package/dist/vite/index.js +3 -3
  18. package/package.json +1 -1
  19. package/skills/comparison/references/framework-comparison.md +3 -2
  20. package/skills/document-cache/SKILL.md +5 -0
  21. package/skills/hooks/state.md +110 -6
  22. package/skills/intercept/SKILL.md +2 -1
  23. package/skills/ppr/SKILL.md +12 -0
  24. package/skills/server-actions/SKILL.md +8 -0
  25. package/skills/testing/client-components.md +44 -19
  26. package/skills/testing/serve-shell-request.md +38 -13
  27. package/skills/ux-architecture/SKILL.md +6 -3
  28. package/src/browser/history-state.ts +24 -2
  29. package/src/browser/react/Link.tsx +10 -13
  30. package/src/browser/react/location-state-shared.ts +8 -1
  31. package/src/browser/rsc-router.tsx +5 -2
  32. package/src/build/runtime-discovery.ts +46 -8
  33. package/src/cache/document-cache-policy.ts +10 -1
  34. package/src/cache/document-cache.ts +7 -0
  35. package/src/route-definition/redirect.ts +5 -0
  36. package/src/router/match-middleware/cache-lookup.ts +23 -6
  37. package/src/router/match-result.ts +9 -1
  38. package/src/router/middleware-types.ts +6 -0
  39. package/src/router/revalidation.ts +23 -3
  40. package/src/rsc/helpers.ts +13 -4
  41. package/src/rsc/progressive-enhancement.ts +5 -0
  42. package/src/rsc/request-stages.ts +28 -1
  43. package/src/rsc/rsc-rendering.ts +39 -9
  44. package/src/server/document-location-state.ts +313 -0
  45. package/src/server/request-context.ts +24 -5
  46. package/src/testing/dispatch.ts +3 -0
  47. package/src/testing/render-route.tsx +40 -6
  48. package/src/testing/run-revalidate.ts +11 -1
  49. package/src/testing/serve-shell-request.ts +16 -0
  50. package/src/types/handler-context.ts +18 -3
  51. package/src/types/segments.ts +6 -0
  52. package/src/vite/utils/forward-user-plugins.ts +13 -5
@@ -69,7 +69,10 @@ export function shouldCacheResponse(
69
69
  response: Response,
70
70
  requestCtx?: Pick<
71
71
  RequestContext,
72
- "_payloadVisitorTheme" | "_payloadDeferred" | "_payloadVisitorLocation"
72
+ | "_payloadVisitorTheme"
73
+ | "_payloadDeferred"
74
+ | "_payloadVisitorLocation"
75
+ | "_documentLocationState"
73
76
  >,
74
77
  ): CacheDirectives | null {
75
78
  // Only cache successful responses
@@ -100,6 +103,12 @@ export function shouldCacheResponse(
100
103
  return null;
101
104
  }
102
105
 
106
+ // The payload carries the visitor's location state (#1330,
107
+ // server/document-location-state.ts).
108
+ if (requestCtx?._documentLocationState) {
109
+ return null;
110
+ }
111
+
103
112
  // prefetch: false. The payload carries deferred units: it answers one
104
113
  // page's prefetch and nothing else. parseCacheControl refuses the
105
114
  // Cache-Control it is sent with (rsc-rendering.ts NOT_REUSED), but a
@@ -276,6 +276,13 @@ export function createDocumentCacheMiddleware<TEnv = any>(
276
276
  return next();
277
277
  }
278
278
 
279
+ // Location state is already set for this request (a document redirect's
280
+ // cookie, an earlier middleware): the response is the visitor's, so a
281
+ // stored body must not answer it, and it is not stored.
282
+ if ((requestCtx._locationState?.length ?? 0) > 0) {
283
+ return next();
284
+ }
285
+
279
286
  // The write gate's start (#977) and the entry's stamp (#1068): the
280
287
  // request's, not this middleware's, also for a stale refresh. A
281
288
  // middleware ahead of this one can read tagged data the document bakes,
@@ -20,6 +20,11 @@ import { prefixBasename } from "../router/basename.js";
20
20
  * Supports an optional state parameter to carry location state through the
21
21
  * redirect. On the target page, state can be read via useLocationState()
22
22
  * (use { flash: true } in createLocationState for read-once flash messages).
23
+ * On a client navigation or action the state travels in the redirect's
24
+ * payload. On a document request (a page load, a no-JS form post) it travels
25
+ * in a one-shot cookie the target's document request reads and clears, as
26
+ * JSON: give it plain JSON data there. `{ external: true }` carries none.
27
+ * Two entries of the same definition keep only the last.
23
28
  *
24
29
  * @param url - The URL to redirect to
25
30
  * @param statusOrOptions - HTTP status code (default: 302) or options object
@@ -313,12 +313,15 @@ function cachedSegmentSends<TEnv>(
313
313
  * Whether a prerender entry's segment is sent. On a partial navigation the
314
314
  * client keeps the segments it holds. The route's own segments (an
315
315
  * intercept's included) are sent again when the params changed (e.g., a
316
- * different guide slug). An overlay entry may be newer than the client's
317
- * copy: on an action or a stale request they are re-sent too, as a live
316
+ * different guide slug), and kept on equal params whatever the search: a
317
+ * prerender does not vary by search. An overlay entry may be newer than the
318
+ * client's copy: on an action or a stale request they are re-sent too, as a live
318
319
  * action re-render re-sends them (revalidation.ts "action:route-segment");
319
320
  * the immutable bundled manifest keeps the client's copy. A held segment
320
321
  * above the route stays held, as a live walk keeps it ("nav:non-route-skip"),
321
- * unless its revalidate() rules ask for it when the params changed.
322
+ * unless its revalidate() rules ask for it when the params changed. A route
323
+ * segment, or one with rules, kept on equal params marks the match
324
+ * source-held: the decision hangs on the source's params.
322
325
  *
323
326
  * Decided once per decoded segment, by object: an intercept variant can hold
324
327
  * a layout's parallel and the intercept's slot under one id. decodeStoredEntry
@@ -333,9 +336,17 @@ function storedEntrySends<TEnv>(
333
336
  const paramsChanged = !paramsEqual(ctx.matched.params, ctx.prevParams);
334
337
  const resendRouteSegments = replaceable && (ctx.isAction || ctx.stale);
335
338
  const entryRevalidateMap =
336
- paramsChanged && router.buildEntryRevalidateMap
337
- ? router.buildEntryRevalidateMap(ctx.entries)
339
+ ctx.clientSegmentSet.size > 0
340
+ ? router.buildEntryRevalidateMap?.(ctx.entries)
338
341
  : undefined;
342
+ // A keep that hangs on the source's params is right for this source only:
343
+ // scope a prefetch of the answer to it, as a predicate that read the
344
+ // source would (#1331).
345
+ const heldForSource = (): false => {
346
+ (ctx.handlerContext as InternalHandlerContext<any, TEnv>)._sourceHeld =
347
+ true;
348
+ return false;
349
+ };
339
350
  const decide = (segment: ResolvedSegment): boolean | Promise<boolean> => {
340
351
  if (!ctx.clientSegmentSet.has(segment.id)) return true;
341
352
  // A fill keeps it (fillSends).
@@ -346,10 +357,16 @@ function storedEntrySends<TEnv>(
346
357
  );
347
358
  if (fill !== undefined) return fill;
348
359
  if (segment.type === "route" || segment.belongsToRoute === true) {
349
- return paramsChanged || resendRouteSegments;
360
+ if (paramsChanged || resendRouteSegments) return true;
361
+ // Kept on equal params, whatever the search: a source with other
362
+ // params is sent it.
363
+ return heldForSource();
350
364
  }
351
365
  const revalidate = entryRevalidateMap?.get(segment.id)?.revalidate;
352
366
  if (!revalidate?.length) return false;
367
+ // Kept on equal params: from another source the rules run and may send
368
+ // it.
369
+ if (!paramsChanged) return heldForSource();
353
370
  return router.evaluateRevalidation({
354
371
  segment,
355
372
  prevParams: ctx.prevParams,
@@ -107,7 +107,11 @@
107
107
  * 3. Diff against previous state with diff[]
108
108
  * 4. Render slot content if slots present
109
109
  */
110
- import type { MatchResult, ResolvedSegment } from "../types.js";
110
+ import type {
111
+ InternalHandlerContext,
112
+ MatchResult,
113
+ ResolvedSegment,
114
+ } from "../types.js";
111
115
  import { isPlaceholder } from "../types/segments.js";
112
116
  import { interceptMatched } from "../browser/intercept-utils.js";
113
117
  import type { MatchContext, MatchPipelineState } from "./match-context.js";
@@ -304,6 +308,10 @@ export function buildMatchResult<TEnv>(
304
308
  slots: Object.keys(state.slots).length > 0 ? state.slots : undefined,
305
309
  ...(interceptTargets.length > 0 ? { interceptTargets } : {}),
306
310
  ...(ctx.interceptTargeted ? { interceptTargeted: true } : {}),
311
+ ...((ctx.handlerContext as InternalHandlerContext<any, TEnv> | undefined)
312
+ ?._sourceHeld
313
+ ? { sourceHeld: true }
314
+ : {}),
307
315
  routeMiddleware:
308
316
  ctx.routeMiddleware.length > 0 ? ctx.routeMiddleware : undefined,
309
317
  };
@@ -78,6 +78,12 @@ export interface MiddlewareContext<
78
78
 
79
79
  setTheme?: (theme: Theme) => void;
80
80
 
81
+ /**
82
+ * Attach location state entries to this response (see the handler
83
+ * context's `setLocationState`). Entries accumulate across definitions;
84
+ * for one definition the last write wins. Set it here rather than in a
85
+ * handler on a `ppr` route: a shell HIT runs no handler.
86
+ */
81
87
  setLocationState(entries: LocationStateEntry | LocationStateEntry[]): void;
82
88
 
83
89
  /** Global route names only; see {@link GlobalReverseFunction}. */
@@ -7,6 +7,7 @@
7
7
  import type {
8
8
  ResolvedSegment,
9
9
  HandlerContext,
10
+ InternalHandlerContext,
10
11
  ShouldRevalidateFn,
11
12
  } from "../types";
12
13
  import type { ActionContext } from "./types";
@@ -154,11 +155,19 @@ export async function evaluateRevalidation<TEnv>(
154
155
  // second, contradicting entry would let flushRevalidationTrace count the
155
156
  // segment as both revalidated and skipped.
156
157
  const floorTrue = defaultOverride?.floor === true && defaultOverride.value;
158
+ // A predicate read the page navigated away from. A segment it keeps then
159
+ // stays held for this source only: a prefetch of the answer, adopted on
160
+ // another page, would keep that page's copy (#1331). A re-render is
161
+ // source-safe, it sends the segment.
162
+ let readSource = false;
157
163
  function decide(finalVal: boolean, reason: string): boolean {
158
164
  if (floorTrue && !finalVal) {
159
165
  pushTrace(defaultShouldRevalidate, true, `${reason}:floored`);
160
166
  return true;
161
167
  }
168
+ if (!finalVal && readSource) {
169
+ (context as InternalHandlerContext<any, TEnv>)._sourceHeld = true;
170
+ }
162
171
  pushTrace(defaultShouldRevalidate, finalVal, reason);
163
172
  return finalVal;
164
173
  }
@@ -253,12 +262,20 @@ export async function evaluateRevalidation<TEnv>(
253
262
 
254
263
  const isActionFn = makeIsAction(actionContext?.actionId);
255
264
 
265
+ const currentParams = prevSegment?.params || prevParams;
266
+
256
267
  for (const { name, fn } of revalidations) {
257
268
  let result: any;
258
269
  try {
259
270
  result = fn({
260
- currentParams: prevSegment?.params || prevParams, // Use segment params if available, else route params
261
- currentUrl: prevUrl,
271
+ get currentParams() {
272
+ readSource = true;
273
+ return currentParams;
274
+ },
275
+ get currentUrl() {
276
+ readSource = true;
277
+ return prevUrl;
278
+ },
262
279
  nextParams,
263
280
  nextUrl,
264
281
  defaultShouldRevalidate: currentSuggestion,
@@ -275,7 +292,10 @@ export async function evaluateRevalidation<TEnv>(
275
292
  formData: actionContext?.formData,
276
293
  method: request.method,
277
294
  routeName: toRouteName,
278
- fromRouteName,
295
+ get fromRouteName() {
296
+ readSource = true;
297
+ return fromRouteName;
298
+ },
279
299
  toRouteName,
280
300
  stale: cacheStale ? true : stale,
281
301
  });
@@ -340,14 +340,23 @@ export function interceptRedirectForPartial(
340
340
  /**
341
341
  * Attach location state set during a request to a payload's metadata.
342
342
  * No-op if no location state was set. Callers must ensure payload.metadata
343
- * is populated (the non-null assertion holds for the partial/action payloads
344
- * that reach this helper).
343
+ * is populated. A `document` payload (a page load's or a no-JS form post's
344
+ * bootstrap payload) that carries state marks the request
345
+ * `_documentLocationState`: it is the visitor's, so no shared cache keeps it
346
+ * (server/document-location-state.ts).
345
347
  */
346
- export function attachLocationStateIfPresent(payload: RscPayload): void {
348
+ export function attachLocationStateIfPresent(
349
+ payload: RscPayload,
350
+ document = false,
351
+ ): void {
347
352
  const locationState = getLocationState();
348
- if (locationState) {
353
+ if (locationState && locationState.length > 0) {
349
354
  payload.metadata!.locationState =
350
355
  resolveLocationStateEntries(locationState);
356
+ if (document) {
357
+ const ctx = _getRequestContext();
358
+ if (ctx) ctx._documentLocationState = true;
359
+ }
351
360
  }
352
361
  }
353
362
 
@@ -25,6 +25,7 @@ import type { RscPayload, ReactFormState } from "./types.js";
25
25
  import type { MatchResult } from "../types.js";
26
26
  import {
27
27
  actionRenderRequest,
28
+ attachLocationStateIfPresent,
28
29
  createResponseWithMergedHeaders,
29
30
  finalizeResponse,
30
31
  buildRouteMiddlewareEntries,
@@ -448,6 +449,10 @@ function renderPeResponse<TEnv>(
448
449
  // slot instead of failing the payload root and the whole document.
449
450
  ...(reactFormState ? { formState: Promise.resolve(reactFormState) } : {}),
450
451
  };
452
+ // The action's location state rides the re-rendered document's bootstrap
453
+ // payload, as a JS action's rides its response. Not on a failure render,
454
+ // like the JS path.
455
+ if (!failure) attachLocationStateIfPresent(payload, true);
451
456
 
452
457
  const trace = getRequestContext()._activeRoutine;
453
458
 
@@ -84,6 +84,11 @@ import {
84
84
  import { INTERNAL_RANGO_DEBUG } from "../internal-debug.js";
85
85
  import type { CacheErrorCategory } from "../cache/cache-error.js";
86
86
  import { WIRE } from "../wire-params.js";
87
+ import {
88
+ carryLocationStateAcrossRedirect,
89
+ consumeDocumentLocationState,
90
+ markDocumentResponsePrivate,
91
+ } from "../server/document-location-state.js";
87
92
 
88
93
  /**
89
94
  * The router members the stages read. The handler's `RangoInternal` carries
@@ -96,6 +101,8 @@ export interface StageRouter<TEnv = unknown> {
96
101
  readonly telemetry?: TelemetrySink;
97
102
  readonly timeouts?: ResolvedTimeouts;
98
103
  readonly onTimeout?: OnTimeoutCallback<TEnv>;
104
+ /** Names the cookie location state crosses a document redirect in. */
105
+ readonly resolvedStateCookieName: string;
99
106
  }
100
107
 
101
108
  /** Report an error through the router's onError. */
@@ -409,6 +416,14 @@ export async function runRequestPasses<TEnv>(
409
416
  opts: RequestPassesOptions<TEnv>,
410
417
  ): Promise<RequestPassesResult> {
411
418
  const { ctx, env, variables } = opts;
419
+ // Before any middleware: a document cache lookup, a PPR shell serve and the
420
+ // render all see the state a document redirect carried here.
421
+ consumeDocumentLocationState(
422
+ opts.reqCtx,
423
+ opts.request,
424
+ opts.url,
425
+ ctx.router.resolvedStateCookieName,
426
+ );
412
427
  let passIndex = 0;
413
428
  let hasMiddleware = false;
414
429
  const runChain = (
@@ -707,7 +722,19 @@ export function finalizeRequestResponse<TEnv>(
707
722
  // guarding browser-followed (3xx) redirects once covers them all and any
708
723
  // future redirect exit. Soft SPA/Flight redirects are 200/204 and pass
709
724
  // through untouched (validated client-side instead).
710
- const guarded = guardOutgoingRedirect(response, url.origin, router.basename);
725
+ // After the guard, so the cookie names the target the browser follows.
726
+ // A document carrying location state, or a redirect carrying it on, leaves
727
+ // private: set after every middleware, so none can make it shared.
728
+ const guarded = markDocumentResponsePrivate(
729
+ carryLocationStateAcrossRedirect(
730
+ guardOutgoingRedirect(response, url.origin, router.basename),
731
+ reqCtx,
732
+ request,
733
+ url,
734
+ router.resolvedStateCookieName,
735
+ ),
736
+ reqCtx,
737
+ );
711
738
 
712
739
  // Stream-idle watchdog (opt-in via timeouts.streamIdleMs): bounds
713
740
  // end-to-end idle flow on the streamed body — see rsc/stream-idle.ts
@@ -430,6 +430,12 @@ function* requestRenderPlan<TEnv>(
430
430
  // chain (executeRender wraps it), so no shell byte can precede a guard
431
431
  // decision — that ordering is what makes a shared shell safe. Routes
432
432
  // without the `ppr` option stay pure axis 1 at zero cost.
433
+ //
434
+ // Location state set by now (middleware, a document redirect's cookie) does
435
+ // not keep a request off the shell: a HIT attaches it to the live tail's
436
+ // payload, and a capture builds its own payload without it, so no shell
437
+ // ever holds a visitor's state (server/document-location-state.ts).
438
+ const stateBeforeRender = reqCtx._locationState?.length ?? 0;
433
439
  const shell = yield* scope("shell-serve", shellServePlan(input));
434
440
  if (shell.kind === "serve") return shell.response;
435
441
 
@@ -441,11 +447,12 @@ function* requestRenderPlan<TEnv>(
441
447
  );
442
448
  if (prepared.kind === "control") return prepared.response;
443
449
 
444
- // For partial requests, include any server-set location state in the payload.
445
- // SSR (full page) requests ignore location state since there's no history.state
446
- // to write to on a fresh page load.
447
- if (isPartial && prepared.payload.metadata) {
448
- attachLocationStateIfPresent(prepared.payload);
450
+ // Server-set location state rides the payload: a navigation's, merged at
451
+ // its commit, or a document's bootstrap payload, merged when the client
452
+ // starts (browser/rsc-router.tsx). A document carrying it is per visitor
453
+ // (server/document-location-state.ts).
454
+ if (prepared.payload.metadata) {
455
+ attachLocationStateIfPresent(prepared.payload, !isPartial);
449
456
  }
450
457
 
451
458
  const response = yield* step("render", () =>
@@ -457,8 +464,14 @@ function* requestRenderPlan<TEnv>(
457
464
  // shell). Capture does not flow through the HTTP pipeline — middleware never
458
465
  // re-runs (it already ran for this request; guarding is serve-time).
459
466
  // reqCtx._dynamic is re-read here on purpose: a loader may have marked the
460
- // request dynamic while rendering.
461
- if (shell.kind === "miss" && !reqCtx._dynamic) {
467
+ // request dynamic while rendering. State a handler set in this render is
468
+ // not captured: a HIT replays the handler's output without running it, so
469
+ // the state would be lost on every HIT. The route renders in full instead.
470
+ if (
471
+ shell.kind === "miss" &&
472
+ !reqCtx._dynamic &&
473
+ (reqCtx._locationState?.length ?? 0) <= stateBeforeRender
474
+ ) {
462
475
  if (
463
476
  response.status === 200 &&
464
477
  (response.headers.get("content-type") ?? "").includes("text/html")
@@ -885,7 +898,10 @@ function* preparePayloadPlan<TEnv>(
885
898
  stateCookieName: ctx.router.resolvedStateCookieName,
886
899
  },
887
900
  },
888
- sourceScoped: result.interceptTargeted === true || deferring,
901
+ sourceScoped:
902
+ result.interceptTargeted === true ||
903
+ result.sourceHeld === true ||
904
+ deferring,
889
905
  pprReplayStatus,
890
906
  healKey,
891
907
  };
@@ -1116,7 +1132,9 @@ function renderPreparedRscResponse<TEnv>(
1116
1132
  // intercept targets renders the modal or the full page depending on the
1117
1133
  // source, so neither response may be reused from another source: a full
1118
1134
  // page prefetched where the intercept does not apply would otherwise
1119
- // serve a later click where it does (#1007).
1135
+ // serve a later click where it does (#1007). So is a diff a revalidate()
1136
+ // decided from the source page: another page adopting it would keep its
1137
+ // own copy of the segment the answer held (#1331).
1120
1138
  if (sourceScoped) {
1121
1139
  rscHeaders["x-rsc-prefetch-scope"] = "source";
1122
1140
  }
@@ -1712,6 +1730,15 @@ function serveShellHit(
1712
1730
  // A visitor-keyed shell shows the visitor's URL: the document cache keys
1713
1731
  // this response by the target.
1714
1732
  if (entry.pathname !== undefined) reqCtx._payloadVisitorLocation = true;
1733
+ // Location state set before the render (middleware, a document redirect's
1734
+ // cookie) rides the tail's payload, not the shell. Marked here, on the
1735
+ // request's own context and before the response leaves (the tail runs on a
1736
+ // derived context, after an await), so the response goes
1737
+ // `private, no-store` and stays out of the document cache
1738
+ // (server/document-location-state.ts).
1739
+ if ((reqCtx._locationState?.length ?? 0) > 0) {
1740
+ reqCtx._documentLocationState = true;
1741
+ }
1715
1742
  const metricsStore = reqCtx._metricsStore;
1716
1743
  // Per-stage tail timing for the `ppr:tail` Server-Timing mirror and the
1717
1744
  // perf console line: always in dev, in production only when this request
@@ -1768,6 +1795,9 @@ function serveShellHit(
1768
1795
  | undefined;
1769
1796
  // Same for the location: the key's URL, or a visitor-keyed shell's.
1770
1797
  payload.metadata.pathname = entry.pathname ?? url.pathname;
1798
+ // The visitor's location state, as a MISS document's payload carries
1799
+ // it. The request was marked per visitor above.
1800
+ attachLocationStateIfPresent(payload);
1771
1801
  }
1772
1802
  // Full Flight render per request: hydration needs the whole payload (there
1773
1803
  // is no Flight-side resume — a React limitation, not ours).