@rangojs/router 0.0.0-experimental.133 → 0.0.0-experimental.135

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/dist/bin/rango.js +7 -2
  2. package/dist/vite/index.js +41 -27
  3. package/package.json +23 -24
  4. package/skills/composability/SKILL.md +0 -1
  5. package/skills/handler-use/SKILL.md +7 -7
  6. package/skills/intercept/SKILL.md +38 -13
  7. package/skills/loader/SKILL.md +10 -0
  8. package/skills/migrate-nextjs/SKILL.md +3 -3
  9. package/skills/migrate-react-router/SKILL.md +144 -1
  10. package/skills/prerender/SKILL.md +20 -17
  11. package/skills/router-setup/SKILL.md +1 -2
  12. package/skills/testing/SKILL.md +1 -0
  13. package/skills/testing/render-handler.md +15 -14
  14. package/skills/use-cache/SKILL.md +11 -0
  15. package/skills/view-transitions/SKILL.md +43 -0
  16. package/src/browser/navigation-bridge.ts +65 -16
  17. package/src/browser/navigation-client.ts +27 -1
  18. package/src/browser/navigation-store.ts +82 -8
  19. package/src/browser/network-error-handler.ts +34 -7
  20. package/src/browser/partial-update.ts +43 -3
  21. package/src/browser/prefetch/cache.ts +8 -0
  22. package/src/browser/prefetch/fetch.ts +32 -4
  23. package/src/browser/react/NavigationProvider.tsx +195 -4
  24. package/src/browser/react/deferred-handle-resolution.ts +75 -0
  25. package/src/browser/response-adapter.ts +38 -9
  26. package/src/browser/types.ts +32 -1
  27. package/src/cache/cache-runtime.ts +26 -5
  28. package/src/cache/document-cache.ts +17 -1
  29. package/src/cache/profile-registry.ts +15 -0
  30. package/src/cache/read-through-swr.ts +15 -1
  31. package/src/handles/MetaTags.tsx +6 -0
  32. package/src/index.rsc.ts +6 -1
  33. package/src/index.ts +6 -4
  34. package/src/internal-debug.ts +11 -8
  35. package/src/render-error-thrower.tsx +20 -0
  36. package/src/route-content-wrapper.tsx +12 -5
  37. package/src/route-definition/dsl-helpers.ts +21 -32
  38. package/src/route-definition/helper-factories.ts +0 -2
  39. package/src/route-definition/helpers-types.ts +38 -39
  40. package/src/route-definition/index.ts +1 -2
  41. package/src/route-definition/resolve-handler-use.ts +0 -1
  42. package/src/route-definition/use-item-types.ts +3 -6
  43. package/src/route-types.ts +0 -5
  44. package/src/router/match-api.ts +5 -1
  45. package/src/router/match-middleware/background-revalidation.ts +40 -23
  46. package/src/router/match-middleware/cache-store.ts +39 -24
  47. package/src/router/segment-resolution/fresh.ts +4 -0
  48. package/src/router/segment-resolution/loader-cache.ts +14 -2
  49. package/src/router/segment-resolution/revalidation.ts +3 -0
  50. package/src/router/segment-resolution/view-transition-default.ts +35 -15
  51. package/src/rsc/progressive-enhancement.ts +56 -2
  52. package/src/rsc/rsc-rendering.ts +7 -2
  53. package/src/rsc/server-action.ts +25 -2
  54. package/src/rsc/transition-gate.ts +89 -0
  55. package/src/segment-system.tsx +59 -8
  56. package/src/server/context.ts +13 -0
  57. package/src/server/loader-registry.ts +13 -1
  58. package/src/server/request-context.ts +52 -3
  59. package/src/testing/index.ts +6 -0
  60. package/src/testing/render-handler.ts +14 -0
  61. package/src/testing/run-transition-when.ts +164 -0
  62. package/src/types/handler-context.ts +1 -1
  63. package/src/types/index.ts +2 -0
  64. package/src/types/segments.ts +100 -0
  65. package/src/urls/path-helper-types.ts +10 -7
  66. package/src/urls/urls-function.ts +0 -1
  67. package/src/vite/inject-client-debug.ts +36 -0
  68. package/src/vite/plugins/version-injector.ts +22 -7
  69. package/src/vite/plugins/virtual-entries.ts +28 -9
  70. package/src/vite/router-discovery.ts +8 -13
  71. package/src/network-error-thrower.tsx +0 -18
package/dist/bin/rango.js CHANGED
@@ -1,8 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  var __defProp = Object.defineProperty;
3
3
  var __getOwnPropNames = Object.getOwnPropertyNames;
4
- var __esm = (fn, res) => function __init() {
5
- return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
4
+ var __esm = (fn, res, err) => function __init() {
5
+ if (err) throw err[0];
6
+ try {
7
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
8
+ } catch (e) {
9
+ throw err = [e], e;
10
+ }
6
11
  };
7
12
  var __export = (target, all) => {
8
13
  for (var name in all)
@@ -2224,7 +2224,14 @@ export const renderHTML = createSSRHandler({
2224
2224
  import.meta.viteRsc.loadBootstrapScriptContent("index"),
2225
2225
  });
2226
2226
  `.trim();
2227
+ var RSC_ENTRY_BOOTSTRAP_IMPORTS = [
2228
+ "virtual:rsc-router/routes-manifest",
2229
+ "virtual:rsc-router/loader-manifest"
2230
+ ];
2227
2231
  function getVirtualEntryRSC(routerPath) {
2232
+ const bootstrapImports = RSC_ENTRY_BOOTSTRAP_IMPORTS.map(
2233
+ (id) => `import "${id}";`
2234
+ ).join("\n");
2228
2235
  return `
2229
2236
  import {
2230
2237
  renderToReadableStream,
@@ -2238,15 +2245,9 @@ import { router } from "${routerPath}";
2238
2245
  import { createRSCHandler } from "@rangojs/router/internal/rsc-handler";
2239
2246
  import { VERSION } from "@rangojs/router:version";
2240
2247
 
2241
- // Import loader manifest to ensure all fetchable loaders are registered at startup
2242
- // This is critical for serverless/multi-process deployments where the loader module
2243
- // might not be imported before a GET request arrives
2244
- import "virtual:rsc-router/loader-manifest";
2245
-
2246
- // Import pre-generated route manifest so href() works immediately on cold start.
2247
- // In build mode, this contains the full route map generated at build time.
2248
- // In dev mode, this is a no-op (manifest is populated in-memory by the discovery plugin).
2249
- import "virtual:rsc-router/routes-manifest";
2248
+ // Startup bootstrap imports (routes + loader manifests). See
2249
+ // RSC_ENTRY_BOOTSTRAP_IMPORTS \u2014 the same list the custom-entry injector uses.
2250
+ ${bootstrapImports}
2250
2251
 
2251
2252
  // Lazily create the handler on first request so that ESM live bindings
2252
2253
  // have resolved by the time we read \`router\`. During HMR the module may
@@ -2296,7 +2297,7 @@ import { resolve } from "node:path";
2296
2297
  // package.json
2297
2298
  var package_default = {
2298
2299
  name: "@rangojs/router",
2299
- version: "0.0.0-experimental.133",
2300
+ version: "0.0.0-experimental.135",
2300
2301
  description: "Django-inspired RSC router with composable URL patterns",
2301
2302
  keywords: [
2302
2303
  "react",
@@ -2471,7 +2472,7 @@ var package_default = {
2471
2472
  },
2472
2473
  dependencies: {
2473
2474
  "@types/debug": "^4.1.12",
2474
- "@vitejs/plugin-rsc": "^0.5.26",
2475
+ "@vitejs/plugin-rsc": "^0.5.27",
2475
2476
  debug: "^4.4.1",
2476
2477
  "magic-string": "^0.30.17",
2477
2478
  picomatch: "^4.0.4",
@@ -2486,22 +2487,22 @@ var package_default = {
2486
2487
  "@types/node": "^24.10.1",
2487
2488
  "@types/react": "catalog:",
2488
2489
  "@types/react-dom": "catalog:",
2489
- esbuild: "^0.27.0",
2490
+ esbuild: "^0.28.1",
2490
2491
  "happy-dom": "^20.10.1",
2491
2492
  jiti: "^2.6.1",
2492
2493
  react: "catalog:",
2493
2494
  "react-dom": "catalog:",
2494
2495
  typescript: "^5.3.0",
2495
- vitest: "^4.0.0"
2496
+ vitest: "^4.1.9"
2496
2497
  },
2497
2498
  peerDependencies: {
2498
- "@cloudflare/vite-plugin": "^1.38.0",
2499
+ "@cloudflare/vite-plugin": "^1.42.1",
2499
2500
  "@playwright/test": "^1.49.1",
2500
2501
  "@testing-library/react": ">=16",
2501
- "@vitejs/plugin-rsc": "^0.5.26",
2502
+ "@vitejs/plugin-rsc": "^0.5.27",
2502
2503
  react: ">=19.2.6 <20",
2503
2504
  "react-dom": ">=19.2.6 <20",
2504
- vite: "^8.0.0",
2505
+ vite: "^8.0.16",
2505
2506
  vitest: ">=3"
2506
2507
  },
2507
2508
  peerDependenciesMeta: {
@@ -3927,9 +3928,9 @@ function createVersionInjectorPlugin(rscEntryPath) {
3927
3928
  if (normalizedId !== normalizedEntry) {
3928
3929
  return null;
3929
3930
  }
3930
- const prepend = [
3931
- `import "virtual:rsc-router/routes-manifest";`
3932
- ];
3931
+ const prepend = RSC_ENTRY_BOOTSTRAP_IMPORTS.map(
3932
+ (id2) => `import "${id2}";`
3933
+ );
3933
3934
  let newCode = code;
3934
3935
  const needsVersion = code.includes("createRSCHandler") && !code.includes("@rangojs/router:version") && /createRSCHandler\s*\(\s*\{/.test(code);
3935
3936
  if (needsVersion) {
@@ -4031,6 +4032,19 @@ import { readFileSync as readFileSync6 } from "node:fs";
4031
4032
  import { createRequire as createRequire2, register } from "node:module";
4032
4033
  import { pathToFileURL } from "node:url";
4033
4034
 
4035
+ // src/vite/inject-client-debug.ts
4036
+ function injectClientDebugFlag(id) {
4037
+ if (!id.includes("internal-debug")) return null;
4038
+ const norm = id.replace(/\\/g, "/");
4039
+ const isInternalDebug = /\/internal-debug\.[cm]?[jt]sx?(\?|$)/.test(norm) && (norm.includes("/@rangojs/router/") || norm.includes("/packages/rangojs-router/"));
4040
+ if (!isInternalDebug) return null;
4041
+ return {
4042
+ code: `export const INTERNAL_RANGO_DEBUG = ${!!process.env.INTERNAL_RANGO_DEBUG};
4043
+ `,
4044
+ map: null
4045
+ };
4046
+ }
4047
+
4034
4048
  // src/vite/plugins/virtual-stub-plugin.ts
4035
4049
  function createVirtualStubPlugin() {
4036
4050
  const STUB_PREFIXES = [
@@ -4318,7 +4332,7 @@ function checkSelfGenWrite(state, filePath, consume) {
4318
4332
  import { AsyncLocalStorage } from "node:async_hooks";
4319
4333
 
4320
4334
  // src/internal-debug.ts
4321
- var INTERNAL_RANGO_DEBUG = typeof __RANGO_DEBUG__ !== "undefined" ? __RANGO_DEBUG__ : typeof process !== "undefined" && Boolean(process.env?.INTERNAL_RANGO_DEBUG);
4335
+ var INTERNAL_RANGO_DEBUG = typeof process !== "undefined" && Boolean(process.env?.INTERNAL_RANGO_DEBUG);
4322
4336
 
4323
4337
  // src/router/logging.ts
4324
4338
  var routerLogContext = new AsyncLocalStorage();
@@ -6102,13 +6116,13 @@ function createRouterDiscoveryPlugin(entryPath, opts) {
6102
6116
  let viteMode = "production";
6103
6117
  return {
6104
6118
  name: "@rangojs/router:discovery",
6105
- config() {
6106
- const config = {
6107
- define: {
6108
- __RANGO_DEBUG__: JSON.stringify(!!process.env.INTERNAL_RANGO_DEBUG)
6109
- }
6110
- };
6111
- return config;
6119
+ // Make INTERNAL_RANGO_DEBUG reach the CLIENT debug logs by just setting the
6120
+ // env var. See injectClientDebugFlag: bakes the resolved flag into the
6121
+ // internal-debug module so FE debug no longer depends on Vite delivering the
6122
+ // `__RANGO_DEBUG__` define to the client (which it does only as an injected
6123
+ // global whose presence varies across consumer setups). Runs in dev and build.
6124
+ transform(_code, id) {
6125
+ return injectClientDebugFlag(id);
6112
6126
  },
6113
6127
  configResolved(config) {
6114
6128
  s.projectRoot = config.root;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.0.0-experimental.133",
3
+ "version": "0.0.0-experimental.135",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -162,20 +162,9 @@
162
162
  "access": "public",
163
163
  "tag": "experimental"
164
164
  },
165
- "scripts": {
166
- "build": "pnpm exec esbuild src/vite/index.ts --bundle --format=esm --outfile=dist/vite/index.js --platform=node --packages=external && mkdir -p dist/vite/plugins && cp src/vite/plugins/cloudflare-protocol-loader-hook.mjs dist/vite/plugins/cloudflare-protocol-loader-hook.mjs && pnpm exec esbuild src/testing/vitest.ts --bundle --format=esm --outfile=dist/testing/vitest.js --platform=node --packages=external && pnpm exec esbuild src/bin/rango.ts --bundle --format=esm --outfile=dist/bin/rango.js --platform=node --packages=external --banner:js='#!/usr/bin/env node' && chmod +x dist/bin/rango.js",
167
- "prepublishOnly": "pnpm build",
168
- "typecheck": "tsc --noEmit && tsc -p tsconfig.strict-check.json --noEmit && tsc -p tsconfig.augment-check.json --noEmit",
169
- "test": "playwright test",
170
- "test:ui": "playwright test --ui",
171
- "test:hmr-local": "playwright test --project=dev-warmup --project=hmr-routes --project=hmr-basename --project=hmr-prerender --no-deps --workers=1",
172
- "test:unit": "vitest run",
173
- "test:unit:watch": "vitest",
174
- "test:unit:rsc": "vitest run --config vitest.rsc.config.ts"
175
- },
176
165
  "dependencies": {
177
166
  "@types/debug": "^4.1.12",
178
- "@vitejs/plugin-rsc": "^0.5.26",
167
+ "@vitejs/plugin-rsc": "^0.5.27",
179
168
  "debug": "^4.4.1",
180
169
  "magic-string": "^0.30.17",
181
170
  "picomatch": "^4.0.4",
@@ -184,28 +173,28 @@
184
173
  },
185
174
  "devDependencies": {
186
175
  "@playwright/test": "^1.49.1",
187
- "@shared/e2e": "workspace:*",
188
176
  "@testing-library/dom": "^10.4.1",
189
177
  "@testing-library/react": "^16.3.2",
190
178
  "@types/node": "^24.10.1",
191
- "@types/react": "catalog:",
192
- "@types/react-dom": "catalog:",
193
- "esbuild": "^0.27.0",
179
+ "@types/react": "^19.2.7",
180
+ "@types/react-dom": "^19.2.3",
181
+ "esbuild": "^0.28.1",
194
182
  "happy-dom": "^20.10.1",
195
183
  "jiti": "^2.6.1",
196
- "react": "catalog:",
197
- "react-dom": "catalog:",
184
+ "react": "^19.2.6",
185
+ "react-dom": "^19.2.6",
198
186
  "typescript": "^5.3.0",
199
- "vitest": "^4.0.0"
187
+ "vitest": "^4.1.9",
188
+ "@shared/e2e": "0.0.1"
200
189
  },
201
190
  "peerDependencies": {
202
- "@cloudflare/vite-plugin": "^1.38.0",
191
+ "@cloudflare/vite-plugin": "^1.42.1",
203
192
  "@playwright/test": "^1.49.1",
204
193
  "@testing-library/react": ">=16",
205
- "@vitejs/plugin-rsc": "^0.5.26",
194
+ "@vitejs/plugin-rsc": "^0.5.27",
206
195
  "react": ">=19.2.6 <20",
207
196
  "react-dom": ">=19.2.6 <20",
208
- "vite": "^8.0.0",
197
+ "vite": "^8.0.16",
209
198
  "vitest": ">=3"
210
199
  },
211
200
  "peerDependenciesMeta": {
@@ -227,5 +216,15 @@
227
216
  },
228
217
  "engines": {
229
218
  "node": "^20.19.0 || >=22.12.0"
219
+ },
220
+ "scripts": {
221
+ "build": "pnpm exec esbuild src/vite/index.ts --bundle --format=esm --outfile=dist/vite/index.js --platform=node --packages=external && mkdir -p dist/vite/plugins && cp src/vite/plugins/cloudflare-protocol-loader-hook.mjs dist/vite/plugins/cloudflare-protocol-loader-hook.mjs && pnpm exec esbuild src/testing/vitest.ts --bundle --format=esm --outfile=dist/testing/vitest.js --platform=node --packages=external && pnpm exec esbuild src/bin/rango.ts --bundle --format=esm --outfile=dist/bin/rango.js --platform=node --packages=external --banner:js='#!/usr/bin/env node' && chmod +x dist/bin/rango.js",
222
+ "typecheck": "tsc --noEmit && tsc -p tsconfig.strict-check.json --noEmit && tsc -p tsconfig.augment-check.json --noEmit",
223
+ "test": "playwright test",
224
+ "test:ui": "playwright test --ui",
225
+ "test:hmr-local": "playwright test --project=dev-warmup --project=hmr-routes --project=hmr-basename --project=hmr-prerender --no-deps --workers=1",
226
+ "test:unit": "vitest run",
227
+ "test:unit:watch": "vitest",
228
+ "test:unit:rsc": "vitest run --config vitest.rsc.config.ts"
230
229
  }
231
- }
230
+ }
@@ -22,7 +22,6 @@ import {
22
22
  loading,
23
23
  parallel,
24
24
  intercept,
25
- when,
26
25
  errorBoundary,
27
26
  notFoundBoundary,
28
27
  } from "@rangojs/router";
@@ -51,13 +51,13 @@ Now `ProductPage` carries its loader, loading state, and response-header middlew
51
51
 
52
52
  `handler.use()` is the same callback shape regardless of where the handler runs, but the runtime validates that the items it returns are valid for the mount site. Driven by `MOUNT_SITE_ALLOWED_TYPES` in [resolve-handler-use.ts](../../src/route-definition/resolve-handler-use.ts):
53
53
 
54
- | Mount site | Allowed item types |
55
- | ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
56
- | `path()` / `route()` | `layout`, `parallel`, `intercept`, `middleware`, `revalidate`, `loader`, `loading`, `errorBoundary`, `notFoundBoundary`, `cache`, `transition` |
57
- | `layout()` | All of the above, plus `route`, `include` |
58
- | `parallel()` (per slot) | `revalidate`, `loader`, `loading`, `errorBoundary`, `notFoundBoundary`, `transition` |
59
- | `intercept()` | `middleware`, `revalidate`, `loader`, `loading`, `errorBoundary`, `notFoundBoundary`, `layout`, `route`, `when`, `transition` |
60
- | Response routes (`path.json()`, `path.text()`, …) | `middleware`, `cache` |
54
+ | Mount site | Allowed item types |
55
+ | ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
56
+ | `path()` / `route()` | `layout`, `parallel`, `intercept`, `middleware`, `revalidate`, `loader`, `loading`, `errorBoundary`, `notFoundBoundary`, `cache`, `transition` |
57
+ | `layout()` | All of the above, plus `route`, `include` |
58
+ | `parallel()` (per slot) | `revalidate`, `loader`, `loading`, `errorBoundary`, `notFoundBoundary`, `transition` |
59
+ | `intercept()` | `middleware`, `revalidate`, `loader`, `loading`, `errorBoundary`, `notFoundBoundary`, `layout`, `route`, `transition` (conditional activation is config-only: `intercept(..., { when })`) |
60
+ | Response routes (`path.json()`, `path.text()`, …) | `middleware`, `cache` |
61
61
 
62
62
  For per-item semantics see the dedicated skills: [middleware](../middleware/SKILL.md), [loader](../loader/SKILL.md), [parallel](../parallel/SKILL.md), [intercept](../intercept/SKILL.md), [layout](../layout/SKILL.md), [view-transitions](../view-transitions/SKILL.md).
63
63
 
@@ -142,18 +142,41 @@ layout(ProductLayout, () => [
142
142
  ]);
143
143
  ```
144
144
 
145
- ## Conditional Intercept with when()
145
+ ## Conditional Intercept with the `when` config
146
146
 
147
- Only intercept based on navigation context:
147
+ Only intercept based on navigation context. `when` is the 4th argument
148
+ (an `InterceptConfig` object); the other use-items go in the 5th-argument
149
+ callback.
148
150
 
149
151
  ```typescript
150
152
  intercept(
151
153
  "@modal",
152
154
  "product",
153
155
  <ProductModal />,
156
+ // Only intercept when coming from a different section
157
+ { when: ({ from }) => !from.pathname.startsWith("/shop/product/") },
158
+ () => [
159
+ loader(ProductLoader),
160
+ ]
161
+ )
162
+ ```
163
+
164
+ `when` is a match-time selector receiving `{ from, to, params, segments, ... }`.
165
+ Pass an array of predicates for AND logic (all must return true). Omit `when`
166
+ entirely and the intercept always activates.
167
+
168
+ ```typescript
169
+ intercept(
170
+ "@modal",
171
+ "product",
172
+ <ProductModal />,
173
+ {
174
+ when: [
175
+ ({ from }) => from.pathname.startsWith("/shop"),
176
+ ({ params }) => params.slug !== "featured",
177
+ ],
178
+ },
154
179
  () => [
155
- // Only intercept when coming from a different section
156
- when(({ from }) => !from.pathname.startsWith("/shop/product/")),
157
180
  loader(ProductLoader),
158
181
  ]
159
182
  )
@@ -242,10 +265,13 @@ layout(ShopLayout, () => [
242
265
  ]),
243
266
 
244
267
  // This intercept is also pre-rendered at build time
245
- intercept("@modal", ".detail", <ProductModal />, () => [
246
- when(({ from }) => from.pathname.startsWith("/shop")),
247
- loader(ProductLoader),
248
- ]),
268
+ intercept(
269
+ "@modal",
270
+ ".detail",
271
+ <ProductModal />,
272
+ { when: ({ from }) => from.pathname.startsWith("/shop") },
273
+ () => [loader(ProductLoader)],
274
+ ),
249
275
  ])
250
276
  ```
251
277
 
@@ -253,8 +279,8 @@ Build-time behavior:
253
279
 
254
280
  - The intercept handler (`<ProductModal />`) is resolved with BuildContext
255
281
  - Result is stored under the key `"detail/paramHash/i"` (intercept variant)
256
- - `when()` conditions are skipped at build time (all intercepts pre-rendered unconditionally)
257
- - `when()` is still evaluated at runtime by the intercept-resolution middleware
282
+ - `when` config conditions are skipped at build time (all intercepts pre-rendered unconditionally)
283
+ - `when` is still evaluated at runtime by the intercept-resolution middleware
258
284
 
259
285
  Runtime behavior:
260
286
 
@@ -305,7 +331,6 @@ export const shopPatterns = urls(({
305
331
  intercept,
306
332
  loader,
307
333
  loading,
308
- when,
309
334
  }) => [
310
335
  layout(<ShopLayout />, () => [
311
336
  parallel({
@@ -317,8 +342,8 @@ export const shopPatterns = urls(({
317
342
  "@modal",
318
343
  "product", // Route name (without prefix)
319
344
  <ProductModalContent />,
345
+ { when: ({ from }) => !from.pathname.startsWith("/shop/product/") },
320
346
  () => [
321
- when(({ from }) => !from.pathname.startsWith("/shop/product/")),
322
347
  layout(<ModalWrapper />),
323
348
  loading(<ProductModalSkeleton />),
324
349
  loader(ProductLoader, () => [cache()]),
@@ -338,7 +363,7 @@ export const shopPatterns = urls(({
338
363
 
339
364
  ## Handler-attached `.use`
340
365
 
341
- Intercept handlers can carry their own middleware, loaders, loading state, error/notFound boundaries, and even nested `layout`/`route`/`when` defaults via `.use` — useful for self-contained modal components that travel with their own data and chrome.
366
+ Intercept handlers can carry their own middleware, loaders, loading state, error/notFound boundaries, and even nested `layout`/`route` defaults via `.use` — useful for self-contained modal components that travel with their own data and chrome. (Conditional activation is set via the `when` config on the mount-site `intercept()` call, not inside `.use`.)
342
367
 
343
368
  ```typescript
344
369
  const QuickViewModal: Handler = async (ctx) => {
@@ -671,6 +671,16 @@ export const SearchLoader = createLoader(async (ctx) => {
671
671
  }, true); // true = fetchable
672
672
  ```
673
673
 
674
+ > **No registration needed — and no worker-entry import.** A fetchable loader
675
+ > does not have to be registered with `loader()` in the route DSL, and it does
676
+ > not have to be imported by any server module. Importing it into the client
677
+ > component that calls `useFetchLoader()` / `load()` is enough. Rango discovers
678
+ > every `createLoader(fn, true)` at build time and registers it for the
679
+ > `_rsc_loader` endpoint, so a loader reachable only through a client component
680
+ > still resolves in production — on both the generated entry and a hand-written
681
+ > worker entry (e.g. a Cloudflare `worker.rsc.tsx`). You do **not** need to
682
+ > force-import the loader in your worker entry to make it resolve.
683
+
674
684
  ### Fetchable Loader with Middleware
675
685
 
676
686
  Pass an options object instead of `true` to attach per-loader middleware.
@@ -193,9 +193,9 @@ The main content always goes through `<Outlet />` via the `path()` handler.
193
193
  // Rango: explicit intercept in layout
194
194
  layout(<ShopLayout />, () => [
195
195
  path("/product/:id", ProductPage, { name: "product" }),
196
- intercept("@modal", ".product", <ProductModal />, () => [
197
- when(({ from }) => from.pathname.startsWith("/shop")),
198
- ]),
196
+ intercept("@modal", ".product", <ProductModal />, {
197
+ when: ({ from }) => from.pathname.startsWith("/shop"),
198
+ }),
199
199
  ])
200
200
  ```
201
201
 
@@ -740,7 +740,135 @@ function ThemeToggle() {
740
740
 
741
741
  See `/theme` for full API including system detection and cookie persistence.
742
742
 
743
- ## 10. Key Conceptual Differences
743
+ ## 10. Cloudflare Workers: streaming, dev tooling, and deploy
744
+
745
+ This is the part of an RR7-on-Cloudflare migration that the API tables above do
746
+ **not** cover, and it produces symptoms that look like router bugs but aren't.
747
+ RR7 serves a fully-rendered HTML/Turbo-Stream response; Rango serves a **streamed**
748
+ RSC/SSR response. Anything in the request path that was harmless for RR7 but
749
+ **buffers the response** silently kills that stream — the `loading()` /
750
+ `<Suspense>` fallback never shows and the page appears "awaited" (old content
751
+ held until everything resolves).
752
+
753
+ ### 10a. Never buffer the Rango response in a custom worker entry
754
+
755
+ RR7 Cloudflare entries commonly **rewrite the response body** — `await
756
+ response.text()` / `await response.arrayBuffer()`, `HTMLRewriter`, cookie/URL
757
+ rewriting, hybrid-proxy bridging. That is fine for RR7's complete HTML, but if it
758
+ runs on the **Rango** path it consumes the stream and you lose streaming
759
+ entirely.
760
+
761
+ Rule: in the worker entry, the Rango response must pass through as a **stream**.
762
+ Buffer only on non-Rango branches (a legacy/SFRA proxy, an error page).
763
+
764
+ ```typescript
765
+ // BAD — buffers the whole RSC stream before returning (no streaming, no fallback)
766
+ const response = await router.fetch(request, { env, ctx });
767
+ const body = await response.text(); // <- drains the stream
768
+ return new Response(rewrite(body), response);
769
+
770
+ // GOOD — pass the body through as a stream (re-wrap headers only)
771
+ const response = await router.fetch(request, { env, ctx });
772
+ return new Response(response.body, response); // <- streams; safe to add headers
773
+ ```
774
+
775
+ If you keep a legacy proxy (e.g. an SFRA/host fallback) that legitimately buffers
776
+ to rewrite HTML, gate it so it only runs for proxied paths — never for the
777
+ `router.fetch()` result.
778
+
779
+ Keep the custom entry itself (host/site resolution, sessions, proxy fallback) —
780
+ that's real product logic, not a deviation. Just don't let it touch the stream.
781
+
782
+ ### 10b. Use `vite dev` / `vite preview`, not `wrangler dev`, for local work
783
+
784
+ The `@cloudflare/vite-plugin` runs **your worker entry** inside miniflare for both
785
+ `vite dev` and `vite preview`, with bindings + `.dev.vars`, and it **streams**.
786
+ `wrangler dev` does **not** stream RSC locally: it gzip-compresses the response by
787
+ buffering the entire body before sending. Measured on a bare streaming Worker
788
+ under `wrangler dev`: `Accept-Encoding: gzip` → first byte at the full delay
789
+ (buffered); `Accept-Encoding: identity` → first byte at ~3ms (streams). The
790
+ deployed Cloudflare edge does **streaming** compression, so this is a
791
+ **local-`wrangler dev`-only artifact** — do not mistake it for a production
792
+ streaming bug (verify the deployed edge with `curl` and watch chunk timing /
793
+ `transfer-encoding: chunked`).
794
+
795
+ ```jsonc
796
+ // package.json — the standard rango-on-Workers shape
797
+ "dev": "vite", // streams, bindings, .dev.vars live
798
+ "build": "vite build",
799
+ "preview": "vite preview", // runs the built worker in workerd; also streams
800
+ // wrangler is only for: wrangler deploy / wrangler secret
801
+ ```
802
+
803
+ If a workflow truly requires `wrangler dev`, declare `Content-Encoding: identity`
804
+ on streamed responses, gated to local hostnames (so the edge still compresses):
805
+
806
+ ```typescript
807
+ if (
808
+ isLocalHostname(url.hostname) &&
809
+ response.body &&
810
+ !response.headers.has("content-encoding")
811
+ ) {
812
+ const ct = (response.headers.get("content-type") ?? "").toLowerCase();
813
+ if (ct.includes("text/html") || ct.includes("text/x-component")) {
814
+ const headers = new Headers(response.headers);
815
+ headers.set("content-encoding", "identity"); // wrangler skips gzip -> streams
816
+ return new Response(response.body, {
817
+ status: response.status,
818
+ statusText: response.statusText,
819
+ headers,
820
+ });
821
+ }
822
+ }
823
+ ```
824
+
825
+ ### 10c. `.dev.vars` loads differently per tool
826
+
827
+ A frequent "my env vars vanished after migration" symptom — it's the tool, not
828
+ the file:
829
+
830
+ | Command | How `.dev.vars` is loaded |
831
+ | -------------- | ------------------------------------------------------------------------------------ |
832
+ | `wrangler dev` | Loaded natively at runtime (RR7's path — why it "worked before") |
833
+ | `vite dev` | Loaded **live** by the plugin (+ `.dev.vars.<CLOUDFLARE_ENV>` if a named env is set) |
834
+ | `vite preview` | Read from the **build output** `dist/<env>/.dev.vars`, written at `vite build` time |
835
+
836
+ So `vite preview` only sees vars that were present when you built — re-run
837
+ `vite build` after editing `.dev.vars`. (`vite dev` is the simplest: live vars +
838
+ streaming, no rebuild.)
839
+
840
+ ### 10d. Build output moves from `build/` to `dist/`
841
+
842
+ RR7's `react-router build` writes to `build/`; Rango's `vite build` writes to
843
+ `dist/` (`dist/client`, `dist/rsc`, …). The leftover `build/` paths in
844
+ `wrangler.toml` and cleanup scripts must move, or you deploy stale/empty assets:
845
+
846
+ ```toml
847
+ # wrangler.toml — was RR7's react-router build output
848
+ - assets = { directory = "./build/client/", binding = "ASSETS" }
849
+ + assets = { directory = "./dist/client/", binding = "ASSETS" }
850
+ ```
851
+
852
+ ```jsonc
853
+ // package.json — clean the real output dir
854
+ - "cleanup": "rimraf ./build ./public/build"
855
+ + "cleanup": "rimraf ./dist ./build ./public/build"
856
+ ```
857
+
858
+ ### 10e. Deploy the built worker, not the source entry
859
+
860
+ `vite build` emits the **deployable** Worker config at `dist/<env>/wrangler.json`
861
+ with `main: "index.js"` (the bundled worker), `no_bundle: true`, and
862
+ `assets: "../client"`. A plain `wrangler deploy` run from the repo root uses the
863
+ **root** `wrangler.toml`, where `main` points at your **source** entry
864
+ (`app/.../worker.ts`) — and wrangler's bundler cannot resolve Rango's
865
+ `virtual:rsc-router/*` modules, so deploying the source entry fails or ships a
866
+ broken worker. Deploy the build output instead (e.g.
867
+ `wrangler deploy -c dist/<env>/wrangler.json`) or use the plugin's deploy flow.
868
+ RR7's `wrangler deploy` from root worked only because its worker was already
869
+ fully bundled by `react-router build`.
870
+
871
+ ## 11. Key Conceptual Differences
744
872
 
745
873
  | Concept | React Router | Rango |
746
874
  | ------------------- | ----------------------------------- | -------------------------------------- |
@@ -769,3 +897,18 @@ See `/theme` for full API including system detection and cookie persistence.
769
897
  11. [ ] Update metadata to use `Meta` handle + `<MetaTags />`
770
898
  12. [ ] Replace custom theme provider with `theme: true` in createRouter (see `/theme`)
771
899
  13. [ ] Run `npx rango generate src/` to generate route types
900
+
901
+ **Cloudflare Workers (if migrating an RR7-on-Workers app):**
902
+
903
+ 14. [ ] Audit the custom worker entry — the `router.fetch()` response must pass
904
+ through as a **stream** (`new Response(response.body, response)`); remove
905
+ any `.text()`/`.arrayBuffer()`/`HTMLRewriter` buffering from the Rango
906
+ path (keep it only on legacy/proxy branches). See §10a.
907
+ 15. [ ] Switch local dev to `vite dev` / `vite preview` (they stream + load
908
+ `.dev.vars` + provide bindings); stop using `wrangler dev` for local
909
+ verification — it gzip-buffers and kills streaming. See §10b.
910
+ 16. [ ] Move build output paths from RR7's `build/` to vite's `dist/` in
911
+ `wrangler.toml` (`assets` → `./dist/client/`) and cleanup scripts. See §10d.
912
+ 17. [ ] Fix deploy to use the built config `dist/<env>/wrangler.json`
913
+ (`no_bundle: true`), not a root `wrangler deploy` against the source
914
+ worker entry (which can't bundle Rango's virtual modules). See §10e.
@@ -243,16 +243,16 @@ path("/blog/:slug", BlogPost, { name: "blog.post" }, () => [
243
243
 
244
244
  ## Interaction with DSL Items
245
245
 
246
- | DSL item | Behavior with Prerender |
247
- | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
248
- | `loader()` | Live at runtime, bundled normally. Use `cache()` for caching. |
249
- | `revalidate()` | Not allowed without Passthrough. Allowed with Passthrough. |
250
- | `cache()` | Orthogonal -- use on parent layouts and loaders. |
251
- | `layout()` | Child layouts inside path are pre-rendered. Parent layouts are live. |
252
- | `parallel()` | Parallel slots inside path are pre-rendered. |
253
- | `middleware()` | Skipped during pre-render (no request). Runs at request time for loaders. |
254
- | `loading()` | Ignored without Passthrough. Works for live fallback with Passthrough. |
255
- | `intercept()` | Pre-rendered at build time. Intercept variant stored under `/i` key alongside main segments. At runtime, the correct variant is served based on `ctx.isIntercept`. `when()` conditions are skipped at build time (all intercepts are pre-rendered unconditionally). |
246
+ | DSL item | Behavior with Prerender |
247
+ | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
248
+ | `loader()` | Live at runtime, bundled normally. Use `cache()` for caching. |
249
+ | `revalidate()` | Not allowed without Passthrough. Allowed with Passthrough. |
250
+ | `cache()` | Orthogonal -- use on parent layouts and loaders. |
251
+ | `layout()` | Child layouts inside path are pre-rendered. Parent layouts are live. |
252
+ | `parallel()` | Parallel slots inside path are pre-rendered. |
253
+ | `middleware()` | Skipped during pre-render (no request). Runs at request time for loaders. |
254
+ | `loading()` | Ignored without Passthrough. Works for live fallback with Passthrough. |
255
+ | `intercept()` | Pre-rendered at build time. Intercept variant stored under `/i` key alongside main segments. At runtime, the correct variant is served based on `ctx.isIntercept`. `when` config conditions are skipped at build time (all intercepts are pre-rendered unconditionally). |
256
256
 
257
257
  When Passthrough revalidation is enabled, remember that revalidation is
258
258
  still partial: opting a child segment into revalidation does not
@@ -609,12 +609,12 @@ At runtime, the cache-lookup middleware checks `ctx.isIntercept`:
609
609
  (filtered by `namespace?.startsWith("intercept:")`) and sets up slots.
610
610
  - **Direct navigation**: looks up `paramHash` (no suffix). Standard prerender path.
611
611
  - **Intercept miss (no `/i` entry)**: falls through to the normal pipeline so
612
- intercept-resolution middleware runs live. This handles `when()` conditions
612
+ intercept-resolution middleware runs live. This handles `when` config conditions
613
613
  that prevented pre-rendering.
614
614
 
615
- The `when()` callback receives an `InterceptSelectorContext` with `from.pathname`
615
+ The `when` config selector receives an `InterceptSelectorContext` with `from.pathname`
616
616
  which is unknown at build time. All intercepts are pre-rendered unconditionally;
617
- `when()` is evaluated at runtime by the intercept-resolution middleware.
617
+ `when` is evaluated at runtime by the intercept-resolution middleware.
618
618
 
619
619
  ### Example: Pre-rendered route with intercept
620
620
 
@@ -633,10 +633,13 @@ layout(ShopLayout, () => [
633
633
 
634
634
  // Intercept detail from shop index into a modal.
635
635
  // At build time, this is resolved and stored under the /i key.
636
- intercept("@modal", ".detail", <ProductModal />, () => [
637
- when(({ from }) => from.pathname === "/shop"),
638
- loader(ProductLoader),
639
- ]),
636
+ intercept(
637
+ "@modal",
638
+ ".detail",
639
+ <ProductModal />,
640
+ { when: ({ from }) => from.pathname === "/shop" },
641
+ () => [loader(ProductLoader)],
642
+ ),
640
643
  ])
641
644
  ```
642
645
 
@@ -60,8 +60,7 @@ urls(
60
60
  cache, // Configure caching
61
61
  middleware, // Add middleware
62
62
  revalidate, // Control revalidation
63
- intercept, // Intercept routes for modals
64
- when, // Conditional rendering
63
+ intercept, // Intercept routes for modals (conditional via intercept(..., { when }))
65
64
  errorBoundary, // Add an error boundary
66
65
  notFoundBoundary, // Add a not-found boundary
67
66
  transition, // Configure view transitions