@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.
- package/dist/testing/vitest.js +1 -1
- 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/deps/rsc-client.d.ts +1 -0
- package/dist/types/deps/rsc.d.ts +1 -1
- package/dist/types/deps/ssr.d.ts +1 -1
- 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/rsc/types.d.ts +9 -9
- package/dist/types/ssr/index.d.ts +27 -4
- package/dist/types/testing/flight.d.ts +4 -3
- 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/testing/vitest-stubs/plugin-rsc.d.ts +2 -0
- package/dist/types/testing/vitest.d.ts +2 -1
- 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/types/vite/plugins/virtual-entries.d.ts +3 -2
- package/dist/vite/index.js +66 -13
- package/package.json +8 -3
- package/skills/client-urls/SKILL.md +26 -4
- package/skills/loader/SKILL.md +1 -0
- package/skills/testing/SKILL.md +1 -0
- package/skills/testing/setup.md +6 -6
- 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/cache/cache-runtime.ts +1 -1
- package/src/cache/segment-codec.ts +2 -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/deps/rsc-client.ts +8 -0
- package/src/deps/rsc.ts +4 -2
- package/src/deps/ssr.ts +1 -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/handler.ts +3 -3
- package/src/rsc/server-action.ts +2 -4
- package/src/rsc/types.ts +9 -9
- package/src/ssr/index.tsx +132 -51
- package/src/testing/flight.ts +4 -3
- 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/testing/vitest-stubs/plugin-rsc.ts +13 -5
- package/src/testing/vitest.ts +3 -2
- 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
- package/src/vite/plugins/use-cache-transform.ts +65 -1
- package/src/vite/plugins/virtual-entries.ts +14 -11
package/dist/vite/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
193
|
-
(`isAction
|
|
194
|
-
|
|
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
|
|
package/skills/loader/SKILL.md
CHANGED
|
@@ -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** —
|
package/skills/testing/SKILL.md
CHANGED
|
@@ -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` |
|
package/skills/testing/setup.md
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
|
@@ -15,8 +15,8 @@ import { segmentFragment } from "../segment-fragments.js";
|
|
|
15
15
|
import {
|
|
16
16
|
renderToReadableStream,
|
|
17
17
|
createTemporaryReferenceSet,
|
|
18
|
-
} from "
|
|
19
|
-
import { createFromReadableStream } from "
|
|
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 {
|
|
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
|
+
}
|