@rangojs/router 0.14.0 → 0.15.1

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 CHANGED
@@ -207,7 +207,6 @@ stock lives in the database:
207
207
  import { createLoader } from "@rangojs/router";
208
208
 
209
209
  export const StockLoader = createLoader(async (ctx) => {
210
- "use server";
211
210
  return db.stockFor(ctx.params.slug);
212
211
  });
213
212
  ```
@@ -14,7 +14,10 @@ import type { ReactNode } from "react";
14
14
  * decodeLoaderResults runs during the SERVER tree build on forceAwait/action
15
15
  * lanes, where a throw would collapse the whole payload — it routes signals
16
16
  * through the errorFallback slot instead (same visual: the slot replaces the
17
- * children under OutletProvider).
17
+ * children under OutletProvider). The document lane's `{ ssr: false }`
18
+ * results take the same non-throwing route (segment-system
19
+ * buildLoaderStreams): they are settled before the tree is built, and the
20
+ * read-site throw would land in the Fizz shell with no boundary to absorb it.
18
21
  */
19
22
  export declare const LOADER_NOT_FOUND_FALLBACK: unique symbol;
20
23
  export declare const LOADER_REDIRECT: unique symbol;
@@ -78,7 +78,6 @@ declare const REFUSED_CAPTURE_MAX_MS = 60000;
78
78
  * docs/design/ppr-shell-resume.md ("Refused-capture backoff").
79
79
  */
80
80
  declare const REFUSED_CAPTURE_DEV_MAX_MS = 2000;
81
- /** True iff `key` is still inside its (exponential) backoff window. */
82
81
  declare function isCaptureBackedOff(key: string): boolean;
83
82
  /**
84
83
  * Record a refused/failed capture, escalating the backoff window exponentially up
@@ -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 bundler (via "use server" directive).
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
  * });
@@ -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;
@@ -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 (hasDirective(ast.body, "use cache")) {
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
- hasDirective
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, hasDirective) {
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, hasDirective)) {
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, hasDirective) {
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 hasDirective(
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.14.0",
3798
+ version: "0.15.1",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.14.0",
3
+ "version": "0.15.1",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -67,7 +67,6 @@ consumption, `clientUrls()` groups) still build trails:
67
67
 
68
68
  ```typescript
69
69
  export const ProductLoader = createLoader(async (ctx) => {
70
- "use server";
71
70
  const product = await getProduct(ctx.params.slug);
72
71
 
73
72
  const pushCrumb = ctx.use(Breadcrumbs);
@@ -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** whose bodies are
58
- `"use server"` — they execute on the server, addressed by id; the client
59
- module only imports the definition object.
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
 
@@ -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}"`);
@@ -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.)
@@ -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
 
@@ -693,6 +695,15 @@ Semantics by lane:
693
695
  | `notFound()` | Not-found UI resolves server-side (nearest `notFoundBoundary` → router option → default) and rides the envelope; the 404 STATUS is **opportunistic** — real only if the rejection beats Response construction. `ssr: false` (below) makes it deterministic. | 404 UI swaps in, URL preserved, payload stays 200 |
694
696
  | `redirect()` | 200 document, then a client-side replace to the target — **no document-lane 302 from loaders**; pre-stream redirect authority belongs to middleware | Redirect envelope navigates to the target |
695
697
 
698
+ With `loader(Def, { ssr: false })` the result is settled BEFORE the document
699
+ flushes, so both signals are resolved server-side while the tree is built:
700
+ `redirect()` replaces the whole page with the redirect carrier (still a 200
701
+ document, the client replaces to the target on hydration) and `notFound()`
702
+ renders the not-found UI at the owning segment with a real 404. No read site
703
+ runs, so a `useLoader` in a layout above every Suspense boundary is safe.
704
+ (Before this was fixed, the flagged read threw inside the Fizz shell and
705
+ 500ed the document.)
706
+
696
707
  Session/auth gates belong in middleware (they are request-shaped, not
697
708
  data-shaped, and middleware CAN emit a real pre-stream 302). Data-dependent
698
709
  "this slug moved / does not exist" belongs in the loader.
@@ -708,7 +719,6 @@ where the data lives:
708
719
  import { Meta, Breadcrumbs } from "./handles";
709
720
 
710
721
  export const ProductLoader = createLoader(async (ctx) => {
711
- "use server";
712
722
  const product = await getProduct(ctx.params.slug);
713
723
 
714
724
  ctx.use(Meta)({ title: `${product.name} — Shop` });
@@ -799,8 +809,6 @@ via `useFetchLoader()` and `load()`:
799
809
  import { createLoader } from "@rangojs/router";
800
810
 
801
811
  export const SearchLoader = createLoader(async (ctx) => {
802
- "use server";
803
-
804
812
  const query = ctx.params.query ?? "";
805
813
  const results = await ctx.env.DB.prepare(
806
814
  "SELECT * FROM products WHERE name LIKE ?",
@@ -812,6 +820,13 @@ export const SearchLoader = createLoader(async (ctx) => {
812
820
  }, true); // true = fetchable
813
821
  ```
814
822
 
823
+ `true` attaches an EMPTY middleware list, and the fetch lane never runs route
824
+ `middleware()` (see `/middleware` → "Request flow"). A fetchable loader that
825
+ reads a var set by route middleware (`ctx.get(CurrentUser)`) therefore sees
826
+ `undefined` on `useFetchLoader()` / `load()` / `useRefreshLoaders()` unless it
827
+ passes that middleware itself: `createLoader(fn, { middleware: [loadSession,
828
+ requireAuth] })` (next section).
829
+
815
830
  > **No registration needed — and no worker-entry import.** A fetchable loader
816
831
  > does not have to be registered with `loader()` in the route DSL, and it does
817
832
  > not have to be imported by any server module. Importing it into the client
@@ -835,8 +850,6 @@ import { rateLimitMiddleware } from "../middleware/rate-limit";
835
850
 
836
851
  export const ProtectedLoader = createLoader(
837
852
  async (ctx) => {
838
- "use server";
839
-
840
853
  const user = ctx.get("user");
841
854
  return { orders: await db.orders.list(user.id) };
842
855
  },
@@ -847,6 +860,12 @@ export const ProtectedLoader = createLoader(
847
860
  The middleware uses the same `MiddlewareFn` signature as route/app middleware,
848
861
  so you can reuse existing middleware functions directly.
849
862
 
863
+ A `redirect()` thrown by per-loader middleware becomes a real 3xx on the fetch
864
+ lane, so the browser's `fetch` follows it and `useFetchLoader()` receives the
865
+ login page's HTML instead of loader data. Reserve redirects for document and
866
+ navigation requests; on the fetch lane return a 401 (or a structured error
867
+ payload) and let the caller decide.
868
+
850
869
  Fetchable loaders support both GET and POST (PUT, PATCH, DELETE) from the client.
851
870
  The `load()` function auto-detects the body type:
852
871
 
@@ -860,8 +879,6 @@ includes additional fields depending on the body type:
860
879
 
861
880
  ```typescript
862
881
  export const MutationLoader = createLoader(async (ctx) => {
863
- "use server";
864
-
865
882
  // JSON body — available as ctx.body (parsed object)
866
883
  const data = ctx.body as { name: string; email: string };
867
884
 
@@ -883,8 +900,6 @@ export const MutationLoader = createLoader(async (ctx) => {
883
900
  import { createLoader } from "@rangojs/router";
884
901
 
885
902
  export const FileUploadLoader = createLoader(async (ctx) => {
886
- "use server";
887
-
888
903
  const file = ctx.formData?.get("file") as File | null;
889
904
  if (file && file.size > 0) {
890
905
  // Save to R2, D1, etc.
@@ -911,8 +926,6 @@ Client usage — see `/hooks useFetchLoader` for the full client-side pattern.
911
926
  import { createLoader } from "@rangojs/router";
912
927
 
913
928
  export const ProductLoader = createLoader(async (ctx) => {
914
- "use server";
915
-
916
929
  const product = await ctx.env.DB
917
930
  .prepare("SELECT * FROM products WHERE slug = ?")
918
931
  .bind(ctx.params.slug)
@@ -926,8 +939,6 @@ export const ProductLoader = createLoader(async (ctx) => {
926
939
  });
927
940
 
928
941
  export const CartLoader = createLoader(async (ctx) => {
929
- "use server";
930
-
931
942
  const user = ctx.get("user");
932
943
  if (!user) return { cart: null };
933
944
 
@@ -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
@@ -18,7 +18,10 @@ import { LoaderRedirect } from "./loader-redirect.js";
18
18
  * decodeLoaderResults runs during the SERVER tree build on forceAwait/action
19
19
  * lanes, where a throw would collapse the whole payload — it routes signals
20
20
  * through the errorFallback slot instead (same visual: the slot replaces the
21
- * children under OutletProvider).
21
+ * children under OutletProvider). The document lane's `{ ssr: false }`
22
+ * results take the same non-throwing route (segment-system
23
+ * buildLoaderStreams): they are settled before the tree is built, and the
24
+ * read-site throw would land in the Fizz shell with no boundary to absorb it.
22
25
  */
23
26
  export const LOADER_NOT_FOUND_FALLBACK: unique symbol = Symbol(
24
27
  "rango.loaderNotFound",
@@ -17,6 +17,7 @@
17
17
  */
18
18
 
19
19
  import React from "react";
20
+ import { isLoaderDataResult } from "../types.js";
20
21
  import { bufferToBase64 } from "../cache/cf/cf-base64.js";
21
22
  import { reportCacheError } from "../cache/cache-error.js";
22
23
  import { runBackground } from "../cache/background-task.js";
@@ -311,6 +312,14 @@ function refusedCaptureCeilingMs(): number {
311
312
  const refusedCaptures = new Map<string, { failures: number; until: number }>();
312
313
 
313
314
  /** True iff `key` is still inside its (exponential) backoff window. */
315
+ function isSettledLoaderSignal(value: unknown): boolean {
316
+ return (
317
+ isLoaderDataResult(value) &&
318
+ !value.ok &&
319
+ (value.redirect !== undefined || value.notFound === true)
320
+ );
321
+ }
322
+
314
323
  function isCaptureBackedOff(key: string): boolean {
315
324
  const entry = refusedCaptures.get(key);
316
325
  if (entry === undefined) return false;
@@ -1936,6 +1945,19 @@ async function captureAndStoreShell(
1936
1945
  // The container itself never settled: it is a hole (under an ancestor
1937
1946
  // boundary) or the trivial-prelude gate already fired. Omit — no pin.
1938
1947
  if (isLoaderHoleMarker(elided.value)) continue;
1948
+ // redirect()/notFound() RESOLVE as ok:false envelopes
1949
+ // (wrapLoaderWithErrorHandling), so the rejection check above never
1950
+ // sees them; the tree builder resolves them to LoaderRedirect / the
1951
+ // not-found UI (segment-system buildLoaderStreams), which must not
1952
+ // bake into a shell every visitor shares.
1953
+ if (isSettledLoaderSignal(elided.value)) {
1954
+ warnCaptureRefusedOnce(
1955
+ capture.key,
1956
+ `the loader for segment "${segmentKey}" settled with redirect()/notFound() during capture; a request-specific signal must not bake into the shared shell. ` +
1957
+ "Give its entry a loading() boundary so it stays on the live lane, or move the decision into middleware.",
1958
+ );
1959
+ return "refused";
1960
+ }
1939
1961
  // Past the hole check: this container settled with real material that
1940
1962
  // bakes into the shell prelude (independent of the snapshot pin below).
1941
1963
  bakedLoaderMaterial = true;
@@ -1,10 +1,16 @@
1
1
  import * as React from "react";
2
- import { createElement, type ReactNode, type ComponentType } from "react";
2
+ import {
3
+ createElement,
4
+ type ReactNode,
5
+ type ComponentType,
6
+ isValidElement,
7
+ } from "react";
3
8
  import { OutletProvider } from "./outlet-provider.js";
4
9
  import { withOptimisticCommitNone } from "./browser/optimistic-commit.js";
5
10
  import { MountContextProvider } from "./browser/react/mount-context.js";
6
11
  import type { ResolvedSegment, RootLayoutProps } from "./types.js";
7
12
  import { decodeLoaderResults } from "./decode-loader-results.js";
13
+ import { LoaderRedirect } from "./loader-redirect.js";
8
14
  import { invariant } from "./errors.js";
9
15
  import {
10
16
  RouteContentWrapper,
@@ -203,20 +209,43 @@ function wrapDefaultOutletContent(
203
209
  * SSR-completeness contract must not depend on. Unflagged siblings keep the
204
210
  * promise (deliberate streaming). Flagged ids are collected as input for the
205
211
  * dev SSR-suspension diagnostic (ssr-suspension-warning.ts).
212
+ *
213
+ * The settled results also go through decodeLoaderResults here: a settled
214
+ * redirect()/notFound() (or an error with an errorBoundary() fallback) must
215
+ * be resolved to its node while the tree is built, exactly as the
216
+ * forceAwait/action lanes do, because the read-site throw has no boundary on
217
+ * the document lane — flagged content renders without Suspense and a layout
218
+ * reader sits above every boundary, so the throw is a Fizz shell error (500).
219
+ * `settledFallback` is that node; the caller plants it and, for a redirect,
220
+ * replaces the whole page (an ancestor reading the loader would still throw).
206
221
  */
207
222
  async function buildLoaderStreams(loaders: ResolvedSegment[]): Promise<{
208
223
  streams: Record<string, unknown>;
209
224
  awaitedIds: string[] | undefined;
225
+ settledFallback: ReactNode | undefined;
210
226
  }> {
211
227
  const streams: Record<string, unknown> = {};
212
228
  let awaitedIds: string[] | undefined;
229
+ let awaitedValues: unknown[] | undefined;
213
230
  for (const l of loaders) {
214
- streams[l.loaderId!] = l.awaitBeforeFlush
215
- ? await l.loaderData
216
- : l.loaderData;
217
- if (l.awaitBeforeFlush) (awaitedIds ??= []).push(l.loaderId!);
231
+ if (l.awaitBeforeFlush) {
232
+ const value = await l.loaderData;
233
+ streams[l.loaderId!] = value;
234
+ (awaitedIds ??= []).push(l.loaderId!);
235
+ (awaitedValues ??= []).push(value);
236
+ } else {
237
+ streams[l.loaderId!] = l.loaderData;
238
+ }
218
239
  }
219
- return { streams, awaitedIds };
240
+ const settledFallback = awaitedIds
241
+ ? (decodeLoaderResults(awaitedValues!, awaitedIds).errorFallback ??
242
+ undefined)
243
+ : undefined;
244
+ return { streams, awaitedIds, settledFallback };
245
+ }
246
+
247
+ function isLoaderRedirectNode(node: ReactNode): boolean {
248
+ return isValidElement(node) && node.type === LoaderRedirect;
220
249
  }
221
250
 
222
251
  /**
@@ -539,10 +568,19 @@ export async function renderSegments(
539
568
  });
540
569
  }
541
570
  } else if (loaderEntries.length > 0) {
571
+ let settledFallback: ReactNode | undefined;
542
572
  ({
543
573
  streams: boundaryLoaderStreams,
544
574
  awaitedIds: boundaryAwaitedLoaderIds,
575
+ settledFallback,
545
576
  } = await buildLoaderStreams(loaderEntries));
577
+ if (settledFallback !== undefined) {
578
+ if (isLoaderRedirectNode(settledFallback)) {
579
+ content = settledFallback;
580
+ break;
581
+ }
582
+ nodeContent = settledFallback;
583
+ }
546
584
  if (segDebug) {
547
585
  segDebugLog(
548
586
  `segment ${id}: per-loader streams via LoaderBoundary (read-site suspense)`,
@@ -614,8 +652,19 @@ export async function renderSegments(
614
652
  }
615
653
  }
616
654
  } else if (layoutLoaders.length > 0) {
617
- ({ streams: loaderStreams, awaitedIds: awaitedLoaderIds } =
618
- await buildLoaderStreams(layoutLoaders));
655
+ let settledFallback: ReactNode | undefined;
656
+ ({
657
+ streams: loaderStreams,
658
+ awaitedIds: awaitedLoaderIds,
659
+ settledFallback,
660
+ } = await buildLoaderStreams(layoutLoaders));
661
+ if (settledFallback !== undefined) {
662
+ if (isLoaderRedirectNode(settledFallback)) {
663
+ content = settledFallback;
664
+ break;
665
+ }
666
+ errorFallback = settledFallback;
667
+ }
619
668
  if (segDebug) {
620
669
  segDebugLog(
621
670
  `segment ${id}: layout loaders streaming to read sites (no loading())`,
@@ -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 bundler (via "use server" directive).
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
  * });
@@ -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 { isExportOnlyFile } from "./export-analysis.js";
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());