@rangojs/router 0.0.0-experimental.10

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 (172) hide show
  1. package/CLAUDE.md +43 -0
  2. package/README.md +19 -0
  3. package/dist/bin/rango.js +227 -0
  4. package/dist/vite/index.js +3039 -0
  5. package/package.json +171 -0
  6. package/skills/caching/SKILL.md +191 -0
  7. package/skills/debug-manifest/SKILL.md +108 -0
  8. package/skills/document-cache/SKILL.md +180 -0
  9. package/skills/fonts/SKILL.md +165 -0
  10. package/skills/hooks/SKILL.md +442 -0
  11. package/skills/intercept/SKILL.md +190 -0
  12. package/skills/layout/SKILL.md +213 -0
  13. package/skills/links/SKILL.md +180 -0
  14. package/skills/loader/SKILL.md +246 -0
  15. package/skills/middleware/SKILL.md +202 -0
  16. package/skills/mime-routes/SKILL.md +124 -0
  17. package/skills/parallel/SKILL.md +228 -0
  18. package/skills/prerender/SKILL.md +283 -0
  19. package/skills/rango/SKILL.md +54 -0
  20. package/skills/response-routes/SKILL.md +358 -0
  21. package/skills/route/SKILL.md +173 -0
  22. package/skills/router-setup/SKILL.md +346 -0
  23. package/skills/tailwind/SKILL.md +129 -0
  24. package/skills/theme/SKILL.md +78 -0
  25. package/skills/typesafety/SKILL.md +394 -0
  26. package/src/__internal.ts +175 -0
  27. package/src/bin/rango.ts +24 -0
  28. package/src/browser/event-controller.ts +876 -0
  29. package/src/browser/index.ts +18 -0
  30. package/src/browser/link-interceptor.ts +121 -0
  31. package/src/browser/lru-cache.ts +69 -0
  32. package/src/browser/merge-segment-loaders.ts +126 -0
  33. package/src/browser/navigation-bridge.ts +913 -0
  34. package/src/browser/navigation-client.ts +165 -0
  35. package/src/browser/navigation-store.ts +823 -0
  36. package/src/browser/partial-update.ts +600 -0
  37. package/src/browser/react/Link.tsx +248 -0
  38. package/src/browser/react/NavigationProvider.tsx +346 -0
  39. package/src/browser/react/ScrollRestoration.tsx +94 -0
  40. package/src/browser/react/context.ts +53 -0
  41. package/src/browser/react/index.ts +52 -0
  42. package/src/browser/react/location-state-shared.ts +120 -0
  43. package/src/browser/react/location-state.ts +62 -0
  44. package/src/browser/react/mount-context.ts +32 -0
  45. package/src/browser/react/use-action.ts +240 -0
  46. package/src/browser/react/use-client-cache.ts +56 -0
  47. package/src/browser/react/use-handle.ts +203 -0
  48. package/src/browser/react/use-href.tsx +40 -0
  49. package/src/browser/react/use-link-status.ts +134 -0
  50. package/src/browser/react/use-mount.ts +31 -0
  51. package/src/browser/react/use-navigation.ts +140 -0
  52. package/src/browser/react/use-segments.ts +188 -0
  53. package/src/browser/request-controller.ts +164 -0
  54. package/src/browser/rsc-router.tsx +352 -0
  55. package/src/browser/scroll-restoration.ts +324 -0
  56. package/src/browser/segment-structure-assert.ts +67 -0
  57. package/src/browser/server-action-bridge.ts +762 -0
  58. package/src/browser/shallow.ts +35 -0
  59. package/src/browser/types.ts +478 -0
  60. package/src/build/generate-manifest.ts +377 -0
  61. package/src/build/generate-route-types.ts +828 -0
  62. package/src/build/index.ts +36 -0
  63. package/src/build/route-trie.ts +239 -0
  64. package/src/cache/cache-scope.ts +563 -0
  65. package/src/cache/cf/cf-cache-store.ts +428 -0
  66. package/src/cache/cf/index.ts +19 -0
  67. package/src/cache/document-cache.ts +340 -0
  68. package/src/cache/index.ts +58 -0
  69. package/src/cache/memory-segment-store.ts +150 -0
  70. package/src/cache/memory-store.ts +253 -0
  71. package/src/cache/types.ts +392 -0
  72. package/src/client.rsc.tsx +83 -0
  73. package/src/client.tsx +643 -0
  74. package/src/component-utils.ts +76 -0
  75. package/src/components/DefaultDocument.tsx +23 -0
  76. package/src/debug.ts +233 -0
  77. package/src/default-error-boundary.tsx +88 -0
  78. package/src/deps/browser.ts +8 -0
  79. package/src/deps/html-stream-client.ts +2 -0
  80. package/src/deps/html-stream-server.ts +2 -0
  81. package/src/deps/rsc.ts +10 -0
  82. package/src/deps/ssr.ts +2 -0
  83. package/src/errors.ts +295 -0
  84. package/src/handle.ts +130 -0
  85. package/src/handles/MetaTags.tsx +193 -0
  86. package/src/handles/index.ts +6 -0
  87. package/src/handles/meta.ts +247 -0
  88. package/src/host/cookie-handler.ts +159 -0
  89. package/src/host/errors.ts +97 -0
  90. package/src/host/index.ts +56 -0
  91. package/src/host/pattern-matcher.ts +214 -0
  92. package/src/host/router.ts +330 -0
  93. package/src/host/testing.ts +79 -0
  94. package/src/host/types.ts +138 -0
  95. package/src/host/utils.ts +25 -0
  96. package/src/href-client.ts +202 -0
  97. package/src/href-context.ts +33 -0
  98. package/src/index.rsc.ts +121 -0
  99. package/src/index.ts +165 -0
  100. package/src/loader.rsc.ts +207 -0
  101. package/src/loader.ts +47 -0
  102. package/src/network-error-thrower.tsx +21 -0
  103. package/src/outlet-context.ts +15 -0
  104. package/src/prerender/param-hash.ts +35 -0
  105. package/src/prerender/store.ts +40 -0
  106. package/src/prerender.ts +156 -0
  107. package/src/reverse.ts +267 -0
  108. package/src/root-error-boundary.tsx +277 -0
  109. package/src/route-content-wrapper.tsx +193 -0
  110. package/src/route-definition.ts +1431 -0
  111. package/src/route-map-builder.ts +242 -0
  112. package/src/route-types.ts +220 -0
  113. package/src/router/error-handling.ts +287 -0
  114. package/src/router/handler-context.ts +158 -0
  115. package/src/router/intercept-resolution.ts +387 -0
  116. package/src/router/loader-resolution.ts +327 -0
  117. package/src/router/manifest.ts +216 -0
  118. package/src/router/match-api.ts +621 -0
  119. package/src/router/match-context.ts +264 -0
  120. package/src/router/match-middleware/background-revalidation.ts +236 -0
  121. package/src/router/match-middleware/cache-lookup.ts +382 -0
  122. package/src/router/match-middleware/cache-store.ts +276 -0
  123. package/src/router/match-middleware/index.ts +81 -0
  124. package/src/router/match-middleware/intercept-resolution.ts +281 -0
  125. package/src/router/match-middleware/segment-resolution.ts +184 -0
  126. package/src/router/match-pipelines.ts +214 -0
  127. package/src/router/match-result.ts +213 -0
  128. package/src/router/metrics.ts +62 -0
  129. package/src/router/middleware.ts +791 -0
  130. package/src/router/pattern-matching.ts +407 -0
  131. package/src/router/revalidation.ts +190 -0
  132. package/src/router/router-context.ts +301 -0
  133. package/src/router/segment-resolution.ts +1315 -0
  134. package/src/router/trie-matching.ts +172 -0
  135. package/src/router/types.ts +163 -0
  136. package/src/router.gen.ts +6 -0
  137. package/src/router.ts +2423 -0
  138. package/src/rsc/handler.ts +1443 -0
  139. package/src/rsc/helpers.ts +64 -0
  140. package/src/rsc/index.ts +56 -0
  141. package/src/rsc/nonce.ts +18 -0
  142. package/src/rsc/types.ts +236 -0
  143. package/src/segment-system.tsx +442 -0
  144. package/src/server/context.ts +466 -0
  145. package/src/server/handle-store.ts +229 -0
  146. package/src/server/loader-registry.ts +174 -0
  147. package/src/server/request-context.ts +554 -0
  148. package/src/server/root-layout.tsx +10 -0
  149. package/src/server/tsconfig.json +14 -0
  150. package/src/server.ts +171 -0
  151. package/src/ssr/index.tsx +296 -0
  152. package/src/theme/ThemeProvider.tsx +291 -0
  153. package/src/theme/ThemeScript.tsx +61 -0
  154. package/src/theme/constants.ts +59 -0
  155. package/src/theme/index.ts +58 -0
  156. package/src/theme/theme-context.ts +70 -0
  157. package/src/theme/theme-script.ts +152 -0
  158. package/src/theme/types.ts +182 -0
  159. package/src/theme/use-theme.ts +44 -0
  160. package/src/types.ts +1757 -0
  161. package/src/urls.gen.ts +8 -0
  162. package/src/urls.ts +1282 -0
  163. package/src/use-loader.tsx +346 -0
  164. package/src/vite/expose-action-id.ts +344 -0
  165. package/src/vite/expose-handle-id.ts +209 -0
  166. package/src/vite/expose-loader-id.ts +426 -0
  167. package/src/vite/expose-location-state-id.ts +177 -0
  168. package/src/vite/expose-prerender-handler-id.ts +429 -0
  169. package/src/vite/index.ts +2068 -0
  170. package/src/vite/package-resolution.ts +125 -0
  171. package/src/vite/version.d.ts +12 -0
  172. package/src/vite/virtual-entries.ts +114 -0
@@ -0,0 +1,407 @@
1
+ /**
2
+ * Router Pattern Matching
3
+ *
4
+ * Route pattern compilation and matching utilities.
5
+ */
6
+
7
+ import type { RouteEntry, TrailingSlashMode } from "../types";
8
+ import type { EntryData } from "../server/context";
9
+
10
+ /**
11
+ * Parsed segment info
12
+ */
13
+ export interface ParsedSegment {
14
+ type: "static" | "param" | "wildcard";
15
+ value: string; // static text, param name, or "*"
16
+ optional: boolean;
17
+ constraint?: string[]; // enum values like ["en", "gb"]
18
+ }
19
+
20
+ /**
21
+ * Parse a route pattern into segments
22
+ *
23
+ * Supports:
24
+ * - Static: /blog, /about
25
+ * - Params: /:slug, /:id
26
+ * - Optional: /:locale?, /:page?
27
+ * - Constrained: /:locale(en|gb), /:type(post|page)
28
+ * - Optional + Constrained: /:locale(en|gb)?
29
+ * - Wildcard: /*
30
+ */
31
+ export function parsePattern(pattern: string): ParsedSegment[] {
32
+ const segments: ParsedSegment[] = [];
33
+ // Match: /segment where segment can be:
34
+ // - static text
35
+ // - :param
36
+ // - :param?
37
+ // - :param(a|b)
38
+ // - :param(a|b)?
39
+ // - *
40
+ const segmentRegex = /\/(:([a-zA-Z_][a-zA-Z0-9_]*)(\(([^)]+)\))?(\?)?|(\*)|([^/]+))/g;
41
+
42
+ let match;
43
+ while ((match = segmentRegex.exec(pattern)) !== null) {
44
+ const [, , paramName, , constraint, optional, wildcard, staticText] = match;
45
+
46
+ if (wildcard) {
47
+ segments.push({ type: "wildcard", value: "*", optional: false });
48
+ } else if (paramName) {
49
+ segments.push({
50
+ type: "param",
51
+ value: paramName,
52
+ optional: optional === "?",
53
+ constraint: constraint ? constraint.split("|") : undefined,
54
+ });
55
+ } else if (staticText) {
56
+ segments.push({ type: "static", value: staticText, optional: false });
57
+ }
58
+ }
59
+
60
+ return segments;
61
+ }
62
+
63
+ /**
64
+ * Compile a route pattern to regex
65
+ *
66
+ * Supports:
67
+ * - Static segments: /blog, /about
68
+ * - Dynamic params: /:slug, /:id
69
+ * - Optional params: /:locale?, /:page?
70
+ * - Constrained params: /:locale(en|gb)
71
+ * - Optional + constrained: /:locale(en|gb)?
72
+ * - Wildcard: /*
73
+ *
74
+ * @example
75
+ * compilePattern("/blog/:slug") // matches /blog/hello
76
+ * compilePattern("/:locale?/blog") // matches /blog or /en/blog
77
+ * compilePattern("/:locale(en|gb)/blog") // matches /en/blog or /gb/blog
78
+ * compilePattern("/:locale(en|gb)?/blog") // matches /blog, /en/blog, or /gb/blog
79
+ */
80
+ export function compilePattern(pattern: string): {
81
+ regex: RegExp;
82
+ paramNames: string[];
83
+ optionalParams: Set<string>;
84
+ hasTrailingSlash: boolean;
85
+ } {
86
+ // Detect if pattern has trailing slash (but not just "/")
87
+ const hasTrailingSlash = pattern.length > 1 && pattern.endsWith("/");
88
+ // Remove trailing slash for parsing (we'll add it back to regex if needed)
89
+ const normalizedPattern = hasTrailingSlash ? pattern.slice(0, -1) : pattern;
90
+
91
+ const segments = parsePattern(normalizedPattern);
92
+ const paramNames: string[] = [];
93
+ const optionalParams = new Set<string>();
94
+
95
+ let regexPattern = "";
96
+
97
+ for (const segment of segments) {
98
+ if (segment.type === "wildcard") {
99
+ paramNames.push("*");
100
+ regexPattern += "/(.*)";
101
+ } else if (segment.type === "param") {
102
+ paramNames.push(segment.value);
103
+ const valuePattern = segment.constraint
104
+ ? `(${segment.constraint.join("|")})`
105
+ : "([^/]+)";
106
+
107
+ if (segment.optional) {
108
+ optionalParams.add(segment.value);
109
+ // Optional: make the whole /segment optional
110
+ regexPattern += `(?:/${valuePattern})?`;
111
+ } else {
112
+ regexPattern += `/${valuePattern}`;
113
+ }
114
+ } else {
115
+ // Static segment
116
+ regexPattern += `/${escapeRegex(segment.value)}`;
117
+ }
118
+ }
119
+
120
+ // Handle root path
121
+ if (regexPattern === "") {
122
+ regexPattern = "/";
123
+ }
124
+
125
+ // Add trailing slash to regex if pattern has one
126
+ if (hasTrailingSlash) {
127
+ regexPattern += "/";
128
+ }
129
+
130
+ return {
131
+ regex: new RegExp(`^${regexPattern}$`),
132
+ paramNames,
133
+ optionalParams,
134
+ hasTrailingSlash,
135
+ };
136
+ }
137
+
138
+ /**
139
+ * Escape special regex characters in a string
140
+ */
141
+ function escapeRegex(str: string): string {
142
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
143
+ }
144
+
145
+ /**
146
+ * Extract the static prefix from a route pattern.
147
+ * Returns everything before the first param/wildcard.
148
+ *
149
+ * Called ONCE at registration time, not at match time.
150
+ *
151
+ * Examples:
152
+ * - "/api" → "/api"
153
+ * - "/site/:locale" → "/site"
154
+ * - "/:locale" → ""
155
+ * - "/admin/users/:id" → "/admin/users"
156
+ * - "/api/*" → "/api"
157
+ */
158
+ export function extractStaticPrefix(pattern: string): string {
159
+ if (!pattern || pattern === "/") return "";
160
+
161
+ // Find the first occurrence of : or *
162
+ const paramIndex = pattern.indexOf(":");
163
+ const wildcardIndex = pattern.indexOf("*");
164
+
165
+ let cutIndex = -1;
166
+ if (paramIndex !== -1 && wildcardIndex !== -1) {
167
+ cutIndex = Math.min(paramIndex, wildcardIndex);
168
+ } else if (paramIndex !== -1) {
169
+ cutIndex = paramIndex;
170
+ } else if (wildcardIndex !== -1) {
171
+ cutIndex = wildcardIndex;
172
+ }
173
+
174
+ if (cutIndex === -1) {
175
+ // No params or wildcards - entire pattern is static
176
+ return pattern;
177
+ }
178
+
179
+ if (cutIndex === 0) {
180
+ // Pattern starts with : or * - no static prefix
181
+ return "";
182
+ }
183
+
184
+ // Find the last / before the param
185
+ const lastSlash = pattern.lastIndexOf("/", cutIndex - 1);
186
+ if (lastSlash === -1 || lastSlash === 0) {
187
+ return "";
188
+ }
189
+
190
+ return pattern.slice(0, lastSlash);
191
+ }
192
+
193
+ /**
194
+ * Match a pathname against registered routes
195
+ *
196
+ * Note: Optional params that are absent in the path will have empty string value.
197
+ * Use the pattern definition to determine if a param is optional.
198
+ *
199
+ * Trailing slash handling (priority order):
200
+ * 1. Per-route `trailingSlash` config from route()
201
+ * 2. Pattern-based detection (pattern ending with `/`)
202
+ *
203
+ * Modes:
204
+ * - "never": Redirect to no trailing slash
205
+ * - "always": Redirect to with trailing slash
206
+ * - "ignore": Match both, no redirect
207
+ */
208
+ /**
209
+ * Result of a route match
210
+ */
211
+ export interface RouteMatchResult<TEnv = any> {
212
+ entry: RouteEntry<TEnv>;
213
+ routeKey: string;
214
+ params: Record<string, string>;
215
+ optionalParams: Set<string>;
216
+ redirectTo?: string;
217
+ /** Ancestry shortCodes for layout pruning (from trie match) */
218
+ ancestry?: string[];
219
+ /** Route has pre-rendered data available (from trie) */
220
+ pr?: true;
221
+ /** Passthrough: handler kept for live fallback on unknown params (from trie) */
222
+ pt?: true;
223
+ /** Response type for non-RSC routes (json, text, image, any) */
224
+ responseType?: string;
225
+ /** Negotiate variants: response-type routes sharing this path */
226
+ negotiateVariants?: Array<{ routeKey: string; responseType: string }>;
227
+ /** RSC-first: RSC route was defined before response-type variants */
228
+ rscFirst?: true;
229
+ }
230
+
231
+ /**
232
+ * Result when a lazy entry needs evaluation before matching
233
+ */
234
+ export interface LazyEvaluationNeeded<TEnv = any> {
235
+ lazyEntry: RouteEntry<TEnv>;
236
+ }
237
+
238
+ /**
239
+ * Type guard to check if result is a lazy evaluation needed response
240
+ */
241
+ export function isLazyEvaluationNeeded<TEnv>(
242
+ result: RouteMatchResult<TEnv> | LazyEvaluationNeeded<TEnv> | null
243
+ ): result is LazyEvaluationNeeded<TEnv> {
244
+ return result !== null && "lazyEntry" in result;
245
+ }
246
+
247
+ // Debug stats type for exports
248
+ interface MatchDebugStats {
249
+ entriesChecked: number;
250
+ entriesSkipped: number;
251
+ routesChecked: number;
252
+ }
253
+
254
+ // Debug stats for route matching (only in debug mode)
255
+ let debugEnabled = false;
256
+ let debugStats = { entriesChecked: 0, entriesSkipped: 0, routesChecked: 0 };
257
+
258
+ export function enableMatchDebug(enabled: boolean): void {
259
+ debugEnabled = enabled;
260
+ }
261
+
262
+ export function getMatchDebugStats(): MatchDebugStats {
263
+ return { entriesChecked: debugStats.entriesChecked, entriesSkipped: debugStats.entriesSkipped, routesChecked: debugStats.routesChecked };
264
+ }
265
+
266
+ export function findMatch<TEnv>(
267
+ pathname: string,
268
+ routesEntries: RouteEntry<TEnv>[]
269
+ ): RouteMatchResult<TEnv> | LazyEvaluationNeeded<TEnv> | null {
270
+ if (debugEnabled) {
271
+ debugStats = { entriesChecked: 0, entriesSkipped: 0, routesChecked: 0 };
272
+ console.log(`[findMatch] pathname="${pathname}", entries=${routesEntries.length}`);
273
+ for (const e of routesEntries) {
274
+ console.log(` entry: prefix="${e.prefix}", staticPrefix="${e.staticPrefix}", routes=${Object.keys(e.routes).length}`);
275
+ }
276
+ }
277
+
278
+ const pathnameHasTrailingSlash = pathname.length > 1 && pathname.endsWith("/");
279
+ // Try alternate pathname for redirect matching
280
+ const alternatePathname = pathnameHasTrailingSlash
281
+ ? pathname.slice(0, -1)
282
+ : pathname + "/";
283
+
284
+ for (const entry of routesEntries) {
285
+ // Short-circuit: skip entry if pathname doesn't start with static prefix
286
+ // staticPrefix is pre-computed at registration time, so this is O(1)
287
+ if (entry.staticPrefix && !pathname.startsWith(entry.staticPrefix)) {
288
+ if (debugEnabled) {
289
+ debugStats.entriesSkipped++;
290
+ console.log(` SKIP entry prefix="${entry.prefix}" (staticPrefix="${entry.staticPrefix}" doesn't match)`);
291
+ }
292
+ continue;
293
+ }
294
+
295
+ // Check if this is a lazy entry that needs evaluation
296
+ // When staticPrefix matches but routes are not yet populated, signal caller to evaluate
297
+ if (entry.lazy && !entry.lazyEvaluated) {
298
+ if (debugEnabled) {
299
+ console.log(` LAZY entry needs evaluation: staticPrefix="${entry.staticPrefix}"`);
300
+ }
301
+ return { lazyEntry: entry };
302
+ }
303
+
304
+ if (debugEnabled) {
305
+ debugStats.entriesChecked++;
306
+ }
307
+
308
+ const routeEntries = Object.entries(entry.routes);
309
+
310
+ for (const [routeKey, pattern] of routeEntries) {
311
+ if (debugEnabled) {
312
+ debugStats.routesChecked++;
313
+ }
314
+
315
+ // Join prefix and pattern, handling edge cases
316
+ let fullPattern: string;
317
+ if (entry.prefix === "" || entry.prefix === "/") {
318
+ fullPattern = pattern;
319
+ } else if (pattern === "/" || pattern === "") {
320
+ fullPattern = entry.prefix;
321
+ } else {
322
+ fullPattern = entry.prefix + pattern;
323
+ }
324
+
325
+ const { regex, paramNames, optionalParams, hasTrailingSlash } = compilePattern(fullPattern);
326
+
327
+ // Get trailing slash mode for this route (per-route config or pattern-based)
328
+ const trailingSlashMode: TrailingSlashMode | undefined = entry.trailingSlash?.[routeKey];
329
+
330
+
331
+ // Try exact match first
332
+ const match = regex.exec(pathname);
333
+ if (match) {
334
+ const params: Record<string, string> = {};
335
+ paramNames.forEach((name, index) => {
336
+ params[name] = match[index + 1] ?? "";
337
+ });
338
+
339
+ if (debugEnabled) {
340
+ console.log(` MATCH: routeKey="${routeKey}", pattern="${fullPattern}"`);
341
+ console.log(` Stats: entriesChecked=${debugStats.entriesChecked}, entriesSkipped=${debugStats.entriesSkipped}, routesChecked=${debugStats.routesChecked}`);
342
+ }
343
+
344
+ // Check if trailing slash mode requires redirect even on exact match
345
+ if (trailingSlashMode === "always" && !pathnameHasTrailingSlash && pathname !== "/") {
346
+ // Mode says always have trailing slash, but pathname doesn't have it
347
+ return { entry, routeKey, params, optionalParams, redirectTo: pathname + "/" };
348
+ } else if (trailingSlashMode === "never" && pathnameHasTrailingSlash) {
349
+ // Mode says never have trailing slash, but pathname has it
350
+ return { entry, routeKey, params, optionalParams, redirectTo: pathname.slice(0, -1) };
351
+ }
352
+
353
+ return { entry, routeKey, params, optionalParams };
354
+ }
355
+
356
+ // Try alternate pathname (opposite trailing slash)
357
+ const altMatch = regex.exec(alternatePathname);
358
+ if (altMatch) {
359
+ const params: Record<string, string> = {};
360
+ paramNames.forEach((name, index) => {
361
+ params[name] = altMatch[index + 1] ?? "";
362
+ });
363
+
364
+ // Determine redirect behavior based on mode
365
+ if (trailingSlashMode === "ignore") {
366
+ // Match without redirect
367
+ return { entry, routeKey, params, optionalParams };
368
+ } else if (trailingSlashMode === "never") {
369
+ // Redirect to no trailing slash
370
+ if (pathnameHasTrailingSlash) {
371
+ return { entry, routeKey, params, optionalParams, redirectTo: alternatePathname };
372
+ }
373
+ return { entry, routeKey, params, optionalParams };
374
+ } else if (trailingSlashMode === "always") {
375
+ // Redirect to with trailing slash
376
+ if (!pathnameHasTrailingSlash) {
377
+ return { entry, routeKey, params, optionalParams, redirectTo: alternatePathname };
378
+ }
379
+ return { entry, routeKey, params, optionalParams };
380
+ } else {
381
+ // No explicit mode - use pattern-based detection
382
+ // Redirect to canonical form (what the pattern defines)
383
+ const canonicalPath = hasTrailingSlash ? alternatePathname : pathname.slice(0, -1);
384
+ return { entry, routeKey, params, optionalParams, redirectTo: canonicalPath };
385
+ }
386
+ }
387
+ }
388
+ }
389
+
390
+ return null;
391
+ }
392
+
393
+ /**
394
+ * Traverse from entry to bottom to top, yielding each EntryData
395
+ * e.g. {child -> parent -> grandparent ...}
396
+ */
397
+ export function* traverseBack(entry: EntryData): Generator<EntryData> {
398
+ let current: EntryData | null = entry;
399
+ const items = [] as EntryData[];
400
+ while (current !== null) {
401
+ items.push(current); // Move up to next parent
402
+ current = current.parent;
403
+ }
404
+ for (let i = items.length - 1; i >= 0; i--) {
405
+ yield items[i];
406
+ }
407
+ }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Router Revalidation Logic
3
+ *
4
+ * Evaluates whether segments should revalidate based on params, actions, and custom functions.
5
+ */
6
+
7
+ import type { ResolvedSegment, HandlerContext } from "../types";
8
+ import type { ActionContext } from "./types";
9
+
10
+ /**
11
+ * Options for revalidation evaluation
12
+ */
13
+ interface EvaluateRevalidationOptions<TEnv> {
14
+ /** Current segment to evaluate */
15
+ segment: ResolvedSegment;
16
+ /** Previous route params (from route match, not segment) */
17
+ prevParams: Record<string, string>;
18
+ /** Lazy function to get previous segment if needed */
19
+ getPrevSegment: (() => Promise<ResolvedSegment | undefined>) | null;
20
+ /** Current request */
21
+ request: Request;
22
+ /** Previous URL */
23
+ prevUrl: URL;
24
+ /** Next URL */
25
+ nextUrl: URL;
26
+ /** Custom revalidation functions */
27
+ revalidations: Array<{ name: string; fn: any }>;
28
+ /** Current route key */
29
+ routeKey: string;
30
+ /** Handler context */
31
+ context: HandlerContext<any, TEnv>;
32
+ /** Action context if triggered by action */
33
+ actionContext?: ActionContext;
34
+ /** If true, this is a stale cache revalidation request */
35
+ stale?: boolean;
36
+ }
37
+
38
+ /**
39
+ * Evaluate if a segment should revalidate using soft/hard decision pattern
40
+ * Optimized to use prevParams directly and avoid building previous segments
41
+ */
42
+ export async function evaluateRevalidation<TEnv>(
43
+ options: EvaluateRevalidationOptions<TEnv>
44
+ ): Promise<boolean> {
45
+ const {
46
+ segment,
47
+ prevParams,
48
+ getPrevSegment,
49
+ request,
50
+ prevUrl,
51
+ nextUrl,
52
+ revalidations,
53
+ routeKey,
54
+ context,
55
+ actionContext,
56
+ stale,
57
+ } = options;
58
+ const nextParams = segment.params || {};
59
+ const paramsChanged =
60
+ Object.keys(nextParams).length !== Object.keys(prevParams).length ||
61
+ Object.keys(nextParams).some(
62
+ (key) => nextParams[key] !== prevParams[key]
63
+ );
64
+
65
+ // Calculate default revalidation based on segment type and request method
66
+ let defaultShouldRevalidate: boolean;
67
+
68
+ if (request.method === "POST") {
69
+ // Actions: revalidate segments that belong to the route, skip parent chain
70
+ if (segment.type === "route") {
71
+ // Route segment always revalidates on actions
72
+ defaultShouldRevalidate = true;
73
+ } else if (segment.type === "loader") {
74
+ // Loaders always revalidate on actions - they often contain action-sensitive data
75
+ // (e.g., cart count after add-to-cart action)
76
+ defaultShouldRevalidate = true;
77
+ } else if (segment.belongsToRoute) {
78
+ // Segment belongs to route (orphan layouts/parallels) - revalidate
79
+ defaultShouldRevalidate = true;
80
+ } else {
81
+ // Parent chain segment (shared layouts/parallels) - don't revalidate
82
+ defaultShouldRevalidate = false;
83
+ }
84
+ } else {
85
+ // Navigation (GET): Conservative defaults to minimize unnecessary revalidations
86
+ // Only the route segment revalidates by default - all others require explicit opt-in
87
+
88
+ if (segment.type === "route") {
89
+ // Route segments revalidate when params change
90
+ // Routes are the primary param-dependent content and always need updates
91
+ defaultShouldRevalidate = paramsChanged;
92
+ if (paramsChanged) {
93
+ console.log(
94
+ `[Router.evaluateRevalidation] ${segment.id}: ROUTE - params changed, revalidating`
95
+ );
96
+ }
97
+ } else {
98
+ // Layouts and parallels default to no revalidation
99
+ // Cannot assume these segments depend on params without explicit declaration
100
+ // Use custom revalidation functions to opt-in when needed
101
+ defaultShouldRevalidate = false;
102
+ console.log(
103
+ `[Router.evaluateRevalidation] ${
104
+ segment.id
105
+ }: ${segment.type.toUpperCase()} segment - skipping (override with custom revalidation if needed)`
106
+ );
107
+ }
108
+ }
109
+
110
+ // No custom revalidations defined - return default behavior without prev segment
111
+ if (revalidations.length === 0) {
112
+ if (defaultShouldRevalidate) {
113
+ console.log(
114
+ `[Router.evaluateRevalidation] ${segment.id}: PARAMS CHANGED (default) - revalidating`,
115
+ { prev: prevParams, next: nextParams }
116
+ );
117
+ } else {
118
+ console.log(
119
+ `[Router.evaluateRevalidation] ${segment.id}: UNCHANGED (default) - skipping`
120
+ );
121
+ }
122
+ return defaultShouldRevalidate;
123
+ }
124
+
125
+ // Custom revalidations exist - may need full prev segment
126
+ // Lazy load prev segment only if getPrevSegment provided
127
+ const prevSegment = getPrevSegment ? await getPrevSegment() : null;
128
+
129
+ // Execute revalidation functions with soft/hard decision pattern
130
+ let currentSuggestion = defaultShouldRevalidate;
131
+
132
+ for (const { name, fn } of revalidations) {
133
+ const result = fn({
134
+ currentParams: prevSegment?.params || prevParams, // Use segment params if available, else route params
135
+ currentUrl: prevUrl,
136
+ nextParams,
137
+ nextUrl,
138
+ defaultShouldRevalidate: currentSuggestion,
139
+ context,
140
+ // Segment metadata (which segment is being evaluated)
141
+ segmentType: segment.type,
142
+ layoutName: segment.layoutName,
143
+ slotName: segment.slot,
144
+ // Action context (only populated when triggered by server action)
145
+ actionId: actionContext?.actionId,
146
+ actionUrl: actionContext?.actionUrl,
147
+ actionResult: actionContext?.actionResult,
148
+ formData: actionContext?.formData,
149
+ method: request.method, // GET for navigation, POST for actions
150
+ routeName: routeKey, // User-friendly route name (e.g., "products.detail")
151
+ // Stale cache context (only true for background revalidation after stale cache render)
152
+ stale,
153
+ });
154
+
155
+ // Check return type:
156
+ // - boolean: hard decision, short-circuit immediately
157
+ // - { defaultShouldRevalidate: boolean }: soft decision, update suggestion and continue
158
+ // - null/undefined: use default behavior (equivalent to returning { defaultShouldRevalidate })
159
+ if (typeof result === "boolean") {
160
+ // Hard decision - short-circuit
161
+ console.log(
162
+ `[Router.evaluateRevalidation] ${segment.id}: REVALIDATE (${name}) HARD: ${result}`
163
+ );
164
+ return result;
165
+ } else if (
166
+ result &&
167
+ typeof result === "object" &&
168
+ "defaultShouldRevalidate" in result
169
+ ) {
170
+ // Soft decision - update suggestion and continue
171
+ currentSuggestion = result.defaultShouldRevalidate;
172
+ console.log(
173
+ `[Router.evaluateRevalidation] ${segment.id}: REVALIDATE (${name}) SOFT: ${currentSuggestion}`
174
+ );
175
+ } else if (result === null || result === undefined) {
176
+ // Defer to default - equivalent to { defaultShouldRevalidate: currentSuggestion }
177
+ // This means "I don't care, use whatever the default is"
178
+ console.log(
179
+ `[Router.evaluateRevalidation] ${segment.id}: REVALIDATE (${name}) DEFER to default: ${currentSuggestion}`
180
+ );
181
+ // currentSuggestion stays the same, continue to next function
182
+ }
183
+ }
184
+
185
+ // All revalidators completed - use final suggestion
186
+ console.log(
187
+ `[Router.evaluateRevalidation] ${segment.id}: Final decision: ${currentSuggestion}`
188
+ );
189
+ return currentSuggestion;
190
+ }