@rangojs/router 0.0.0-experimental.104 → 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.
- package/dist/vite/index.js +9 -4
- package/package.json +1 -1
- package/skills/cache-guide/SKILL.md +177 -13
- package/skills/caching/SKILL.md +1 -1
- package/skills/composability/SKILL.md +27 -2
- package/skills/intercept/SKILL.md +1 -4
- package/skills/layout/SKILL.md +4 -7
- package/skills/loader/SKILL.md +127 -5
- package/skills/middleware/SKILL.md +10 -6
- package/skills/migrate-nextjs/SKILL.md +1 -1
- package/skills/observability/SKILL.md +135 -0
- package/skills/parallel/SKILL.md +3 -6
- package/skills/prerender/SKILL.md +1 -20
- package/skills/rango/SKILL.md +201 -27
- package/skills/route/SKILL.md +9 -4
- package/skills/server-actions/SKILL.md +53 -41
- package/skills/typesafety/SKILL.md +42 -0
- package/src/context-var.ts +5 -5
- package/src/index.rsc.ts +1 -0
- package/src/index.ts +1 -0
- package/src/route-definition/helpers-types.ts +4 -2
- package/src/router/revalidation.ts +43 -1
- package/src/types/handler-context.ts +48 -6
- package/src/types/index.ts +1 -0
- package/src/vite/utils/manifest-utils.ts +21 -5
|
@@ -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
|
package/src/context-var.ts
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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
package/src/index.ts
CHANGED
|
@@ -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")
|
|
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")
|
|
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
|
|
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 ||
|
|
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")
|
|
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/")
|
|
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. */
|
package/src/types/index.ts
CHANGED
|
@@ -4,20 +4,33 @@
|
|
|
4
4
|
* used directly by evaluateLazyEntry() without running the handler.
|
|
5
5
|
* Non-leaf nodes are skipped because they have nested lazy includes that
|
|
6
6
|
* require the handler to run for discovery.
|
|
7
|
+
*
|
|
8
|
+
* A leaf is also skipped when its staticPrefix collides with an ancestor
|
|
9
|
+
* include node's staticPrefix. That happens when a dynamic param collapses the
|
|
10
|
+
* staticPrefix of nested includes onto the parent's (e.g. `/m/:id/edit` -> sp
|
|
11
|
+
* `/m`): precomputing such a leaf under the collapsed prefix would let the
|
|
12
|
+
* ancestor's lazy entry claim a route it cannot register (the route is behind
|
|
13
|
+
* further nested lazy includes), producing a RouteNotFoundError at request time
|
|
14
|
+
* (issue #506). Those routes are resolved via the handler chain instead.
|
|
7
15
|
*/
|
|
8
16
|
export function flattenLeafEntries(
|
|
9
17
|
prefixTree: Record<string, any>,
|
|
10
18
|
routeManifest: Record<string, string>,
|
|
11
19
|
result: Array<{ staticPrefix: string; routes: Record<string, string> }>,
|
|
12
20
|
): void {
|
|
13
|
-
function visit(node: any): void {
|
|
21
|
+
function visit(node: any, ancestorStaticPrefixes: Set<string>): void {
|
|
14
22
|
const children = node.children || {};
|
|
15
23
|
if (
|
|
16
24
|
Object.keys(children).length === 0 &&
|
|
17
25
|
node.routes &&
|
|
18
26
|
node.routes.length > 0
|
|
19
27
|
) {
|
|
20
|
-
// Leaf node
|
|
28
|
+
// Leaf node. Skip if its staticPrefix collides with an ancestor include
|
|
29
|
+
// node's staticPrefix (dynamic-param collapse) — see doc comment above.
|
|
30
|
+
if (ancestorStaticPrefixes.has(node.staticPrefix)) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
// Collect its routes from the manifest
|
|
21
34
|
const routes: Record<string, string> = {};
|
|
22
35
|
for (const name of node.routes) {
|
|
23
36
|
if (name in routeManifest) {
|
|
@@ -26,14 +39,17 @@ export function flattenLeafEntries(
|
|
|
26
39
|
}
|
|
27
40
|
result.push({ staticPrefix: node.staticPrefix, routes });
|
|
28
41
|
} else {
|
|
29
|
-
// Non-leaf: recurse into children
|
|
42
|
+
// Non-leaf: recurse into children, tracking this node's staticPrefix as
|
|
43
|
+
// an ancestor so a collapsed nested leaf below it is not over-claimed.
|
|
44
|
+
const nextAncestors = new Set(ancestorStaticPrefixes);
|
|
45
|
+
nextAncestors.add(node.staticPrefix);
|
|
30
46
|
for (const child of Object.values(children)) {
|
|
31
|
-
visit(child);
|
|
47
|
+
visit(child, nextAncestors);
|
|
32
48
|
}
|
|
33
49
|
}
|
|
34
50
|
}
|
|
35
51
|
for (const node of Object.values(prefixTree)) {
|
|
36
|
-
visit(node);
|
|
52
|
+
visit(node, new Set());
|
|
37
53
|
}
|
|
38
54
|
}
|
|
39
55
|
|