@rangojs/router 0.0.0-experimental.15 → 0.0.0-experimental.16

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 (36) hide show
  1. package/dist/vite/index.js +74 -3
  2. package/package.json +14 -15
  3. package/src/browser/action-response-classifier.ts +104 -0
  4. package/src/browser/event-controller.ts +14 -5
  5. package/src/browser/intercept-utils.ts +56 -0
  6. package/src/browser/logging.ts +11 -0
  7. package/src/browser/lru-cache.ts +1 -9
  8. package/src/browser/merge-segment-loaders.ts +6 -5
  9. package/src/browser/navigation-bridge.ts +44 -142
  10. package/src/browser/navigation-client.ts +4 -1
  11. package/src/browser/network-error-handler.ts +61 -0
  12. package/src/browser/partial-update.ts +77 -169
  13. package/src/browser/react/use-navigation.ts +9 -2
  14. package/src/browser/scroll-restoration.ts +71 -5
  15. package/src/browser/segment-reconciler.ts +216 -0
  16. package/src/browser/segment-structure-assert.ts +16 -0
  17. package/src/browser/server-action-bridge.ts +212 -454
  18. package/src/build/route-trie.ts +14 -7
  19. package/src/cache/cache-scope.ts +15 -4
  20. package/src/cache/memory-segment-store.ts +49 -7
  21. package/src/errors.ts +8 -0
  22. package/src/prerender/param-hash.ts +4 -2
  23. package/src/prerender.ts +5 -7
  24. package/src/router/handler-context.ts +1 -1
  25. package/src/router/loader-resolution.ts +137 -107
  26. package/src/router/match-result.ts +16 -2
  27. package/src/router/pattern-matching.ts +44 -7
  28. package/src/router/segment-resolution.ts +35 -5
  29. package/src/router/trie-matching.ts +29 -13
  30. package/src/rsc/handler.ts +2 -2
  31. package/src/segment-system.tsx +1 -1
  32. package/src/server/context.ts +8 -1
  33. package/src/server/loader-registry.ts +4 -10
  34. package/src/server/request-context.ts +7 -1
  35. package/src/vite/index.ts +115 -3
  36. package/dist/vite/index.named-routes.gen.ts +0 -103
@@ -1778,7 +1778,7 @@ import { resolve as resolve2 } from "node:path";
1778
1778
  // package.json
1779
1779
  var package_default = {
1780
1780
  name: "@rangojs/router",
1781
- version: "0.0.0-experimental.15",
1781
+ version: "0.0.0-experimental.16",
1782
1782
  type: "module",
1783
1783
  description: "Django-inspired RSC router with composable URL patterns",
1784
1784
  author: "Ivo Todorov",
@@ -2061,7 +2061,7 @@ function createVirtualEntriesPlugin(entries, routerPath) {
2061
2061
  };
2062
2062
  }
2063
2063
  function onwarn(warning, defaultHandler) {
2064
- if (warning.code === "MODULE_LEVEL_DIRECTIVE" || warning.code === "SOURCEMAP_ERROR") {
2064
+ if (warning.code === "MODULE_LEVEL_DIRECTIVE" || warning.code === "SOURCEMAP_ERROR" || warning.code === "EMPTY_BUNDLE") {
2065
2065
  return;
2066
2066
  }
2067
2067
  if (warning.message?.includes("Sourcemap is likely to be incorrect")) {
@@ -2586,6 +2586,13 @@ ${err.stack}`
2586
2586
  setTimeout(() => discover().then(resolve4, resolve4), 0);
2587
2587
  });
2588
2588
  let prerenderTempServer = null;
2589
+ server.httpServer?.on("close", () => {
2590
+ if (prerenderTempServer) {
2591
+ prerenderTempServer.close().catch(() => {
2592
+ });
2593
+ prerenderTempServer = null;
2594
+ }
2595
+ });
2589
2596
  let prerenderNodeRegistry = null;
2590
2597
  let mainRegistry = null;
2591
2598
  server.middlewares.use("/__rsc_prerender", async (req, res) => {
@@ -2998,6 +3005,53 @@ globalThis.__PRERENDER_MANIFEST = __pm;
2998
3005
  }
2999
3006
  };
3000
3007
  }
3008
+ function stripJsonComments(input) {
3009
+ let result = "";
3010
+ let i = 0;
3011
+ const len = input.length;
3012
+ while (i < len) {
3013
+ const ch = input[i];
3014
+ if (ch === '"') {
3015
+ result += ch;
3016
+ i++;
3017
+ while (i < len) {
3018
+ const sc = input[i];
3019
+ result += sc;
3020
+ i++;
3021
+ if (sc === "\\") {
3022
+ if (i < len) {
3023
+ result += input[i];
3024
+ i++;
3025
+ }
3026
+ } else if (sc === '"') {
3027
+ break;
3028
+ }
3029
+ }
3030
+ continue;
3031
+ }
3032
+ if (ch === "/" && i + 1 < len && input[i + 1] === "/") {
3033
+ i += 2;
3034
+ while (i < len && input[i] !== "\n") {
3035
+ i++;
3036
+ }
3037
+ continue;
3038
+ }
3039
+ if (ch === "/" && i + 1 < len && input[i + 1] === "*") {
3040
+ i += 2;
3041
+ while (i < len) {
3042
+ if (input[i] === "*" && i + 1 < len && input[i + 1] === "/") {
3043
+ i += 2;
3044
+ break;
3045
+ }
3046
+ i++;
3047
+ }
3048
+ continue;
3049
+ }
3050
+ result += ch;
3051
+ i++;
3052
+ }
3053
+ return result;
3054
+ }
3001
3055
  var VIRTUAL_ROUTES_MANIFEST_ID = "virtual:rsc-router/routes-manifest";
3002
3056
  function resolveDiscoveryEntryPath(options, routerPath) {
3003
3057
  if (options.preset === "cloudflare") {
@@ -3006,7 +3060,7 @@ function resolveDiscoveryEntryPath(options, routerPath) {
3006
3060
  if (existsSync3(filename)) {
3007
3061
  try {
3008
3062
  const raw = readFileSync2(filename, "utf-8");
3009
- const cleaned = raw.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
3063
+ const cleaned = stripJsonComments(raw);
3010
3064
  const config = JSON.parse(cleaned);
3011
3065
  if (config.main) {
3012
3066
  return config.main;
@@ -3353,6 +3407,23 @@ ${list}`
3353
3407
  );
3354
3408
  }
3355
3409
  }
3410
+ plugins.push({
3411
+ name: "@rangojs/router:client-component-hmr",
3412
+ hotUpdate(ctx) {
3413
+ const envName = this.environment?.name;
3414
+ if (envName !== "rsc" && envName !== "ssr") return;
3415
+ const file = ctx.file;
3416
+ if (!file.endsWith(".tsx") && !file.endsWith(".ts") && !file.endsWith(".jsx") && !file.endsWith(".js")) return;
3417
+ try {
3418
+ const source = readFileSync2(file, "utf-8");
3419
+ const trimmed = source.trimStart();
3420
+ if (trimmed.startsWith('"use client"') || trimmed.startsWith("'use client'")) {
3421
+ return [];
3422
+ }
3423
+ } catch {
3424
+ }
3425
+ }
3426
+ });
3356
3427
  plugins.push(exposeActionId());
3357
3428
  plugins.push(exposeInternalIds());
3358
3429
  plugins.push(exposeRouterId());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.0.0-experimental.15",
3
+ "version": "0.0.0-experimental.16",
4
4
  "type": "module",
5
5
  "description": "Django-inspired RSC router with composable URL patterns",
6
6
  "author": "Ivo Todorov",
@@ -127,15 +127,6 @@
127
127
  "bin": {
128
128
  "rango": "./dist/bin/rango.js"
129
129
  },
130
- "scripts": {
131
- "build": "pnpm dlx esbuild src/vite/index.ts --bundle --format=esm --outfile=dist/vite/index.js --platform=node --packages=external && pnpm dlx esbuild src/bin/rango.ts --bundle --format=esm --outfile=dist/bin/rango.js --platform=node --packages=external --banner:js='#!/usr/bin/env node'",
132
- "prepublishOnly": "pnpm build",
133
- "typecheck": "tsc --noEmit",
134
- "test": "playwright test",
135
- "test:ui": "playwright test --ui",
136
- "test:unit": "vitest run",
137
- "test:unit:watch": "vitest"
138
- },
139
130
  "peerDependencies": {
140
131
  "@cloudflare/vite-plugin": "^1.21.0",
141
132
  "@vitejs/plugin-rsc": "^0.5.14",
@@ -159,14 +150,22 @@
159
150
  "devDependencies": {
160
151
  "@playwright/test": "^1.49.1",
161
152
  "@types/node": "^24.10.1",
162
- "@types/react": "catalog:",
163
- "@types/react-dom": "catalog:",
153
+ "@types/react": "^19.2.7",
154
+ "@types/react-dom": "^19.2.3",
164
155
  "esbuild": "^0.27.0",
165
156
  "jiti": "^2.6.1",
166
- "react": "catalog:",
167
- "react-dom": "catalog:",
157
+ "react": "^19.2.4",
158
+ "react-dom": "^19.2.4",
168
159
  "tinyexec": "^0.3.2",
169
160
  "typescript": "^5.3.0",
170
161
  "vitest": "^4.0.0"
162
+ },
163
+ "scripts": {
164
+ "build": "pnpm dlx esbuild src/vite/index.ts --bundle --format=esm --outfile=dist/vite/index.js --platform=node --packages=external && pnpm dlx esbuild src/bin/rango.ts --bundle --format=esm --outfile=dist/bin/rango.js --platform=node --packages=external --banner:js='#!/usr/bin/env node'",
165
+ "typecheck": "tsc --noEmit",
166
+ "test": "playwright test",
167
+ "test:ui": "playwright test --ui",
168
+ "test:unit": "vitest run",
169
+ "test:unit:watch": "vitest"
171
170
  }
172
- }
171
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Discriminated union of post-reconciliation action response scenarios.
3
+ *
4
+ * Error and full-update-unsupported are handled inline in the bridge
5
+ * before reconciliation. This classifier only runs for partial responses
6
+ * that have been successfully reconciled.
7
+ */
8
+ export type ActionScenario =
9
+ | {
10
+ type: "navigated-away";
11
+ historyKeyChanged: boolean;
12
+ onInterceptRoute: boolean;
13
+ }
14
+ | { type: "hmr-missing" }
15
+ | { type: "consolidation-needed"; segmentIds: string[] }
16
+ | { type: "concurrent-skip"; otherFetchingCount: number }
17
+ | { type: "normal" };
18
+
19
+ /**
20
+ * Pure data inputs for classifying a partial action response.
21
+ * All values come from the bridge but no browser APIs or side effects.
22
+ */
23
+ export interface ClassifierInput {
24
+ /** window.location.pathname captured at action start */
25
+ actionStartPathname: string;
26
+ /** window.location.pathname at classification time */
27
+ currentPathname: string;
28
+ /** window.history.state?.key captured at action start */
29
+ actionStartLocationKey: string | undefined;
30
+ /** window.history.state?.key at classification time */
31
+ currentLocationKey: string | undefined;
32
+ /** Number of segments after reconciliation */
33
+ reconciledSegmentCount: number;
34
+ /** Number of matched segment IDs from server */
35
+ matchedCount: number;
36
+ /** Segment IDs needing consolidation (from concurrent action tracking) */
37
+ consolidationSegments: string[] | null;
38
+ /** Number of other actions still in "fetching" phase */
39
+ otherFetchingActionCount: number;
40
+ /** Current intercept source URL (null when not on intercept route) */
41
+ currentInterceptSource: string | null;
42
+ }
43
+
44
+ /**
45
+ * Classify a partial action response into one of 5 post-reconciliation
46
+ * scenarios.
47
+ *
48
+ * Called after error and full-update cases are handled inline by the bridge.
49
+ * The classification order matches the priority chain:
50
+ * 1. User navigated away during action
51
+ * 2. HMR missing segments (fewer reconciled than matched)
52
+ * 3. Consolidation needed (concurrent actions finished)
53
+ * 4. Concurrent skip (other actions still fetching)
54
+ * 5. Normal (single action, no issues)
55
+ *
56
+ * This is a pure function with no side effects - the bridge handles
57
+ * all UI updates, store mutations, and network requests based on the
58
+ * returned scenario.
59
+ */
60
+ export function classifyActionResponse(
61
+ input: ClassifierInput,
62
+ ): ActionScenario {
63
+ // Check if user navigated away during the action
64
+ const userNavigatedAway =
65
+ input.currentPathname !== input.actionStartPathname ||
66
+ input.currentLocationKey !== input.actionStartLocationKey;
67
+
68
+ if (userNavigatedAway) {
69
+ const historyKeyChanged =
70
+ input.currentLocationKey !== input.actionStartLocationKey;
71
+ return {
72
+ type: "navigated-away",
73
+ historyKeyChanged,
74
+ onInterceptRoute: input.currentInterceptSource !== null,
75
+ };
76
+ }
77
+
78
+ // HMR resilience: segments missing after reconciliation
79
+ if (input.reconciledSegmentCount < input.matchedCount) {
80
+ return { type: "hmr-missing" };
81
+ }
82
+
83
+ // Consolidation needed for concurrent actions
84
+ if (
85
+ input.consolidationSegments &&
86
+ input.consolidationSegments.length > 0
87
+ ) {
88
+ return {
89
+ type: "consolidation-needed",
90
+ segmentIds: input.consolidationSegments,
91
+ };
92
+ }
93
+
94
+ // Other actions still fetching - skip UI update
95
+ if (input.otherFetchingActionCount > 0) {
96
+ return {
97
+ type: "concurrent-skip",
98
+ otherFetchingCount: input.otherFetchingActionCount,
99
+ };
100
+ }
101
+
102
+ // Normal single-action completion
103
+ return { type: "normal" };
104
+ }
@@ -40,7 +40,7 @@ export interface NavigationEntry {
40
40
  abort: AbortController;
41
41
  phase: NavigationPhase;
42
42
  startedAt: number;
43
- options?: NavigateOptions;
43
+ options?: NavigateOptions & { skipLoadingState?: boolean };
44
44
  }
45
45
 
46
46
  /**
@@ -176,7 +176,7 @@ export interface ActionHandle extends Disposable {
176
176
  */
177
177
  export interface EventController {
178
178
  // Navigation operations
179
- startNavigation(url: string, options?: NavigateOptions): NavigationHandle;
179
+ startNavigation(url: string, options?: NavigateOptions & { skipLoadingState?: boolean }): NavigationHandle;
180
180
  abortNavigation(): void;
181
181
 
182
182
  // Action operations
@@ -367,9 +367,13 @@ export function createEventController(
367
367
  }));
368
368
 
369
369
  // State: loading if navigation OR actions are in progress
370
+ // Background revalidations (skipLoadingState) don't affect visible state
370
371
  const hasActiveActions = inflightActionsList.length > 0;
372
+ const isVisibleNavigation =
373
+ currentNavigation !== null &&
374
+ !currentNavigation.options?.skipLoadingState;
371
375
  const state =
372
- currentNavigation !== null || hasActiveActions ? "loading" : "idle";
376
+ isVisibleNavigation || hasActiveActions ? "loading" : "idle";
373
377
 
374
378
  // Streaming: true if any active streams (navigation or action) or loading
375
379
  const isStreaming = activeStreamCount > 0 || state === "loading";
@@ -379,7 +383,12 @@ export function createEventController(
379
383
  isStreaming,
380
384
  location,
381
385
  // pendingUrl only during fetching phase - once streaming starts (URL changed), not pending
382
- pendingUrl: currentNavigation?.phase === "fetching" ? currentNavigation.url : null,
386
+ // Background revalidations don't expose a pending URL
387
+ pendingUrl:
388
+ currentNavigation?.phase === "fetching" &&
389
+ !currentNavigation.options?.skipLoadingState
390
+ ? currentNavigation.url
391
+ : null,
383
392
  inflightActions: inflightActionsList,
384
393
  };
385
394
  }
@@ -431,7 +440,7 @@ export function createEventController(
431
440
 
432
441
  function startNavigation(
433
442
  url: string,
434
- options?: NavigateOptions
443
+ options?: NavigateOptions & { skipLoadingState?: boolean }
435
444
  ): NavigationHandle {
436
445
  // Cancel existing navigation (switchMap semantics)
437
446
  if (currentNavigation) {
@@ -0,0 +1,56 @@
1
+ import type { ResolvedSegment } from "./types.js";
2
+ import type { SlotState } from "../types.js";
3
+
4
+ /**
5
+ * Check if a segment is an intercept segment.
6
+ * Intercept segments have namespace starting with "intercept:" or are parallel
7
+ * segments with ".@" in their ID (e.g., "L0.@modal").
8
+ */
9
+ export function isInterceptSegment(s: ResolvedSegment): boolean {
10
+ return (
11
+ s.namespace?.startsWith("intercept:") ||
12
+ (s.type === "parallel" && s.id.includes(".@"))
13
+ );
14
+ }
15
+
16
+ /**
17
+ * Split an array of segments into main and intercept groups.
18
+ * Intercept segments are separated for explicit injection into the render tree
19
+ * via the interceptSegments render option.
20
+ */
21
+ export function splitInterceptSegments(segments: ResolvedSegment[]): {
22
+ main: ResolvedSegment[];
23
+ intercept: ResolvedSegment[];
24
+ } {
25
+ const main: ResolvedSegment[] = [];
26
+ const intercept: ResolvedSegment[] = [];
27
+ for (const s of segments) {
28
+ if (isInterceptSegment(s)) {
29
+ intercept.push(s);
30
+ } else {
31
+ main.push(s);
32
+ }
33
+ }
34
+ return { main, intercept };
35
+ }
36
+
37
+ /**
38
+ * Check if any slot is currently active (has content to render).
39
+ * Active slots indicate an intercept response where a parallel segment
40
+ * (e.g., @modal) has matched and should be rendered.
41
+ */
42
+ export function hasActiveIntercept(
43
+ slots?: Record<string, SlotState>,
44
+ ): boolean {
45
+ if (!slots) return false;
46
+ return Object.values(slots).some((slot) => slot.active);
47
+ }
48
+
49
+ /**
50
+ * Check if cached segments contain any intercept segments.
51
+ * Intercept caches shouldn't be used for optimistic rendering since
52
+ * whether interception happens depends on the current page context.
53
+ */
54
+ export function isInterceptOnlyCache(segments: ResolvedSegment[]): boolean {
55
+ return segments.some(isInterceptSegment);
56
+ }
@@ -42,3 +42,14 @@ export function browserDebugLog(
42
42
 
43
43
  console.log(`${prefix} ${message}`);
44
44
  }
45
+
46
+ /**
47
+ * Simple gated console.log for browser-side debug output.
48
+ * Unlike browserDebugLog, this doesn't require a transaction context -
49
+ * use it for standalone debug messages in partial-update, navigation-bridge, etc.
50
+ */
51
+ export function debugLog(msg: string, ...args: unknown[]): void {
52
+ if (INTERNAL_RANGO_DEBUG) {
53
+ console.log(msg, ...args);
54
+ }
55
+ }
@@ -40,15 +40,7 @@ export class LRUCache<K, V> {
40
40
  }
41
41
 
42
42
  has(key: K): boolean {
43
- if (!this.cache.has(key)) {
44
- return false;
45
- }
46
-
47
- // Move to end (most recently used) - same as get()
48
- const value = this.cache.get(key)!;
49
- this.cache.delete(key);
50
- this.cache.set(key, value);
51
- return true;
43
+ return this.cache.has(key);
52
44
  }
53
45
 
54
46
  delete(key: K): boolean {
@@ -1,4 +1,5 @@
1
1
  import type { ResolvedSegment } from "./types.js";
2
+ import { debugLog } from "./logging.js";
2
3
 
3
4
  /**
4
5
  * Merge partial loader data from server with cached loader data.
@@ -18,8 +19,8 @@ export function mergeSegmentLoaders(
18
19
  const serverLoaderIds = fromServer.loaderIds || [];
19
20
  const cachedLoaderIds = fromCache.loaderIds || [];
20
21
 
21
- console.log(
22
- `[Browser] Merging partial loaders: server has ${serverLoaderIds.join(", ")}, cache has ${cachedLoaderIds.join(", ")}`
22
+ debugLog(
23
+ `[Browser] Merging partial loaders: server has ${serverLoaderIds.join(", ")}, cache has ${cachedLoaderIds.join(", ")}`,
23
24
  );
24
25
 
25
26
  return {
@@ -105,8 +106,8 @@ export function insertMissingDiffSegments(
105
106
  if (parentIndex !== -1) {
106
107
  // Insert loader segment right after its parent layout
107
108
  allSegments.splice(parentIndex + 1, 0, fromServer);
108
- console.log(
109
- `[Browser] Inserted diff segment ${diffId} after ${parentLayoutId}`
109
+ debugLog(
110
+ `[Browser] Inserted diff segment ${diffId} after ${parentLayoutId}`,
110
111
  );
111
112
  } else {
112
113
  // Fallback: append to end if parent not found
@@ -118,7 +119,7 @@ export function insertMissingDiffSegments(
118
119
  } else {
119
120
  // Non-loader diff segment not in matched - append to end
120
121
  allSegments.push(fromServer);
121
- console.log(`[Browser] Appended diff segment ${diffId}`);
122
+ debugLog(`[Browser] Appended diff segment ${diffId}`);
122
123
  }
123
124
  }
124
125
  }