@rangojs/router 0.22.1 → 0.23.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 (179) hide show
  1. package/dist/bin/rango.js +8 -3
  2. package/dist/testing/vitest.js +31 -1
  3. package/dist/types/browser/defer.d.ts +48 -0
  4. package/dist/types/browser/document-revealed.d.ts +17 -0
  5. package/dist/types/browser/event-controller.d.ts +7 -1
  6. package/dist/types/browser/navigation-transaction.d.ts +21 -7
  7. package/dist/types/browser/partial-update.d.ts +34 -5
  8. package/dist/types/browser/react/NavigationProvider.d.ts +13 -3
  9. package/dist/types/browser/react/filter-segment-order.d.ts +7 -0
  10. package/dist/types/browser/response-adapter.d.ts +7 -0
  11. package/dist/types/browser/types.d.ts +19 -9
  12. package/dist/types/browser/validate-redirect-origin.d.ts +11 -0
  13. package/dist/types/build/generate-route-types.d.ts +1 -1
  14. package/dist/types/build/route-types/router-processing.d.ts +1 -1
  15. package/dist/types/build/route-types/scan-filter.d.ts +16 -2
  16. package/dist/types/cache/cf/cf-cache-store.d.ts +16 -1
  17. package/dist/types/cache/cf/cf-cache-types.d.ts +23 -2
  18. package/dist/types/client-urls/client-urls.d.ts +1 -1
  19. package/dist/types/client-urls/hoisted-revalidate.d.ts +13 -0
  20. package/dist/types/client-urls/navigation.d.ts +0 -37
  21. package/dist/types/client-urls/revalidate-registry.d.ts +4 -0
  22. package/dist/types/client-urls/server-projection.d.ts +29 -1
  23. package/dist/types/client-urls/types.d.ts +34 -69
  24. package/dist/types/client.d.ts +0 -1
  25. package/dist/types/errors.d.ts +5 -0
  26. package/dist/types/handles/deferred-resolution.d.ts +2 -1
  27. package/dist/types/handles/pushed-by.d.ts +23 -0
  28. package/dist/types/loader-store.d.ts +8 -0
  29. package/dist/types/prefetch-markers.d.ts +11 -0
  30. package/dist/types/route-definition/helpers-types.d.ts +27 -0
  31. package/dist/types/router/is-action.d.ts +2 -1
  32. package/dist/types/router/segment-resolution/loader-mask.d.ts +3 -2
  33. package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +241 -0
  34. package/dist/types/server/context.d.ts +21 -1
  35. package/dist/types/server/handle-store.d.ts +9 -1
  36. package/dist/types/server/request-context.d.ts +14 -1
  37. package/dist/types/server.d.ts +1 -0
  38. package/dist/types/testing/flight.entry.d.ts +1 -1
  39. package/dist/types/testing/index.d.ts +3 -3
  40. package/dist/types/testing/internal/action-id.d.ts +10 -1
  41. package/dist/types/testing/internal/client-urls-projection.d.ts +16 -0
  42. package/dist/types/testing/internal/test-action.d.ts +8 -0
  43. package/dist/types/testing/run-loader.d.ts +2 -1
  44. package/dist/types/testing/run-revalidate.d.ts +74 -0
  45. package/dist/types/testing/run-transition-when.d.ts +2 -3
  46. package/dist/types/testing/serve-shell-request.d.ts +38 -0
  47. package/dist/types/testing/vitest.d.ts +11 -0
  48. package/dist/types/types/boundaries.d.ts +9 -0
  49. package/dist/types/types/handler-context.d.ts +14 -0
  50. package/dist/types/types/loader-types.d.ts +30 -0
  51. package/dist/types/types/segments.d.ts +21 -4
  52. package/dist/types/urls/path-helper-types.d.ts +25 -4
  53. package/dist/types/urls/type-extraction.d.ts +6 -2
  54. package/dist/types/vite/discovery/discover-routers.d.ts +9 -0
  55. package/dist/types/vite/discovery/state.d.ts +11 -4
  56. package/dist/types/vite/plugins/client-urls-revalidate-hoist.d.ts +54 -0
  57. package/dist/types/vite/plugins/hoist/literal.d.ts +83 -0
  58. package/dist/types/vite/plugins/hoist/scope.d.ts +40 -0
  59. package/dist/types/vite/plugins/hoist/source.d.ts +41 -0
  60. package/dist/types/vite/plugins/transition-when-hoist.d.ts +8 -11
  61. package/dist/types/vite/router-discovery.d.ts +10 -0
  62. package/dist/vite/index.js +842 -308
  63. package/package.json +1 -1
  64. package/skills/caching/SKILL.md +42 -5
  65. package/skills/catalog.json +2 -2
  66. package/skills/client-urls/SKILL.md +113 -24
  67. package/skills/cloudflare/SKILL.md +14 -9
  68. package/skills/hooks/SKILL.md +11 -3
  69. package/skills/links/SKILL.md +24 -0
  70. package/skills/loader/SKILL.md +192 -1
  71. package/skills/migrate-nextjs/SKILL.md +2 -2
  72. package/skills/migrate-react-router/SKILL.md +10 -7
  73. package/skills/migrate-react-router/data-and-actions.md +1 -1
  74. package/skills/ppr/SKILL.md +13 -0
  75. package/skills/prerender/SKILL.md +22 -8
  76. package/skills/rango/SKILL.md +12 -6
  77. package/skills/route/SKILL.md +139 -0
  78. package/skills/testing/SKILL.md +3 -2
  79. package/skills/testing/client-components.md +1 -1
  80. package/skills/testing/predicates.md +67 -32
  81. package/skills/testing/serve-shell-request.md +88 -18
  82. package/skills/testing/setup.md +2 -2
  83. package/skills/typesafety/route-types.md +2 -2
  84. package/skills/view-transitions/SKILL.md +26 -0
  85. package/src/browser/defer.ts +82 -0
  86. package/src/browser/document-revealed.ts +52 -0
  87. package/src/browser/event-controller.ts +43 -32
  88. package/src/browser/navigation-bridge.ts +64 -14
  89. package/src/browser/navigation-client.ts +48 -36
  90. package/src/browser/navigation-store.ts +13 -0
  91. package/src/browser/navigation-transaction.ts +25 -8
  92. package/src/browser/partial-update.ts +528 -135
  93. package/src/browser/prefetch/fetch.ts +2 -1
  94. package/src/browser/react/NavigationProvider.tsx +47 -28
  95. package/src/browser/react/filter-segment-order.ts +13 -2
  96. package/src/browser/response-adapter.ts +20 -0
  97. package/src/browser/rsc-router.tsx +17 -45
  98. package/src/browser/server-action-bridge.ts +19 -63
  99. package/src/browser/types.ts +19 -9
  100. package/src/browser/validate-redirect-origin.ts +16 -0
  101. package/src/build/generate-route-types.ts +1 -0
  102. package/src/build/route-types/router-processing.ts +3 -6
  103. package/src/build/route-types/scan-filter.ts +37 -10
  104. package/src/cache/cf/cf-cache-store.ts +59 -21
  105. package/src/cache/cf/cf-cache-types.ts +24 -2
  106. package/src/cache/document-cache.ts +22 -1
  107. package/src/client-urls/client-urls.ts +22 -6
  108. package/src/client-urls/hoisted-revalidate.ts +25 -0
  109. package/src/client-urls/navigation.ts +0 -96
  110. package/src/client-urls/revalidate-registry.ts +30 -0
  111. package/src/client-urls/server-projection.ts +151 -65
  112. package/src/client-urls/types.ts +39 -71
  113. package/src/client.tsx +9 -5
  114. package/src/errors.ts +15 -0
  115. package/src/handles/deferred-resolution.ts +28 -5
  116. package/src/handles/pushed-by.ts +122 -0
  117. package/src/loader-store.ts +18 -7
  118. package/src/prefetch-markers.ts +13 -0
  119. package/src/route-content-wrapper.tsx +18 -4
  120. package/src/route-definition/dsl-helpers.ts +27 -4
  121. package/src/route-definition/helpers-types.ts +27 -1
  122. package/src/router/intercept-resolution.ts +6 -4
  123. package/src/router/is-action.ts +2 -1
  124. package/src/router/loader-resolution.ts +83 -4
  125. package/src/router/match-api.ts +36 -1
  126. package/src/router/match-middleware/background-revalidation.ts +11 -1
  127. package/src/router/match-middleware/cache-lookup.ts +29 -6
  128. package/src/router/match-middleware/cache-store.ts +17 -1
  129. package/src/router/match-result.ts +14 -2
  130. package/src/router/navigation-snapshot.ts +2 -1
  131. package/src/router/segment-resolution/fresh.ts +22 -18
  132. package/src/router/segment-resolution/loader-cache.ts +2 -1
  133. package/src/router/segment-resolution/loader-mask.ts +5 -3
  134. package/src/router/segment-resolution/prefetch-deferral.ts +543 -0
  135. package/src/router/segment-resolution/revalidation.ts +169 -60
  136. package/src/rsc/rsc-rendering.ts +50 -7
  137. package/src/rsc/shell-capture.ts +2 -1
  138. package/src/segment-loader-promise.ts +23 -20
  139. package/src/segment-system.tsx +60 -26
  140. package/src/server/context.ts +39 -5
  141. package/src/server/handle-store.ts +104 -8
  142. package/src/server/request-context.ts +21 -1
  143. package/src/server.ts +4 -0
  144. package/src/testing/action-form.ts +2 -11
  145. package/src/testing/e2e/page-helpers.ts +3 -1
  146. package/src/testing/flight.entry.ts +1 -0
  147. package/src/testing/index.ts +6 -3
  148. package/src/testing/internal/action-id.ts +16 -1
  149. package/src/testing/internal/client-urls-projection.ts +70 -0
  150. package/src/testing/internal/test-action.ts +25 -0
  151. package/src/testing/run-loader.ts +2 -1
  152. package/src/testing/run-revalidate.ts +142 -0
  153. package/src/testing/run-transition-when.ts +7 -8
  154. package/src/testing/serve-shell-request.ts +116 -6
  155. package/src/testing/vitest.ts +67 -1
  156. package/src/types/boundaries.ts +12 -0
  157. package/src/types/handler-context.ts +14 -0
  158. package/src/types/loader-types.ts +30 -0
  159. package/src/types/segments.ts +26 -4
  160. package/src/urls/path-helper-types.ts +27 -5
  161. package/src/urls/type-extraction.ts +30 -10
  162. package/src/use-loader.tsx +18 -13
  163. package/src/vite/discovery/client-urls-projection.ts +20 -21
  164. package/src/vite/discovery/discover-routers.ts +94 -26
  165. package/src/vite/discovery/state.ts +20 -4
  166. package/src/vite/discovery/virtual-module-codegen.ts +22 -17
  167. package/src/vite/plugins/client-urls-revalidate-hoist.ts +564 -0
  168. package/src/vite/plugins/hoist/literal.ts +417 -0
  169. package/src/vite/plugins/hoist/scope.ts +370 -0
  170. package/src/vite/plugins/hoist/source.ts +84 -0
  171. package/src/vite/plugins/transition-when-hoist.ts +113 -779
  172. package/src/vite/rango.ts +2 -0
  173. package/src/vite/router-discovery.ts +93 -32
  174. package/dist/types/client-urls/revalidate-chain.d.ts +0 -33
  175. package/dist/types/client-urls/revalidation-protocol.d.ts +0 -25
  176. package/dist/types/testing/run-client-revalidate.d.ts +0 -43
  177. package/src/client-urls/revalidate-chain.ts +0 -83
  178. package/src/client-urls/revalidation-protocol.ts +0 -56
  179. package/src/testing/run-client-revalidate.ts +0 -92
@@ -1,4 +1,3 @@
1
- import { requestHeaders } from "../server/request-headers.js";
2
1
  import * as React from "react";
3
2
  import type { SearchSchemaValue } from "../search-params.js";
4
3
  import { getLoaderLazy } from "../server/loader-registry.js";
@@ -7,7 +6,11 @@ import {
7
6
  getUrlPrefix,
8
7
  type InterceptSelectorContext,
9
8
  } from "../server/context.js";
10
- import type { LoaderDefinition, TrailingSlashMode } from "../types.js";
9
+ import type {
10
+ LoaderDefinition,
11
+ ShouldRevalidateFn,
12
+ TrailingSlashMode,
13
+ } from "../types.js";
11
14
  import type {
12
15
  PartialPrerenderProps,
13
16
  PathOptions,
@@ -16,11 +19,8 @@ import type {
16
19
  import type { PathHelpers } from "../urls/path-helper-types.js";
17
20
  import type { AllUseItems } from "../route-types.js";
18
21
  import { urls } from "../urls/urls-function.js";
19
- import {
20
- CLIENT_REVALIDATION_HEADER,
21
- decodeClientRevalidationDecisions,
22
- type ClientRevalidationDecisions,
23
- } from "./revalidation-protocol.js";
22
+ import { isHoistedRevalidateRef } from "./hoisted-revalidate.js";
23
+ import { getClientUrlRevalidator } from "./revalidate-registry.js";
24
24
  import {
25
25
  ClientUrlsGroupLayout,
26
26
  ClientUrlsInterceptLoading,
@@ -96,6 +96,16 @@ export interface ClientUrlProjectionRoute {
96
96
  * document renders await them before first flush. Absent (= none) in
97
97
  * projections serialized before the option existed. */
98
98
  readonly awaitedLoaderIndices?: readonly number[];
99
+ /** Indices into loaderIds of loaders declared loader(Def, { prefetch:
100
+ * false }); materialization passes the option through to the server
101
+ * loader(), so a prefetch of the route defers them. Absent (= none) in
102
+ * projections serialized before the option existed. */
103
+ readonly deferrableLoaderIndices?: readonly number[];
104
+ /** Per loader (aligned with loaderIds), the keys of its hoisted
105
+ * revalidate() predicates in declaration order; materialization attaches
106
+ * the registered predicates (revalidate-registry.ts). Absent when no
107
+ * loader declares one. */
108
+ readonly loaderRevalidate?: readonly (readonly string[])[];
99
109
  /** Data-only transition config (never `when`); absent in projections
100
110
  * serialized before transition support. */
101
111
  readonly transition?: Readonly<ClientTransitionConfig>;
@@ -268,7 +278,28 @@ function serializeTransition(
268
278
  return Object.freeze(config) as Readonly<ClientTransitionConfig>;
269
279
  }
270
280
 
271
- function serializeRoute(route: ClientUrlRouteRecord): ClientUrlProjectionRoute {
281
+ /** Options for {@link serializeClientUrlPatterns}. */
282
+ export interface SerializeClientUrlPatternsOptions {
283
+ /**
284
+ * The definition was loaded through the rango Vite plugin (route
285
+ * discovery), which moves every revalidate() predicate to the server and
286
+ * leaves a marker: a plain function then is a site the build could not
287
+ * see, and fails loudly.
288
+ */
289
+ readonly hoisted?: boolean;
290
+ }
291
+
292
+ function indicesWhere<T>(
293
+ items: readonly T[],
294
+ pred: (item: T) => boolean,
295
+ ): number[] {
296
+ return items.flatMap((item, index) => (pred(item) ? [index] : []));
297
+ }
298
+
299
+ function serializeRoute(
300
+ route: ClientUrlRouteRecord,
301
+ options: SerializeClientUrlPatternsOptions,
302
+ ): ClientUrlProjectionRoute {
272
303
  const loaderIds = route.loaders.map(({ loader }, index) => {
273
304
  if (typeof loader.$$id !== "string" || loader.$$id.length === 0) {
274
305
  throw projectionError(
@@ -279,9 +310,29 @@ function serializeRoute(route: ClientUrlRouteRecord): ClientUrlProjectionRoute {
279
310
  return loader.$$id;
280
311
  });
281
312
 
282
- const awaitedLoaderIndices = route.loaders
283
- .map(({ ssr }, index) => (ssr === false ? index : -1))
284
- .filter((index) => index >= 0);
313
+ const loaderRevalidate = route.loaders.map(({ loader, revalidate }) =>
314
+ revalidate.flatMap((entry) => {
315
+ if (isHoistedRevalidateRef(entry)) return [entry.$$rangoRevalidate];
316
+ if (options.hoisted) {
317
+ throw new Error(
318
+ `clientUrls() revalidate() on loader "${loader.$$id}" of route ` +
319
+ `"${route.pattern}" was not hoisted to the server: call the ` +
320
+ "builder's revalidate() helper with an inline function or an " +
321
+ "imported predicate.",
322
+ );
323
+ }
324
+ return [];
325
+ }),
326
+ );
327
+
328
+ const awaitedLoaderIndices = indicesWhere(
329
+ route.loaders,
330
+ ({ ssr }) => ssr === false,
331
+ );
332
+ const deferrableLoaderIndices = indicesWhere(
333
+ route.loaders,
334
+ ({ prefetch }) => prefetch === false,
335
+ );
285
336
 
286
337
  const transition = serializeTransition(route);
287
338
  return Object.freeze({
@@ -294,6 +345,16 @@ function serializeRoute(route: ClientUrlRouteRecord): ClientUrlProjectionRoute {
294
345
  ...(awaitedLoaderIndices.length > 0
295
346
  ? { awaitedLoaderIndices: Object.freeze(awaitedLoaderIndices) }
296
347
  : {}),
348
+ ...(deferrableLoaderIndices.length > 0
349
+ ? { deferrableLoaderIndices: Object.freeze(deferrableLoaderIndices) }
350
+ : {}),
351
+ ...(loaderRevalidate.some((keys) => keys.length > 0)
352
+ ? {
353
+ loaderRevalidate: Object.freeze(
354
+ loaderRevalidate.map((keys) => Object.freeze(keys)),
355
+ ),
356
+ }
357
+ : {}),
297
358
  ...(transition ? { transition } : {}),
298
359
  ...(typeof route.transition?.when === "function"
299
360
  ? { transitionWhen: true as const }
@@ -325,16 +386,41 @@ function serializeIntercept(
325
386
 
326
387
  export function serializeClientUrlPatterns(
327
388
  patterns: ClientUrlPatterns,
389
+ options: SerializeClientUrlPatternsOptions = {},
328
390
  ): ClientUrlProjection {
329
391
  return Object.freeze({
330
392
  version: 1 as const,
331
- routes: Object.freeze(patterns.routes.map(serializeRoute)),
393
+ routes: Object.freeze(
394
+ patterns.routes.map((route) => serializeRoute(route, options)),
395
+ ),
332
396
  intercepts: Object.freeze(
333
397
  (patterns.intercepts ?? []).map(serializeIntercept),
334
398
  ),
335
399
  });
336
400
  }
337
401
 
402
+ /**
403
+ * The projection of a clientUrls() module from its namespace, or undefined
404
+ * when its default export is not a clientUrls() definition. Discovery
405
+ * (vite/discovery/client-urls-projection.ts) and the testing "use client"
406
+ * transform (testing/internal/client-urls-projection.ts) both project a
407
+ * module through it.
408
+ */
409
+ export function projectClientUrlsModule(
410
+ namespace: Readonly<Record<string, unknown>>,
411
+ options: SerializeClientUrlPatternsOptions = {},
412
+ ): ClientUrlProjection | undefined {
413
+ const definition = namespace.default;
414
+ if (
415
+ (typeof definition !== "object" && typeof definition !== "function") ||
416
+ definition === null ||
417
+ (definition as { __brand?: unknown }).__brand !== "client-urls"
418
+ ) {
419
+ return undefined;
420
+ }
421
+ return serializeClientUrlPatterns(definition as ClientUrlPatterns, options);
422
+ }
423
+
338
424
  export function isClientUrlPatterns(
339
425
  value: unknown,
340
426
  ): value is ClientUrlPatterns {
@@ -425,53 +511,42 @@ export function clearClientUrlProjections(): void {
425
511
  }
426
512
 
427
513
  /**
428
- * Per-request memo for decoded client revalidation decisions: the synthesized
429
- * predicate below runs once per loader stub per revalidation pass, and every
430
- * stub reads the same header.
431
- */
432
- const decisionsByRequest = new WeakMap<
433
- Request,
434
- ClientRevalidationDecisions | null
435
- >();
436
-
437
- function clientDecisionsFor(
438
- request: Request | undefined,
439
- ): ClientRevalidationDecisions | null {
440
- if (!request) return null;
441
- if (decisionsByRequest.has(request)) {
442
- return decisionsByRequest.get(request) ?? null;
443
- }
444
- const decoded = decodeClientRevalidationDecisions(
445
- requestHeaders(request).get(CLIENT_REVALIDATION_HEADER),
446
- );
447
- decisionsByRequest.set(request, decoded);
448
- return decoded;
449
- }
450
-
451
- /**
452
- * The server end of client-run per-loader revalidation: clientUrls()
453
- * revalidate() predicates EXECUTE in the browser (they are client-module
454
- * code), and their decisions arrive on the request header. This synthesized
455
- * predicate — attached to every materialized loader stub — honors a decision
456
- * addressed to its loader id and otherwise returns the locked default, which
457
- * is also the behavior for requests that carry no decisions (no-JS, PE,
458
- * prefetch, document loads). Decisions can only address client-urls stubs;
459
- * server-tree loaders never see this predicate.
514
+ * The revalidate() predicates of one materialized loader. A definition
515
+ * object (unit tests, the testing primitives) carries its functions. A
516
+ * client reference resolves the projection's content keys in the registry
517
+ * the group's hoisted module filled when it loaded; a key with no
518
+ * registration fails loudly here, at materialization, never at a request.
460
519
  */
461
- function makeClientDecisionRevalidate(
462
- loaderId: string,
463
- ): (args: {
464
- defaultShouldRevalidate: boolean;
465
- context: { request: Request };
466
- }) => boolean {
467
- return ({ defaultShouldRevalidate, context }) => {
468
- const decisions = clientDecisionsFor(context?.request);
469
- if (decisions) {
470
- if (decisions.skip.includes(loaderId)) return false;
471
- if (decisions.force.includes(loaderId)) return true;
520
+ function loaderRevalidators(
521
+ reference: ClientUrlDefinitionSource,
522
+ route: ClientUrlProjectionRoute,
523
+ loaderIndex: number,
524
+ ): ShouldRevalidateFn<any, any>[] {
525
+ const fromRegistry = (key: string): ShouldRevalidateFn<any, any> => {
526
+ if (!getClientUrlRevalidator(key)) {
527
+ const id = isClientUrlReference(reference) ? reference.$$id : "(inline)";
528
+ throw new Error(
529
+ `clientUrls() revalidate() on loader "${route.loaderIds[loaderIndex]}" ` +
530
+ `of route "${route.pattern}" in "${id}" has no server ` +
531
+ `registration (key "${key}"). The rango Vite plugin registers ` +
532
+ "hoisted predicates when the group's module loads in the RSC " +
533
+ "environment; a transform that changed the predicate's syntax " +
534
+ "in one environment only also causes this.",
535
+ );
472
536
  }
473
- return defaultShouldRevalidate;
537
+ // Looked up per call: a dev re-evaluation of the hoisted module
538
+ // re-registers the key with functions bound to the current imports.
539
+ return (args) => getClientUrlRevalidator(key)!(args);
474
540
  };
541
+ if (isClientUrlPatterns(reference)) {
542
+ const record = reference.routes.find(({ id }) => id === route.id);
543
+ return (record?.loaders[loaderIndex]?.revalidate ?? []).map((entry) =>
544
+ isHoistedRevalidateRef(entry)
545
+ ? fromRegistry(entry.$$rangoRevalidate)
546
+ : entry,
547
+ );
548
+ }
549
+ return (route.loaderRevalidate?.[loaderIndex] ?? []).map(fromRegistry);
475
550
  }
476
551
 
477
552
  function createLoaderStub(id: string): LoaderDefinition<unknown> {
@@ -551,15 +626,25 @@ function materializeRouteItems(
551
626
  }),
552
627
  materializedPathOptions(route, clientGroup),
553
628
  () => [
554
- ...route.loaderIds.map((id, loaderIndex) =>
555
- loader(
629
+ ...route.loaderIds.map((id, loaderIndex) => {
630
+ const revalidators = loaderRevalidators(
631
+ reference,
632
+ route,
633
+ loaderIndex,
634
+ );
635
+ return loader(
556
636
  createLoaderStub(id),
557
- route.awaitedLoaderIndices?.includes(loaderIndex)
558
- ? { ssr: false }
559
- : undefined,
560
- () => [revalidate(makeClientDecisionRevalidate(id))],
561
- ),
562
- ),
637
+ {
638
+ ...(route.awaitedLoaderIndices?.includes(loaderIndex) && {
639
+ ssr: false,
640
+ }),
641
+ ...(route.deferrableLoaderIndices?.includes(loaderIndex) && {
642
+ prefetch: false,
643
+ }),
644
+ },
645
+ () => revalidators.map((fn) => revalidate(fn)),
646
+ );
647
+ }),
563
648
  ...(route.hasLoading
564
649
  ? [
565
650
  loading(
@@ -703,7 +788,8 @@ export function clientUrlIncludePatterns(
703
788
  throw new Error(
704
789
  `include() could not resolve the server projection for clientUrls() module "${id}". ` +
705
790
  'The definition must be the default export of a "use client" module so the ' +
706
- "Vite discovery pass can project it; in unit tests pass the clientUrls() object directly.",
791
+ "Vite discovery pass can project it; in a Vitest project, rangoUseClientTransform() " +
792
+ "from @rangojs/router/testing/vitest projects it.",
707
793
  );
708
794
  }
709
795
  return materializeRouteItems(source, projection, helpers);
@@ -1,10 +1,12 @@
1
1
  import type { ComponentType, ReactNode } from "react";
2
2
  import type {
3
- IsActionFn,
4
3
  LoaderDefinition,
5
4
  LoaderOptions,
5
+ ShouldRevalidateFn,
6
6
  TransitionConfig,
7
7
  } from "../types.js";
8
+ import type { DefaultEnv } from "../types/global-namespace.js";
9
+ import type { HoistedRevalidateRef } from "./hoisted-revalidate.js";
8
10
  import type { TrieMatchResult } from "../router/trie-matching.js";
9
11
  import type { PathOptions } from "../urls/pattern-types.js";
10
12
  import type { SearchSchema } from "../search-params.js";
@@ -50,69 +52,17 @@ export type ClientLayoutFn = <const TItems extends ClientUrlItems>(
50
52
  children: () => TItems,
51
53
  ) => ClientUrlItem & TypedLayoutItem<ExtractRoutes<TItems>>;
52
54
 
53
- /**
54
- * Arguments a clientUrls() revalidate() predicate receives. A client-computable
55
- * subset of the server ShouldRevalidateFn args — the predicate RUNS IN THE
56
- * BROWSER (it is declared in a "use client" module and never crosses the
57
- * projection boundary); only its decision is sent to the server. There is no
58
- * `context` — no server handler context exists where this executes. `isAction`
59
- * is the same callable matcher as on the server, not a boolean.
60
- */
61
- export interface ClientRevalidateArgs {
62
- /** Full URL of the page being navigated away from (current location). */
63
- readonly currentUrl: URL;
64
- /** Full URL of the navigation target (equals currentUrl for actions). */
65
- readonly nextUrl: URL;
66
- /** Route params of the held client route (definition-local match). */
67
- readonly currentParams: Record<string, string>;
68
- /** Route params for the navigation target (definition-local match). */
69
- readonly nextParams: Record<string, string>;
70
- /**
71
- * The current default decision for this loader, computed client-side with
72
- * the same rules the server applies to the request the decisions ride on:
73
- * `true` when they ride the action POST itself, otherwise `true` when
74
- * params/search changed. (An action-triggered refetch GET gets navigation
75
- * defaults — matching the server — even though `isAction()` is true.)
76
- * Earlier predicates' `{ defaultShouldRevalidate }` verdicts thread into
77
- * this value, exactly like the server chain. Return it for default
78
- * behavior plus your own conditions.
79
- */
80
- readonly defaultShouldRevalidate: boolean;
81
- /** True when this is a stale history-entry background revalidation. */
82
- readonly stale: boolean;
83
- /**
84
- * Same {@link IsActionFn} the server `revalidate()` predicate receives.
85
- * In the browser the match is against the action stub's hashed `$$id`
86
- * (the id the action request carries) — not the RSC file-path `$id`.
87
- */
88
- readonly isAction: IsActionFn;
89
- /**
90
- * The triggering server action's id, when this is an action. In the
91
- * browser this is the hashed `hash#export` form (`$$id`), not the RSC
92
- * file-path `src/...#export`. Prefer `isAction(ref)` — a substring of
93
- * `path#export` will not match here in production.
94
- */
95
- readonly actionId?: string;
96
- }
97
-
98
- /**
99
- * Client-run per-loader predicate, with the same chain semantics as the
100
- * server's `revalidate()` (src/router/revalidation.ts): a boolean is a HARD
101
- * decision that short-circuits the rest of the chain; a
102
- * `{ defaultShouldRevalidate }` object updates the running suggestion, which
103
- * later predicates receive as their `defaultShouldRevalidate`; `void` /
104
- * `null` / `undefined` defers to the current suggestion — so
105
- * `isAction(CartActions) || undefined` defers to the locked default.
106
- * Predicates must be synchronous; the object form requires a boolean value.
107
- */
108
- export type ClientRevalidateFn = (
109
- args: ClientRevalidateArgs,
110
- ) => boolean | { defaultShouldRevalidate: boolean } | null | void;
111
-
112
55
  export interface ClientUrlLoaderRecord {
113
56
  readonly loader: LoaderDefinition<any, any>;
114
- /** Client-run per-loader revalidation predicates; empty = locked defaults. */
115
- readonly revalidate: readonly ClientRevalidateFn[];
57
+ /**
58
+ * Per-loader revalidate() predicates, run on the server; empty = the
59
+ * router's defaults. Under the rango Vite plugin the browser and SSR copies
60
+ * hold the marker the build left in place of each hoisted predicate.
61
+ */
62
+ readonly revalidate: readonly (
63
+ | ShouldRevalidateFn<any, DefaultEnv>
64
+ | HoistedRevalidateRef
65
+ )[];
116
66
  /**
117
67
  * loader(Def, { ssr: false }): document renders await this loader before
118
68
  * first flush (see {@link LoaderOptions}). Projected into the server tree,
@@ -120,6 +70,14 @@ export interface ClientUrlLoaderRecord {
120
70
  * regardless.
121
71
  */
122
72
  readonly ssr?: false;
73
+ /**
74
+ * loader(Def, { prefetch: false }): a prefetch of the route defers this
75
+ * loader and the click that adopts it runs it in its fill request (see
76
+ * {@link LoaderOptions}). Projected onto the materialized stub. Inside
77
+ * intercept() it is not recorded: intercept loaders ignore the flag, as in
78
+ * urls().
79
+ */
80
+ readonly prefetch?: false;
123
81
  }
124
82
 
125
83
  /**
@@ -172,8 +130,7 @@ export interface ClientUrlHelpers {
172
130
  readonly layout: ClientLayoutFn;
173
131
  /**
174
132
  * Attach a projected loader. The optional use callback may contain
175
- * revalidate() only — a CLIENT-RUN per-loader predicate; its decision (not
176
- * the function) is sent with the revalidation request.
133
+ * revalidate() only: a per-loader predicate the server runs, as in urls().
177
134
  *
178
135
  * Pass `{ ssr: false }` — the same knob as loading(fallback, { ssr:
179
136
  * false }) — to await this loader before first flush on DOCUMENT requests
@@ -183,22 +140,33 @@ export interface ClientUrlHelpers {
183
140
  * route the flag BAKES: the loader executes at shell capture and its
184
141
  * settled return freezes into the shell (nested promises stay live
185
142
  * holes).
143
+ *
144
+ * Pass `{ prefetch: false }` to keep this loader out of the route's
145
+ * prefetches (see {@link LoaderOptions}): an in-group click still shows
146
+ * the destination at once, commits the adopted prefetch with the other
147
+ * loaders' data, and runs only the flagged loaders in its one fill
148
+ * request. A `layout()` loader with the flag is flagged on every route
149
+ * below it. Inside intercept() the flag is accepted and ignored, as in
150
+ * urls().
186
151
  */
187
152
  readonly loader: <TData>(
188
153
  definition: LoaderDefinition<TData>,
189
- optionsOrUse?: LoaderOptions | ClientUrlUse,
154
+ optionsOrUse?: Pick<LoaderOptions, "ssr" | "prefetch"> | ClientUrlUse,
190
155
  use?: ClientUrlUse,
191
156
  ) => ClientUrlItem;
192
157
  readonly loading: (component: ReactNode) => ClientUrlItem;
193
158
  /**
194
159
  * Per-loader revalidation predicate, valid inside a loader() use callback
195
- * only. Runs IN THE BROWSER with client-computable args (including the
196
- * callable `isAction(...refs)` matcher); return true to re-run the loader,
197
- * false to keep held data. Absent predicates (and requests that carry no
198
- * decisions: no-JS, PE, prefetch, document loads) follow the locked server
199
- * defaults.
160
+ * only. It runs ON THE SERVER with the same arguments as a urls() loader's
161
+ * revalidate() (`actionResult`, `formData`, `context`, `isAction`, route
162
+ * names, ...): the build moves it out of this "use client" module. It may
163
+ * reference imports and globals only; a binding of this module, `this`,
164
+ * `import()`, a browser global, an `async` function or an import from a
165
+ * "use client" module fails the build with its file and line.
200
166
  */
201
- readonly revalidate: (fn: ClientRevalidateFn) => ClientUrlItem;
167
+ readonly revalidate: (
168
+ fn: ShouldRevalidateFn<any, DefaultEnv>,
169
+ ) => ClientUrlItem;
202
170
  /**
203
171
  * Declare an intercept for a named route in THIS definition. The target is
204
172
  * dot-local (`.detail`); scoping is module-local — only navigations whose
package/src/client.tsx CHANGED
@@ -30,6 +30,7 @@ import { AuditedOutletProvider, OutletProvider } from "./outlet-provider.js";
30
30
  import { INTERNAL_RANGO_SUSPENSE_AUDIT } from "./internal-suspense-audit.js";
31
31
  import { MountContextProvider } from "./browser/react/mount-context.js";
32
32
  import { getBoundaryContent } from "./segment-boundary-content.js";
33
+ import { asStream } from "./browser/defer.js";
33
34
 
34
35
  /**
35
36
  * Render the content for a named parallel/intercept slot segment.
@@ -41,6 +42,13 @@ import { getBoundaryContent } from "./segment-boundary-content.js";
41
42
  function renderSlotContent(segment: ResolvedSegment | null): ReactNode {
42
43
  if (!segment) return null;
43
44
 
45
+ // A slot that streams with no loading() gets a boundary with no fallback
46
+ // around its promise. A deferred slot's hole (`prefetch: false`) is a
47
+ // Defer, not a promise: it is what its fill resolves, and the slot it
48
+ // stands for was awaited, so every later render of the page has the
49
+ // content here with no boundary around it. The hole takes that place, read
50
+ // by React (browser/defer.ts asStream): another element here and the slot
51
+ // is mounted again when the page is next rendered.
44
52
  const content: ReactNode =
45
53
  segment.loading || segment.component instanceof Promise ? (
46
54
  <RouteContentWrapper
@@ -49,7 +57,7 @@ function renderSlotContent(segment: ResolvedSegment | null): ReactNode {
49
57
  segmentId={segment.id}
50
58
  />
51
59
  ) : (
52
- (segment.component ?? null)
60
+ ((asStream(segment.component) as ReactNode) ?? null)
53
61
  );
54
62
 
55
63
  const hasOwnLoaders = !!(segment.loaderDataPromise && segment.loaderIds);
@@ -511,7 +519,3 @@ export type { ScopedReverseFunction, LocalReverseFunction } from "./reverse.js";
511
519
 
512
520
  export type { LoaderDefinition } from "./types.js";
513
521
  export type { ActionRef, IsActionFn } from "./types.js";
514
- export type {
515
- ClientRevalidateArgs,
516
- ClientRevalidateFn,
517
- } from "./client-urls/types.js";
package/src/errors.ts CHANGED
@@ -345,6 +345,21 @@ export function invariant(condition: any, message: string): asserts condition {
345
345
  }
346
346
  }
347
347
 
348
+ /**
349
+ * Rejects a boolean-only DSL option that holds anything else (undefined
350
+ * passes), as an invariant.
351
+ */
352
+ export function assertBooleanOption(
353
+ value: unknown,
354
+ option: string,
355
+ effect: string,
356
+ ): void {
357
+ invariant(
358
+ value === undefined || typeof value === "boolean",
359
+ `${option} must be a boolean (got ${JSON.stringify(value)}). Omit it (or pass true) to ${effect}.`,
360
+ );
361
+ }
362
+
348
363
  /**
349
364
  * Sanitize errors for production - prevents leaking sensitive information
350
365
  *
@@ -1,5 +1,6 @@
1
1
  import type { HandleData, HandleStore } from "../server/handle-store.js";
2
2
  import { isThenable } from "./is-thenable.js";
3
+ import { isPushedByKey, pushedByKey } from "./pushed-by.js";
3
4
 
4
5
  /**
5
6
  * Runtime-neutral resolution of deferred (Promise) handle values. Shared by the
@@ -39,7 +40,8 @@ export function deferredHandleNames(data: HandleData): Set<string> {
39
40
  * thenables). A rejected deferred is dropped, and a deferred that resolves to
40
41
  * `null`/`undefined` is dropped (the `.defer({ else })` skip semantics), so a
41
42
  * nullish deferred slot never reaches a collector. Sync (non-thenable) values —
42
- * including a legitimate sync `null` push — pass through unchanged.
43
+ * including a legitimate sync `null` push — pass through unchanged. A
44
+ * pushedBy record (pushed-by.ts) loses the entries of the dropped values.
43
45
  */
44
46
  export async function resolveDeferredHandleValues(
45
47
  data: HandleData,
@@ -47,22 +49,37 @@ export async function resolveDeferredHandleValues(
47
49
  const out: HandleData = {};
48
50
  await Promise.all(
49
51
  Object.entries(data).flatMap(([handleName, segments]) => {
52
+ if (isPushedByKey(handleName)) return [];
50
53
  out[handleName] = {};
54
+ const byName = pushedByKey(handleName);
55
+ const pushedBy = data[byName];
51
56
  return Object.entries(segments).map(async ([segmentId, values]) => {
52
- out[handleName][segmentId] = await resolveValues(values);
57
+ const resolved = await resolveValues(values);
58
+ out[handleName][segmentId] = keptValues(resolved);
59
+ const by = pushedBy?.[segmentId];
60
+ if (by) {
61
+ (out[byName] ??= {})[segmentId] = by.filter(
62
+ (_, i) => resolved[i].keep,
63
+ );
64
+ }
53
65
  });
54
66
  }),
55
67
  );
56
68
  return out;
57
69
  }
58
70
 
71
+ interface ResolvedValue {
72
+ keep: boolean;
73
+ value: unknown;
74
+ }
75
+
59
76
  // Resolve each entry IN ORDER. A non-thenable passes through verbatim (including
60
77
  // a legitimate sync `null`/`undefined` push — the "await iff thenable" contract).
61
78
  // A thenable is awaited and dropped when it rejects OR resolves to null/undefined
62
79
  // (the `.defer({ else })` skip semantics: a forgotten resolver times out to
63
80
  // `else ?? undefined`), so a nullish deferred slot never reaches a collector.
64
- async function resolveValues(values: unknown[]): Promise<unknown[]> {
65
- const resolved = await Promise.all(
81
+ function resolveValues(values: unknown[]): Promise<ResolvedValue[]> {
82
+ return Promise.all(
66
83
  values.map(async (v) => {
67
84
  if (!isThenable(v)) return { keep: true, value: v };
68
85
  try {
@@ -73,6 +90,9 @@ async function resolveValues(values: unknown[]): Promise<unknown[]> {
73
90
  }
74
91
  }),
75
92
  );
93
+ }
94
+
95
+ function keptValues(resolved: ResolvedValue[]): unknown[] {
76
96
  return resolved.filter((r) => r.keep).map((r) => r.value);
77
97
  }
78
98
 
@@ -141,8 +161,11 @@ export function recordedHandleData(
141
161
  const recorded: HandleData = {};
142
162
  for (const segmentId of segmentIds) {
143
163
  const kept = handleStore.getDataForSegment(segmentId, true);
164
+ const pushedBy = handleStore.getPushedBy(segmentId, true);
144
165
  for (const handleName in kept) {
145
166
  (recorded[handleName] ??= {})[segmentId] = kept[handleName];
167
+ const by = pushedBy?.[handleName];
168
+ if (by) (recorded[pushedByKey(handleName)] ??= {})[segmentId] = by;
146
169
  }
147
170
  }
148
171
  return recorded;
@@ -163,7 +186,7 @@ export async function resolveSegmentHandleValues(
163
186
  const out: Record<string, unknown[]> = {};
164
187
  await Promise.all(
165
188
  Object.entries(segHandles).map(async ([handleName, values]) => {
166
- out[handleName] = await resolveValues(values);
189
+ out[handleName] = keptValues(await resolveValues(values));
167
190
  }),
168
191
  );
169
192
  return out;