@rangojs/router 0.13.0 → 0.15.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/README.md +0 -1
- package/dist/types/route-content-wrapper.d.ts +20 -0
- package/dist/types/server/context.d.ts +2 -0
- package/dist/types/types/loader-types.d.ts +2 -7
- package/dist/types/types/segments.d.ts +3 -0
- package/dist/types/urls/pattern-types.d.ts +3 -0
- package/dist/types/vite/plugins/expose-ids/loader-transform.d.ts +14 -0
- package/dist/types/vite/plugins/expose-internal-ids.d.ts +7 -0
- package/dist/vite/index.js +58 -8
- package/package.json +1 -1
- package/skills/breadcrumbs/SKILL.md +0 -1
- package/skills/client-urls/SKILL.md +5 -5
- package/skills/hooks/data.md +0 -2
- package/skills/loader/SKILL.md +22 -20
- package/skills/middleware/SKILL.md +3 -0
- package/skills/migrate-nextjs/SKILL.md +0 -1
- package/skills/router-setup/SKILL.md +0 -1
- package/skills/shell-manifest/SKILL.md +0 -1
- package/src/browser/partial-update.ts +5 -2
- package/src/cache/segment-codec.ts +1 -0
- package/src/client-urls/client-root.tsx +18 -18
- package/src/client-urls/server-projection.ts +9 -2
- package/src/route-content-wrapper.tsx +24 -3
- package/src/router/segment-resolution/fresh.ts +1 -0
- package/src/router/segment-resolution/revalidation.ts +1 -0
- package/src/segment-system.tsx +15 -4
- package/src/server/context.ts +2 -0
- package/src/types/loader-types.ts +2 -7
- package/src/types/segments.ts +3 -0
- package/src/urls/path-helper.ts +1 -0
- package/src/urls/pattern-types.ts +3 -0
- package/src/vite/plugins/expose-ids/loader-transform.ts +54 -1
- package/src/vite/plugins/expose-internal-ids.ts +46 -0
- package/src/vite/rango.ts +2 -0
package/README.md
CHANGED
|
@@ -23,15 +23,35 @@ import type { ResolvedSegment } from "./types.js";
|
|
|
23
23
|
*/
|
|
24
24
|
export declare class StreamedLoaderErrorBoundary extends Component<{
|
|
25
25
|
children: ReactNode;
|
|
26
|
+
resetKey?: string;
|
|
26
27
|
}, {
|
|
27
28
|
error: unknown;
|
|
29
|
+
resetKey?: string;
|
|
28
30
|
}> {
|
|
29
31
|
state: {
|
|
30
32
|
error: unknown;
|
|
33
|
+
resetKey?: string;
|
|
31
34
|
};
|
|
32
35
|
static getDerivedStateFromError(error: unknown): {
|
|
33
36
|
error: unknown;
|
|
34
37
|
};
|
|
38
|
+
/**
|
|
39
|
+
* A caught marker (redirect, notFound, error fallback) belongs to ONE route
|
|
40
|
+
* + params. Group-keyed segments (ResolvedSegment.clientGroup) keep this
|
|
41
|
+
* instance alive across in-group navigations, so the error must clear when
|
|
42
|
+
* the route or params change — otherwise a redirect caught for /legacy
|
|
43
|
+
* keeps rendering LoaderRedirect for /state. `resetKey` is the segment's
|
|
44
|
+
* id-params identity, the cadence the per-route remount used to provide.
|
|
45
|
+
*/
|
|
46
|
+
static getDerivedStateFromProps(props: {
|
|
47
|
+
resetKey?: string;
|
|
48
|
+
}, state: {
|
|
49
|
+
error: unknown;
|
|
50
|
+
resetKey?: string;
|
|
51
|
+
}): {
|
|
52
|
+
error: unknown;
|
|
53
|
+
resetKey?: string;
|
|
54
|
+
} | null;
|
|
35
55
|
render(): ReactNode;
|
|
36
56
|
}
|
|
37
57
|
/**
|
|
@@ -48,6 +48,8 @@ export type EntryPropCommon = {
|
|
|
48
48
|
cache?: EntryCacheConfig;
|
|
49
49
|
/** URL prefix from include() scope, used for MountContext on client */
|
|
50
50
|
mountPath?: string;
|
|
51
|
+
/** clientUrls() group key (PathOptions.clientGroup); route entries only. */
|
|
52
|
+
clientGroup?: string;
|
|
51
53
|
};
|
|
52
54
|
/**
|
|
53
55
|
* Attachments resolved by walking the parent chain, not owned by the entry:
|
|
@@ -19,14 +19,12 @@ import type { RequestScope } from "./request-scope.js";
|
|
|
19
19
|
* @example
|
|
20
20
|
* ```typescript
|
|
21
21
|
* const CartLoader = createLoader(async (ctx) => {
|
|
22
|
-
* "use server";
|
|
23
22
|
* const user = ctx.get("user"); // From auth middleware
|
|
24
23
|
* return await db.cart.get(user.id);
|
|
25
24
|
* });
|
|
26
25
|
*
|
|
27
26
|
* // With typed params:
|
|
28
27
|
* const ProductLoader = createLoader<Product, { slug: string }>(async (ctx) => {
|
|
29
|
-
* "use server";
|
|
30
28
|
* const { slug } = ctx.params; // slug is typed as string
|
|
31
29
|
* return await db.products.findBySlug(slug);
|
|
32
30
|
* });
|
|
@@ -69,7 +67,6 @@ export type LoaderContext<TParams = Record<string, string | undefined>, TEnv = D
|
|
|
69
67
|
* @example
|
|
70
68
|
* ```typescript
|
|
71
69
|
* export const ProductLoader = createLoader(async (ctx) => {
|
|
72
|
-
* "use server";
|
|
73
70
|
* const product = await getProduct(ctx.params.slug);
|
|
74
71
|
* ctx.use(Meta)({ title: product.name });
|
|
75
72
|
* ctx.use(Breadcrumbs)({ label: product.name });
|
|
@@ -95,7 +92,6 @@ export type LoaderContext<TParams = Record<string, string | undefined>, TEnv = D
|
|
|
95
92
|
* @example
|
|
96
93
|
* ```typescript
|
|
97
94
|
* const PricesLoader = createLoader(async (ctx) => {
|
|
98
|
-
* "use server";
|
|
99
95
|
* await ctx.rendered();
|
|
100
96
|
* const products = ctx.get(Products); // reads handle data
|
|
101
97
|
* return pricing.getLive(products.map(p => p.id));
|
|
@@ -204,7 +200,8 @@ export type LoadOptions = {
|
|
|
204
200
|
* Loader definition object
|
|
205
201
|
*
|
|
206
202
|
* Created via createLoader(). Contains the loader name and function.
|
|
207
|
-
* On client builds, the fn is stripped by the
|
|
203
|
+
* On client builds, the fn is stripped by the router's loader transform; the
|
|
204
|
+
* definition must not carry a "use server" directive (the Vite plugin rejects it).
|
|
208
205
|
*
|
|
209
206
|
* @template T - The return type of the loader
|
|
210
207
|
* @template TParams - Route params type (for type-safe params access)
|
|
@@ -213,13 +210,11 @@ export type LoadOptions = {
|
|
|
213
210
|
* ```typescript
|
|
214
211
|
* // Definition (same file works on server and client)
|
|
215
212
|
* export const CartLoader = createLoader(async (ctx) => {
|
|
216
|
-
* "use server";
|
|
217
213
|
* return await db.cart.get(ctx.get("user").id);
|
|
218
214
|
* });
|
|
219
215
|
*
|
|
220
216
|
* // With typed params:
|
|
221
217
|
* export const ProductLoader = createLoader<Product, { slug: string }>(async (ctx) => {
|
|
222
|
-
* "use server";
|
|
223
218
|
* const { slug } = ctx.params; // slug is typed as string
|
|
224
219
|
* return await db.products.findBySlug(slug);
|
|
225
220
|
* });
|
|
@@ -163,6 +163,9 @@ export interface ResolvedSegment {
|
|
|
163
163
|
error?: ErrorInfo;
|
|
164
164
|
notFoundInfo?: NotFoundInfo;
|
|
165
165
|
mountPath?: string;
|
|
166
|
+
/** clientUrls() group key (the include mount), shared by every route
|
|
167
|
+
* segment of one group; see the group-route branch in segment-system.tsx. */
|
|
168
|
+
clientGroup?: string;
|
|
166
169
|
/**
|
|
167
170
|
* @internal Server-side marker: true when the segment's handler actually ran
|
|
168
171
|
* this request (not skipped via the revalidate cache path). Used by
|
|
@@ -102,6 +102,9 @@ export interface PathOptions<TName extends string = string, TSearch extends Sear
|
|
|
102
102
|
trailingSlash?: TrailingSlashMode;
|
|
103
103
|
/** Response type marker (set by path.json(), etc.) */
|
|
104
104
|
[RESPONSE_TYPE]?: string;
|
|
105
|
+
/** @internal clientUrls() group key stamped by server-projection.ts; see
|
|
106
|
+
* ResolvedSegment.clientGroup. */
|
|
107
|
+
clientGroup?: string;
|
|
105
108
|
}
|
|
106
109
|
/**
|
|
107
110
|
* Result of urls() - contains the route definitions
|
|
@@ -6,3 +6,17 @@ export declare function generateClientLoaderStubs(bindings: CreateExportBinding[
|
|
|
6
6
|
map?: undefined;
|
|
7
7
|
} | null;
|
|
8
8
|
export declare function transformLoaders(bindings: CreateExportBinding[], s: MagicString, filePath: string, isBuild: boolean): boolean;
|
|
9
|
+
export interface LoaderServerDirective {
|
|
10
|
+
line: number;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Locate an inline `"use server"` directive at the top of a createLoader()
|
|
14
|
+
* callback body. Only top-level `const X = createLoader(...)` bindings (bare
|
|
15
|
+
* or exported) are checked — the same shapes the loader transform registers.
|
|
16
|
+
* Loaders are never client-callable, and the directive makes plugin-rsc
|
|
17
|
+
* hoist the body and `registerServerReference` it, so the loader body
|
|
18
|
+
* becomes a public action reachable with caller-supplied arguments (verified
|
|
19
|
+
* against a production build: a POST to `?_rsc_action=<id>` ran a loader body
|
|
20
|
+
* with a forged ctx). The guard plugin throws on the first hit.
|
|
21
|
+
*/
|
|
22
|
+
export declare function findLoaderServerDirective(code: string, fnNames: readonly string[], program: any): LoaderServerDirective | null;
|
|
@@ -11,6 +11,13 @@ export type { ExposeInternalIdsApi } from "./expose-ids/types.js";
|
|
|
11
11
|
* validation rather than being hidden behind an opaque scan stub.
|
|
12
12
|
*/
|
|
13
13
|
export declare function createLoaderScanStubPlugin(): Plugin;
|
|
14
|
+
/**
|
|
15
|
+
* Refuse an inline `"use server"` directive inside a createLoader() callback.
|
|
16
|
+
* Runs `pre` so it sees the raw source before plugin-rsc's directive transform
|
|
17
|
+
* hoists the body into a registered server reference. See
|
|
18
|
+
* findLoaderServerDirective for why that registration is a problem.
|
|
19
|
+
*/
|
|
20
|
+
export declare function createLoaderDirectiveGuardPlugin(): Plugin;
|
|
14
21
|
export declare function exposeInternalIds(options?: {
|
|
15
22
|
forceBuild?: boolean;
|
|
16
23
|
}): Plugin;
|
package/dist/vite/index.js
CHANGED
|
@@ -2242,6 +2242,7 @@ function buildUnsupportedShapeWarning(filePath, fnName, sites = []) {
|
|
|
2242
2242
|
}
|
|
2243
2243
|
|
|
2244
2244
|
// src/vite/plugins/expose-ids/loader-transform.ts
|
|
2245
|
+
import { hasDirective } from "@vitejs/plugin-rsc/transforms";
|
|
2245
2246
|
function hasCreateLoaderImport(code) {
|
|
2246
2247
|
return /import\s*\{[^}]*\bcreateLoader\b[^}]*\}\s*from\s*["']@rangojs\/router(?:\/server)?["']/.test(
|
|
2247
2248
|
code
|
|
@@ -2276,6 +2277,27 @@ ${binding.localName}.$$id = "${loaderId}";`;
|
|
|
2276
2277
|
}
|
|
2277
2278
|
return hasChanges;
|
|
2278
2279
|
}
|
|
2280
|
+
function loaderCallback(node, names) {
|
|
2281
|
+
if (node?.type !== "CallExpression" || node.callee?.type !== "Identifier" || !names.has(node.callee.name)) {
|
|
2282
|
+
return void 0;
|
|
2283
|
+
}
|
|
2284
|
+
const fn = node.arguments?.[0];
|
|
2285
|
+
return (fn?.type === "ArrowFunctionExpression" || fn?.type === "FunctionExpression") && fn.body?.type === "BlockStatement" ? fn : void 0;
|
|
2286
|
+
}
|
|
2287
|
+
function findLoaderServerDirective(code, fnNames, program) {
|
|
2288
|
+
const names = new Set(fnNames);
|
|
2289
|
+
for (const statement of program.body ?? []) {
|
|
2290
|
+
const declaration = statement.type === "ExportNamedDeclaration" ? statement.declaration : statement;
|
|
2291
|
+
if (declaration?.type !== "VariableDeclaration") continue;
|
|
2292
|
+
for (const declarator of declaration.declarations) {
|
|
2293
|
+
const fn = loaderCallback(declarator.init, names);
|
|
2294
|
+
if (fn && hasDirective(fn.body.body, "use server")) {
|
|
2295
|
+
return { line: offsetToLineColumn(code, fn.body.body[0].start).line };
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
return null;
|
|
2300
|
+
}
|
|
2279
2301
|
|
|
2280
2302
|
// src/vite/plugins/expose-ids/handler-transform.ts
|
|
2281
2303
|
function transformHandles(bindings, s, filePath, isBuild) {
|
|
@@ -2499,6 +2521,33 @@ function createLoaderScanStubPlugin() {
|
|
|
2499
2521
|
}
|
|
2500
2522
|
};
|
|
2501
2523
|
}
|
|
2524
|
+
function createLoaderDirectiveGuardPlugin() {
|
|
2525
|
+
return {
|
|
2526
|
+
name: "@rangojs/router:loader-directive-guard",
|
|
2527
|
+
enforce: "pre",
|
|
2528
|
+
transform(code, id) {
|
|
2529
|
+
if (id.includes("/node_modules/") || !code.includes("use server") || !code.includes("createLoader") || !hasCreateLoaderImport(code)) {
|
|
2530
|
+
return;
|
|
2531
|
+
}
|
|
2532
|
+
const cleanId = id.split("?", 1)[0];
|
|
2533
|
+
let program;
|
|
2534
|
+
try {
|
|
2535
|
+
program = parseAst2(code, { lang: "tsx" });
|
|
2536
|
+
} catch {
|
|
2537
|
+
return;
|
|
2538
|
+
}
|
|
2539
|
+
const hit = findLoaderServerDirective(
|
|
2540
|
+
code,
|
|
2541
|
+
getImportedFnNames(code, "createLoader"),
|
|
2542
|
+
program
|
|
2543
|
+
);
|
|
2544
|
+
if (!hit) return;
|
|
2545
|
+
throw new Error(
|
|
2546
|
+
`[rango] createLoader() body at ${cleanId}:${hit.line} carries a "use server" directive. Loader bodies already run only on the server; the directive would register the body as a client-callable server reference (reachable through the action endpoint with caller-supplied arguments). Remove the directive.`
|
|
2547
|
+
);
|
|
2548
|
+
}
|
|
2549
|
+
};
|
|
2550
|
+
}
|
|
2502
2551
|
function exposeInternalIds(options) {
|
|
2503
2552
|
let config;
|
|
2504
2553
|
let isBuild = false;
|
|
@@ -3037,7 +3086,7 @@ function useCacheTransform() {
|
|
|
3037
3086
|
}
|
|
3038
3087
|
}
|
|
3039
3088
|
const {
|
|
3040
|
-
hasDirective,
|
|
3089
|
+
hasDirective: hasDirective2,
|
|
3041
3090
|
transformWrapExport,
|
|
3042
3091
|
transformHoistInlineDirective
|
|
3043
3092
|
} = rscTransforms;
|
|
@@ -3051,7 +3100,7 @@ function useCacheTransform() {
|
|
|
3051
3100
|
stripNullDirectiveFields(ast);
|
|
3052
3101
|
const filePath = normalizePath(path5.relative(projectRoot, id));
|
|
3053
3102
|
const isLayoutOrTemplate = LAYOUT_TEMPLATE_PATTERN.test(id);
|
|
3054
|
-
if (
|
|
3103
|
+
if (hasDirective2(ast.body, "use cache")) {
|
|
3055
3104
|
return transformFileLevelUseCache(
|
|
3056
3105
|
code,
|
|
3057
3106
|
ast,
|
|
@@ -3060,7 +3109,7 @@ function useCacheTransform() {
|
|
|
3060
3109
|
isBuild,
|
|
3061
3110
|
isLayoutOrTemplate,
|
|
3062
3111
|
transformWrapExport,
|
|
3063
|
-
|
|
3112
|
+
hasDirective2
|
|
3064
3113
|
);
|
|
3065
3114
|
}
|
|
3066
3115
|
const functionResult = transformFunctionLevelUseCache(
|
|
@@ -3079,7 +3128,7 @@ function useCacheTransform() {
|
|
|
3079
3128
|
}
|
|
3080
3129
|
};
|
|
3081
3130
|
}
|
|
3082
|
-
function transformFileLevelUseCache(code, ast, filePath, sourceId, isBuild, isLayoutOrTemplate, transformWrapExport,
|
|
3131
|
+
function transformFileLevelUseCache(code, ast, filePath, sourceId, isBuild, isLayoutOrTemplate, transformWrapExport, hasDirective2) {
|
|
3083
3132
|
const unconfirmedExports = [];
|
|
3084
3133
|
const { exportNames, output } = transformWrapExport(code, ast, {
|
|
3085
3134
|
runtime: (value, name) => {
|
|
@@ -3091,7 +3140,7 @@ function transformFileLevelUseCache(code, ast, filePath, sourceId, isBuild, isLa
|
|
|
3091
3140
|
if (name === "default" && isLayoutOrTemplate) return false;
|
|
3092
3141
|
if (name.startsWith("$$hoist_")) return false;
|
|
3093
3142
|
if (isHoistedServerReferenceRebind(meta.valueNode)) return false;
|
|
3094
|
-
if (functionHasUseServerDirective(meta.valueNode,
|
|
3143
|
+
if (functionHasUseServerDirective(meta.valueNode, hasDirective2)) {
|
|
3095
3144
|
return false;
|
|
3096
3145
|
}
|
|
3097
3146
|
if (meta.isFunction !== true) {
|
|
@@ -3184,13 +3233,13 @@ function isHoistedServerReferenceRebind(valueNode) {
|
|
|
3184
3233
|
const first = valueNode.arguments[0];
|
|
3185
3234
|
return first !== void 0 && first.type === "Identifier" && first.name.startsWith("$$hoist_");
|
|
3186
3235
|
}
|
|
3187
|
-
function functionHasUseServerDirective(valueNode,
|
|
3236
|
+
function functionHasUseServerDirective(valueNode, hasDirective2) {
|
|
3188
3237
|
if (!valueNode || !("body" in valueNode)) return false;
|
|
3189
3238
|
const { body } = valueNode;
|
|
3190
3239
|
if (!body || Array.isArray(body) || body.type !== "BlockStatement") {
|
|
3191
3240
|
return false;
|
|
3192
3241
|
}
|
|
3193
|
-
return
|
|
3242
|
+
return hasDirective2(
|
|
3194
3243
|
body.body,
|
|
3195
3244
|
"use server"
|
|
3196
3245
|
);
|
|
@@ -3746,7 +3795,7 @@ import { resolve } from "node:path";
|
|
|
3746
3795
|
// package.json
|
|
3747
3796
|
var package_default = {
|
|
3748
3797
|
name: "@rangojs/router",
|
|
3749
|
-
version: "0.
|
|
3798
|
+
version: "0.15.0",
|
|
3750
3799
|
description: "Django-inspired RSC router with composable URL patterns",
|
|
3751
3800
|
keywords: [
|
|
3752
3801
|
"react",
|
|
@@ -12304,6 +12353,7 @@ If this is a multi-app host router, export a createHostRouter() instance and set
|
|
|
12304
12353
|
}
|
|
12305
12354
|
}
|
|
12306
12355
|
});
|
|
12356
|
+
plugins.push(createLoaderDirectiveGuardPlugin());
|
|
12307
12357
|
plugins.push(createLoaderScanStubPlugin());
|
|
12308
12358
|
plugins.push(exposeActionId());
|
|
12309
12359
|
plugins.push(useCacheTransform());
|
package/package.json
CHANGED
|
@@ -54,9 +54,10 @@ Consequences you will hit:
|
|
|
54
54
|
`path("/x", <HomePage />)` and not an inline arrow — the projection stores a
|
|
55
55
|
component reference, and an anonymous value throws
|
|
56
56
|
(`clientUrls() path() expects a named client component value`).
|
|
57
|
-
- **Loaders must be `createLoader()` definitions
|
|
58
|
-
|
|
59
|
-
|
|
57
|
+
- **Loaders must be `createLoader()` definitions.** They execute on the
|
|
58
|
+
server, addressed by id; the client module only imports the definition
|
|
59
|
+
object. Do NOT put a `"use server"` directive in the body — it is not
|
|
60
|
+
needed, and the build rejects it (see `/loader` → "Creating a Loader").
|
|
60
61
|
- **Everything in the definition must be JSON-projectable.** That is why the
|
|
61
62
|
unsupported helpers below are rejected rather than silently dropped.
|
|
62
63
|
|
|
@@ -130,7 +131,7 @@ typing work exactly as for server routes (`/typesafety`).
|
|
|
130
131
|
| `loader()` | `loader(Def, use?)` or `loader(Def, { ssr: false }, use?)` — see below |
|
|
131
132
|
| `loading()` | Route-level boundary around the optimistic render; inline `<Suspense>` at read sites keeps the destination's chrome visible while only the reads wait |
|
|
132
133
|
| `revalidate()` | Valid **inside a loader() use callback only**; runs in the browser |
|
|
133
|
-
| `transition()` | Data-only ViewTransition config — no `when
|
|
134
|
+
| `transition()` | Data-only ViewTransition animation config — no `when`; same-route navs in a group already hold previous content without it |
|
|
134
135
|
| `intercept()` | Dot-local named target in the SAME definition; use may contain `loader()`/`loading()` |
|
|
135
136
|
|
|
136
137
|
`include`, `parallel`, `cache`, `middleware`, `errorBoundary`,
|
|
@@ -230,7 +231,6 @@ is server code either way.
|
|
|
230
231
|
|
|
231
232
|
```ts
|
|
232
233
|
export const ProductLoader = createLoader(async (ctx) => {
|
|
233
|
-
"use server";
|
|
234
234
|
const moved = LEGACY_SLUGS[ctx.params.slug];
|
|
235
235
|
if (moved) throw redirect(`/shop/product/${moved}`);
|
|
236
236
|
if (!(await exists(ctx.params.slug))) notFound(`No "${ctx.params.slug}"`);
|
package/skills/hooks/data.md
CHANGED
|
@@ -270,8 +270,6 @@ Server-side loader for the upload:
|
|
|
270
270
|
import { createLoader } from "@rangojs/router";
|
|
271
271
|
|
|
272
272
|
export const FileUploadLoader = createLoader(async (ctx) => {
|
|
273
|
-
"use server";
|
|
274
|
-
|
|
275
273
|
const file = ctx.formData?.get("file") as File | null;
|
|
276
274
|
if (file && file.size > 0) {
|
|
277
275
|
// Process file (save to R2, D1, etc.)
|
package/skills/loader/SKILL.md
CHANGED
|
@@ -24,8 +24,6 @@ flow is _action mutates → loader re-reads → UI updates_.
|
|
|
24
24
|
import { createLoader } from "@rangojs/router";
|
|
25
25
|
|
|
26
26
|
export const ProductLoader = createLoader(async (ctx) => {
|
|
27
|
-
"use server";
|
|
28
|
-
|
|
29
27
|
const product = await ctx.env.DB.prepare(
|
|
30
28
|
"SELECT * FROM products WHERE slug = ?",
|
|
31
29
|
)
|
|
@@ -36,6 +34,15 @@ export const ProductLoader = createLoader(async (ctx) => {
|
|
|
36
34
|
});
|
|
37
35
|
```
|
|
38
36
|
|
|
37
|
+
Loader bodies already run only on the server — never add a `"use server"`
|
|
38
|
+
directive inside the callback. `"use server"` means "expose this function to
|
|
39
|
+
the client as a callable server reference", not "server only": the directive
|
|
40
|
+
would hoist the body into a registered server reference that anyone can invoke
|
|
41
|
+
through the action endpoint with caller-supplied arguments. The Vite plugin
|
|
42
|
+
rejects it at transform time (`createLoader() body at <file>:<line> carries a
|
|
43
|
+
"use server" directive`). For a build-time guarantee that a module never
|
|
44
|
+
reaches the client graph, use `import "server-only"` instead.
|
|
45
|
+
|
|
39
46
|
### Supported export patterns
|
|
40
47
|
|
|
41
48
|
All of the following are equivalent and fully supported by the Vite transform:
|
|
@@ -194,8 +201,6 @@ Loaders receive the same context shape as route handlers.
|
|
|
194
201
|
|
|
195
202
|
```typescript
|
|
196
203
|
export const ProductLoader = createLoader(async (ctx) => {
|
|
197
|
-
"use server";
|
|
198
|
-
|
|
199
204
|
// URL params (may include client-provided overrides for fetchable loaders)
|
|
200
205
|
const { slug } = ctx.params;
|
|
201
206
|
|
|
@@ -242,8 +247,6 @@ or resource scoping:
|
|
|
242
247
|
|
|
243
248
|
```typescript
|
|
244
249
|
export const OrderLoader = createLoader(async (ctx) => {
|
|
245
|
-
"use server";
|
|
246
|
-
|
|
247
250
|
// Use routeParams for auth checks — client cannot spoof the URL-matched ID
|
|
248
251
|
const { orderId } = ctx.routeParams;
|
|
249
252
|
const user = ctx.get("user");
|
|
@@ -674,7 +677,6 @@ signal instead of re-checking existence at each read site:
|
|
|
674
677
|
import { createLoader, notFound, redirect } from "@rangojs/router";
|
|
675
678
|
|
|
676
679
|
export const ProductLoader = createLoader(async (ctx) => {
|
|
677
|
-
"use server";
|
|
678
680
|
const moved = LEGACY_SLUGS[ctx.params.slug];
|
|
679
681
|
if (moved) throw redirect(`/shop/product/${moved}`);
|
|
680
682
|
|
|
@@ -708,7 +710,6 @@ where the data lives:
|
|
|
708
710
|
import { Meta, Breadcrumbs } from "./handles";
|
|
709
711
|
|
|
710
712
|
export const ProductLoader = createLoader(async (ctx) => {
|
|
711
|
-
"use server";
|
|
712
713
|
const product = await getProduct(ctx.params.slug);
|
|
713
714
|
|
|
714
715
|
ctx.use(Meta)({ title: `${product.name} — Shop` });
|
|
@@ -799,8 +800,6 @@ via `useFetchLoader()` and `load()`:
|
|
|
799
800
|
import { createLoader } from "@rangojs/router";
|
|
800
801
|
|
|
801
802
|
export const SearchLoader = createLoader(async (ctx) => {
|
|
802
|
-
"use server";
|
|
803
|
-
|
|
804
803
|
const query = ctx.params.query ?? "";
|
|
805
804
|
const results = await ctx.env.DB.prepare(
|
|
806
805
|
"SELECT * FROM products WHERE name LIKE ?",
|
|
@@ -812,6 +811,13 @@ export const SearchLoader = createLoader(async (ctx) => {
|
|
|
812
811
|
}, true); // true = fetchable
|
|
813
812
|
```
|
|
814
813
|
|
|
814
|
+
`true` attaches an EMPTY middleware list, and the fetch lane never runs route
|
|
815
|
+
`middleware()` (see `/middleware` → "Request flow"). A fetchable loader that
|
|
816
|
+
reads a var set by route middleware (`ctx.get(CurrentUser)`) therefore sees
|
|
817
|
+
`undefined` on `useFetchLoader()` / `load()` / `useRefreshLoaders()` unless it
|
|
818
|
+
passes that middleware itself: `createLoader(fn, { middleware: [loadSession,
|
|
819
|
+
requireAuth] })` (next section).
|
|
820
|
+
|
|
815
821
|
> **No registration needed — and no worker-entry import.** A fetchable loader
|
|
816
822
|
> does not have to be registered with `loader()` in the route DSL, and it does
|
|
817
823
|
> not have to be imported by any server module. Importing it into the client
|
|
@@ -835,8 +841,6 @@ import { rateLimitMiddleware } from "../middleware/rate-limit";
|
|
|
835
841
|
|
|
836
842
|
export const ProtectedLoader = createLoader(
|
|
837
843
|
async (ctx) => {
|
|
838
|
-
"use server";
|
|
839
|
-
|
|
840
844
|
const user = ctx.get("user");
|
|
841
845
|
return { orders: await db.orders.list(user.id) };
|
|
842
846
|
},
|
|
@@ -847,6 +851,12 @@ export const ProtectedLoader = createLoader(
|
|
|
847
851
|
The middleware uses the same `MiddlewareFn` signature as route/app middleware,
|
|
848
852
|
so you can reuse existing middleware functions directly.
|
|
849
853
|
|
|
854
|
+
A `redirect()` thrown by per-loader middleware becomes a real 3xx on the fetch
|
|
855
|
+
lane, so the browser's `fetch` follows it and `useFetchLoader()` receives the
|
|
856
|
+
login page's HTML instead of loader data. Reserve redirects for document and
|
|
857
|
+
navigation requests; on the fetch lane return a 401 (or a structured error
|
|
858
|
+
payload) and let the caller decide.
|
|
859
|
+
|
|
850
860
|
Fetchable loaders support both GET and POST (PUT, PATCH, DELETE) from the client.
|
|
851
861
|
The `load()` function auto-detects the body type:
|
|
852
862
|
|
|
@@ -860,8 +870,6 @@ includes additional fields depending on the body type:
|
|
|
860
870
|
|
|
861
871
|
```typescript
|
|
862
872
|
export const MutationLoader = createLoader(async (ctx) => {
|
|
863
|
-
"use server";
|
|
864
|
-
|
|
865
873
|
// JSON body — available as ctx.body (parsed object)
|
|
866
874
|
const data = ctx.body as { name: string; email: string };
|
|
867
875
|
|
|
@@ -883,8 +891,6 @@ export const MutationLoader = createLoader(async (ctx) => {
|
|
|
883
891
|
import { createLoader } from "@rangojs/router";
|
|
884
892
|
|
|
885
893
|
export const FileUploadLoader = createLoader(async (ctx) => {
|
|
886
|
-
"use server";
|
|
887
|
-
|
|
888
894
|
const file = ctx.formData?.get("file") as File | null;
|
|
889
895
|
if (file && file.size > 0) {
|
|
890
896
|
// Save to R2, D1, etc.
|
|
@@ -911,8 +917,6 @@ Client usage — see `/hooks useFetchLoader` for the full client-side pattern.
|
|
|
911
917
|
import { createLoader } from "@rangojs/router";
|
|
912
918
|
|
|
913
919
|
export const ProductLoader = createLoader(async (ctx) => {
|
|
914
|
-
"use server";
|
|
915
|
-
|
|
916
920
|
const product = await ctx.env.DB
|
|
917
921
|
.prepare("SELECT * FROM products WHERE slug = ?")
|
|
918
922
|
.bind(ctx.params.slug)
|
|
@@ -926,8 +930,6 @@ export const ProductLoader = createLoader(async (ctx) => {
|
|
|
926
930
|
});
|
|
927
931
|
|
|
928
932
|
export const CartLoader = createLoader(async (ctx) => {
|
|
929
|
-
"use server";
|
|
930
|
-
|
|
931
933
|
const user = ctx.get("user");
|
|
932
934
|
if (!user) return { cart: null };
|
|
933
935
|
|
|
@@ -38,6 +38,9 @@ Request flow (with action):
|
|
|
38
38
|
Request flow (no action):
|
|
39
39
|
global mw -> route mw -> render pass
|
|
40
40
|
|
|
41
|
+
Fetchable loader request (_rsc_loader: useFetchLoader / load / useRefreshLoaders):
|
|
42
|
+
global mw -> per-loader { middleware } list -> loader (route mw does NOT run)
|
|
43
|
+
|
|
41
44
|
Progressive enhancement (no-JS form POST):
|
|
42
45
|
global mw -> action executes -> route mw -> full page re-render
|
|
43
46
|
```
|
|
@@ -737,7 +737,6 @@ maps to a Meta push from the LOADER that owns the data, plus
|
|
|
737
737
|
// Rango: push from the loader; the flag makes the document render await it,
|
|
738
738
|
// so the title is in the SSR'd <head> like generateMetadata guarantees.
|
|
739
739
|
export const ProductLoader = createLoader(async (ctx) => {
|
|
740
|
-
"use server";
|
|
741
740
|
const product = await getProduct(ctx.params.slug);
|
|
742
741
|
ctx.use(Meta)({ title: product.name });
|
|
743
742
|
return product;
|
|
@@ -356,7 +356,6 @@ path("/product/:slug", async (ctx) => {
|
|
|
356
356
|
|
|
357
357
|
// In a loader — data-dependent authority lives with the data
|
|
358
358
|
export const ProductLoader = createLoader(async (ctx) => {
|
|
359
|
-
"use server";
|
|
360
359
|
if (!(await exists(ctx.params.slug))) notFound("Product not found");
|
|
361
360
|
return getProduct(ctx.params.slug);
|
|
362
361
|
});
|
|
@@ -92,7 +92,6 @@ import { createLoader } from "@rangojs/router";
|
|
|
92
92
|
import { RenderedProducts } from "../handles/rendered-products";
|
|
93
93
|
|
|
94
94
|
export const PriceLoader = createLoader(async (ctx) => {
|
|
95
|
-
"use server";
|
|
96
95
|
await ctx.rendered();
|
|
97
96
|
const ids = ctx.get(RenderedProducts);
|
|
98
97
|
return db.pricesFor(ids); // Map<string, number> keyed by product id
|
|
@@ -614,7 +614,7 @@ export function createPartialUpdater(
|
|
|
614
614
|
scroll: scrollPayload,
|
|
615
615
|
});
|
|
616
616
|
});
|
|
617
|
-
} else if (fullyPrefetched || isSameStructureNav) {
|
|
617
|
+
} else if (fullyPrefetched || isSameStructureNav || optimisticPresented) {
|
|
618
618
|
// Content-hold commit, two triggers. Fully-prefetched nav: the payload
|
|
619
619
|
// is fully resolved (forceAwait above), so the transition commits
|
|
620
620
|
// synchronously — no fallback flash. Same-structure nav: the re-run
|
|
@@ -633,7 +633,10 @@ export function createPartialUpdater(
|
|
|
633
633
|
// instead of revealing that boundary's fallback; its render happens
|
|
634
634
|
// pre-commit inside the transition, so userland effects cannot run
|
|
635
635
|
// first. Boundaries newly mounted by this nav still reveal their
|
|
636
|
-
// fallbacks (React shows new boundaries inside transitions).
|
|
636
|
+
// fallbacks (React shows new boundaries inside transitions). An
|
|
637
|
+
// optimistic clientUrls() presentation commits here too: the group
|
|
638
|
+
// segment reconciles in place (clientGroup key), so a read that still
|
|
639
|
+
// suspends must hold the presented content, not flash a fallback.
|
|
637
640
|
startTransition(() => {
|
|
638
641
|
if (optimisticPresented && addTransitionType) {
|
|
639
642
|
addTransitionType(OPTIMISTIC_COMMIT_TRANSITION_TYPE);
|
|
@@ -174,13 +174,17 @@ export function ClientUrlsRoot({
|
|
|
174
174
|
[optimisticRoute, presented],
|
|
175
175
|
);
|
|
176
176
|
|
|
177
|
+
// The wrapper chain below is IDENTICAL in the optimistic and the canonical
|
|
178
|
+
// render (only prop values change): together with the group-keyed segment
|
|
179
|
+
// (segment-system.tsx, ResolvedSegment.clientGroup) that is what lets the
|
|
180
|
+
// destination instance survive the canonical commit.
|
|
177
181
|
let content: ReactNode = createElement(route.component, { key: route.id });
|
|
178
|
-
if (
|
|
179
|
-
// loading() is the route-level boundary
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
+
if (route.loading !== undefined) {
|
|
183
|
+
// loading() is the route-level boundary for group routes (segment-system
|
|
184
|
+
// places no LoaderBoundary around them); presence mirrors the
|
|
185
|
+
// projection's hasLoading (loading("") is still a configured fallback).
|
|
182
186
|
content = createElement(Suspense, {
|
|
183
|
-
fallback:
|
|
187
|
+
fallback: route.loading,
|
|
184
188
|
children: content,
|
|
185
189
|
});
|
|
186
190
|
}
|
|
@@ -195,19 +199,15 @@ export function ClientUrlsRoot({
|
|
|
195
199
|
});
|
|
196
200
|
}
|
|
197
201
|
|
|
198
|
-
|
|
199
|
-
content
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
});
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
return content;
|
|
202
|
+
return createElement(OutletProvider, {
|
|
203
|
+
content: null,
|
|
204
|
+
loaderStreams: optimistic?.streams,
|
|
205
|
+
pending,
|
|
206
|
+
children: createElement(OptimisticLocationContext.Provider, {
|
|
207
|
+
value: optimistic?.location ?? null,
|
|
208
|
+
children: content,
|
|
209
|
+
}),
|
|
210
|
+
});
|
|
211
211
|
}
|
|
212
212
|
|
|
213
213
|
export function ClientUrlsLoading({
|
|
@@ -481,8 +481,12 @@ function createLoaderStub(id: string): LoaderDefinition<unknown> {
|
|
|
481
481
|
};
|
|
482
482
|
}
|
|
483
483
|
|
|
484
|
-
function materializedPathOptions(
|
|
484
|
+
function materializedPathOptions(
|
|
485
|
+
route: ClientUrlProjectionRoute,
|
|
486
|
+
clientGroup: string,
|
|
487
|
+
): PathOptions {
|
|
485
488
|
return {
|
|
489
|
+
clientGroup,
|
|
486
490
|
...(route.name === null ? {} : { name: route.name }),
|
|
487
491
|
...(route.options.search ? { search: { ...route.options.search } } : {}),
|
|
488
492
|
...(route.options.trailingSlash
|
|
@@ -517,6 +521,9 @@ function materializeRouteItems(
|
|
|
517
521
|
// route-name prefix is available here. ClientUrlsRoot needs it to compose
|
|
518
522
|
// canonical names for intercept-target coordination in the browser.
|
|
519
523
|
const namePrefix = getNamePrefix();
|
|
524
|
+
// One key per group MOUNT (the include's URL prefix): renderSegments keys
|
|
525
|
+
// every route segment of the group by it (ResolvedSegment.clientGroup).
|
|
526
|
+
const clientGroup = getUrlPrefix() || "/";
|
|
520
527
|
|
|
521
528
|
// Helper calls attach to the CURRENT ctx.parent as they execute, so these
|
|
522
529
|
// builders must run where the items belong: at the module top level for the
|
|
@@ -533,7 +540,7 @@ function materializeRouteItems(
|
|
|
533
540
|
routeId: route.id,
|
|
534
541
|
namePrefix,
|
|
535
542
|
}),
|
|
536
|
-
materializedPathOptions(route),
|
|
543
|
+
materializedPathOptions(route, clientGroup),
|
|
537
544
|
() => [
|
|
538
545
|
...route.loaderIds.map((id, loaderIndex) =>
|
|
539
546
|
loader(
|
|
@@ -32,15 +32,36 @@ import { LoaderRedirect } from "./loader-redirect.js";
|
|
|
32
32
|
* Errors without any marker rethrow to the app's own boundaries.
|
|
33
33
|
*/
|
|
34
34
|
export class StreamedLoaderErrorBoundary extends Component<
|
|
35
|
-
{ children: ReactNode },
|
|
36
|
-
{ error: unknown }
|
|
35
|
+
{ children: ReactNode; resetKey?: string },
|
|
36
|
+
{ error: unknown; resetKey?: string }
|
|
37
37
|
> {
|
|
38
|
-
state: { error: unknown } = {
|
|
38
|
+
state: { error: unknown; resetKey?: string } = {
|
|
39
|
+
error: null,
|
|
40
|
+
resetKey: this.props.resetKey,
|
|
41
|
+
};
|
|
39
42
|
|
|
40
43
|
static getDerivedStateFromError(error: unknown): { error: unknown } {
|
|
41
44
|
return { error };
|
|
42
45
|
}
|
|
43
46
|
|
|
47
|
+
/**
|
|
48
|
+
* A caught marker (redirect, notFound, error fallback) belongs to ONE route
|
|
49
|
+
* + params. Group-keyed segments (ResolvedSegment.clientGroup) keep this
|
|
50
|
+
* instance alive across in-group navigations, so the error must clear when
|
|
51
|
+
* the route or params change — otherwise a redirect caught for /legacy
|
|
52
|
+
* keeps rendering LoaderRedirect for /state. `resetKey` is the segment's
|
|
53
|
+
* id-params identity, the cadence the per-route remount used to provide.
|
|
54
|
+
*/
|
|
55
|
+
static getDerivedStateFromProps(
|
|
56
|
+
props: { resetKey?: string },
|
|
57
|
+
state: { error: unknown; resetKey?: string },
|
|
58
|
+
): { error: unknown; resetKey?: string } | null {
|
|
59
|
+
if (props.resetKey !== state.resetKey) {
|
|
60
|
+
return { error: null, resetKey: props.resetKey };
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
44
65
|
render(): ReactNode {
|
|
45
66
|
const { error } = this.state;
|
|
46
67
|
if (error !== null && error !== undefined) {
|
|
@@ -907,6 +907,7 @@ export async function resolveEntryHandlerWithRevalidation<TEnv>(
|
|
|
907
907
|
),
|
|
908
908
|
params,
|
|
909
909
|
belongsToRoute,
|
|
910
|
+
...(entry.clientGroup ? { clientGroup: entry.clientGroup } : {}),
|
|
910
911
|
...(entry.type === "layout" || entry.type === "cache"
|
|
911
912
|
? { layoutName: entry.id }
|
|
912
913
|
: {}),
|
package/src/segment-system.tsx
CHANGED
|
@@ -350,6 +350,13 @@ export async function renderSegments(
|
|
|
350
350
|
`Expected layout, route, error, or notFound segment, got ${node.segment.type}`,
|
|
351
351
|
);
|
|
352
352
|
const { component, id, params, loading } = node.segment;
|
|
353
|
+
// clientUrls() group routes: ONE React key and ONE wrapper shape per group
|
|
354
|
+
// mount (no LoaderBoundary/RouteContentWrapper — ClientUrlsRoot renders
|
|
355
|
+
// loading() itself), so an in-group navigation reconciles the mounted
|
|
356
|
+
// ClientUrlsRoot: the optimistic destination keeps its instance across the
|
|
357
|
+
// commit and same-route param navs hold instead of remounting. The
|
|
358
|
+
// server-side loading value still drives PPR masking and SSR.
|
|
359
|
+
const clientGroup = node.segment.clientGroup;
|
|
353
360
|
const segNodeStart = segDebug ? performance.now() : 0;
|
|
354
361
|
|
|
355
362
|
// Param-agnostic keys are opt-in via the transition() DSL (see
|
|
@@ -385,7 +392,10 @@ export async function renderSegments(
|
|
|
385
392
|
.map(([k, v]) => `${k}=${v}`)
|
|
386
393
|
.join(",")
|
|
387
394
|
: "";
|
|
388
|
-
|
|
395
|
+
// Route identity the per-route remount used to provide; group routes key
|
|
396
|
+
// by the group instead and pass it to the error boundary as its resetKey.
|
|
397
|
+
const idParamsKey = paramStr ? `${id}-${paramStr}` : id;
|
|
398
|
+
const key = clientGroup ? `cg:${clientGroup}` : idParamsKey;
|
|
389
399
|
|
|
390
400
|
const loaderEntries = node.loaders.filter(
|
|
391
401
|
(loader) => loader.loaderId && loader.loaderData !== undefined,
|
|
@@ -403,7 +413,7 @@ export async function renderSegments(
|
|
|
403
413
|
}
|
|
404
414
|
|
|
405
415
|
let nodeContent: ReactNode = null;
|
|
406
|
-
if (isRenderableLoading(loading)) {
|
|
416
|
+
if (!clientGroup && isRenderableLoading(loading)) {
|
|
407
417
|
// forceAwait (popstate, stale-revalidation, fully-prefetched nav) renders a
|
|
408
418
|
// loading() route with the route content ALREADY resolved, so its
|
|
409
419
|
// RouteContentWrapper Suspender does not suspend for a microtask and flash
|
|
@@ -499,13 +509,14 @@ export async function renderSegments(
|
|
|
499
509
|
// escaping to app boundaries. Wrapped UNCONDITIONALLY on loader presence
|
|
500
510
|
// — streams and forceAwait lanes must produce the same tree shape or
|
|
501
511
|
// lane changes remount the subtree (docs/tree-structure.md).
|
|
502
|
-
if (loaderEntries.length > 0) {
|
|
512
|
+
if (loaderEntries.length > 0 || clientGroup) {
|
|
503
513
|
nodeContent = createElement(StreamedLoaderErrorBoundary, {
|
|
514
|
+
resetKey: idParamsKey,
|
|
504
515
|
children: nodeContent,
|
|
505
516
|
});
|
|
506
517
|
}
|
|
507
518
|
|
|
508
|
-
if (loading !== undefined && loading !== null) {
|
|
519
|
+
if (!clientGroup && loading !== undefined && loading !== null) {
|
|
509
520
|
const loaderDataPromise = getMemoizedLoaderPromise(loaderEntries);
|
|
510
521
|
let boundaryLoaderData: Promise<any[]> | any[] = loaderDataPromise;
|
|
511
522
|
// SPIKE (streaming useLoader): per-loader streams for the boundary's
|
package/src/server/context.ts
CHANGED
|
@@ -69,6 +69,8 @@ export type EntryPropCommon = {
|
|
|
69
69
|
cache?: EntryCacheConfig;
|
|
70
70
|
/** URL prefix from include() scope, used for MountContext on client */
|
|
71
71
|
mountPath?: string;
|
|
72
|
+
/** clientUrls() group key (PathOptions.clientGroup); route entries only. */
|
|
73
|
+
clientGroup?: string;
|
|
72
74
|
};
|
|
73
75
|
|
|
74
76
|
/**
|
|
@@ -24,14 +24,12 @@ import type { RequestScope } from "./request-scope.js";
|
|
|
24
24
|
* @example
|
|
25
25
|
* ```typescript
|
|
26
26
|
* const CartLoader = createLoader(async (ctx) => {
|
|
27
|
-
* "use server";
|
|
28
27
|
* const user = ctx.get("user"); // From auth middleware
|
|
29
28
|
* return await db.cart.get(user.id);
|
|
30
29
|
* });
|
|
31
30
|
*
|
|
32
31
|
* // With typed params:
|
|
33
32
|
* const ProductLoader = createLoader<Product, { slug: string }>(async (ctx) => {
|
|
34
|
-
* "use server";
|
|
35
33
|
* const { slug } = ctx.params; // slug is typed as string
|
|
36
34
|
* return await db.products.findBySlug(slug);
|
|
37
35
|
* });
|
|
@@ -81,7 +79,6 @@ export type LoaderContext<
|
|
|
81
79
|
* @example
|
|
82
80
|
* ```typescript
|
|
83
81
|
* export const ProductLoader = createLoader(async (ctx) => {
|
|
84
|
-
* "use server";
|
|
85
82
|
* const product = await getProduct(ctx.params.slug);
|
|
86
83
|
* ctx.use(Meta)({ title: product.name });
|
|
87
84
|
* ctx.use(Breadcrumbs)({ label: product.name });
|
|
@@ -111,7 +108,6 @@ export type LoaderContext<
|
|
|
111
108
|
* @example
|
|
112
109
|
* ```typescript
|
|
113
110
|
* const PricesLoader = createLoader(async (ctx) => {
|
|
114
|
-
* "use server";
|
|
115
111
|
* await ctx.rendered();
|
|
116
112
|
* const products = ctx.get(Products); // reads handle data
|
|
117
113
|
* return pricing.getLive(products.map(p => p.id));
|
|
@@ -234,7 +230,8 @@ export type LoadOptions =
|
|
|
234
230
|
* Loader definition object
|
|
235
231
|
*
|
|
236
232
|
* Created via createLoader(). Contains the loader name and function.
|
|
237
|
-
* On client builds, the fn is stripped by the
|
|
233
|
+
* On client builds, the fn is stripped by the router's loader transform; the
|
|
234
|
+
* definition must not carry a "use server" directive (the Vite plugin rejects it).
|
|
238
235
|
*
|
|
239
236
|
* @template T - The return type of the loader
|
|
240
237
|
* @template TParams - Route params type (for type-safe params access)
|
|
@@ -243,13 +240,11 @@ export type LoadOptions =
|
|
|
243
240
|
* ```typescript
|
|
244
241
|
* // Definition (same file works on server and client)
|
|
245
242
|
* export const CartLoader = createLoader(async (ctx) => {
|
|
246
|
-
* "use server";
|
|
247
243
|
* return await db.cart.get(ctx.get("user").id);
|
|
248
244
|
* });
|
|
249
245
|
*
|
|
250
246
|
* // With typed params:
|
|
251
247
|
* export const ProductLoader = createLoader<Product, { slug: string }>(async (ctx) => {
|
|
252
|
-
* "use server";
|
|
253
248
|
* const { slug } = ctx.params; // slug is typed as string
|
|
254
249
|
* return await db.products.findBySlug(slug);
|
|
255
250
|
* });
|
package/src/types/segments.ts
CHANGED
|
@@ -193,6 +193,9 @@ export interface ResolvedSegment {
|
|
|
193
193
|
notFoundInfo?: NotFoundInfo; // For notFound segments: the not found information
|
|
194
194
|
// Mount path from include() scope, used for MountContext.Provider wrapping
|
|
195
195
|
mountPath?: string;
|
|
196
|
+
/** clientUrls() group key (the include mount), shared by every route
|
|
197
|
+
* segment of one group; see the group-route branch in segment-system.tsx. */
|
|
198
|
+
clientGroup?: string;
|
|
196
199
|
/**
|
|
197
200
|
* @internal Server-side marker: true when the segment's handler actually ran
|
|
198
201
|
* this request (not skipped via the revalidate cache path). Used by
|
package/src/urls/path-helper.ts
CHANGED
|
@@ -154,6 +154,7 @@ export function createPathHelper<TEnv>(): PathFn<TEnv> {
|
|
|
154
154
|
handler: wrappedHandler,
|
|
155
155
|
pattern: prefixedPattern,
|
|
156
156
|
...(urlPrefix ? { mountPath: urlPrefix } : {}),
|
|
157
|
+
...(options?.clientGroup ? { clientGroup: options.clientGroup } : {}),
|
|
157
158
|
...(isPassthroughHandler(handler)
|
|
158
159
|
? {
|
|
159
160
|
isPrerender: true as const,
|
|
@@ -112,6 +112,9 @@ export interface PathOptions<
|
|
|
112
112
|
trailingSlash?: TrailingSlashMode;
|
|
113
113
|
/** Response type marker (set by path.json(), etc.) */
|
|
114
114
|
[RESPONSE_TYPE]?: string;
|
|
115
|
+
/** @internal clientUrls() group key stamped by server-projection.ts; see
|
|
116
|
+
* ResolvedSegment.clientGroup. */
|
|
117
|
+
clientGroup?: string;
|
|
115
118
|
}
|
|
116
119
|
|
|
117
120
|
/**
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type MagicString from "magic-string";
|
|
2
2
|
import { makeStubId } from "../expose-id-utils.js";
|
|
3
3
|
import type { CreateExportBinding } from "./types.js";
|
|
4
|
-
import {
|
|
4
|
+
import { hasDirective } from "@vitejs/plugin-rsc/transforms";
|
|
5
|
+
import { isExportOnlyFile, offsetToLineColumn } from "./export-analysis.js";
|
|
5
6
|
|
|
6
7
|
export function hasCreateLoaderImport(code: string): boolean {
|
|
7
8
|
return /import\s*\{[^}]*\bcreateLoader\b[^}]*\}\s*from\s*["']@rangojs\/router(?:\/server)?["']/.test(
|
|
@@ -62,3 +63,55 @@ export function transformLoaders(
|
|
|
62
63
|
|
|
63
64
|
return hasChanges;
|
|
64
65
|
}
|
|
66
|
+
|
|
67
|
+
export interface LoaderServerDirective {
|
|
68
|
+
line: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function loaderCallback(node: any, names: Set<string>): any {
|
|
72
|
+
if (
|
|
73
|
+
node?.type !== "CallExpression" ||
|
|
74
|
+
node.callee?.type !== "Identifier" ||
|
|
75
|
+
!names.has(node.callee.name)
|
|
76
|
+
) {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
const fn = node.arguments?.[0];
|
|
80
|
+
return (fn?.type === "ArrowFunctionExpression" ||
|
|
81
|
+
fn?.type === "FunctionExpression") &&
|
|
82
|
+
fn.body?.type === "BlockStatement"
|
|
83
|
+
? fn
|
|
84
|
+
: undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Locate an inline `"use server"` directive at the top of a createLoader()
|
|
89
|
+
* callback body. Only top-level `const X = createLoader(...)` bindings (bare
|
|
90
|
+
* or exported) are checked — the same shapes the loader transform registers.
|
|
91
|
+
* Loaders are never client-callable, and the directive makes plugin-rsc
|
|
92
|
+
* hoist the body and `registerServerReference` it, so the loader body
|
|
93
|
+
* becomes a public action reachable with caller-supplied arguments (verified
|
|
94
|
+
* against a production build: a POST to `?_rsc_action=<id>` ran a loader body
|
|
95
|
+
* with a forged ctx). The guard plugin throws on the first hit.
|
|
96
|
+
*/
|
|
97
|
+
export function findLoaderServerDirective(
|
|
98
|
+
code: string,
|
|
99
|
+
fnNames: readonly string[],
|
|
100
|
+
program: any,
|
|
101
|
+
): LoaderServerDirective | null {
|
|
102
|
+
const names = new Set(fnNames);
|
|
103
|
+
for (const statement of program.body ?? []) {
|
|
104
|
+
const declaration =
|
|
105
|
+
statement.type === "ExportNamedDeclaration"
|
|
106
|
+
? statement.declaration
|
|
107
|
+
: statement;
|
|
108
|
+
if (declaration?.type !== "VariableDeclaration") continue;
|
|
109
|
+
for (const declarator of declaration.declarations) {
|
|
110
|
+
const fn = loaderCallback(declarator.init, names);
|
|
111
|
+
if (fn && hasDirective(fn.body.body, "use server")) {
|
|
112
|
+
return { line: offsetToLineColumn(code, fn.body.body[0].start).line };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
hasCreateLoaderImport,
|
|
36
36
|
generateClientLoaderStubs,
|
|
37
37
|
transformLoaders,
|
|
38
|
+
findLoaderServerDirective,
|
|
38
39
|
} from "./expose-ids/loader-transform.js";
|
|
39
40
|
import {
|
|
40
41
|
transformHandles,
|
|
@@ -114,6 +115,51 @@ export function createLoaderScanStubPlugin(): Plugin {
|
|
|
114
115
|
};
|
|
115
116
|
}
|
|
116
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Refuse an inline `"use server"` directive inside a createLoader() callback.
|
|
120
|
+
* Runs `pre` so it sees the raw source before plugin-rsc's directive transform
|
|
121
|
+
* hoists the body into a registered server reference. See
|
|
122
|
+
* findLoaderServerDirective for why that registration is a problem.
|
|
123
|
+
*/
|
|
124
|
+
export function createLoaderDirectiveGuardPlugin(): Plugin {
|
|
125
|
+
return {
|
|
126
|
+
name: "@rangojs/router:loader-directive-guard",
|
|
127
|
+
enforce: "pre",
|
|
128
|
+
transform(code, id) {
|
|
129
|
+
if (
|
|
130
|
+
id.includes("/node_modules/") ||
|
|
131
|
+
!code.includes("use server") ||
|
|
132
|
+
!code.includes("createLoader") ||
|
|
133
|
+
!hasCreateLoaderImport(code)
|
|
134
|
+
) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const cleanId = id.split("?", 1)[0];
|
|
139
|
+
let program: any;
|
|
140
|
+
try {
|
|
141
|
+
program = parseAst(code, { lang: "tsx" });
|
|
142
|
+
} catch {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const hit = findLoaderServerDirective(
|
|
147
|
+
code,
|
|
148
|
+
getImportedFnNames(code, "createLoader"),
|
|
149
|
+
program,
|
|
150
|
+
);
|
|
151
|
+
if (!hit) return;
|
|
152
|
+
|
|
153
|
+
throw new Error(
|
|
154
|
+
`[rango] createLoader() body at ${cleanId}:${hit.line} carries a "use server" directive. ` +
|
|
155
|
+
`Loader bodies already run only on the server; the directive would register the body as a ` +
|
|
156
|
+
`client-callable server reference (reachable through the action endpoint with caller-supplied ` +
|
|
157
|
+
`arguments). Remove the directive.`,
|
|
158
|
+
);
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
117
163
|
export function exposeInternalIds(options?: { forceBuild?: boolean }): Plugin {
|
|
118
164
|
let config: ResolvedConfig;
|
|
119
165
|
let isBuild = false;
|
package/src/vite/rango.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { resolve } from "node:path";
|
|
|
4
4
|
import { exposeActionId } from "./plugins/expose-action-id.js";
|
|
5
5
|
import { defineEncryptionKeyExpr } from "./encryption-key.js";
|
|
6
6
|
import {
|
|
7
|
+
createLoaderDirectiveGuardPlugin,
|
|
7
8
|
createLoaderScanStubPlugin,
|
|
8
9
|
exposeInternalIds,
|
|
9
10
|
exposeRouterId,
|
|
@@ -561,6 +562,7 @@ export async function rango(options?: RangoOptions): Promise<PluginOption[]> {
|
|
|
561
562
|
},
|
|
562
563
|
});
|
|
563
564
|
|
|
565
|
+
plugins.push(createLoaderDirectiveGuardPlugin());
|
|
564
566
|
plugins.push(createLoaderScanStubPlugin());
|
|
565
567
|
plugins.push(exposeActionId());
|
|
566
568
|
plugins.push(useCacheTransform());
|