@rangojs/router 0.34.0 → 0.34.1

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/dist/bin/rango.js CHANGED
@@ -229,7 +229,7 @@ ${formatNestedRouterConflictError(nestedRouterConflict, "[rango]")}
229
229
  }
230
230
  let discoverAndWriteRouteTypes;
231
231
  try {
232
- const mod = await import("./runtime-discovery-2ZTYRZ3V.js");
232
+ const mod = await import("./runtime-discovery-RY6NMQM3.js");
233
233
  discoverAndWriteRouteTypes = mod.discoverAndWriteRouteTypes;
234
234
  } catch (err) {
235
235
  if (err.code === "ERR_MODULE_NOT_FOUND" || err.code === "MODULE_NOT_FOUND") {
@@ -163,7 +163,7 @@ async function discoverAndWriteRouteTypes(opts) {
163
163
  "Runtime discovery requires '@vitejs/plugin-rsc'. Install it with: pnpm add -D @vitejs/plugin-rsc"
164
164
  );
165
165
  }
166
- const { createTempRscServer } = await import("./temp-rsc-server-EUNKRFJD.js");
166
+ const { createTempRscServer } = await import("./temp-rsc-server-F7AHOPV4.js");
167
167
  const { createDiscoveryState } = await import("./state-ACD2MGKJ.js");
168
168
  const { discoverClientUrlProjections } = await import("./client-urls-projection-4Q5EQDAL.js");
169
169
  const {
@@ -47,7 +47,7 @@ var DEFAULT_ROUTER_VERSIONS_KEY = "*";
47
47
  // package.json
48
48
  var package_default = {
49
49
  name: "@rangojs/router",
50
- version: "0.34.0",
50
+ version: "0.34.1",
51
51
  description: "Django-inspired RSC router with composable URL patterns",
52
52
  keywords: [
53
53
  "react",
@@ -28,6 +28,10 @@
28
28
  * cancelled on resubscribe so a StrictMode / transition remount does not
29
29
  * reclaim a bucket that is about to be reused, and is held until any
30
30
  * in-flight load settles.
31
+ * - Owned buckets (one reader's private refresh-group bucket,
32
+ * `useLoader(L, { refreshGroup })` without a key) are sticky while that
33
+ * reader is mounted and dropped like an ephemeral bucket once it leaves:
34
+ * no other reader names the bucket, and a remount takes a new one.
31
35
  *
32
36
  * Each router's NavigationStore owns one (navigation-store.ts), and hooks reach
33
37
  * it through NavigationStoreContext: the router store is the single source of
@@ -79,6 +83,13 @@ export interface SubscribeOptions {
79
83
  * subscriber attaches, and from then on resets via `clearFamily`.
80
84
  */
81
85
  ephemeral?: boolean;
86
+ /**
87
+ * When true, the bucket belongs to this subscriber alone (a private
88
+ * refresh-group bucket, which no other reader names): it is reset by
89
+ * `clearFamily` while subscribed, and dropped after the subscriber leaves
90
+ * even when sticky. Read at unsubscribe, so pass it on every subscribe.
91
+ */
92
+ owned?: boolean;
82
93
  /**
83
94
  * Cross-loader refresh group name(s). Tags this bucket so `refreshGroups(name)`
84
95
  * can refresh it alongside buckets of other loaders. A bucket may be tagged
@@ -130,8 +141,8 @@ export declare class LoaderStore {
130
141
  * Subscribe to entry changes for `bucketKey`.
131
142
  * Returns an unsubscribe function. A sticky bucket's entry is kept around even
132
143
  * after the last subscriber leaves so that an in-flight `load()` can still
133
- * commit if the consumer remounts. An ephemeral bucket is dropped once its
134
- * last subscriber leaves (deferred, see maybeScheduleRefcountClear).
144
+ * commit if the consumer remounts. An ephemeral or owned bucket is dropped
145
+ * once its last subscriber leaves (deferred, see maybeScheduleRefcountClear).
135
146
  */
136
147
  subscribe(bucketKey: string, cb: () => void, options?: SubscribeOptions): () => void;
137
148
  private addToGroup;
@@ -149,14 +160,14 @@ export declare class LoaderStore {
149
160
  */
150
161
  refreshGroups(groups: string | string[]): Promise<void>;
151
162
  /**
152
- * Schedule a deferred drop for an ad-hoc (never-sticky) bucket whose last
153
- * subscriber just left. Deferred to a microtask so a StrictMode / transition
154
- * remount that resubscribes in the same tick cancels it.
163
+ * Schedule a deferred drop for an ad-hoc (never-sticky) or owned bucket
164
+ * whose last subscriber just left. Deferred to a microtask so a StrictMode /
165
+ * transition remount that resubscribes in the same tick cancels it.
155
166
  */
156
167
  private maybeScheduleRefcountClear;
157
168
  /** Remove a bucket entry entirely and prune it from its family/group indexes. */
158
169
  private dropBucket;
159
- /** Drop an ephemeral bucket that settled with no subscribers left. */
170
+ /** Drop an ephemeral or owned bucket that settled with no subscribers left. */
160
171
  private dropIfSettled;
161
172
  /**
162
173
  * Returns the current snapshot for `bucketKey`. Stable reference between
@@ -205,7 +216,8 @@ export declare class LoaderStore {
205
216
  * Reset a single bucket entry. Bumps `latestRequestId` so any in-flight
206
217
  * `load()` whose promise is still pending will fail its gate when it resolves
207
218
  * and be dropped — prevents pre-navigation loads from clobbering the new
208
- * route's context. The entry itself is kept (sticky-bucket semantics).
219
+ * route's context. The entry itself is kept (sticky-bucket semantics),
220
+ * unless it was only waiting for that request to be dropped.
209
221
  */
210
222
  clear(bucketKey: string): void;
211
223
  /**
@@ -112,6 +112,7 @@ import type { CacheScope } from "../cache/cache-scope.js";
112
112
  import type { EntryData, InterceptSelectorContext, MetricsStore } from "../server/context.js";
113
113
  import type { HandlerContext, ResolvedSegment } from "../types.js";
114
114
  import type { RouteMatchResult } from "./pattern-matching.js";
115
+ import type { PrevPage } from "./navigation-snapshot.js";
115
116
  import type { InterceptResult } from "./router-context.js";
116
117
  import type { ResolutionPassInput } from "./segment-resolution/resolution-pass.js";
117
118
  import type { ActionContext } from "./types.js";
@@ -130,7 +131,10 @@ export interface MatchContext<TEnv = any> {
130
131
  heldBoundary?: string;
131
132
  prevUrl: URL;
132
133
  prevParams: Record<string, string>;
134
+ /** The match of the page left: the intercept's URL while one is open. */
133
135
  prevMatch: RouteMatchResult | null;
136
+ /** The page an open intercept's slot was rendered for. */
137
+ interceptPrev: PrevPage;
134
138
  matched: RouteMatchResult;
135
139
  manifestEntry: EntryData;
136
140
  entries: EntryData[];
@@ -169,6 +173,14 @@ export interface MatchContext<TEnv = any> {
169
173
  export declare function rendersLive(ctx: Pick<MatchContext, "isAction" | "stale">): boolean;
170
174
  /** This match as the input of a revalidating resolution pass (resolution-pass.ts). */
171
175
  export declare function resolutionPassInput<TEnv>(ctx: MatchContext<TEnv>): ResolutionPassInput<TEnv>;
176
+ /**
177
+ * This match as the input of the pass that resolves its intercept's slot: its
178
+ * held segments compare with the intercept's own URL (`interceptPrev`), not
179
+ * with the source page under it. From /item/2 over /list/1, a link to /item/1
180
+ * compared with the source's params would read as param-equal and keep item
181
+ * 2's slot.
182
+ */
183
+ export declare function interceptPassInput<TEnv>(ctx: MatchContext<TEnv>): ResolutionPassInput<TEnv>;
172
184
  /**
173
185
  * Mutable state that flows through the pipeline
174
186
  */
@@ -1,10 +1,27 @@
1
1
  import type { RouteMatchResult } from "./pattern-matching.js";
2
+ /** A page held segments were rendered for: what their revalidation compares with. */
3
+ export interface PrevPage {
4
+ url: URL;
5
+ params: Record<string, string>;
6
+ }
2
7
  export interface NavigationSnapshot {
8
+ /**
9
+ * `prevUrl` and `prevParams`: the page the held page segments were rendered
10
+ * for. That is the page left, or the source page while an intercept is open
11
+ * (`hasInterceptSource`).
12
+ */
3
13
  prevUrl: URL;
4
14
  /** The visitor's URL of the page left, when `prevUrl` is its rewrite target. */
5
15
  prevVisitorUrl: URL | null;
6
16
  prevParams: Record<string, string>;
17
+ /** The match of the page left: the intercept's URL while one is open. */
7
18
  prevMatch: RouteMatchResult | null;
19
+ /**
20
+ * The page an open intercept's slot was rendered for: the intercept's own
21
+ * URL (match-context.ts interceptPassInput). Equals `prevUrl`/`prevParams`
22
+ * when the request names no intercept source.
23
+ */
24
+ interceptPrev: PrevPage;
8
25
  interceptContextUrl: URL;
9
26
  interceptContextMatch: RouteMatchResult | null;
10
27
  clientSegmentIds: string[];
@@ -7344,7 +7344,7 @@ function resolveVersionsFrom(table, routerId2) {
7344
7344
  // package.json
7345
7345
  var package_default = {
7346
7346
  name: "@rangojs/router",
7347
- version: "0.34.0",
7347
+ version: "0.34.1",
7348
7348
  description: "Django-inspired RSC router with composable URL patterns",
7349
7349
  keywords: [
7350
7350
  "react",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.34.0",
3
+ "version": "0.34.1",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -261,7 +261,10 @@ tag (`"account"`) covers everything, while a finer tag (`"orders"`) targets a
261
261
  subset. Sharing within a group is opt-in via `key`: members that share a `key`
262
262
  share one value (and one fetch); a grouped reader **without** a `key` gets its own
263
263
  private bucket, so a group refresh updates only that read and never leaks into
264
- unrelated unkeyed reads of the same loader. A bucket may belong to several groups
264
+ unrelated unkeyed reads of the same loader. That private bucket lives as long as
265
+ its read is mounted, and a hidden React `<Activity>` counts as unmounted: when it
266
+ shows again, the read is back on the route's data until the next refresh. To keep
267
+ a refreshed value across an `<Activity>` hide, give the read a `key`. A bucket may belong to several groups
265
268
  at once (one read tagged with multiple names, or different reads tagging the same
266
269
  keyed bucket with different names). Keep parameterized loaders on the single-loader
267
270
  `key` — a plain-GET group refresh sends no params.
@@ -28,6 +28,10 @@
28
28
  * cancelled on resubscribe so a StrictMode / transition remount does not
29
29
  * reclaim a bucket that is about to be reused, and is held until any
30
30
  * in-flight load settles.
31
+ * - Owned buckets (one reader's private refresh-group bucket,
32
+ * `useLoader(L, { refreshGroup })` without a key) are sticky while that
33
+ * reader is mounted and dropped like an ephemeral bucket once it leaves:
34
+ * no other reader names the bucket, and a remount takes a new one.
31
35
  *
32
36
  * Each router's NavigationStore owns one (navigation-store.ts), and hooks reach
33
37
  * it through NavigationStoreContext: the router store is the single source of
@@ -97,6 +101,13 @@ export interface SubscribeOptions {
97
101
  * subscriber attaches, and from then on resets via `clearFamily`.
98
102
  */
99
103
  ephemeral?: boolean;
104
+ /**
105
+ * When true, the bucket belongs to this subscriber alone (a private
106
+ * refresh-group bucket, which no other reader names): it is reset by
107
+ * `clearFamily` while subscribed, and dropped after the subscriber leaves
108
+ * even when sticky. Read at unsubscribe, so pass it on every subscribe.
109
+ */
110
+ owned?: boolean;
100
111
  /**
101
112
  * Cross-loader refresh group name(s). Tags this bucket so `refreshGroups(name)`
102
113
  * can refresh it alongside buckets of other loaders. A bucket may be tagged
@@ -130,8 +141,8 @@ interface InternalEntry {
130
141
  /** Family id (loader.$$id) this bucket belongs to. */
131
142
  loaderId: string;
132
143
  /**
133
- * True once any non-ephemeral subscriber has attached. Sticky buckets are
134
- * never reference-count-dropped; they reset via clearFamily().
144
+ * True once any non-ephemeral subscriber has attached. Sticky buckets reset
145
+ * via clearFamily() and are reference-count-dropped only when owned.
135
146
  */
136
147
  sticky: boolean;
137
148
  /** A deferred refcount-drop microtask is scheduled and not yet cancelled. */
@@ -223,8 +234,8 @@ export class LoaderStore {
223
234
  * Subscribe to entry changes for `bucketKey`.
224
235
  * Returns an unsubscribe function. A sticky bucket's entry is kept around even
225
236
  * after the last subscriber leaves so that an in-flight `load()` can still
226
- * commit if the consumer remounts. An ephemeral bucket is dropped once its
227
- * last subscriber leaves (deferred, see maybeScheduleRefcountClear).
237
+ * commit if the consumer remounts. An ephemeral or owned bucket is dropped
238
+ * once its last subscriber leaves (deferred, see maybeScheduleRefcountClear).
228
239
  */
229
240
  subscribe(
230
241
  bucketKey: string,
@@ -261,7 +272,7 @@ export class LoaderStore {
261
272
  // different groups stays in each group until ALL of that group's
262
273
  // subscribers have left (order-independent).
263
274
  for (const group of groups) this.releaseGroup(group, bucketKey, e);
264
- this.maybeScheduleRefcountClear(bucketKey, e);
275
+ this.maybeScheduleRefcountClear(bucketKey, e, options?.owned);
265
276
  };
266
277
  }
267
278
 
@@ -354,17 +365,18 @@ export class LoaderStore {
354
365
  }
355
366
 
356
367
  /**
357
- * Schedule a deferred drop for an ad-hoc (never-sticky) bucket whose last
358
- * subscriber just left. Deferred to a microtask so a StrictMode / transition
359
- * remount that resubscribes in the same tick cancels it.
368
+ * Schedule a deferred drop for an ad-hoc (never-sticky) or owned bucket
369
+ * whose last subscriber just left. Deferred to a microtask so a StrictMode /
370
+ * transition remount that resubscribes in the same tick cancels it.
360
371
  */
361
372
  private maybeScheduleRefcountClear(
362
373
  bucketKey: string,
363
374
  e: InternalEntry,
375
+ owned: boolean | undefined,
364
376
  ): void {
365
377
  if (e.listeners.size > 0) return;
366
- // Sticky buckets persist for remount; they reset via clearFamily().
367
- if (e.sticky) return;
378
+ // Sticky buckets persist for remount unless owned; they reset via clearFamily().
379
+ if (e.sticky && !owned) return;
368
380
  if (e.pendingClear) return;
369
381
  e.pendingClear = true;
370
382
  queueMicrotask(() => {
@@ -393,7 +405,7 @@ export class LoaderStore {
393
405
  }
394
406
  }
395
407
 
396
- /** Drop an ephemeral bucket that settled with no subscribers left. */
408
+ /** Drop an ephemeral or owned bucket that settled with no subscribers left. */
397
409
  private dropIfSettled(bucketKey: string, e: InternalEntry): void {
398
410
  if (e.clearWhenSettled && e.listeners.size === 0) {
399
411
  e.clearWhenSettled = false;
@@ -523,7 +535,8 @@ export class LoaderStore {
523
535
  * Reset a single bucket entry. Bumps `latestRequestId` so any in-flight
524
536
  * `load()` whose promise is still pending will fail its gate when it resolves
525
537
  * and be dropped — prevents pre-navigation loads from clobbering the new
526
- * route's context. The entry itself is kept (sticky-bucket semantics).
538
+ * route's context. The entry itself is kept (sticky-bucket semantics),
539
+ * unless it was only waiting for that request to be dropped.
527
540
  */
528
541
  clear(bucketKey: string): void {
529
542
  const e = this.entries.get(bucketKey);
@@ -532,6 +545,9 @@ export class LoaderStore {
532
545
  if (e.snapshot === EMPTY_SNAPSHOT) return;
533
546
  e.snapshot = EMPTY_SNAPSHOT;
534
547
  this.notify(e);
548
+ // An owned bucket left mid-flight waits for its request to settle, which
549
+ // the bump above stops from committing.
550
+ this.dropIfSettled(bucketKey, e);
535
551
  }
536
552
 
537
553
  /**
@@ -203,6 +203,7 @@ export async function createMatchContextForFull<TEnv>(
203
203
  prevUrl: cleanUrl,
204
204
  prevParams: {},
205
205
  prevMatch: null,
206
+ interceptPrev: { url: cleanUrl, params: {} },
206
207
  matched,
207
208
  manifestEntry: snapshot.manifestEntry,
208
209
  entries: snapshot.entries,
@@ -520,6 +521,7 @@ export async function createMatchContextForPartial<TEnv>(
520
521
  prevUrl: nav.prevUrl,
521
522
  prevParams: nav.prevParams,
522
523
  prevMatch: nav.prevMatch,
524
+ interceptPrev: nav.interceptPrev,
523
525
  matched,
524
526
  manifestEntry: snapshot.manifestEntry,
525
527
  entries: snapshot.entries,
@@ -116,6 +116,7 @@ import type {
116
116
  } from "../server/context.js";
117
117
  import type { HandlerContext, ResolvedSegment } from "../types.js";
118
118
  import type { RouteMatchResult } from "./pattern-matching.js";
119
+ import type { PrevPage } from "./navigation-snapshot.js";
119
120
  import type { InterceptResult } from "./router-context.js";
120
121
  import type { ResolutionPassInput } from "./segment-resolution/resolution-pass.js";
121
122
  import type { ActionContext } from "./types.js";
@@ -139,10 +140,13 @@ export interface MatchContext<TEnv = any> {
139
140
  /** NavigationSnapshot.heldBoundary: the walk stops above it. */
140
141
  heldBoundary?: string;
141
142
 
142
- // Previous navigation state
143
+ // Previous navigation state: see NavigationSnapshot.
143
144
  prevUrl: URL;
144
145
  prevParams: Record<string, string>;
146
+ /** The match of the page left: the intercept's URL while one is open. */
145
147
  prevMatch: RouteMatchResult | null;
148
+ /** The page an open intercept's slot was rendered for. */
149
+ interceptPrev: PrevPage;
146
150
 
147
151
  // Current route match
148
152
  matched: RouteMatchResult;
@@ -225,6 +229,23 @@ export function resolutionPassInput<TEnv>(
225
229
  };
226
230
  }
227
231
 
232
+ /**
233
+ * This match as the input of the pass that resolves its intercept's slot: its
234
+ * held segments compare with the intercept's own URL (`interceptPrev`), not
235
+ * with the source page under it. From /item/2 over /list/1, a link to /item/1
236
+ * compared with the source's params would read as param-equal and keep item
237
+ * 2's slot.
238
+ */
239
+ export function interceptPassInput<TEnv>(
240
+ ctx: MatchContext<TEnv>,
241
+ ): ResolutionPassInput<TEnv> {
242
+ return {
243
+ ...resolutionPassInput(ctx),
244
+ prevUrl: ctx.interceptPrev.url,
245
+ prevParams: ctx.interceptPrev.params,
246
+ };
247
+ }
248
+
228
249
  /**
229
250
  * Mutable state that flows through the pipeline
230
251
  */
@@ -49,6 +49,7 @@ import {
49
49
  type MatchPipelineState,
50
50
  } from "../match-context.js";
51
51
  import { getRouterContext, type RouterContext } from "../router-context.js";
52
+ import type { PrevPage } from "../navigation-snapshot.js";
52
53
  import { freshPassInput } from "../segment-resolution/resolution-pass.js";
53
54
  import { observeEvent } from "../instrument.js";
54
55
  import { pushRevalidationTraceEntry, isTraceActive } from "../logging.js";
@@ -336,7 +337,12 @@ function storedEntrySends<TEnv>(
336
337
  router: RouterContext<TEnv>,
337
338
  ): (segment: ResolvedSegment) => boolean | Promise<boolean> {
338
339
  if (ctx.isFullMatch) return () => true;
339
- const paramsChanged = !paramsEqual(ctx.matched.params, ctx.prevParams);
340
+ // An intercept variant's slot compares with the intercept's own page, the
341
+ // page under it with the source (MatchContext.interceptPrev).
342
+ const pagePrev: PrevPage = { url: ctx.prevUrl, params: ctx.prevParams };
343
+ const slotPrev = ctx.interceptPrev;
344
+ const pageParamsChanged = !paramsEqual(ctx.matched.params, pagePrev.params);
345
+ const slotParamsChanged = !paramsEqual(ctx.matched.params, slotPrev.params);
340
346
  const resendRouteSegments = replaceable && (ctx.isAction || ctx.stale);
341
347
  const entryRevalidateMap =
342
348
  ctx.clientSegmentSet.size > 0
@@ -345,11 +351,11 @@ function storedEntrySends<TEnv>(
345
351
  // A keep that hangs on the source's params is right for the pages with
346
352
  // the same params only: scope a prefetch of the answer to them, as a
347
353
  // predicate that compared every param would (#1331).
348
- const heldForSource = (): false => {
354
+ const heldForSource = (prevParams: Record<string, string>): false => {
349
355
  markSourceHeld(
350
356
  ctx.handlerContext as InternalHandlerContext<any, TEnv>,
351
- readsOfAllParams(ctx.prevParams),
352
- { params: ctx.prevParams, routeName: undefined },
357
+ readsOfAllParams(prevParams),
358
+ { params: prevParams, routeName: undefined },
353
359
  );
354
360
  return false;
355
361
  };
@@ -362,23 +368,26 @@ function storedEntrySends<TEnv>(
362
368
  ctx.clientSegmentSet,
363
369
  );
364
370
  if (fill !== undefined) return fill;
371
+ const ofSlot = isInterceptSegment(segment);
372
+ const prev = ofSlot ? slotPrev : pagePrev;
373
+ const paramsChanged = ofSlot ? slotParamsChanged : pageParamsChanged;
365
374
  if (segment.type === "route" || segment.belongsToRoute === true) {
366
375
  if (paramsChanged || resendRouteSegments) return true;
367
376
  // Kept on equal params, whatever the search: a source with other
368
377
  // params is sent it.
369
- return heldForSource();
378
+ return heldForSource(prev.params);
370
379
  }
371
380
  const revalidate = entryRevalidateMap?.get(segment.id)?.revalidate;
372
381
  if (!revalidate?.length) return false;
373
382
  // Kept on equal params: from another source the rules run and may send
374
383
  // it.
375
- if (!paramsChanged) return heldForSource();
384
+ if (!paramsChanged) return heldForSource(prev.params);
376
385
  return router.evaluateRevalidation({
377
386
  segment,
378
- prevParams: ctx.prevParams,
387
+ prevParams: prev.params,
379
388
  getPrevSegment: null,
380
389
  request: ctx.request,
381
- prevUrl: ctx.prevUrl,
390
+ prevUrl: prev.url,
382
391
  nextUrl: ctx.url,
383
392
  revalidations: revalidate.map((fn, i) => ({
384
393
  name: `revalidate${i}`,
@@ -103,7 +103,7 @@
103
103
  */
104
104
  import type { ResolvedSegment } from "../../types.js";
105
105
  import type { MatchContext, MatchPipelineState } from "../match-context.js";
106
- import { resolutionPassInput } from "../match-context.js";
106
+ import { interceptPassInput } from "../match-context.js";
107
107
  import { getRouterContext } from "../router-context.js";
108
108
  import type { GeneratorMiddleware } from "./cache-lookup.js";
109
109
  import { debugLog } from "../logging.js";
@@ -176,7 +176,7 @@ export function withInterceptResolution<TEnv>(
176
176
  const Store = ctx.Store;
177
177
  const interceptSegments = await Store.run(() =>
178
178
  resolveInterceptEntry(
179
- resolutionPassInput(ctx),
179
+ interceptPassInput(ctx),
180
180
  ctx.interceptResult!.intercept,
181
181
  ctx.interceptResult!.entry,
182
182
  true, // belongsToRoute
@@ -232,7 +232,7 @@ async function handleCacheHitIntercept<TEnv>(
232
232
  const loaderSegments = resolveInterceptLoadersOnly
233
233
  ? await ctx.Store.run(() =>
234
234
  resolveInterceptLoadersOnly(
235
- resolutionPassInput(ctx),
235
+ interceptPassInput(ctx),
236
236
  ctx.interceptResult!.intercept,
237
237
  ctx.interceptResult!.entry,
238
238
  true, // belongsToRoute
@@ -3,12 +3,30 @@ import { requestHeaders } from "../server/request-headers.js";
3
3
  import type { RouteMatchResult } from "./pattern-matching.js";
4
4
  import { WIRE } from "../wire-params.js";
5
5
 
6
+ /** A page held segments were rendered for: what their revalidation compares with. */
7
+ export interface PrevPage {
8
+ url: URL;
9
+ params: Record<string, string>;
10
+ }
11
+
6
12
  export interface NavigationSnapshot {
13
+ /**
14
+ * `prevUrl` and `prevParams`: the page the held page segments were rendered
15
+ * for. That is the page left, or the source page while an intercept is open
16
+ * (`hasInterceptSource`).
17
+ */
7
18
  prevUrl: URL;
8
19
  /** The visitor's URL of the page left, when `prevUrl` is its rewrite target. */
9
20
  prevVisitorUrl: URL | null;
10
21
  prevParams: Record<string, string>;
22
+ /** The match of the page left: the intercept's URL while one is open. */
11
23
  prevMatch: RouteMatchResult | null;
24
+ /**
25
+ * The page an open intercept's slot was rendered for: the intercept's own
26
+ * URL (match-context.ts interceptPassInput). Equals `prevUrl`/`prevParams`
27
+ * when the request names no intercept source.
28
+ */
29
+ interceptPrev: PrevPage;
12
30
 
13
31
  interceptContextUrl: URL;
14
32
  interceptContextMatch: RouteMatchResult | null;
@@ -176,29 +194,40 @@ export async function resolveNavigation(
176
194
  }
177
195
  // An intercept's request diffs from its source, which _rsc_rt does not name.
178
196
  const shown = interceptSourceUrl ? null : getShownRouteUrl(url);
179
- const prevUrl = shown ?? visitor;
197
+ const left = shown ?? visitor;
180
198
 
181
199
  let interceptContextUrl: URL;
182
200
  try {
183
201
  interceptContextUrl = interceptSourceUrl
184
202
  ? withoutHash(new URL(interceptSourceUrl, url.origin))
185
- : prevUrl;
203
+ : left;
186
204
  } catch {
187
- interceptContextUrl = prevUrl;
205
+ interceptContextUrl = left;
188
206
  }
189
207
 
190
- const prevMatch = await deps.findMatch(prevUrl.pathname);
191
- const prevParams = prevMatch?.params || {};
208
+ const prevMatch = await deps.findMatch(left.pathname);
192
209
  const interceptContextMatch = interceptSourceUrl
193
210
  ? await deps.findMatch(interceptContextUrl.pathname)
194
211
  : prevMatch;
212
+ const interceptPrev: PrevPage = {
213
+ url: left,
214
+ params: prevMatch?.params || {},
215
+ };
216
+ // The page under an open intercept was rendered for the source, with its
217
+ // search, not for the intercept's URL.
218
+ const prev: PrevPage = interceptSourceUrl
219
+ ? {
220
+ url: formSearch(new URL(interceptContextUrl)),
221
+ params: interceptContextMatch?.params || {},
222
+ }
223
+ : interceptPrev;
195
224
 
196
225
  const isSameRouteNavigation = !!(
197
226
  interceptContextMatch && interceptContextMatch.routeKey === currentRouteKey
198
227
  );
199
228
 
200
229
  const hasInterceptSource = !!interceptSourceUrl;
201
- const effectiveFromUrl = hasInterceptSource ? interceptContextUrl : prevUrl;
230
+ const effectiveFromUrl = hasInterceptSource ? interceptContextUrl : left;
202
231
  const effectiveFromMatch = hasInterceptSource
203
232
  ? interceptContextMatch
204
233
  : prevMatch;
@@ -212,10 +241,11 @@ export async function resolveNavigation(
212
241
  const clientSegmentSet = new Set(clientSegmentIds);
213
242
 
214
243
  return {
215
- prevUrl,
244
+ prevUrl: prev.url,
216
245
  prevVisitorUrl: shown && visitor,
217
- prevParams,
246
+ prevParams: prev.params,
218
247
  prevMatch,
248
+ interceptPrev,
219
249
  interceptContextUrl,
220
250
  interceptContextMatch,
221
251
  clientSegmentIds,
@@ -240,6 +270,7 @@ export function createNavigationSnapshot(
240
270
  prevVisitorUrl: null,
241
271
  prevParams: {},
242
272
  prevMatch: null,
273
+ interceptPrev: { url: defaultUrl, params: {} },
243
274
  interceptContextUrl: defaultUrl,
244
275
  interceptContextMatch: null,
245
276
  clientSegmentIds: [],
@@ -256,12 +256,13 @@ function useLoaderInternal<T>(
256
256
  [groupKey],
257
257
  );
258
258
  const hasGroups = groupList.length > 0;
259
+ // A grouped read without a key refreshes a bucket no other reader names.
260
+ const usesPrivateBucket = hasGroups && key === undefined;
259
261
  const privateBucketIdRef = useRef<string | null>(null);
260
- if (hasGroups && key === undefined && privateBucketIdRef.current === null) {
262
+ if (usesPrivateBucket && privateBucketIdRef.current === null) {
261
263
  privateBucketIdRef.current = `__rg${privateGroupBucketSeq++}`;
262
264
  }
263
- const effectiveKey =
264
- key ?? (hasGroups ? privateBucketIdRef.current! : undefined);
265
+ const effectiveKey = usesPrivateBucket ? privateBucketIdRef.current! : key;
265
266
  const bucketKey =
266
267
  effectiveKey === undefined ? loaderId : `${loaderId}::${effectiveKey}`;
267
268
 
@@ -352,6 +353,7 @@ function useLoaderInternal<T>(
352
353
  {
353
354
  loaderId,
354
355
  ephemeral: !hasContextData,
356
+ owned: usesPrivateBucket,
355
357
  group: hasGroups ? groupList : undefined,
356
358
  refetch: hasGroups ? groupRefetch : undefined,
357
359
  onStreamPending: hasContextData