@rangojs/router 0.10.1 → 0.11.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.
- package/dist/types/browser/partial-update.d.ts +1 -0
- package/dist/types/client-urls/navigation.d.ts +11 -0
- package/dist/types/client-urls/revalidate-chain.d.ts +33 -0
- package/dist/types/client-urls/types.d.ts +41 -14
- package/dist/types/client.d.ts +2 -0
- package/dist/types/index.d.ts +1 -1
- package/dist/types/index.rsc.d.ts +1 -1
- package/dist/types/router/is-action.d.ts +34 -0
- package/dist/types/testing/index.d.ts +3 -1
- package/dist/types/testing/run-client-revalidate.d.ts +43 -0
- package/dist/types/testing/to-url.d.ts +2 -0
- package/dist/types/types/handler-context.d.ts +12 -5
- package/dist/types/types/index.d.ts +1 -1
- package/dist/types/vite/plugins/expose-action-id.d.ts +14 -0
- package/dist/vite/index.js +14 -2
- package/package.json +1 -1
- package/skills/client-urls/SKILL.md +26 -4
- package/skills/loader/SKILL.md +1 -0
- package/skills/testing/SKILL.md +1 -0
- package/skills/typesafety/route-types.md +1 -0
- package/src/browser/partial-update.ts +11 -2
- package/src/browser/server-action-bridge.ts +9 -2
- package/src/client-urls/navigation.ts +40 -42
- package/src/client-urls/revalidate-chain.ts +83 -0
- package/src/client-urls/types.ts +41 -13
- package/src/client.tsx +5 -0
- package/src/index.rsc.ts +1 -0
- package/src/index.ts +1 -0
- package/src/router/is-action.ts +100 -0
- package/src/router/revalidation.ts +5 -48
- package/src/rsc/server-action.ts +2 -4
- package/src/testing/index.ts +4 -1
- package/src/testing/run-client-revalidate.ts +108 -0
- package/src/testing/run-transition-when.ts +1 -3
- package/src/testing/to-url.ts +5 -0
- package/src/types/handler-context.ts +13 -5
- package/src/types/index.ts +1 -0
- package/src/vite/plugins/expose-action-id.ts +29 -1
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { startTransition } from "react";
|
|
4
|
+
import { makeIsAction } from "../router/is-action.js";
|
|
5
|
+
import {
|
|
6
|
+
lockedClientDefault,
|
|
7
|
+
runClientRevalidateChain,
|
|
8
|
+
} from "./revalidate-chain.js";
|
|
4
9
|
import { encodeClientRevalidationDecisions } from "./revalidation-protocol.js";
|
|
5
|
-
import type {
|
|
10
|
+
import type { ClientUrlPatterns } from "./types.js";
|
|
6
11
|
|
|
7
12
|
export interface ClientUrlNavigationIntent {
|
|
8
13
|
readonly routeId: string;
|
|
@@ -156,6 +161,17 @@ export function beginClientUrlNavigation(
|
|
|
156
161
|
export function collectClientRevalidationDecisions(options: {
|
|
157
162
|
currentUrl: URL;
|
|
158
163
|
nextUrl: URL;
|
|
164
|
+
/**
|
|
165
|
+
* True only when the decisions ride the action POST itself — the one
|
|
166
|
+
* request the server evaluates with actionContext (locked default true).
|
|
167
|
+
* Action-triggered refetch GETs (partial-update terminals) pass false:
|
|
168
|
+
* the server gives those navigation defaults, and the delta gate below
|
|
169
|
+
* must diff against the default the SERVER will use, or a force decision
|
|
170
|
+
* on the refetch would be silently swallowed as "equals default".
|
|
171
|
+
*/
|
|
172
|
+
actionRequest: boolean;
|
|
173
|
+
/** Action TRUTH for the predicates' isAction() matcher; may be true on
|
|
174
|
+
* refetch GETs where actionRequest is false. */
|
|
159
175
|
isAction: boolean;
|
|
160
176
|
actionId?: string;
|
|
161
177
|
stale: boolean;
|
|
@@ -163,7 +179,8 @@ export function collectClientRevalidationDecisions(options: {
|
|
|
163
179
|
const group = activeGroup;
|
|
164
180
|
if (!group) return null;
|
|
165
181
|
|
|
166
|
-
const { currentUrl, nextUrl, isAction, actionId, stale } =
|
|
182
|
+
const { currentUrl, nextUrl, actionRequest, isAction, actionId, stale } =
|
|
183
|
+
options;
|
|
167
184
|
const currentLocal = stripMountPrefix(currentUrl.pathname, group.mount);
|
|
168
185
|
if (currentLocal === null) return null;
|
|
169
186
|
const currentMatch = group.definition.match(currentLocal);
|
|
@@ -178,52 +195,33 @@ export function collectClientRevalidationDecisions(options: {
|
|
|
178
195
|
nextLocal === null ? null : group.definition.match(nextLocal);
|
|
179
196
|
const nextParams = nextMatch?.params ?? {};
|
|
180
197
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
198
|
+
const defaultShouldRevalidate = lockedClientDefault({
|
|
199
|
+
actionRequest,
|
|
200
|
+
currentParams: currentMatch.params,
|
|
201
|
+
nextParams,
|
|
202
|
+
currentUrl,
|
|
203
|
+
nextUrl,
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const baseArgs = {
|
|
207
|
+
currentUrl,
|
|
208
|
+
nextUrl,
|
|
209
|
+
currentParams: currentMatch.params,
|
|
210
|
+
nextParams,
|
|
211
|
+
stale,
|
|
212
|
+
isAction: makeIsAction(actionId, isAction),
|
|
213
|
+
...(actionId !== undefined ? { actionId } : {}),
|
|
192
214
|
};
|
|
193
|
-
const defaultShouldRevalidate = isAction
|
|
194
|
-
? true
|
|
195
|
-
: !paramsEqual(currentMatch.params, nextParams) ||
|
|
196
|
-
currentUrl.search !== nextUrl.search;
|
|
197
|
-
|
|
198
215
|
const skip: string[] = [];
|
|
199
216
|
const force: string[] = [];
|
|
200
217
|
for (const { loader, revalidate } of record.loaders) {
|
|
201
218
|
if (revalidate.length === 0) continue;
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
currentParams: currentMatch.params,
|
|
206
|
-
nextParams,
|
|
219
|
+
const decision = runClientRevalidateChain(
|
|
220
|
+
revalidate,
|
|
221
|
+
baseArgs,
|
|
207
222
|
defaultShouldRevalidate,
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
...(actionId !== undefined ? { actionId } : {}),
|
|
211
|
-
};
|
|
212
|
-
// Same iteration contract as the server: every predicate runs, the last
|
|
213
|
-
// boolean verdict wins. A throwing predicate fails open to the default
|
|
214
|
-
// (mirrors evaluateRevalidation's fail-open).
|
|
215
|
-
let decision = defaultShouldRevalidate;
|
|
216
|
-
for (const fn of revalidate) {
|
|
217
|
-
try {
|
|
218
|
-
const verdict = fn(args);
|
|
219
|
-
if (typeof verdict === "boolean") decision = verdict;
|
|
220
|
-
} catch (error) {
|
|
221
|
-
console.error(
|
|
222
|
-
`[@rangojs/router] clientUrls revalidate() threw for loader "${loader.$$id}"; using default decision:`,
|
|
223
|
-
error,
|
|
224
|
-
);
|
|
225
|
-
}
|
|
226
|
-
}
|
|
223
|
+
`loader "${loader.$$id}"`,
|
|
224
|
+
);
|
|
227
225
|
if (decision === defaultShouldRevalidate) continue;
|
|
228
226
|
(decision ? force : skip).push(loader.$$id);
|
|
229
227
|
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The clientUrls revalidate() chain evaluator, shared by the browser
|
|
3
|
+
* collector (navigation.ts) and the public testing primitive
|
|
4
|
+
* (testing/run-client-revalidate.ts) so the two can never drift.
|
|
5
|
+
*
|
|
6
|
+
* Semantics mirror the server's evaluateRevalidation
|
|
7
|
+
* (src/router/revalidation.ts): a boolean verdict is a hard decision and
|
|
8
|
+
* short-circuits the rest of the chain; a `{ defaultShouldRevalidate }`
|
|
9
|
+
* object updates the running suggestion, which later predicates receive as
|
|
10
|
+
* their `defaultShouldRevalidate`; null/undefined defers; a throwing
|
|
11
|
+
* predicate fails open to the current suggestion (logged). One deliberate
|
|
12
|
+
* divergence: the object form is accepted only with a boolean value — the
|
|
13
|
+
* server is laxer, but never re-compares the value, while this decision
|
|
14
|
+
* feeds a strict-equality delta gate and the wire encoding
|
|
15
|
+
* (navigation.ts), where a truthy non-boolean would invert intent.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { paramsEqual } from "../router/params-util.js";
|
|
19
|
+
import type { ClientRevalidateArgs, ClientRevalidateFn } from "./types.js";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The locked default the server will apply to the request these decisions
|
|
23
|
+
* ride on. `actionRequest` is about the REQUEST, not the user gesture: only
|
|
24
|
+
* the action POST itself is evaluated server-side with actionContext
|
|
25
|
+
* (default `true`); the follow-up refetch GETs an action can trigger carry
|
|
26
|
+
* no actionContext and get navigation defaults — even though their
|
|
27
|
+
* predicates still see `isAction()` as true.
|
|
28
|
+
*/
|
|
29
|
+
export function lockedClientDefault(options: {
|
|
30
|
+
actionRequest: boolean;
|
|
31
|
+
currentParams: Record<string, string>;
|
|
32
|
+
nextParams: Record<string, string>;
|
|
33
|
+
currentUrl: URL;
|
|
34
|
+
nextUrl: URL;
|
|
35
|
+
}): boolean {
|
|
36
|
+
if (options.actionRequest) return true;
|
|
37
|
+
return (
|
|
38
|
+
!paramsEqual(options.currentParams, options.nextParams) ||
|
|
39
|
+
options.currentUrl.search !== options.nextUrl.search
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function runClientRevalidateChain(
|
|
44
|
+
fns: readonly ClientRevalidateFn[],
|
|
45
|
+
baseArgs: Omit<ClientRevalidateArgs, "defaultShouldRevalidate">,
|
|
46
|
+
lockedDefault: boolean,
|
|
47
|
+
label: string,
|
|
48
|
+
): boolean {
|
|
49
|
+
let suggestion = lockedDefault;
|
|
50
|
+
for (const fn of fns) {
|
|
51
|
+
let verdict: ReturnType<ClientRevalidateFn>;
|
|
52
|
+
try {
|
|
53
|
+
verdict = fn({ ...baseArgs, defaultShouldRevalidate: suggestion });
|
|
54
|
+
} catch (error) {
|
|
55
|
+
console.error(
|
|
56
|
+
`[@rangojs/router] clientUrls revalidate() threw for ${label}; using default decision:`,
|
|
57
|
+
error,
|
|
58
|
+
);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (
|
|
62
|
+
process.env.NODE_ENV !== "production" &&
|
|
63
|
+
verdict != null &&
|
|
64
|
+
typeof (verdict as { then?: unknown }).then === "function"
|
|
65
|
+
) {
|
|
66
|
+
console.warn(
|
|
67
|
+
`[rango] clientUrls revalidate() for ${label} returned a Promise; ` +
|
|
68
|
+
`predicates must be synchronous (return a boolean, ` +
|
|
69
|
+
`{ defaultShouldRevalidate }, or null/undefined). The async result ` +
|
|
70
|
+
`was IGNORED and the default (${suggestion}) was kept.`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
if (typeof verdict === "boolean") return verdict;
|
|
74
|
+
if (
|
|
75
|
+
verdict &&
|
|
76
|
+
typeof verdict === "object" &&
|
|
77
|
+
typeof verdict.defaultShouldRevalidate === "boolean"
|
|
78
|
+
) {
|
|
79
|
+
suggestion = verdict.defaultShouldRevalidate;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return suggestion;
|
|
83
|
+
}
|
package/src/client-urls/types.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ComponentType, ReactNode } from "react";
|
|
2
2
|
import type {
|
|
3
|
+
IsActionFn,
|
|
3
4
|
LoaderDefinition,
|
|
4
5
|
LoaderOptions,
|
|
5
6
|
TransitionConfig,
|
|
@@ -54,7 +55,8 @@ export type ClientLayoutFn = <const TItems extends ClientUrlItems>(
|
|
|
54
55
|
* subset of the server ShouldRevalidateFn args — the predicate RUNS IN THE
|
|
55
56
|
* BROWSER (it is declared in a "use client" module and never crosses the
|
|
56
57
|
* projection boundary); only its decision is sent to the server. There is no
|
|
57
|
-
* `context` — no server handler context exists where this executes.
|
|
58
|
+
* `context` — no server handler context exists where this executes. `isAction`
|
|
59
|
+
* is the same callable matcher as on the server, not a boolean.
|
|
58
60
|
*/
|
|
59
61
|
export interface ClientRevalidateArgs {
|
|
60
62
|
/** Full URL of the page being navigated away from (current location). */
|
|
@@ -66,21 +68,46 @@ export interface ClientRevalidateArgs {
|
|
|
66
68
|
/** Route params for the navigation target (definition-local match). */
|
|
67
69
|
readonly nextParams: Record<string, string>;
|
|
68
70
|
/**
|
|
69
|
-
* The
|
|
70
|
-
* the same rules the server
|
|
71
|
-
*
|
|
72
|
-
*
|
|
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.
|
|
73
79
|
*/
|
|
74
80
|
readonly defaultShouldRevalidate: boolean;
|
|
75
81
|
/** True when this is a stale history-entry background revalidation. */
|
|
76
82
|
readonly stale: boolean;
|
|
77
|
-
/**
|
|
78
|
-
|
|
79
|
-
|
|
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
|
+
*/
|
|
80
95
|
readonly actionId?: string;
|
|
81
96
|
}
|
|
82
97
|
|
|
83
|
-
|
|
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;
|
|
84
111
|
|
|
85
112
|
export interface ClientUrlLoaderRecord {
|
|
86
113
|
readonly loader: LoaderDefinition<any, any>;
|
|
@@ -158,10 +185,11 @@ export interface ClientUrlHelpers {
|
|
|
158
185
|
readonly loading: (component: ReactNode) => ClientUrlItem;
|
|
159
186
|
/**
|
|
160
187
|
* Per-loader revalidation predicate, valid inside a loader() use callback
|
|
161
|
-
* only. Runs IN THE BROWSER with client-computable args
|
|
162
|
-
* re-run the loader,
|
|
163
|
-
*
|
|
164
|
-
* follow the locked server
|
|
188
|
+
* only. Runs IN THE BROWSER with client-computable args (including the
|
|
189
|
+
* callable `isAction(...refs)` matcher); return true to re-run the loader,
|
|
190
|
+
* false to keep held data. Absent predicates (and requests that carry no
|
|
191
|
+
* decisions: no-JS, PE, prefetch, document loads) follow the locked server
|
|
192
|
+
* defaults.
|
|
165
193
|
*/
|
|
166
194
|
readonly revalidate: (fn: ClientRevalidateFn) => ClientUrlItem;
|
|
167
195
|
/**
|
package/src/client.tsx
CHANGED
|
@@ -470,3 +470,8 @@ export { useReverse } from "./browser/react/use-reverse.js";
|
|
|
470
470
|
export type { ScopedReverseFunction, LocalReverseFunction } from "./reverse.js";
|
|
471
471
|
|
|
472
472
|
export type { LoaderDefinition } from "./types.js";
|
|
473
|
+
export type { ActionRef, IsActionFn } from "./types.js";
|
|
474
|
+
export type {
|
|
475
|
+
ClientRevalidateArgs,
|
|
476
|
+
ClientRevalidateFn,
|
|
477
|
+
} from "./client-urls/types.js";
|
package/src/index.rsc.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared `isAction()` matcher for revalidate predicates (server + clientUrls).
|
|
3
|
+
*
|
|
4
|
+
* Action identity is `actionId`. The helper resolves an imported reference the
|
|
5
|
+
* same way the action boundary derives that id (`$id ?? $$id`), so a
|
|
6
|
+
* rename-safe `isAction(fn)` / `isAction(namespace)` match works in both the
|
|
7
|
+
* RSC environment (file-path `$id`) and the browser (hashed `$$id`).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ActionRef, IsActionFn } from "../types.js";
|
|
11
|
+
|
|
12
|
+
// Bind preservation is per-reference, in expose-action-id.ts. Do not patch
|
|
13
|
+
// Function.prototype.bind here (rsc+ssr share a realm; global wrap stacks).
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Resolve a server-action reference's stable id, mirroring how the action
|
|
17
|
+
* boundary derives `actionContext.actionId` in `rsc/server-action.ts`
|
|
18
|
+
* (`$id ?? $$id`): the file-path `$id` set by the expose-action-id plugin in a
|
|
19
|
+
* production RSC build when present, otherwise React's `$$id`. Resolving both
|
|
20
|
+
* the incoming `actionId` and the reference with the same precedence makes
|
|
21
|
+
* `isAction()` form-agnostic across dev and production.
|
|
22
|
+
*/
|
|
23
|
+
export function resolveActionRefId(ref: unknown): string | undefined {
|
|
24
|
+
if (ref == null) return undefined;
|
|
25
|
+
const r = ref as { $id?: unknown; $$id?: unknown };
|
|
26
|
+
if (typeof r.$id === "string") return r.$id;
|
|
27
|
+
if (typeof r.$$id === "string") return r.$$id;
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Depth cap for the object walk. The supported shapes need at most two
|
|
33
|
+
* object levels — a namespace (values are functions) and a grouped-namespace
|
|
34
|
+
* literal (`{ Cart: CartActions }`, values are namespaces). The cap keeps an
|
|
35
|
+
* accidentally passed arbitrary object (a ctx, a data blob — it type-checks
|
|
36
|
+
* as Record<string, unknown>) from triggering a full deep traversal on every
|
|
37
|
+
* predicate call.
|
|
38
|
+
*/
|
|
39
|
+
const MAX_ACTION_REF_DEPTH = 3;
|
|
40
|
+
|
|
41
|
+
function matchesActionRef(
|
|
42
|
+
ref: unknown,
|
|
43
|
+
currentActionId: string,
|
|
44
|
+
seen: Set<object>,
|
|
45
|
+
depth: number,
|
|
46
|
+
): boolean {
|
|
47
|
+
if (ref == null) return false;
|
|
48
|
+
if (typeof ref === "function") {
|
|
49
|
+
return resolveActionRefId(ref) === currentActionId;
|
|
50
|
+
}
|
|
51
|
+
if (typeof ref !== "object") return false;
|
|
52
|
+
if (depth >= MAX_ACTION_REF_DEPTH) return false;
|
|
53
|
+
if (seen.has(ref)) return false;
|
|
54
|
+
seen.add(ref);
|
|
55
|
+
// Namespace, object literal, or grouped namespaces
|
|
56
|
+
// (`{ Cart: CartActions, Order: OrderActions }`): walk every value.
|
|
57
|
+
// Object.values invokes getters; a throwing getter must not abort the
|
|
58
|
+
// predicate into the fail-open path, so treat it as "no match here".
|
|
59
|
+
let values: unknown[];
|
|
60
|
+
try {
|
|
61
|
+
values = Object.values(ref);
|
|
62
|
+
} catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
for (const value of values) {
|
|
66
|
+
if (matchesActionRef(value, currentActionId, seen, depth + 1)) return true;
|
|
67
|
+
}
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Build the `isAction()` helper bound to the current action. Called with no
|
|
73
|
+
* arguments it answers "is this request an action at all?" — `true` during
|
|
74
|
+
* action handling (including action-triggered refetches that carry no id
|
|
75
|
+
* yet), `false` on plain navigation. Called with one or more action
|
|
76
|
+
* references it narrows to those: a single imported action, several
|
|
77
|
+
* (variadic), a namespace import (`import * as Mod`), an object literal of
|
|
78
|
+
* actions (`{ addToCart, removeFromCart }`), or a grouped namespace object.
|
|
79
|
+
* Returns `false` when there is no action or nothing matches.
|
|
80
|
+
*
|
|
81
|
+
* `inAction` is the request kind. It defaults to "an id is present" so
|
|
82
|
+
* existing server call sites stay a single argument. The client passes the
|
|
83
|
+
* explicit flag so a refetch terminal with `isAction: true` still answers
|
|
84
|
+
* bare `isAction()` even if `actionId` was not threaded.
|
|
85
|
+
*/
|
|
86
|
+
export function makeIsAction(
|
|
87
|
+
currentActionId: string | undefined,
|
|
88
|
+
inAction: boolean = currentActionId !== undefined,
|
|
89
|
+
): IsActionFn {
|
|
90
|
+
return (...actions: ActionRef[]): boolean => {
|
|
91
|
+
if (!inAction) return false;
|
|
92
|
+
if (actions.length === 0) return true;
|
|
93
|
+
if (!currentActionId) return false;
|
|
94
|
+
const seen = new Set<object>();
|
|
95
|
+
for (const action of actions) {
|
|
96
|
+
if (matchesActionRef(action, currentActionId, seen, 0)) return true;
|
|
97
|
+
}
|
|
98
|
+
return false;
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -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
|
|
7
|
+
import type { ResolvedSegment, HandlerContext } from "../types";
|
|
8
8
|
import type { ActionContext } from "./types";
|
|
9
9
|
import {
|
|
10
10
|
debugLog,
|
|
@@ -15,52 +15,7 @@ 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
|
import { paramsEqual } from "./params-util.js";
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Resolve a server-action reference's stable id, mirroring how the action
|
|
21
|
-
* boundary derives `actionContext.actionId` in `rsc/server-action.ts`
|
|
22
|
-
* (`$id ?? $$id`): the file-path `$id` set by the expose-action-id plugin in a
|
|
23
|
-
* production RSC build when present, otherwise React's `$$id`. Resolving both
|
|
24
|
-
* the incoming `actionId` and the reference with the same precedence makes
|
|
25
|
-
* `isAction()` form-agnostic across dev and production.
|
|
26
|
-
*/
|
|
27
|
-
function resolveActionRefId(ref: unknown): string | undefined {
|
|
28
|
-
if (ref == null) return undefined;
|
|
29
|
-
const r = ref as { $id?: unknown; $$id?: unknown };
|
|
30
|
-
if (typeof r.$id === "string") return r.$id;
|
|
31
|
-
if (typeof r.$$id === "string") return r.$$id;
|
|
32
|
-
return undefined;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Build the `isAction()` helper bound to the current action's id. Called with no
|
|
37
|
-
* arguments it answers "is this request an action at all?" (any action) — `true`
|
|
38
|
-
* during action handling, `false` on plain navigation. Called with one or more
|
|
39
|
-
* action references it narrows to those: a single imported action, several
|
|
40
|
-
* (variadic), or any export of a namespace import (`import * as Mod`). Returns
|
|
41
|
-
* `false` when there is no action (plain navigation) or nothing matches.
|
|
42
|
-
*/
|
|
43
|
-
function makeIsAction(
|
|
44
|
-
currentActionId: string | undefined,
|
|
45
|
-
): (...actions: ActionRef[]) => boolean {
|
|
46
|
-
return (...actions: ActionRef[]): boolean => {
|
|
47
|
-
if (!currentActionId) return false;
|
|
48
|
-
// Bare isAction(): an action is in flight (currentActionId is set) and the
|
|
49
|
-
// caller did not narrow to a specific action, so this is "any action".
|
|
50
|
-
if (actions.length === 0) return true;
|
|
51
|
-
for (const action of actions) {
|
|
52
|
-
if (typeof action === "function") {
|
|
53
|
-
if (resolveActionRefId(action) === currentActionId) return true;
|
|
54
|
-
} else if (action && typeof action === "object") {
|
|
55
|
-
// Namespace import: match any export of the module.
|
|
56
|
-
for (const value of Object.values(action)) {
|
|
57
|
-
if (resolveActionRefId(value) === currentActionId) return true;
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
return false;
|
|
62
|
-
};
|
|
63
|
-
}
|
|
18
|
+
import { makeIsAction } from "./is-action.js";
|
|
64
19
|
|
|
65
20
|
/**
|
|
66
21
|
* Options for revalidation evaluation
|
|
@@ -249,6 +204,8 @@ export async function evaluateRevalidation<TEnv>(
|
|
|
249
204
|
? prevRouteKey
|
|
250
205
|
: undefined;
|
|
251
206
|
|
|
207
|
+
const isActionFn = makeIsAction(actionContext?.actionId);
|
|
208
|
+
|
|
252
209
|
for (const { name, fn } of revalidations) {
|
|
253
210
|
let result: any;
|
|
254
211
|
try {
|
|
@@ -265,7 +222,7 @@ export async function evaluateRevalidation<TEnv>(
|
|
|
265
222
|
slotName: segment.slot,
|
|
266
223
|
// Action context (only populated when triggered by server action)
|
|
267
224
|
actionId: actionContext?.actionId,
|
|
268
|
-
isAction:
|
|
225
|
+
isAction: isActionFn,
|
|
269
226
|
actionUrl: actionContext?.actionUrl,
|
|
270
227
|
actionResult: actionContext?.actionResult,
|
|
271
228
|
formData: actionContext?.formData,
|
package/src/rsc/server-action.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
setRequestContextParams,
|
|
21
21
|
} from "../server/request-context.js";
|
|
22
22
|
import { observePhase, PHASES } from "../router/instrument.js";
|
|
23
|
+
import { resolveActionRefId } from "../router/is-action.js";
|
|
23
24
|
import type { TraceSpan } from "../router/tracing.js";
|
|
24
25
|
import { gateTransitions } from "./transition-gate.js";
|
|
25
26
|
import type { RscPayload } from "./types.js";
|
|
@@ -302,10 +303,7 @@ export async function executeServerAction<TEnv>(
|
|
|
302
303
|
}
|
|
303
304
|
|
|
304
305
|
// Build continuation for the revalidation phase
|
|
305
|
-
const
|
|
306
|
-
| { $id?: string; $$id?: string }
|
|
307
|
-
| undefined;
|
|
308
|
-
const resolvedActionId = actionMeta?.$id ?? actionMeta?.$$id ?? actionId;
|
|
306
|
+
const resolvedActionId = resolveActionRefId(loadedAction) ?? actionId;
|
|
309
307
|
|
|
310
308
|
return {
|
|
311
309
|
returnValue,
|
package/src/testing/index.ts
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
* condition and would throw if pulled into this barrel.
|
|
27
27
|
*
|
|
28
28
|
* Layers:
|
|
29
|
-
* - Unit: runMiddleware, runLoader
|
|
29
|
+
* - Unit: runMiddleware, runLoader, runClientRevalidate
|
|
30
30
|
* - Integration: dispatch (request -> Response)
|
|
31
31
|
* - Cross-cut: assertCacheStatus, assertShellStatus, assertGeneratedRoutesMatch
|
|
32
32
|
* - Component: see @rangojs/router/testing/dom (renderRoute)
|
|
@@ -53,6 +53,9 @@ export type {
|
|
|
53
53
|
RunTransitionWhenResult,
|
|
54
54
|
} from "./run-transition-when.js";
|
|
55
55
|
|
|
56
|
+
export { runClientRevalidate } from "./run-client-revalidate.js";
|
|
57
|
+
export type { RunClientRevalidateOptions } from "./run-client-revalidate.js";
|
|
58
|
+
|
|
56
59
|
export { dispatch } from "./dispatch.js";
|
|
57
60
|
export type { DispatchOptions } from "./dispatch.js";
|
|
58
61
|
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runClientRevalidate — unit-test clientUrls() revalidate() predicates.
|
|
3
|
+
*
|
|
4
|
+
* Builds the same {@link ClientRevalidateArgs} the browser collector passes
|
|
5
|
+
* and evaluates the predicate(s) through the SAME chain evaluator production
|
|
6
|
+
* uses (client-urls/revalidate-chain.ts) — locked default, boolean
|
|
7
|
+
* short-circuit, soft-verdict threading, and fail-open are the production
|
|
8
|
+
* code paths, not a re-implementation. Pass an array to test a chain.
|
|
9
|
+
*
|
|
10
|
+
* Synchronous: client revalidate functions must be sync.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { makeIsAction, resolveActionRefId } from "../router/is-action.js";
|
|
14
|
+
import {
|
|
15
|
+
lockedClientDefault,
|
|
16
|
+
runClientRevalidateChain,
|
|
17
|
+
} from "../client-urls/revalidate-chain.js";
|
|
18
|
+
import { toURL } from "./to-url.js";
|
|
19
|
+
import type {
|
|
20
|
+
ClientRevalidateArgs,
|
|
21
|
+
ClientRevalidateFn,
|
|
22
|
+
} from "../client-urls/types.js";
|
|
23
|
+
|
|
24
|
+
const DEFAULT_URL = "http://localhost/";
|
|
25
|
+
|
|
26
|
+
function resolveActionId(
|
|
27
|
+
action: ((...args: never[]) => unknown) | string | undefined,
|
|
28
|
+
): string | undefined {
|
|
29
|
+
if (action === undefined) return undefined;
|
|
30
|
+
if (typeof action === "string") return action;
|
|
31
|
+
const id = resolveActionRefId(action);
|
|
32
|
+
if (id === undefined) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
"runClientRevalidate: `action` must be a single imported server action " +
|
|
35
|
+
"(carrying its build-injected id) or an actionId string. The passed " +
|
|
36
|
+
"function has no $id/$$id — outside a built app, pass the id string " +
|
|
37
|
+
'your predicate should match (e.g. "src/actions/cart.ts#addToCart").',
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
return id;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Options for {@link runClientRevalidate}. Defaults model a same-URL
|
|
45
|
+
* navigation with no action (locked default `false`).
|
|
46
|
+
*/
|
|
47
|
+
export interface RunClientRevalidateOptions {
|
|
48
|
+
currentUrl?: string | URL;
|
|
49
|
+
nextUrl?: string | URL;
|
|
50
|
+
currentParams?: Record<string, string>;
|
|
51
|
+
nextParams?: Record<string, string>;
|
|
52
|
+
stale?: boolean;
|
|
53
|
+
/**
|
|
54
|
+
* The triggering action: a single imported reference (id resolved via
|
|
55
|
+
* `$id ?? $$id`; throws if the function carries neither) or a raw actionId
|
|
56
|
+
* string. A namespace/object is rejected — it cannot identify the ONE
|
|
57
|
+
* action that triggered the request. Omit for a plain navigation.
|
|
58
|
+
*/
|
|
59
|
+
action?: ((...args: never[]) => unknown) | string;
|
|
60
|
+
/**
|
|
61
|
+
* Model an action-triggered refetch GET: predicates see `isAction()` as
|
|
62
|
+
* true, but the locked default stays the navigation default, matching how
|
|
63
|
+
* the server evaluates that request (no actionContext). Defaults to
|
|
64
|
+
* treating a provided `action` as the action POST itself.
|
|
65
|
+
*/
|
|
66
|
+
actionRequest?: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Run one clientUrls `revalidate()` predicate — or a chain, in declaration
|
|
71
|
+
* order — against production-built args. Returns the final boolean decision
|
|
72
|
+
* (locked default if every predicate defers or throws).
|
|
73
|
+
*/
|
|
74
|
+
export function runClientRevalidate(
|
|
75
|
+
fn: ClientRevalidateFn | readonly ClientRevalidateFn[],
|
|
76
|
+
opts: RunClientRevalidateOptions = {},
|
|
77
|
+
): boolean {
|
|
78
|
+
const currentUrl = toURL(opts.currentUrl, new URL(DEFAULT_URL));
|
|
79
|
+
const nextUrl = toURL(opts.nextUrl, currentUrl);
|
|
80
|
+
const currentParams = opts.currentParams ?? {};
|
|
81
|
+
const nextParams = opts.nextParams ?? currentParams;
|
|
82
|
+
const inAction = opts.action !== undefined;
|
|
83
|
+
const actionId = resolveActionId(opts.action);
|
|
84
|
+
const defaultShouldRevalidate = lockedClientDefault({
|
|
85
|
+
actionRequest: opts.actionRequest ?? inAction,
|
|
86
|
+
currentParams,
|
|
87
|
+
nextParams,
|
|
88
|
+
currentUrl,
|
|
89
|
+
nextUrl,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const baseArgs: Omit<ClientRevalidateArgs, "defaultShouldRevalidate"> = {
|
|
93
|
+
currentUrl,
|
|
94
|
+
nextUrl,
|
|
95
|
+
currentParams,
|
|
96
|
+
nextParams,
|
|
97
|
+
stale: opts.stale ?? false,
|
|
98
|
+
isAction: makeIsAction(actionId, inAction),
|
|
99
|
+
...(actionId !== undefined ? { actionId } : {}),
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
return runClientRevalidateChain(
|
|
103
|
+
Array.isArray(fn) ? fn : [fn],
|
|
104
|
+
baseArgs,
|
|
105
|
+
defaultShouldRevalidate,
|
|
106
|
+
"runClientRevalidate predicate",
|
|
107
|
+
);
|
|
108
|
+
}
|
|
@@ -35,9 +35,7 @@ import type { OnErrorCallback } from "../types/error-types.js";
|
|
|
35
35
|
import type { EntryData } from "../server/context.js";
|
|
36
36
|
import { evaluatePprTransitionWhen } from "../router/transition-when.js";
|
|
37
37
|
import { invokeOnError } from "../router/error-handling.js";
|
|
38
|
-
|
|
39
|
-
const toURL = (v: string | URL, base: URL): URL =>
|
|
40
|
-
typeof v === "string" ? new URL(v, base.origin) : v;
|
|
38
|
+
import { toURL } from "./to-url.js";
|
|
41
39
|
|
|
42
40
|
/**
|
|
43
41
|
* Options for runTransitionWhen. All navigation/action fields are optional and
|