@rangojs/router 0.0.0-experimental.105 → 0.0.0-experimental.106

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.
@@ -325,6 +325,12 @@ export const NamedRoutes = {
325
325
  } as const;
326
326
  ```
327
327
 
328
+ You never open a `.gen.ts` by hand — the generated types exist only to make call
329
+ sites honest. Treat the generated machinery as invisible: don't import from
330
+ `*.gen.ts`, don't reach for `RSCRouter.GeneratedRouteMap` directly, and if a type
331
+ error points at the generated map instead of your call site, that's a smell — fix
332
+ the call site (or regenerate), never edit the generated file.
333
+
328
334
  ## Loader Type Safety
329
335
 
330
336
  Loaders have typed return values:
@@ -493,6 +499,42 @@ RSC Flight serialization calls `toJSON()` on both loaders and handles,
493
499
  sending only `{ __brand, $$id }` to the client. The hooks recover the
494
500
  full functionality from module-level registries.
495
501
 
502
+ ## Stable identity: `path#export`
503
+
504
+ Loaders, handles, cached functions (`functionId`), and server actions
505
+ (`actionId`) all share one identity scheme: `{modulePath}#{exportName}`,
506
+ injected at build by the `exposeInternalIds` and `exposeActionId` Vite plugins.
507
+ This is also the identity React server actions carry across the Flight boundary,
508
+ which is why a `revalidate()` predicate sees an action as a `path#export` string:
509
+
510
+ ```typescript
511
+ revalidate(
512
+ ({ actionId }) => actionId === "src/actions/cart.ts#addToCart" || undefined,
513
+ );
514
+ ```
515
+
516
+ `actionId` is the only stable reference React exposes across the Flight boundary,
517
+ so it stays as the floor and escape hatch. The hand-written-string surface
518
+ (`actionId?.includes("cart.ts#")`) is brittle: a renamed action or moved file
519
+ silently stops matching with no compile error. Prefer **`ctx.isAction()`** in a
520
+ revalidate predicate — it resolves the action's id from an imported reference, so
521
+ a rename is a type error in one place instead of silent drift:
522
+
523
+ ```ts
524
+ import { addToCart, removeFromCart } from "./actions/cart";
525
+ import * as CartActions from "./actions/cart";
526
+
527
+ revalidate((ctx) => ctx.isAction(addToCart) || undefined); // one action
528
+ revalidate((ctx) => ctx.isAction(addToCart, removeFromCart) || undefined); // several
529
+ revalidate((ctx) => ctx.isAction(CartActions) || undefined); // any action in the module
530
+ ```
531
+
532
+ `ctx.isAction()` (only available on the revalidate predicate's context) returns a
533
+ raw boolean — combine with `|| undefined` for the "revalidate on match, else
534
+ defer" intent. It resolves the reference the same way the router derives
535
+ `actionId` (`$id` in production, `$$id` in dev), so matching
536
+ works in both modes. `actionId` stays available for advanced cases.
537
+
496
538
  ## Location State Type Safety
497
539
 
498
540
  ```typescript
@@ -12,7 +12,7 @@
12
12
  * interface PaginationData { current: number; total: number }
13
13
  * export const Pagination = createVar<PaginationData>();
14
14
  *
15
- * // Non-cacheable var — throws if set/get inside cache() or "use cache"
15
+ * // Non-cacheable var — ctx.get(User) throws inside a cache() boundary
16
16
  * export const User = createVar<UserData>({ cache: false });
17
17
  *
18
18
  * // handler
@@ -26,7 +26,7 @@
26
26
  export interface ContextVar<T> {
27
27
  readonly __brand: "context-var";
28
28
  readonly key: symbol;
29
- /** When false, the var is non-cacheable — throws inside cache() / "use cache" */
29
+ /** When false, ctx.get(var) throws inside a cache() boundary. */
30
30
  readonly cache: boolean;
31
31
  /** Phantom field to carry the type parameter. Never set at runtime. */
32
32
  readonly __type?: T;
@@ -35,9 +35,9 @@ export interface ContextVar<T> {
35
35
  export interface ContextVarOptions {
36
36
  /**
37
37
  * When false, marks this variable as non-cacheable.
38
- * Setting or getting this var inside a cache() boundary or "use cache"
39
- * function will throw. Use for inherently request-specific data (user
40
- * sessions, auth tokens, etc.) that must never be baked into cached segments.
38
+ * Reading this var with ctx.get() inside a cache() boundary throws. Use for
39
+ * inherently request-specific data (user sessions, auth tokens, etc.) that
40
+ * must never be baked into cached segments.
41
41
  *
42
42
  * @default true
43
43
  */
package/src/index.rsc.ts CHANGED
@@ -43,6 +43,7 @@ export type {
43
43
  // Revalidation types
44
44
  RevalidateParams,
45
45
  Revalidate,
46
+ ActionRef,
46
47
  RouteKeys,
47
48
  // Loader types
48
49
  LoaderDefinition,
package/src/index.ts CHANGED
@@ -43,6 +43,7 @@ export type {
43
43
  // Revalidation types
44
44
  RevalidateParams,
45
45
  Revalidate,
46
+ ActionRef,
46
47
  RouteKeys,
47
48
  // Loader types
48
49
  LoaderDefinition,
@@ -250,8 +250,10 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
250
250
  * )
251
251
  *
252
252
  * // Revalidate after specific actions (actionId format: "path/to/file.ts#exportName")
253
+ * // Use `|| undefined` (defer), not `?? false` (hard short-circuit), so the
254
+ * // chain and the segment default still apply when there is no match.
253
255
  * revalidate(({ actionId }) =>
254
- * actionId?.includes("Cart") ?? false
256
+ * actionId?.includes("Cart") || undefined
255
257
  * )
256
258
  *
257
259
  * // Soft decision (suggest but allow override)
@@ -274,7 +276,7 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
274
276
  *
275
277
  * // With loader-specific revalidation (match by file or export name)
276
278
  * loader(CartLoader, () => [
277
- * revalidate(({ actionId }) => actionId?.includes("Cart") ?? false),
279
+ * revalidate(({ actionId }) => actionId?.includes("Cart") || undefined),
278
280
  * ])
279
281
  *
280
282
  * // Consume in client components with useLoader()
@@ -4,7 +4,7 @@
4
4
  * Evaluates whether segments should revalidate based on params, actions, and custom functions.
5
5
  */
6
6
 
7
- import type { ResolvedSegment, HandlerContext } from "../types";
7
+ import type { ResolvedSegment, HandlerContext, ActionRef } from "../types";
8
8
  import type { ActionContext } from "./types";
9
9
  import {
10
10
  debugLog,
@@ -15,6 +15,47 @@ import type { RevalidationTraceEntry } from "./logging.js";
15
15
  import { _getRequestContext } from "../server/request-context.js";
16
16
  import { isAutoGeneratedRouteName } from "../route-name.js";
17
17
 
18
+ /**
19
+ * Resolve a server-action reference's stable id, mirroring how the action
20
+ * boundary derives `actionContext.actionId` in `rsc/server-action.ts`
21
+ * (`$id ?? $$id`): the file-path `$id` set by the expose-action-id plugin in a
22
+ * production RSC build when present, otherwise React's `$$id`. Resolving both
23
+ * the incoming `actionId` and the reference with the same precedence makes
24
+ * `isAction()` form-agnostic across dev and production.
25
+ */
26
+ function resolveActionRefId(ref: unknown): string | undefined {
27
+ if (ref == null) return undefined;
28
+ const r = ref as { $id?: unknown; $$id?: unknown };
29
+ if (typeof r.$id === "string") return r.$id;
30
+ if (typeof r.$$id === "string") return r.$$id;
31
+ return undefined;
32
+ }
33
+
34
+ /**
35
+ * Build the `isAction()` helper bound to the current action's id. Matches a
36
+ * single imported action reference, several (variadic), or any export of a
37
+ * namespace import (`import * as Mod`). Returns `false` when there is no action
38
+ * (plain navigation) or nothing matches.
39
+ */
40
+ function makeIsAction(
41
+ currentActionId: string | undefined,
42
+ ): (...actions: ActionRef[]) => boolean {
43
+ return (...actions: ActionRef[]): boolean => {
44
+ if (!currentActionId) return false;
45
+ for (const action of actions) {
46
+ if (typeof action === "function") {
47
+ if (resolveActionRefId(action) === currentActionId) return true;
48
+ } else if (action && typeof action === "object") {
49
+ // Namespace import: match any export of the module.
50
+ for (const value of Object.values(action)) {
51
+ if (resolveActionRefId(value) === currentActionId) return true;
52
+ }
53
+ }
54
+ }
55
+ return false;
56
+ };
57
+ }
58
+
18
59
  function paramsEqual(
19
60
  a: Record<string, string>,
20
61
  b: Record<string, string>,
@@ -240,6 +281,7 @@ export async function evaluateRevalidation<TEnv>(
240
281
  slotName: segment.slot,
241
282
  // Action context (only populated when triggered by server action)
242
283
  actionId: actionContext?.actionId,
284
+ isAction: makeIsAction(actionContext?.actionId),
243
285
  actionUrl: actionContext?.actionUrl,
244
286
  actionResult: actionContext?.actionResult,
245
287
  formData: actionContext?.formData,
@@ -513,6 +513,19 @@ export type RevalidateParams<TParams = GenericParams, TEnv = any> = Parameters<
513
513
  * })
514
514
  * ```
515
515
  */
516
+ /**
517
+ * A reference to a server action, used by `isAction()` in a revalidate predicate.
518
+ *
519
+ * Either a directly imported action (`import { addToCart }`) or a namespace
520
+ * import of an action module (`import * as CartActions`). Matching resolves the
521
+ * action's build-injected id (`path#export`) — the same identity the router uses
522
+ * for `actionId` — so a renamed or moved action breaks at compile time instead
523
+ * of silently failing to match.
524
+ */
525
+ export type ActionRef =
526
+ | ((...args: never[]) => unknown)
527
+ | Record<string, unknown>;
528
+
516
529
  /**
517
530
  * Revalidation function called during client-side navigation to decide whether
518
531
  * a segment (layout, route, parallel slot, or loader) should be re-rendered.
@@ -524,9 +537,10 @@ export type RevalidateParams<TParams = GenericParams, TEnv = any> = Parameters<
524
537
  *
525
538
  * @example
526
539
  * ```ts
527
- * // Re-render only when a cart action happened or browser signals staleness
540
+ * // Re-render when a cart action happened or the browser signals staleness;
541
+ * // defer otherwise (|| undefined) so the segment default still applies
528
542
  * revalidate(({ actionId, stale }) =>
529
- * actionId?.includes("cart") || stale || false
543
+ * actionId?.includes("cart") || stale || undefined
530
544
  * )
531
545
  *
532
546
  * // Always re-render when params change (default behavior made explicit)
@@ -570,21 +584,49 @@ export type ShouldRevalidateFn<TParams = GenericParams, TEnv = any> = (args: {
570
584
  * relative to the project root, followed by `#` and the exported function name.
571
585
  *
572
586
  * This is stable and can be used for path-based matching to revalidate
573
- * when any action in a module or directory fires:
587
+ * when any action in a module or directory fires. Prefer `|| undefined`
588
+ * (defer to the segment default / downstream revalidators) over `?? false`
589
+ * (hard short-circuit that suppresses the default and ends the chain):
574
590
  *
575
591
  * @example
576
592
  * ```ts
577
593
  * // Match a specific action
578
- * revalidate(({ actionId }) => actionId === "src/actions/cart.ts#addToCart")
594
+ * revalidate(({ actionId }) => actionId === "src/actions/cart.ts#addToCart" || undefined)
579
595
  *
580
596
  * // Match any action in the cart module
581
- * revalidate(({ actionId }) => actionId?.includes("cart") ?? false)
597
+ * revalidate(({ actionId }) => actionId?.includes("cart") || undefined)
582
598
  *
583
599
  * // Match any action under src/apps/store/actions/
584
- * revalidate(({ actionId }) => actionId?.startsWith("src/apps/store/actions/") ?? false)
600
+ * revalidate(({ actionId }) => actionId?.startsWith("src/apps/store/actions/") || undefined)
585
601
  * ```
586
602
  */
587
603
  actionId?: string;
604
+ /**
605
+ * Typed, rename-safe action matching. Returns `true` when the action that
606
+ * triggered this revalidation is one of the given references — or, for a
607
+ * namespace import (`import * as CartActions`), any export of that module —
608
+ * and `false` otherwise (including plain navigation with no action).
609
+ *
610
+ * Prefer this over hand-written `actionId` substring matches: it resolves the
611
+ * action's stable `path#export` id from the imported reference, so a rename is
612
+ * a type error in one place instead of silent drift across consumers. It
613
+ * resolves the reference the same way the action boundary derives `actionId`
614
+ * (`$id ?? $$id`), so it matches in both dev and production.
615
+ *
616
+ * Returns a raw boolean, so for the common "revalidate on match, else defer"
617
+ * intent combine with `|| undefined`:
618
+ *
619
+ * @example
620
+ * ```ts
621
+ * import { addToCart, removeFromCart } from "./actions/cart";
622
+ * import * as CartActions from "./actions/cart";
623
+ *
624
+ * revalidate((ctx) => ctx.isAction(addToCart) || undefined); // one action
625
+ * revalidate((ctx) => ctx.isAction(addToCart, removeFromCart) || undefined); // several
626
+ * revalidate((ctx) => ctx.isAction(CartActions) || undefined); // any in the module
627
+ * ```
628
+ */
629
+ isAction: (...actions: ActionRef[]) => boolean;
588
630
  /** URL where the action was executed (the page the user was on when they triggered the action). */
589
631
  actionUrl?: URL;
590
632
  /** Return value from the action execution. Can be used to conditionally revalidate based on the action's outcome. */
@@ -42,6 +42,7 @@ export type {
42
42
  GenericParams,
43
43
  RevalidateParams,
44
44
  ShouldRevalidateFn,
45
+ ActionRef,
45
46
  RouteKeys,
46
47
  ExtractRouteParams,
47
48
  HandlersForRouteMap,