@rangojs/router 0.1.0 → 0.1.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.
Files changed (89) hide show
  1. package/dist/bin/rango.js +11 -1
  2. package/dist/types/browser/dev-discovery.d.ts +11 -0
  3. package/dist/types/browser/navigation-store.d.ts +3 -13
  4. package/dist/types/browser/prefetch/loader.d.ts +10 -0
  5. package/dist/types/browser/prefetch/runtime.d.ts +3 -0
  6. package/dist/types/browser/types.d.ts +4 -16
  7. package/dist/types/build/generate-manifest.d.ts +6 -0
  8. package/dist/types/cache/cache-scope.d.ts +7 -5
  9. package/dist/types/cache/cf/cf-cache-store.d.ts +10 -9
  10. package/dist/types/cache/memory-segment-store.d.ts +6 -6
  11. package/dist/types/cache/shell-snapshot.d.ts +21 -8
  12. package/dist/types/cache/types.d.ts +24 -7
  13. package/dist/types/cache/vercel/vercel-cache-store.d.ts +9 -8
  14. package/dist/types/dev-discovery-protocol.d.ts +5 -0
  15. package/dist/types/prerender/store.d.ts +1 -0
  16. package/dist/types/route-map-builder.d.ts +4 -29
  17. package/dist/types/router/prerender-match.d.ts +4 -1
  18. package/dist/types/router/router-interfaces.d.ts +3 -1
  19. package/dist/types/rsc/handler-context.d.ts +1 -0
  20. package/dist/types/rsc/render-pipeline.d.ts +3 -2
  21. package/dist/types/rsc/rsc-rendering.d.ts +5 -10
  22. package/dist/types/rsc/shell-capture.d.ts +4 -8
  23. package/dist/types/rsc/types.d.ts +2 -0
  24. package/dist/types/server/context.d.ts +16 -0
  25. package/dist/types/server/request-context.d.ts +18 -8
  26. package/dist/types/server.d.ts +1 -1
  27. package/dist/types/types/route-entry.d.ts +3 -0
  28. package/dist/types/vite/discovery/state.d.ts +3 -2
  29. package/dist/types/vite/utils/manifest-utils.d.ts +1 -1
  30. package/dist/vite/index.js +194 -107
  31. package/package.json +7 -2
  32. package/skills/caching/SKILL.md +1 -1
  33. package/skills/ppr/SKILL.md +48 -4
  34. package/src/browser/dev-discovery.ts +66 -0
  35. package/src/browser/navigation-client.ts +6 -0
  36. package/src/browser/navigation-store.ts +4 -271
  37. package/src/browser/partial-update.ts +7 -14
  38. package/src/browser/prefetch/cache.ts +1 -1
  39. package/src/browser/prefetch/loader.ts +110 -0
  40. package/src/browser/prefetch/runtime.ts +7 -0
  41. package/src/browser/react/Link.tsx +7 -10
  42. package/src/browser/react/NavigationProvider.tsx +1 -1
  43. package/src/browser/react/use-router.ts +1 -1
  44. package/src/browser/rsc-router.tsx +4 -2
  45. package/src/browser/types.ts +4 -27
  46. package/src/build/generate-manifest.ts +11 -0
  47. package/src/cache/cache-scope.ts +17 -6
  48. package/src/cache/cf/cf-cache-store.ts +63 -39
  49. package/src/cache/memory-segment-store.ts +17 -6
  50. package/src/cache/shell-snapshot.ts +45 -15
  51. package/src/cache/types.ts +23 -6
  52. package/src/cache/vercel/vercel-cache-store.ts +52 -22
  53. package/src/dev-discovery-protocol.ts +11 -0
  54. package/src/prerender/build-shell-capture.ts +7 -1
  55. package/src/prerender/store.ts +5 -0
  56. package/src/route-definition/dsl-helpers.ts +7 -2
  57. package/src/route-map-builder.ts +56 -49
  58. package/src/router/handler-context.ts +13 -5
  59. package/src/router/lazy-includes.ts +1 -0
  60. package/src/router/match-api.ts +8 -4
  61. package/src/router/prerender-match.ts +19 -2
  62. package/src/router/router-interfaces.ts +4 -0
  63. package/src/router/segment-resolution/static-store.ts +36 -10
  64. package/src/router.ts +34 -3
  65. package/src/rsc/handler-context.ts +1 -0
  66. package/src/rsc/handler.ts +3 -1
  67. package/src/rsc/loader-fetch.ts +2 -2
  68. package/src/rsc/manifest-init.ts +4 -11
  69. package/src/rsc/render-pipeline.ts +10 -2
  70. package/src/rsc/rsc-rendering.ts +207 -146
  71. package/src/rsc/shell-capture.ts +34 -11
  72. package/src/rsc/types.ts +2 -0
  73. package/src/server/context.ts +28 -0
  74. package/src/server/request-context.ts +32 -10
  75. package/src/server.ts +0 -2
  76. package/src/testing/dispatch.ts +1 -0
  77. package/src/types/route-entry.ts +3 -0
  78. package/src/urls/include-helper.ts +1 -0
  79. package/src/urls/path-helper.ts +8 -4
  80. package/src/vite/discovery/bundle-postprocess.ts +10 -7
  81. package/src/vite/discovery/discover-routers.ts +7 -66
  82. package/src/vite/discovery/prerender-collection.ts +13 -2
  83. package/src/vite/discovery/state.ts +4 -4
  84. package/src/vite/discovery/virtual-module-codegen.ts +75 -42
  85. package/src/vite/plugins/version-injector.ts +0 -1
  86. package/src/vite/plugins/virtual-entries.ts +11 -1
  87. package/src/vite/router-discovery.ts +132 -22
  88. package/src/vite/utils/manifest-utils.ts +1 -4
  89. package/src/vite/utils/shared-utils.ts +7 -0
@@ -2309,7 +2309,17 @@ async function initializeApp() {
2309
2309
  // context, including strictMode (default true) from createRouter. StrictMode
2310
2310
  // is the default; createRouter({ strictMode: false }) ships the opt-out in the
2311
2311
  // payload metadata. StrictMode emits no DOM, so toggling never changes markup.
2312
- const { strictMode } = await initBrowserApp({ rscStream, deps });
2312
+ const { strictMode, initialPayload } = await initBrowserApp({ rscStream, deps });
2313
+
2314
+ if (import.meta.hot) {
2315
+ const { startDevDiscoveryHandshake } = await import(
2316
+ "@rangojs/router/internal/browser/dev-discovery"
2317
+ );
2318
+ startDevDiscoveryHandshake(
2319
+ initialPayload.metadata?.devDiscoveryEpoch,
2320
+ import.meta.hot
2321
+ );
2322
+ }
2313
2323
 
2314
2324
  const app = createElement(Rango);
2315
2325
  hydrateRoot(
@@ -2520,7 +2530,7 @@ import { resolve } from "node:path";
2520
2530
  // package.json
2521
2531
  var package_default = {
2522
2532
  name: "@rangojs/router",
2523
- version: "0.1.0",
2533
+ version: "0.1.1",
2524
2534
  description: "Django-inspired RSC router with composable URL patterns",
2525
2535
  keywords: [
2526
2536
  "react",
@@ -2620,6 +2630,10 @@ var package_default = {
2620
2630
  "react-server": "./src/rsc/handler.ts",
2621
2631
  default: "./src/rsc/handler.ts"
2622
2632
  },
2633
+ "./internal/browser/dev-discovery": {
2634
+ types: "./dist/types/browser/dev-discovery.d.ts",
2635
+ default: "./src/browser/dev-discovery.ts"
2636
+ },
2623
2637
  "./cache": {
2624
2638
  types: "./dist/types/cache/index.d.ts",
2625
2639
  "react-server": "./src/cache/index.ts",
@@ -2699,7 +2713,8 @@ var package_default = {
2699
2713
  typecheck: "tsc --noEmit && tsc -p tsconfig.strict-check.json --noEmit && tsc -p tsconfig.augment-check.json --noEmit",
2700
2714
  test: "playwright test",
2701
2715
  "test:ui": "playwright test --ui",
2702
- "test:hmr-local": "playwright test --project=dev-warmup --project=hmr-routes --project=hmr-basename --project=hmr-prerender --no-deps --workers=1",
2716
+ "test:hmr-local": "playwright test --project=dev-warmup --project=hmr-basename --project=hmr-prerender --no-deps --workers=1 && RANGO_E2E_ROUTE_HMR_ONLY=1 playwright test --project=hmr-routes --no-deps --workers=1",
2717
+ "test:hmr-routes-local": "RANGO_E2E_ROUTE_HMR_ONLY=1 playwright test --project=hmr-routes --no-deps --workers=1",
2703
2718
  "test:unit": "pnpm run build:types && vitest run",
2704
2719
  "test:unit:watch": "vitest",
2705
2720
  "test:unit:rsc": "vitest run --config vitest.rsc.config.ts"
@@ -4236,6 +4251,11 @@ function onwarn(warning, defaultHandler) {
4236
4251
  }
4237
4252
  function getManualChunks(id) {
4238
4253
  const normalized = Vite.normalizePath(id);
4254
+ if (/\/browser\/prefetch\/(?:runtime|fetch|queue|observer|policy|resource-ready)\.[cm]?[jt]sx?(?:[?#].*)?$/.test(
4255
+ normalized
4256
+ )) {
4257
+ return void 0;
4258
+ }
4239
4259
  if (normalized.includes("node_modules/react/") || normalized.includes("node_modules/react-dom/") || normalized.includes("node_modules/react-server-dom-webpack/") || normalized.includes("node_modules/@vitejs/plugin-rsc/")) {
4240
4260
  return "react";
4241
4261
  }
@@ -4673,6 +4693,12 @@ import { readFileSync as readFileSync7 } from "node:fs";
4673
4693
  import { createRequire as createRequire3, register } from "node:module";
4674
4694
  import { pathToFileURL as pathToFileURL2 } from "node:url";
4675
4695
 
4696
+ // src/dev-discovery-protocol.ts
4697
+ var DEV_DISCOVERY_READY_EVENT = "rango:dev-discovery-ready";
4698
+ var DEV_DISCOVERY_QUERY_EVENT = "rango:dev-discovery-query";
4699
+ var DEV_DISCOVERY_PROBE_HEADER = "x-rango-dev-discovery-probe";
4700
+ var DEV_DISCOVERY_EPOCH_HEADER = "x-rango-dev-discovery-epoch";
4701
+
4676
4702
  // src/rsc/shell-serve.ts
4677
4703
  import React from "react";
4678
4704
 
@@ -4955,8 +4981,6 @@ function createDiscoveryState(entryPath, opts) {
4955
4981
  opts,
4956
4982
  mergedRouteManifest: null,
4957
4983
  perRouterManifests: [],
4958
- mergedPrecomputedEntries: null,
4959
- mergedRouteTrie: null,
4960
4984
  perRouterTrieMap: /* @__PURE__ */ new Map(),
4961
4985
  perRouterPrecomputedMap: /* @__PURE__ */ new Map(),
4962
4986
  perRouterManifestDataMap: /* @__PURE__ */ new Map(),
@@ -4971,6 +4995,7 @@ function createDiscoveryState(entryPath, opts) {
4971
4995
  resolvedPrerenderModules: void 0,
4972
4996
  resolvedStaticModules: void 0,
4973
4997
  discoveryDone: null,
4998
+ devDiscoveryEpoch: opts?.preset === "cloudflare" ? Date.now() : void 0,
4974
4999
  devServerOrigin: null,
4975
5000
  devServer: null,
4976
5001
  selfWrittenGenFiles: /* @__PURE__ */ new Map(),
@@ -5887,15 +5912,21 @@ async function renderStaticHandlers(state, rscEnv, registry) {
5887
5912
  if (def.options?.passthrough) continue;
5888
5913
  const startHandler = performance.now();
5889
5914
  let handled = false;
5890
- for (const [, routerInstance] of registry) {
5915
+ const owner = def.$$routerId;
5916
+ const owned = owner !== void 0 ? registry.get(owner) : void 0;
5917
+ const candidates = owned ? [owned] : [...registry.values()];
5918
+ for (const routerInstance of candidates) {
5891
5919
  if (!routerInstance.renderStaticSegment) continue;
5892
5920
  try {
5893
5921
  const result = await routerInstance.renderStaticSegment(
5894
5922
  def.handler,
5895
5923
  def.$$id,
5896
- def.$$routePrefix,
5924
+ // Full route name when stamped; the historical $$routePrefix is a
5925
+ // name PREFIX (never in the root-scope registry) kept as fallback.
5926
+ def.$$routeName ?? def.$$routePrefix,
5897
5927
  state.resolvedBuildEnv,
5898
- !state.isBuildMode
5928
+ !state.isBuildMode,
5929
+ def.$$rootScoped
5899
5930
  );
5900
5931
  if (result) {
5901
5932
  const hasHandles = result.handles !== "";
@@ -6107,12 +6138,10 @@ async function discoverRouters(state, rscEnv) {
6107
6138
  throw new Error(formatNestedRouterConflictError(nestedRouterConflict));
6108
6139
  }
6109
6140
  const newMergedRouteManifest = {};
6110
- const newMergedPrecomputedEntries = [];
6111
6141
  const newPerRouterManifests = [];
6112
6142
  const newPerRouterManifestDataMap = /* @__PURE__ */ new Map();
6113
6143
  const newPerRouterPrecomputedMap = /* @__PURE__ */ new Map();
6114
6144
  const newPerRouterTrieMap = /* @__PURE__ */ new Map();
6115
- let mergedRouteTrailingSlash = {};
6116
6145
  let routerMountIndex = 0;
6117
6146
  const allManifests = [];
6118
6147
  const clientChunkCtx = state.opts?.clientChunkCtx;
@@ -6133,6 +6162,7 @@ async function discoverRouters(state, rscEnv) {
6133
6162
  router.urlpatterns,
6134
6163
  routerMountIndex,
6135
6164
  {
6165
+ routerId: id,
6136
6166
  ...router.__basename ? { urlPrefix: router.__basename } : {},
6137
6167
  ...collectClientFallbackRef ? { collectClientFallbackRef } : {}
6138
6168
  }
@@ -6170,20 +6200,17 @@ async function discoverRouters(state, rscEnv) {
6170
6200
  newPerRouterManifests.push({
6171
6201
  id,
6172
6202
  routeManifest: manifest.routeManifest,
6203
+ routeTrailingSlash: manifest.routeTrailingSlash,
6173
6204
  routeSearchSchemas: manifest.routeSearchSchemas,
6174
6205
  sourceFile: router.__sourceFile,
6175
6206
  factoryOnlyPrefixes
6176
6207
  });
6177
- if (manifest.routeTrailingSlash) {
6178
- Object.assign(mergedRouteTrailingSlash, manifest.routeTrailingSlash);
6179
- }
6180
6208
  const routerPrecomputed = [];
6181
6209
  flattenLeafEntries(
6182
6210
  manifest.prefixTree,
6183
6211
  manifest.routeManifest,
6184
6212
  routerPrecomputed
6185
6213
  );
6186
- newMergedPrecomputedEntries.push(...routerPrecomputed);
6187
6214
  newPerRouterManifestDataMap.set(id, manifest.routeManifest);
6188
6215
  newPerRouterPrecomputedMap.set(id, routerPrecomputed);
6189
6216
  console.log(
@@ -6205,44 +6232,8 @@ async function discoverRouters(state, rscEnv) {
6205
6232
  allManifests.length,
6206
6233
  (performance.now() - manifestGenStart).toFixed(1)
6207
6234
  );
6208
- let newMergedRouteTrie = null;
6209
6235
  const trieStart = debug10 ? performance.now() : 0;
6210
6236
  if (Object.keys(newMergedRouteManifest).length > 0) {
6211
- const routeToStaticPrefix = {};
6212
- for (const { manifest } of allManifests) {
6213
- for (const name of Object.keys(manifest.routeManifest)) {
6214
- if (!(name in routeToStaticPrefix)) {
6215
- routeToStaticPrefix[name] = "";
6216
- }
6217
- }
6218
- buildRouteToStaticPrefix(manifest.prefixTree, routeToStaticPrefix);
6219
- }
6220
- const prerenderRouteNames = /* @__PURE__ */ new Set();
6221
- const passthroughRouteNames = /* @__PURE__ */ new Set();
6222
- const mergedResponseTypeRoutes = {};
6223
- for (const { manifest } of allManifests) {
6224
- if (manifest.prerenderRoutes) {
6225
- for (const name of manifest.prerenderRoutes) {
6226
- prerenderRouteNames.add(name);
6227
- }
6228
- }
6229
- if (manifest.passthroughRoutes) {
6230
- for (const name of manifest.passthroughRoutes) {
6231
- passthroughRouteNames.add(name);
6232
- }
6233
- }
6234
- if (manifest.responseTypeRoutes) {
6235
- Object.assign(mergedResponseTypeRoutes, manifest.responseTypeRoutes);
6236
- }
6237
- }
6238
- newMergedRouteTrie = buildRouteTrie(
6239
- newMergedRouteManifest,
6240
- routeToStaticPrefix,
6241
- mergedRouteTrailingSlash,
6242
- prerenderRouteNames,
6243
- passthroughRouteNames,
6244
- mergedResponseTypeRoutes
6245
- );
6246
6237
  for (const { id, manifest } of allManifests) {
6247
6238
  const perRouterTrie = buildPerRouterTrie(manifest);
6248
6239
  if (perRouterTrie) {
@@ -6255,15 +6246,10 @@ async function discoverRouters(state, rscEnv) {
6255
6246
  (performance.now() - trieStart).toFixed(1)
6256
6247
  );
6257
6248
  state.mergedRouteManifest = newMergedRouteManifest;
6258
- state.mergedPrecomputedEntries = newMergedPrecomputedEntries;
6259
6249
  state.perRouterManifests = newPerRouterManifests;
6260
6250
  state.perRouterManifestDataMap = newPerRouterManifestDataMap;
6261
6251
  state.perRouterPrecomputedMap = newPerRouterPrecomputedMap;
6262
6252
  state.perRouterTrieMap = newPerRouterTrieMap;
6263
- state.mergedRouteTrie = newMergedRouteTrie;
6264
- if (serverMod.setRouteTrie && newMergedRouteTrie) {
6265
- serverMod.setRouteTrie(newMergedRouteTrie);
6266
- }
6267
6253
  const perRouterSetters = [
6268
6254
  [newPerRouterManifestDataMap, "setRouterManifest"],
6269
6255
  [newPerRouterTrieMap, "setRouterTrie"],
@@ -6696,12 +6682,18 @@ function supplementGenFilesWithRuntimeRoutes(state) {
6696
6682
  import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync5 } from "node:fs";
6697
6683
  import { dirname as dirname4, basename, join as join6 } from "node:path";
6698
6684
  var MANIFEST_EXTERNALIZE_THRESHOLD = 512 * 1024;
6685
+ function devDiscoveryBootstrap(state) {
6686
+ if (state.isBuildMode || state.opts?.preset !== "cloudflare" || state.devDiscoveryEpoch === void 0) {
6687
+ return [];
6688
+ }
6689
+ const epoch = state.devDiscoveryEpoch;
6690
+ return [`globalThis.__RANGO_DEV_DISCOVERY_EPOCH = ${epoch};`];
6691
+ }
6699
6692
  function generateRoutesManifestModule(state) {
6700
6693
  const hasManifest = state.mergedRouteManifest && Object.keys(state.mergedRouteManifest).length > 0;
6701
6694
  if (hasManifest) {
6702
6695
  const genFileImports = [];
6703
- const genFileVars = [];
6704
- const routersWithoutGenFile = [];
6696
+ const genFileVarByEntry = [];
6705
6697
  let varIdx = 0;
6706
6698
  for (const entry of state.perRouterManifests) {
6707
6699
  if (entry.sourceFile) {
@@ -6718,16 +6710,19 @@ function generateRoutesManifestModule(state) {
6718
6710
  genFileImports.push(
6719
6711
  `import { NamedRoutes as ${varName} } from ${JSON.stringify(genPath)};`
6720
6712
  );
6721
- genFileVars.push(varName);
6713
+ genFileVarByEntry.push(varName);
6722
6714
  } else {
6723
- routersWithoutGenFile.push({
6724
- id: entry.id,
6725
- manifest: entry.routeManifest
6726
- });
6715
+ genFileVarByEntry.push(null);
6727
6716
  }
6728
6717
  }
6729
- const lines = [
6730
- `import { setCachedManifest, setRouterManifest, registerRouterManifestLoader, clearAllRouterData } from "@rangojs/router/server";`,
6718
+ const serverImports = [
6719
+ "setCachedManifest",
6720
+ "setRouterManifest",
6721
+ ...state.isBuildMode ? ["registerRouterManifestLoader"] : [],
6722
+ "clearAllRouterData"
6723
+ ];
6724
+ const lines2 = [
6725
+ `import { ${serverImports.join(", ")} } from "@rangojs/router/server";`,
6731
6726
  ...genFileImports,
6732
6727
  // Clear stale per-router cached data (manifest, trie, precomputed entries)
6733
6728
  // before re-populating. In Cloudflare dev mode, program reloads re-evaluate
@@ -6736,53 +6731,56 @@ function generateRoutesManifestModule(state) {
6736
6731
  // handler finds stale trie data and never rebuilds from updated urlpatterns.
6737
6732
  `clearAllRouterData();`
6738
6733
  ];
6739
- if (genFileVars.length > 0) {
6740
- lines.push(
6734
+ if (varIdx > 0) {
6735
+ lines2.push(
6741
6736
  `function __flat(r) { const o = {}; for (const [k, v] of Object.entries(r)) o[k] = typeof v === "string" ? v : v.path; return o; }`
6742
6737
  );
6743
6738
  }
6744
- if (genFileVars.length === 1 && routersWithoutGenFile.length === 0) {
6745
- lines.push(`setCachedManifest(__flat(${genFileVars[0]}));`);
6739
+ const routerMapVars = [];
6740
+ for (const [i, entry] of state.perRouterManifests.entries()) {
6741
+ const mapVar = `__m${i}`;
6742
+ const genVar = genFileVarByEntry[i];
6743
+ const init = genVar ? `__flat(${genVar})` : jsonParseExpression(entry.routeManifest);
6744
+ lines2.push(`const ${mapVar} = ${init};`);
6745
+ routerMapVars.push(mapVar);
6746
+ }
6747
+ if (routerMapVars.length === 1) {
6748
+ lines2.push(`setCachedManifest(${routerMapVars[0]});`);
6746
6749
  } else {
6747
- const parts = [];
6748
- for (const v of genFileVars) parts.push(`...__flat(${v})`);
6749
- for (const { manifest } of routersWithoutGenFile)
6750
- parts.push(`...${jsonParseExpression(manifest)}`);
6751
- lines.push(`setCachedManifest({ ${parts.join(", ")} });`);
6750
+ lines2.push(
6751
+ `setCachedManifest({ ${routerMapVars.map((v) => `...${v}`).join(", ")} });`
6752
+ );
6752
6753
  }
6753
- let genVarIdx = 0;
6754
- for (const entry of state.perRouterManifests) {
6755
- if (entry.sourceFile) {
6756
- const varName = genFileVars[genVarIdx++];
6757
- lines.push(
6758
- `setRouterManifest(${JSON.stringify(entry.id)}, __flat(${varName}));`
6759
- );
6760
- } else {
6761
- lines.push(
6762
- `setRouterManifest(${JSON.stringify(entry.id)}, ${jsonParseExpression(entry.routeManifest)});`
6754
+ for (const [i, entry] of state.perRouterManifests.entries()) {
6755
+ lines2.push(
6756
+ `setRouterManifest(${JSON.stringify(entry.id)}, ${routerMapVars[i]});`
6757
+ );
6758
+ }
6759
+ if (state.isBuildMode) {
6760
+ for (const routerId of state.perRouterManifestDataMap.keys()) {
6761
+ lines2.push(
6762
+ `registerRouterManifestLoader(${JSON.stringify(routerId)}, () => import(${JSON.stringify(VIRTUAL_ROUTES_MANIFEST_ID + "/" + routerId)}));`
6763
6763
  );
6764
6764
  }
6765
6765
  }
6766
- for (const routerId of state.perRouterManifestDataMap.keys()) {
6767
- lines.push(
6768
- `registerRouterManifestLoader(${JSON.stringify(routerId)}, () => import(${JSON.stringify(VIRTUAL_ROUTES_MANIFEST_ID + "/" + routerId)}));`
6769
- );
6770
- }
6771
6766
  if (!state.isBuildMode && state.devServerOrigin) {
6772
- lines.push(
6767
+ lines2.push(
6773
6768
  `globalThis.__PRERENDER_DEV_URL = ${JSON.stringify(state.devServerOrigin)};`
6774
6769
  );
6775
6770
  }
6776
- return lines.join("\n");
6771
+ lines2.push(...devDiscoveryBootstrap(state));
6772
+ return lines2.join("\n");
6777
6773
  }
6774
+ const lines = [];
6778
6775
  if (!state.isBuildMode) {
6779
6776
  const origin = state.devServerOrigin || state.devServer?.resolvedUrls?.local?.[0]?.replace(/\/$/, "") || state.devServer && `http://localhost:${state.devServer.config.server.port || 5173}`;
6780
6777
  if (origin) {
6781
6778
  state.devServerOrigin = origin;
6782
- return `globalThis.__PRERENDER_DEV_URL = ${JSON.stringify(origin)};`;
6779
+ lines.push(`globalThis.__PRERENDER_DEV_URL = ${JSON.stringify(origin)};`);
6783
6780
  }
6784
6781
  }
6785
- return `// Route manifest will be populated at runtime`;
6782
+ lines.push(...devDiscoveryBootstrap(state));
6783
+ return lines.join("\n") || `// Route manifest will be populated at runtime`;
6786
6784
  }
6787
6785
  function shortHash(input) {
6788
6786
  let h = 2166136261;
@@ -6965,7 +6963,7 @@ function postprocessBundle(state) {
6965
6963
  );
6966
6964
  writeFileSync6(manifestPath, manifestCode);
6967
6965
  totalBytes += Buffer.byteLength(manifestCode);
6968
- const injection = `import "./__static-manifest.js";
6966
+ const injection = `globalThis.__loadStaticManifestModule = () => import("./__static-manifest.js");
6969
6967
  `;
6970
6968
  writeFileSync6(rscEntryPath, injection + rscCode);
6971
6969
  const totalKB = (totalBytes / 1024).toFixed(1);
@@ -7300,6 +7298,43 @@ function createRouterDiscoveryPlugin(entryPath, opts) {
7300
7298
  if (s.isBuildMode) return;
7301
7299
  if (globalThis.__rscRouterDiscoveryActive) return;
7302
7300
  s.devServer = server;
7301
+ let workerReadyEpoch;
7302
+ const publishDevDiscoveryReady = (epoch) => {
7303
+ if (epoch <= (workerReadyEpoch ?? -1)) return;
7304
+ workerReadyEpoch = epoch;
7305
+ server.environments?.client?.hot?.send({
7306
+ type: "custom",
7307
+ event: DEV_DISCOVERY_READY_EVENT,
7308
+ data: { epoch }
7309
+ });
7310
+ debugDiscovery?.("hmr: workerd ready at epoch %d", epoch);
7311
+ };
7312
+ if (opts?.preset === "cloudflare") {
7313
+ const registeredClientHotChannels = /* @__PURE__ */ new Set();
7314
+ const registerDevDiscoveryHotChannels = () => {
7315
+ const clientHot = server.environments?.client?.hot;
7316
+ if (clientHot && !registeredClientHotChannels.has(clientHot)) {
7317
+ registeredClientHotChannels.add(clientHot);
7318
+ clientHot.on(
7319
+ DEV_DISCOVERY_QUERY_EVENT,
7320
+ (_payload, client) => {
7321
+ if (workerReadyEpoch === void 0) return;
7322
+ client.send(DEV_DISCOVERY_READY_EVENT, {
7323
+ epoch: workerReadyEpoch
7324
+ });
7325
+ }
7326
+ );
7327
+ }
7328
+ debugDiscovery?.(
7329
+ "hmr: dev discovery browser channel registered (client=%s)",
7330
+ !!clientHot
7331
+ );
7332
+ };
7333
+ registerDevDiscoveryHotChannels();
7334
+ if (server.httpServer && !server.httpServer.listening) {
7335
+ server.httpServer.once("listening", registerDevDiscoveryHotChannels);
7336
+ }
7337
+ }
7303
7338
  server.middlewares.use(internalDebugNoCacheMiddleware());
7304
7339
  let resolveDiscovery;
7305
7340
  const discoveryPromise = new Promise((resolve13) => {
@@ -7309,9 +7344,11 @@ function createRouterDiscoveryPlugin(entryPath, opts) {
7309
7344
  const beginDiscoveryGate = gate.beginGate;
7310
7345
  const resolveDiscoveryGate = gate.resolveGate;
7311
7346
  const getDevServerOrigin = () => server.resolvedUrls?.local?.[0]?.replace(/\/$/, "") || `http://localhost:${server.config.server.port || 5173}`;
7347
+ let devServerClosed = false;
7312
7348
  let prerenderTempServer = null;
7313
7349
  let prerenderNodeRegistry = null;
7314
7350
  server.httpServer?.on("close", () => {
7351
+ devServerClosed = true;
7315
7352
  if (prerenderTempServer) {
7316
7353
  prerenderTempServer.close().catch(() => {
7317
7354
  });
@@ -7587,12 +7624,6 @@ function createRouterDiscoveryPlugin(entryPath, opts) {
7587
7624
  if (s.mergedRouteManifest && serverMod.setCachedManifest) {
7588
7625
  serverMod.setCachedManifest(s.mergedRouteManifest);
7589
7626
  }
7590
- if (s.mergedPrecomputedEntries && s.mergedPrecomputedEntries.length > 0 && serverMod.setPrecomputedEntries) {
7591
- serverMod.setPrecomputedEntries(s.mergedPrecomputedEntries);
7592
- }
7593
- if (s.mergedRouteTrie && serverMod.setRouteTrie) {
7594
- serverMod.setRouteTrie(s.mergedRouteTrie);
7595
- }
7596
7627
  const perRouterSetters = [
7597
7628
  [s.perRouterManifestDataMap, "setRouterManifest"],
7598
7629
  [s.perRouterTrieMap, "setRouterTrie"],
@@ -7928,6 +7959,21 @@ function createRouterDiscoveryPlugin(entryPath, opts) {
7928
7959
  writeCombinedRouteTypesWithTracking(s);
7929
7960
  }
7930
7961
  };
7962
+ const routeShapeSignature = () => JSON.stringify(
7963
+ s.perRouterManifests.map(
7964
+ ({
7965
+ id,
7966
+ routeManifest,
7967
+ routeTrailingSlash,
7968
+ routeSearchSchemas
7969
+ }) => ({
7970
+ id,
7971
+ routeManifest,
7972
+ routeTrailingSlash,
7973
+ routeSearchSchemas
7974
+ })
7975
+ )
7976
+ );
7931
7977
  const maybeHandleGeneratedRouteFileMutation = (filePath) => {
7932
7978
  if (!isGeneratedRouteFile(filePath)) return false;
7933
7979
  if (consumeSelfGenWrite(s, filePath)) return true;
@@ -7941,6 +7987,7 @@ function createRouterDiscoveryPlugin(entryPath, opts) {
7941
7987
  const rscEnv = server.environments?.rsc;
7942
7988
  const hasMainRunner = !!rscEnv?.runner;
7943
7989
  if (!hasMainRunner && s.perRouterManifests.length === 0) return;
7990
+ let previousRouteShape = routeShapeSignature();
7944
7991
  await gate.runRefreshCycle(async () => {
7945
7992
  const hmrStart = performance.now();
7946
7993
  try {
@@ -7989,7 +8036,19 @@ function createRouterDiscoveryPlugin(entryPath, opts) {
7989
8036
  );
7990
8037
  s.lastDiscoveryError = null;
7991
8038
  }
7992
- if (rscEnv && !rscEnv.runner) forceCloudflareWorkerReload(rscEnv);
8039
+ if (rscEnv && !rscEnv.runner) {
8040
+ const nextRouteShape = routeShapeSignature();
8041
+ let expectedEpoch;
8042
+ if (nextRouteShape !== previousRouteShape) {
8043
+ expectedEpoch = Math.max(
8044
+ Date.now(),
8045
+ (s.devDiscoveryEpoch ?? 0) + 1
8046
+ );
8047
+ s.devDiscoveryEpoch = expectedEpoch;
8048
+ }
8049
+ previousRouteShape = nextRouteShape;
8050
+ forceCloudflareWorkerReload(rscEnv, expectedEpoch);
8051
+ }
7993
8052
  } catch (err) {
7994
8053
  s.lastDiscoveryError = {
7995
8054
  message: err?.message ?? String(err),
@@ -8010,10 +8069,10 @@ function createRouterDiscoveryPlugin(entryPath, opts) {
8010
8069
  }
8011
8070
  });
8012
8071
  };
8013
- const forceCloudflareWorkerReload = (rscEnv) => {
8072
+ const forceCloudflareWorkerReload = (rscEnv, expectedEpoch) => {
8014
8073
  if (!rscEnv?.hot) return;
8015
- try {
8016
- const graph = rscEnv.moduleGraph;
8074
+ const graph = rscEnv.moduleGraph;
8075
+ const reloadWorkerd = () => {
8017
8076
  if (graph?.invalidateAll) {
8018
8077
  graph.invalidateAll();
8019
8078
  debugDiscovery?.("hmr: invalidated workerd rsc module graph");
@@ -8022,12 +8081,40 @@ function createRouterDiscoveryPlugin(entryPath, opts) {
8022
8081
  debugDiscovery?.(
8023
8082
  "hmr: forced workerd rsc env reload (full-reload)"
8024
8083
  );
8025
- } catch (err) {
8084
+ };
8085
+ reloadWorkerd();
8086
+ if (expectedEpoch === void 0) return;
8087
+ void (async () => {
8088
+ const deadline = Date.now() + 15e3;
8089
+ do {
8090
+ if (devServerClosed || expectedEpoch !== s.devDiscoveryEpoch) {
8091
+ return;
8092
+ }
8093
+ await new Promise((resolve13) => setTimeout(resolve13, 25));
8094
+ if (devServerClosed || expectedEpoch !== s.devDiscoveryEpoch) {
8095
+ return;
8096
+ }
8097
+ try {
8098
+ const response = await fetch(getDevServerOrigin() + "/", {
8099
+ cache: "no-store",
8100
+ headers: {
8101
+ [DEV_DISCOVERY_PROBE_HEADER]: String(expectedEpoch)
8102
+ },
8103
+ signal: AbortSignal.timeout(1e3)
8104
+ });
8105
+ if (!devServerClosed && expectedEpoch === s.devDiscoveryEpoch && response.headers.get(DEV_DISCOVERY_EPOCH_HEADER) === String(expectedEpoch)) {
8106
+ publishDevDiscoveryReady(expectedEpoch);
8107
+ return;
8108
+ }
8109
+ reloadWorkerd();
8110
+ } catch {
8111
+ }
8112
+ } while (Date.now() < deadline);
8026
8113
  debugDiscovery?.(
8027
- "hmr: workerd reload failed: %s",
8028
- err?.message ?? err
8114
+ "hmr: workerd readiness probe timed out at epoch %d",
8115
+ expectedEpoch
8029
8116
  );
8030
- }
8117
+ })();
8031
8118
  };
8032
8119
  const scheduleRouteRegeneration = () => {
8033
8120
  clearTimeout(routeChangeTimer);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -100,6 +100,10 @@
100
100
  "react-server": "./src/rsc/handler.ts",
101
101
  "default": "./src/rsc/handler.ts"
102
102
  },
103
+ "./internal/browser/dev-discovery": {
104
+ "types": "./dist/types/browser/dev-discovery.d.ts",
105
+ "default": "./src/browser/dev-discovery.ts"
106
+ },
103
107
  "./cache": {
104
108
  "types": "./dist/types/cache/index.d.ts",
105
109
  "react-server": "./src/cache/index.ts",
@@ -179,7 +183,8 @@
179
183
  "typecheck": "tsc --noEmit && tsc -p tsconfig.strict-check.json --noEmit && tsc -p tsconfig.augment-check.json --noEmit",
180
184
  "test": "playwright test",
181
185
  "test:ui": "playwright test --ui",
182
- "test:hmr-local": "playwright test --project=dev-warmup --project=hmr-routes --project=hmr-basename --project=hmr-prerender --no-deps --workers=1",
186
+ "test:hmr-local": "playwright test --project=dev-warmup --project=hmr-basename --project=hmr-prerender --no-deps --workers=1 && RANGO_E2E_ROUTE_HMR_ONLY=1 playwright test --project=hmr-routes --no-deps --workers=1",
187
+ "test:hmr-routes-local": "RANGO_E2E_ROUTE_HMR_ONLY=1 playwright test --project=hmr-routes --no-deps --workers=1",
183
188
  "test:unit": "pnpm run build:types && vitest run",
184
189
  "test:unit:watch": "vitest",
185
190
  "test:unit:rsc": "vitest run --config vitest.rsc.config.ts"
@@ -171,7 +171,7 @@ export async function POST() {
171
171
  | `updateTag(...tags)` | awaitable (`Promise<void>`) | server actions | immediate; next read is fresh |
172
172
  | `revalidateTag(...tags)` | background (`void`) | route handlers / webhooks | background (non-blocking); next read re-renders fresh |
173
173
 
174
- Both built-in stores support tags. For `CFCacheStore`, distributed (cross-colo)
174
+ All three built-in stores support tags. For `CFCacheStore`, distributed (cross-colo)
175
175
  invalidation requires a `kv` namespace — the tag-invalidation markers live in
176
176
  that same namespace; there is **no** separate tag-invalidation store to wire.
177
177
  If no tag-capable store is configured, `updateTag`/`revalidateTag` warn and no-op.
@@ -170,6 +170,48 @@ On a document GET to a ppr route the router runs:
170
170
  point is after the chain, an unauthorized request NEVER sees shell bytes — put
171
171
  auth middleware anywhere (global or route DSL) and it guards PPR for free.
172
172
 
173
+ ### Soft navigation reuses the captured segment shell
174
+
175
+ A usable shell snapshot also accelerates ordinary partial RSC navigations to
176
+ the same URL. The capture records the canonical document segment tree alongside
177
+ the HTML prelude. On a partial request the server replays only that segment
178
+ record through the normal `matchPartial()` pipeline, which then:
179
+
180
+ - preserves client-owned shared layouts by segment id;
181
+ - returns only new or revalidating destination segments;
182
+ - runs DSL loaders fresh with their normal `loading()` streaming behavior;
183
+ - keeps the existing prefetch key, source scope, and in-flight lock unchanged.
184
+
185
+ This is deliberately invisible to the browser: the response is the same
186
+ `RscPayload` shape as any other partial navigation. Captured item/response values
187
+ and loader-container pins are NOT replayed on this path, so loader reads stay
188
+ live. A route's own `cache()` scope still resolves its normal store, key, TTL,
189
+ SWR, tags, and condition; only the implicit document scope sees the replay
190
+ overlay, and fresh segment writes there stay request-local rather than polluting
191
+ the canonical `doc:` namespace. Intercepts, handler-live holes,
192
+ `transition({ when })`, an active nonce, and an absent/corrupt segment snapshot
193
+ fall open to the ordinary partial path when encountered by the fresh shell
194
+ capture. A transition already replayed from an explicit cache tier remains
195
+ frozen by that tier's normal semantics.
196
+
197
+ Only fresh shells replay. Production may use either a runtime entry or the
198
+ local build manifest; development uses runtime entries only, because probing
199
+ `/__rsc_shell` would block the foreground navigation on an on-demand capture.
200
+ A passive stale read does not claim SWR ownership because partial requests
201
+ cannot recapture the HTML shell. Custom `SegmentCacheStore` implementations
202
+ must set `supportsPassiveShellReads: true` and honor
203
+ `getShell(key, { claimRevalidation: false })` to opt into navigation replay.
204
+ There is still no Flight resume API; this is segment replay followed by normal
205
+ Flight streaming, not reuse of the HTML `prelude`/`postponed` bytes.
206
+
207
+ ### Capture-generation invalidation
208
+
209
+ If handler or bake-lane code calls `updateTag()` on one of the shell's own tags
210
+ while capture is running, that generation is rejected. Built-in stores report it;
211
+ Rango warns with the shell key and backs capture off instead of rendering the
212
+ same doomed generation on every request. Move a deterministic self-invalidation
213
+ out of render code if you want the shell to persist.
214
+
173
215
  ### Opting out per request with `ctx.dynamic()`
174
216
 
175
217
  Middleware and handlers can call `ctx.dynamic()` to force this request back to
@@ -405,7 +447,8 @@ Four hard edges (each e2e/unit-pinned):
405
447
  Pitfalls: the session-object bake trap).
406
448
  - **A rejecting bake-lane loader refuses.** Error UI never bakes.
407
449
  - **Baked containers show CAPTURE-time data** for the shell's lifetime on
408
- document GETs (client navigations stay fresh axis 1). That IS the bake
450
+ document GETs. Soft navigations may replay the captured handler segments, but
451
+ DSL loaders and their item/response reads remain fresh. That IS the bake
409
452
  lane's meaning; if a value must be fresh on every serve, it belongs on the
410
453
  live lane (`loading()`) or in a nested promise.
411
454
 
@@ -539,9 +582,10 @@ bake-lane container — or key per variant at the CDN tier.
539
582
 
540
583
  ## What always stays on axis 1
541
584
 
542
- Non-GET, RSC/partial/action/loader fetches, per-request CSP nonce,
543
- `streamMode: "allReady"`, redirects, 404s, error renders, routes without `ppr`,
544
- and any store without the shell family. A stored shell is invalidated when
585
+ Non-GET, non-partial RSC/action/loader fetches, partial requests without an
586
+ eligible captured segment snapshot, per-request CSP nonce, `streamMode:
587
+ "allReady"`, redirects, 404s, error renders, routes without `ppr`, and any store
588
+ without the shell family. A stored shell is invalidated when
545
589
  `React.version` changes (postponed state is build-coupled), so deploys
546
590
  self-heal via recapture.
547
591