@rangojs/router 0.10.1 → 0.12.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 (62) hide show
  1. package/dist/testing/vitest.js +1 -1
  2. package/dist/types/browser/partial-update.d.ts +1 -0
  3. package/dist/types/client-urls/navigation.d.ts +11 -0
  4. package/dist/types/client-urls/revalidate-chain.d.ts +33 -0
  5. package/dist/types/client-urls/types.d.ts +41 -14
  6. package/dist/types/client.d.ts +2 -0
  7. package/dist/types/deps/rsc-client.d.ts +1 -0
  8. package/dist/types/deps/rsc.d.ts +1 -1
  9. package/dist/types/deps/ssr.d.ts +1 -1
  10. package/dist/types/index.d.ts +1 -1
  11. package/dist/types/index.rsc.d.ts +1 -1
  12. package/dist/types/router/is-action.d.ts +34 -0
  13. package/dist/types/rsc/types.d.ts +9 -9
  14. package/dist/types/ssr/index.d.ts +27 -4
  15. package/dist/types/testing/flight.d.ts +4 -3
  16. package/dist/types/testing/index.d.ts +3 -1
  17. package/dist/types/testing/run-client-revalidate.d.ts +43 -0
  18. package/dist/types/testing/to-url.d.ts +2 -0
  19. package/dist/types/testing/vitest-stubs/plugin-rsc.d.ts +2 -0
  20. package/dist/types/testing/vitest.d.ts +2 -1
  21. package/dist/types/types/handler-context.d.ts +12 -5
  22. package/dist/types/types/index.d.ts +1 -1
  23. package/dist/types/vite/plugins/expose-action-id.d.ts +14 -0
  24. package/dist/types/vite/plugins/virtual-entries.d.ts +3 -2
  25. package/dist/vite/index.js +66 -13
  26. package/package.json +8 -3
  27. package/skills/client-urls/SKILL.md +26 -4
  28. package/skills/loader/SKILL.md +1 -0
  29. package/skills/testing/SKILL.md +1 -0
  30. package/skills/testing/setup.md +6 -6
  31. package/skills/typesafety/route-types.md +1 -0
  32. package/src/browser/partial-update.ts +11 -2
  33. package/src/browser/server-action-bridge.ts +9 -2
  34. package/src/cache/cache-runtime.ts +1 -1
  35. package/src/cache/segment-codec.ts +2 -2
  36. package/src/client-urls/navigation.ts +40 -42
  37. package/src/client-urls/revalidate-chain.ts +83 -0
  38. package/src/client-urls/types.ts +41 -13
  39. package/src/client.tsx +5 -0
  40. package/src/deps/rsc-client.ts +8 -0
  41. package/src/deps/rsc.ts +4 -2
  42. package/src/deps/ssr.ts +1 -0
  43. package/src/index.rsc.ts +1 -0
  44. package/src/index.ts +1 -0
  45. package/src/router/is-action.ts +100 -0
  46. package/src/router/revalidation.ts +5 -48
  47. package/src/rsc/handler.ts +3 -3
  48. package/src/rsc/server-action.ts +2 -4
  49. package/src/rsc/types.ts +9 -9
  50. package/src/ssr/index.tsx +132 -51
  51. package/src/testing/flight.ts +4 -3
  52. package/src/testing/index.ts +4 -1
  53. package/src/testing/run-client-revalidate.ts +108 -0
  54. package/src/testing/run-transition-when.ts +1 -3
  55. package/src/testing/to-url.ts +5 -0
  56. package/src/testing/vitest-stubs/plugin-rsc.ts +13 -5
  57. package/src/testing/vitest.ts +3 -2
  58. package/src/types/handler-context.ts +13 -5
  59. package/src/types/index.ts +1 -0
  60. package/src/vite/plugins/expose-action-id.ts +29 -1
  61. package/src/vite/plugins/use-cache-transform.ts +65 -1
  62. package/src/vite/plugins/virtual-entries.ts +14 -11
@@ -1401,6 +1401,14 @@ function isUseServerModule(filePath) {
1401
1401
  return false;
1402
1402
  }
1403
1403
  }
1404
+ var ACTION_BIND_HELPER_NAME = "__rangoActionBind";
1405
+ var ACTION_BIND_HELPER_SOURCE = `var ${ACTION_BIND_HELPER_NAME} = function () {
1406
+ var bound = Function.prototype.bind.apply(this, arguments);
1407
+ if (typeof this.$id === "string") bound.$id = this.$id;
1408
+ if (typeof this.$$id === "string") bound.$$id = this.$$id;
1409
+ bound.bind = ${ACTION_BIND_HELPER_NAME};
1410
+ return bound;
1411
+ };`;
1404
1412
  function applyServerReferenceWrapping(code, s, hashToFileMap) {
1405
1413
  if (!code.includes("createServerReference(")) {
1406
1414
  return false;
@@ -1425,9 +1433,13 @@ function applyServerReferenceWrapping(code, s, hashToFileMap) {
1425
1433
  }
1426
1434
  }
1427
1435
  }
1428
- const replacement = `(function(fn) { fn.$$id = ${finalIdArg}; return fn; })(${fnCall}(${idArg}${rest}))`;
1436
+ const replacement = `(function(fn) { fn.$$id = ${finalIdArg}; if (!Object.prototype.hasOwnProperty.call(fn, "bind")) fn.bind = ${ACTION_BIND_HELPER_NAME}; return fn; })(${fnCall}(${idArg}${rest}))`;
1429
1437
  s.overwrite(start, end, replacement);
1430
1438
  }
1439
+ if (hasChanges) {
1440
+ s.prepend(`${ACTION_BIND_HELPER_SOURCE}
1441
+ `);
1442
+ }
1431
1443
  return hasChanges;
1432
1444
  }
1433
1445
  function transformServerReferences(code, sourceId, hashToFileMap) {
@@ -3036,6 +3048,7 @@ function useCacheTransform() {
3036
3048
  } catch {
3037
3049
  return;
3038
3050
  }
3051
+ stripNullDirectiveFields(ast);
3039
3052
  const filePath = normalizePath(path5.relative(projectRoot, id));
3040
3053
  const isLayoutOrTemplate = LAYOUT_TEMPLATE_PATTERN.test(id);
3041
3054
  if (hasDirective(ast.body, "use cache")) {
@@ -3046,7 +3059,8 @@ function useCacheTransform() {
3046
3059
  id,
3047
3060
  isBuild,
3048
3061
  isLayoutOrTemplate,
3049
- transformWrapExport
3062
+ transformWrapExport,
3063
+ hasDirective
3050
3064
  );
3051
3065
  }
3052
3066
  const functionResult = transformFunctionLevelUseCache(
@@ -3065,7 +3079,7 @@ function useCacheTransform() {
3065
3079
  }
3066
3080
  };
3067
3081
  }
3068
- function transformFileLevelUseCache(code, ast, filePath, sourceId, isBuild, isLayoutOrTemplate, transformWrapExport) {
3082
+ function transformFileLevelUseCache(code, ast, filePath, sourceId, isBuild, isLayoutOrTemplate, transformWrapExport, hasDirective) {
3069
3083
  const unconfirmedExports = [];
3070
3084
  const { exportNames, output } = transformWrapExport(code, ast, {
3071
3085
  runtime: (value, name) => {
@@ -3075,6 +3089,11 @@ function transformFileLevelUseCache(code, ast, filePath, sourceId, isBuild, isLa
3075
3089
  rejectNonAsyncFunction: false,
3076
3090
  filter: (name, meta) => {
3077
3091
  if (name === "default" && isLayoutOrTemplate) return false;
3092
+ if (name.startsWith("$$hoist_")) return false;
3093
+ if (isHoistedServerReferenceRebind(meta.valueNode)) return false;
3094
+ if (functionHasUseServerDirective(meta.valueNode, hasDirective)) {
3095
+ return false;
3096
+ }
3078
3097
  if (meta.isFunction !== true) {
3079
3098
  unconfirmedExports.push(name);
3080
3099
  return false;
@@ -3146,6 +3165,36 @@ function transformFunctionLevelUseCache(code, ast, filePath, sourceId, isBuild,
3146
3165
  return;
3147
3166
  }
3148
3167
  }
3168
+ function stripNullDirectiveFields(node) {
3169
+ if (!node || typeof node !== "object") return;
3170
+ const rec = node;
3171
+ if (rec.type === "ExpressionStatement" && typeof rec.directive !== "string") {
3172
+ delete rec.directive;
3173
+ }
3174
+ for (const value of Object.values(rec)) {
3175
+ if (Array.isArray(value)) {
3176
+ for (const item of value) stripNullDirectiveFields(item);
3177
+ } else if (value && typeof value === "object" && "type" in value) {
3178
+ stripNullDirectiveFields(value);
3179
+ }
3180
+ }
3181
+ }
3182
+ function isHoistedServerReferenceRebind(valueNode) {
3183
+ if (!valueNode || valueNode.type !== "CallExpression") return false;
3184
+ const first = valueNode.arguments[0];
3185
+ return first !== void 0 && first.type === "Identifier" && first.name.startsWith("$$hoist_");
3186
+ }
3187
+ function functionHasUseServerDirective(valueNode, hasDirective) {
3188
+ if (!valueNode || !("body" in valueNode)) return false;
3189
+ const { body } = valueNode;
3190
+ if (!body || Array.isArray(body) || body.type !== "BlockStatement") {
3191
+ return false;
3192
+ }
3193
+ return hasDirective(
3194
+ body.body,
3195
+ "use server"
3196
+ );
3197
+ }
3149
3198
  function findFileLevelDirective(ast) {
3150
3199
  for (const node of ast.body ?? []) {
3151
3200
  if (node.type === "ExpressionStatement" && node.expression?.type === "Literal" && typeof node.expression.value === "string" && node.expression.value.startsWith("use cache")) {
@@ -3489,7 +3538,7 @@ function emitProgressiveChunkSize(value) {
3489
3538
  }
3490
3539
  function getVirtualEntrySSR(headScripts = "preinit", progressiveChunkSize) {
3491
3540
  const preinit = headScripts !== "preload";
3492
- const depsImportNames = preinit ? "createFromReadableStream,\n setOnClientReference," : "createFromReadableStream,";
3541
+ const depsImportNames = preinit ? "createFromReadableStream,\n setOnClientReference,\n getClientEntryUrl," : "createFromReadableStream,";
3493
3542
  const ssrImportNames = preinit ? "\n installClientReferencePreinit," : "";
3494
3543
  const install = preinit ? `
3495
3544
  // Upgrade client-reference modulepreload hints to executing module scripts in
@@ -3497,6 +3546,8 @@ function getVirtualEntrySSR(headScripts = "preinit", progressiveChunkSize) {
3497
3546
  // See src/ssr/preinit-client-references.ts for the full rationale.
3498
3547
  installClientReferencePreinit(setOnClientReference);
3499
3548
  ` : "";
3549
+ const bootstrapDep = preinit ? "getClientEntryUrl," : `loadBootstrapScriptContent: () =>
3550
+ import.meta.viteRsc.loadBootstrapScriptContent("index"),`;
3500
3551
  const hs = JSON.stringify(headScripts);
3501
3552
  const pcs = progressiveChunkSize !== void 0 ? `
3502
3553
  progressiveChunkSize: ${emitProgressiveChunkSize(progressiveChunkSize)},` : "";
@@ -3518,8 +3569,7 @@ export const renderHTML = createSSRHandler({
3518
3569
  renderToReadableStream,
3519
3570
  injectRSCPayload,
3520
3571
  headScripts: ${hs},${pcs}
3521
- loadBootstrapScriptContent: () =>
3522
- import.meta.viteRsc.loadBootstrapScriptContent("index"),
3572
+ ${bootstrapDep}
3523
3573
  });
3524
3574
 
3525
3575
  export const captureShellHTML = createShellCaptureHandler({
@@ -3529,8 +3579,7 @@ export const captureShellHTML = createShellCaptureHandler({
3529
3579
  prerender,
3530
3580
  resume,
3531
3581
  headScripts: ${hs},${pcs}
3532
- loadBootstrapScriptContent: () =>
3533
- import.meta.viteRsc.loadBootstrapScriptContent("index"),
3582
+ ${bootstrapDep}
3534
3583
  });
3535
3584
 
3536
3585
  export const resumeShellHTML = createShellResumeHandler({
@@ -3540,8 +3589,7 @@ export const resumeShellHTML = createShellResumeHandler({
3540
3589
  prerender,
3541
3590
  resume,
3542
3591
  headScripts: ${hs},${pcs}
3543
- loadBootstrapScriptContent: () =>
3544
- import.meta.viteRsc.loadBootstrapScriptContent("index"),
3592
+ ${bootstrapDep}
3545
3593
  });
3546
3594
  `.trim();
3547
3595
  }
@@ -3698,7 +3746,7 @@ import { resolve } from "node:path";
3698
3746
  // package.json
3699
3747
  var package_default = {
3700
3748
  name: "@rangojs/router",
3701
- version: "0.10.1",
3749
+ version: "0.12.0",
3702
3750
  description: "Django-inspired RSC router with composable URL patterns",
3703
3751
  keywords: [
3704
3752
  "react",
@@ -3786,6 +3834,11 @@ var package_default = {
3786
3834
  "react-server": "./src/deps/rsc.ts",
3787
3835
  default: "./src/deps/rsc.ts"
3788
3836
  },
3837
+ "./internal/deps/rsc-client": {
3838
+ types: "./dist/types/deps/rsc-client.d.ts",
3839
+ "react-server": "./src/deps/rsc-client.ts",
3840
+ default: "./src/deps/rsc-client.ts"
3841
+ },
3789
3842
  "./internal/deps/html-stream-client": {
3790
3843
  types: "./dist/types/deps/html-stream-client.d.ts",
3791
3844
  default: "./src/deps/html-stream-client.ts"
@@ -3891,7 +3944,7 @@ var package_default = {
3891
3944
  },
3892
3945
  dependencies: {
3893
3946
  "@types/debug": "^4.1.12",
3894
- "@vitejs/plugin-rsc": "^0.5.31",
3947
+ "@vitejs/plugin-rsc": "^0.5.34",
3895
3948
  debug: "^4.4.1",
3896
3949
  "magic-string": "^0.30.17",
3897
3950
  picomatch: "^4.0.4",
@@ -3924,7 +3977,7 @@ var package_default = {
3924
3977
  "@playwright/test": "^1.49.1",
3925
3978
  "@testing-library/react": ">=16",
3926
3979
  "@vercel/functions": "^3.0.0",
3927
- "@vitejs/plugin-rsc": "^0.5.31",
3980
+ "@vitejs/plugin-rsc": "^0.5.34",
3928
3981
  react: ">=19.2.8 <20",
3929
3982
  "react-dom": ">=19.2.8 <20",
3930
3983
  vite: "^8.0.16",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.10.1",
3
+ "version": "0.12.0",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -88,6 +88,11 @@
88
88
  "react-server": "./src/deps/rsc.ts",
89
89
  "default": "./src/deps/rsc.ts"
90
90
  },
91
+ "./internal/deps/rsc-client": {
92
+ "types": "./dist/types/deps/rsc-client.d.ts",
93
+ "react-server": "./src/deps/rsc-client.ts",
94
+ "default": "./src/deps/rsc-client.ts"
95
+ },
91
96
  "./internal/deps/html-stream-client": {
92
97
  "types": "./dist/types/deps/html-stream-client.d.ts",
93
98
  "default": "./src/deps/html-stream-client.ts"
@@ -179,7 +184,7 @@
179
184
  },
180
185
  "dependencies": {
181
186
  "@types/debug": "^4.1.12",
182
- "@vitejs/plugin-rsc": "^0.5.31",
187
+ "@vitejs/plugin-rsc": "^0.5.34",
183
188
  "debug": "^4.4.1",
184
189
  "magic-string": "^0.30.17",
185
190
  "picomatch": "^4.0.4",
@@ -212,7 +217,7 @@
212
217
  "@playwright/test": "^1.49.1",
213
218
  "@testing-library/react": ">=16",
214
219
  "@vercel/functions": "^3.0.0",
215
- "@vitejs/plugin-rsc": "^0.5.31",
220
+ "@vitejs/plugin-rsc": "^0.5.34",
216
221
  "react": ">=19.2.8 <20",
217
222
  "react-dom": ">=19.2.8 <20",
218
223
  "vite": "^8.0.16",
@@ -91,7 +91,7 @@ export default clientUrls(({ path, layout, loader, revalidate }) => [
91
91
  nextParams,
92
92
  defaultShouldRevalidate,
93
93
  }) => {
94
- if (isAction) return false;
94
+ if (isAction()) return false;
95
95
  return currentParams.slug !== nextParams.slug
96
96
  ? defaultShouldRevalidate
97
97
  : false;
@@ -184,14 +184,36 @@ only its _decision_ crosses the wire with the navigation request. Requests that
184
184
  carry no decisions (no-JS, progressive enhancement, prefetch, document loads)
185
185
  follow the locked server defaults.
186
186
 
187
+ `isAction` is the same callable matcher as the server predicate, not a
188
+ boolean. Action identity is `actionId`, whose FORM differs per environment:
189
+ file-path `$id` (`path#export`) in the RSC env, hashed `$$id` in the
190
+ browser — which is why the matcher (resolving an imported reference's
191
+ `$id ?? $$id`) is the supported surface and a file-path substring on
192
+ `actionId` is not:
193
+
194
+ ```ts
195
+ import { addToCart, removeFromCart } from "./actions/cart";
196
+ import * as CartActions from "./actions/cart";
197
+
198
+ revalidate(({ isAction }) => isAction()); // any action
199
+ revalidate(({ isAction }) => isAction(addToCart)); // one action
200
+ revalidate(({ isAction }) => isAction(addToCart, removeFromCart)); // several
201
+ revalidate(({ isAction }) => isAction(CartActions)); // import * as
202
+ revalidate(({ isAction }) => isAction({ addToCart, removeFromCart })); // object
203
+ ```
204
+
205
+ Bare `isAction()` is "is this an action at all?". `actionId` stays as the
206
+ string escape hatch. Return `isAction(refs) || undefined` to defer to the
207
+ locked default on a non-match (same idiom as the server).
208
+
187
209
  Two scars worth copying:
188
210
 
189
211
  - A blunt `() => false` keeps serving the OLD product on product→product
190
212
  navigations (same route, new param). Make predicates param-sensitive:
191
213
  return `defaultShouldRevalidate` when the identifying param changed.
192
- - One action, per-loader outcomes: a cart badge loader revalidates on actions
193
- (`isAction ? defaultShouldRevalidate : false`) while product/related loaders
194
- hold — three freshness outcomes in a single commit, decided per loader.
214
+ - One action, per-loader outcomes: a cart badge loader revalidates on cart
215
+ actions (`isAction(CartActions)`) while product/related loaders hold —
216
+ three freshness outcomes in a single commit, decided per loader.
195
217
 
196
218
  ## Loaders are full citizens: signals and handles
197
219
 
@@ -359,6 +359,7 @@ loader(CartLoader, () => [
359
359
  ]);
360
360
  revalidate((ctx) => ctx.isAction(addToCart, removeFromCart) || undefined); // several
361
361
  revalidate((ctx) => ctx.isAction(CartActions) || undefined); // any action in the module
362
+ revalidate((ctx) => ctx.isAction({ addToCart, removeFromCart }) || undefined); // object form
362
363
  ```
363
364
 
364
365
  `isAction()` is a method on the revalidate predicate's **context argument** —
@@ -89,6 +89,7 @@ Each primitive links to its sub-file (API + recipe + caveats).
89
89
  | one middleware's ordering / short-circuit / cookie+header merge | unit (node) | [`runMiddleware`](./middleware.md) | `@rangojs/router/testing` |
90
90
  | a `"use server"` action's cookie / header / flash output (even on `throw redirect()`) | unit (node) | [`runInRequestContext`](./server-actions.md) | `@rangojs/router/testing` |
91
91
  | a `transition({ when })` gate (keep/drop) against nav source / target / action metadata | unit (node) | `runTransitionWhen` (`{ kept, whenContext }`; pass `{ ppr: true }` for pre-handler timing) | `@rangojs/router/testing` |
92
+ | a `clientUrls()` `revalidate()` / `isAction(ref)` predicate | unit (node) | `runClientRevalidate` (production `makeIsAction` + locked defaults) | `@rangojs/router/testing` |
92
93
  | a handle's `collect`/accumulator, a seeded handle read, or a loader handle write | unit | [`collectHandle` / seeded `handles` / `handlePushes`](./handles.md) | `@rangojs/router/testing` |
93
94
  | a CLIENT component reading router context (`useParams`/`useReverse`/`Outlet`/`useNavigation`/`useLoader`) | unit (DOM) | [`renderRoute`](./client-components.md) | `@rangojs/router/testing/dom` |
94
95
  | a redirect / status / headers / cookies / **response route** (json/text/html/xml/md), no Flight | integration | [`dispatch`](./response-routes.md) | `@rangojs/router/testing` |
@@ -14,11 +14,11 @@ Real machinery: Vite transpiles `@rangojs/router`'s shipped TS source and resolv
14
14
 
15
15
  ### Functions
16
16
 
17
- | Function | Returns | Use |
18
- | --------------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
19
- | `rangoTestConfig(opts?)` | `{ alias, server: { deps: { inline } } }` | Recommended. Spread into the node/DOM project's `test` block. Bundles the resolve aliases AND `server.deps.inline`. |
20
- | `rangoTestAliases(opts?)` | `TestAlias[]` (`{ find, replacement }[]`) | Lower-level. The bare `@rangojs/router` -> `index.rsc.ts` alias plus the `:version` / `@vitejs/plugin-rsc/rsc` stubs (and CF stubs under `preset:"cloudflare"`). Used in the rsc project's `resolve.alias`. |
21
- | `rangoUseClientTransform()` | a Vite plugin (`{ name, transform }`) | Add to the rsc project `plugins`. Applies the `"use client"` transform so `renderServerTree` auto-discovers client islands from the server tree's imports. |
17
+ | Function | Returns | Use |
18
+ | --------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
19
+ | `rangoTestConfig(opts?)` | `{ alias, server: { deps: { inline } } }` | Recommended. Spread into the node/DOM project's `test` block. Bundles the resolve aliases AND `server.deps.inline`. |
20
+ | `rangoTestAliases(opts?)` | `TestAlias[]` (`{ find, replacement }[]`) | Lower-level. The bare `@rangojs/router` -> `index.rsc.ts` alias plus the `:version` / `@vitejs/plugin-rsc/rsc` (`/rsc/server`, `/rsc/client`) stubs (and CF stubs under `preset:"cloudflare"`). Used in the rsc project's `resolve.alias`. |
21
+ | `rangoUseClientTransform()` | a Vite plugin (`{ name, transform }`) | Add to the rsc project `plugins`. Applies the `"use client"` transform so `renderServerTree` auto-discovers client islands from the server tree's imports. |
22
22
 
23
23
  ### Returns — `RangoTestConfig` (from `rangoTestConfig`)
24
24
 
@@ -112,7 +112,7 @@ Scripts:
112
112
  - The rsc project needs BOTH `resolve.conditions: ["react-server"]` AND the bare `@rangojs/router` -> `index.rsc.ts` alias from `rangoTestAliases({ preset })`. `resolve.conditions` alone is not reliably applied to bare-package export resolution; without the alias a handler/component reading `getRequestContext()` / `cookies()` resolves the throwing out-of-react-server stub (symptom: `renderHandler` returns `tree: undefined`). `renderToFlightString` / `renderServerTree` now self-diagnose this exact misconfiguration — they reject with an actionable message naming `rangoTestAliases`, rather than surfacing the opaque stub error.
113
113
  - `NODE_ENV` must be `"production"` in the rsc project. Dev `NODE_ENV` crashes the bare worker (jsxDEV owner-stack machinery uninitialized) and emits volatile debug rows that defeat stable Flight snapshots.
114
114
  - The forked rsc worker (`pool: "forks"`) must force the condition via `execArgv: ["--conditions=react-server"]`, or React throws "the react-server condition must be enabled".
115
- - The `@rangojs/router:version` and `@vitejs/plugin-rsc/rsc` virtuals must be stubbed; the preset does it. A bare router import without stubbing throws.
115
+ - The `@rangojs/router:version` and `@vitejs/plugin-rsc/rsc` (`/rsc/server`, `/rsc/client`) virtuals must be stubbed; the preset does it. A bare router import without stubbing throws.
116
116
  - The rango fragment goes under `test` (`test.alias` + `test.server.deps.inline`, both returned by `rangoTestConfig`), NOT under top-level `resolve`.
117
117
  - Wire `rangoUseClientTransform()` into the rsc project `plugins` so islands auto-discover from the server tree imports (see `./server-tree.md`); without it, register islands explicitly with `clientComponents`.
118
118
 
@@ -204,6 +204,7 @@ import * as CartActions from "./actions/cart";
204
204
  revalidate((ctx) => ctx.isAction(addToCart) || undefined); // one action
205
205
  revalidate((ctx) => ctx.isAction(addToCart, removeFromCart) || undefined); // several
206
206
  revalidate((ctx) => ctx.isAction(CartActions) || undefined); // any action in the module
207
+ revalidate((ctx) => ctx.isAction({ addToCart, removeFromCart }) || undefined); // object form
207
208
  ```
208
209
 
209
210
  `ctx.isAction()` (only available on the revalidate predicate's context) returns a
@@ -101,7 +101,7 @@ export type UpdateMode =
101
101
  }
102
102
  | { type: "leave-intercept"; interceptSourceUrl?: string }
103
103
  | { type: "stale-revalidation"; interceptSourceUrl?: string }
104
- | { type: "action"; interceptSourceUrl?: string };
104
+ | { type: "action"; interceptSourceUrl?: string; actionId?: string };
105
105
 
106
106
  /**
107
107
  * Type for the fetchPartialUpdate function
@@ -200,7 +200,16 @@ export function createPartialUpdater(
200
200
  clientRevalidation = collectClientRevalidationDecisions({
201
201
  currentUrl: new URL(previousUrl, window.location.origin),
202
202
  nextUrl: new URL(url, window.location.origin),
203
- isAction: false,
203
+ // This partial fetch is a GET the server evaluates WITHOUT
204
+ // actionContext (navigation defaults) even when it is an
205
+ // action-triggered refetch — so the decision baseline is never the
206
+ // action default here. Predicates still see isAction()/actionId
207
+ // truthfully for matching.
208
+ actionRequest: false,
209
+ isAction: mode.type === "action",
210
+ ...(mode.type === "action" && mode.actionId !== undefined
211
+ ? { actionId: mode.actionId }
212
+ : {}),
204
213
  stale: mode.type === "stale-revalidation",
205
214
  });
206
215
  } catch {
@@ -145,6 +145,7 @@ export function createServerActionBridge(
145
145
  async function refetchRoute(opts?: {
146
146
  segments?: string[];
147
147
  interceptSourceUrl?: string | null;
148
+ actionId?: string;
148
149
  }): Promise<void> {
149
150
  const src = opts?.interceptSourceUrl ?? null;
150
151
  const navTx = createNavigationTransaction(
@@ -167,6 +168,7 @@ export function createServerActionBridge(
167
168
  {
168
169
  type: "action" as const,
169
170
  ...(src ? { interceptSourceUrl: src } : {}),
171
+ ...(opts?.actionId !== undefined ? { actionId: opts.actionId } : {}),
170
172
  },
171
173
  );
172
174
  } finally {
@@ -309,6 +311,9 @@ export function createServerActionBridge(
309
311
  clientRevalidation = collectClientRevalidationDecisions({
310
312
  currentUrl: actionPageUrl,
311
313
  nextUrl: actionPageUrl,
314
+ // Decisions ride the action POST itself — the server evaluates it
315
+ // with actionContext, so the locked default is the action default.
316
+ actionRequest: true,
312
317
  isAction: true,
313
318
  actionId: id,
314
319
  stale: false,
@@ -707,7 +712,7 @@ export function createServerActionBridge(
707
712
  // Invalidation is deferred to finalizeAction(); here we only trigger
708
713
  // the revalidation refetch of the new route (suppressed on keep).
709
714
  if (!scenario.onInterceptRoute && !keepCache) {
710
- refetchRoute().catch((error) => {
715
+ refetchRoute({ actionId: id }).catch((error) => {
711
716
  if (isBackgroundSuppressible(error)) return;
712
717
  console.error(
713
718
  "[Browser] Background revalidation failed:",
@@ -724,6 +729,7 @@ export function createServerActionBridge(
724
729
  if (!keepCache) {
725
730
  await refetchRoute({
726
731
  interceptSourceUrl: store.getInterceptSourceUrl(),
732
+ actionId: id,
727
733
  });
728
734
  }
729
735
  break;
@@ -737,7 +743,7 @@ export function createServerActionBridge(
737
743
  // resolving last must discharge a directive-free sibling's repair.
738
744
  // See the keep row in docs/design/rango-state-cookie.md (the all-keep
739
745
  // edge, and the benign re-mark-stale-after-refetch end-state delta).
740
- await refetchRoute({ interceptSourceUrl });
746
+ await refetchRoute({ interceptSourceUrl, actionId: id });
741
747
  break;
742
748
  }
743
749
 
@@ -759,6 +765,7 @@ export function createServerActionBridge(
759
765
  await refetchRoute({
760
766
  segments: segmentsToSend,
761
767
  interceptSourceUrl,
768
+ actionId: id,
762
769
  });
763
770
  break;
764
771
  }
@@ -19,7 +19,7 @@
19
19
  import {
20
20
  encodeReply,
21
21
  createClientTemporaryReferenceSet,
22
- } from "@vitejs/plugin-rsc/rsc";
22
+ } from "../deps/rsc-client.js";
23
23
  import {
24
24
  getRequestContext,
25
25
  runWithRequestContext,
@@ -15,8 +15,8 @@ import { segmentFragment } from "../segment-fragments.js";
15
15
  import {
16
16
  renderToReadableStream,
17
17
  createTemporaryReferenceSet,
18
- } from "@vitejs/plugin-rsc/rsc";
19
- import { createFromReadableStream } from "@vitejs/plugin-rsc/rsc";
18
+ } from "../deps/rsc.js";
19
+ import { createFromReadableStream } from "../deps/rsc-client.js";
20
20
 
21
21
  // Preserve embedded server references on a cache/prerender HIT so they
22
22
  // re-serialize to the client instead of resolving to a raw function React
@@ -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 { ClientRevalidateArgs, ClientUrlPatterns } from "./types.js";
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 } = options;
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
- // Locked default, mirrored client-side: actions re-run route-owned loaders;
182
- // navigations re-run them when params or search changed.
183
- const paramsEqual = (
184
- a: Record<string, string>,
185
- b: Record<string, string>,
186
- ): boolean => {
187
- const aKeys = Object.keys(a);
188
- return (
189
- aKeys.length === Object.keys(b).length &&
190
- aKeys.every((key) => a[key] === b[key])
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 args: ClientRevalidateArgs = {
203
- currentUrl,
204
- nextUrl,
205
- currentParams: currentMatch.params,
206
- nextParams,
219
+ const decision = runClientRevalidateChain(
220
+ revalidate,
221
+ baseArgs,
207
222
  defaultShouldRevalidate,
208
- stale,
209
- isAction,
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
+ }