@ivogt/rsc-router 0.0.0-experimental.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 (123) hide show
  1. package/README.md +19 -0
  2. package/package.json +131 -0
  3. package/src/__mocks__/version.ts +6 -0
  4. package/src/__tests__/route-definition.test.ts +63 -0
  5. package/src/browser/event-controller.ts +876 -0
  6. package/src/browser/index.ts +18 -0
  7. package/src/browser/link-interceptor.ts +121 -0
  8. package/src/browser/lru-cache.ts +69 -0
  9. package/src/browser/merge-segment-loaders.ts +126 -0
  10. package/src/browser/navigation-bridge.ts +891 -0
  11. package/src/browser/navigation-client.ts +155 -0
  12. package/src/browser/navigation-store.ts +823 -0
  13. package/src/browser/partial-update.ts +545 -0
  14. package/src/browser/react/Link.tsx +248 -0
  15. package/src/browser/react/NavigationProvider.tsx +228 -0
  16. package/src/browser/react/ScrollRestoration.tsx +94 -0
  17. package/src/browser/react/context.ts +53 -0
  18. package/src/browser/react/index.ts +52 -0
  19. package/src/browser/react/location-state-shared.ts +120 -0
  20. package/src/browser/react/location-state.ts +62 -0
  21. package/src/browser/react/use-action.ts +240 -0
  22. package/src/browser/react/use-client-cache.ts +56 -0
  23. package/src/browser/react/use-handle.ts +178 -0
  24. package/src/browser/react/use-link-status.ts +134 -0
  25. package/src/browser/react/use-navigation.ts +150 -0
  26. package/src/browser/react/use-segments.ts +188 -0
  27. package/src/browser/request-controller.ts +149 -0
  28. package/src/browser/rsc-router.tsx +310 -0
  29. package/src/browser/scroll-restoration.ts +324 -0
  30. package/src/browser/server-action-bridge.ts +747 -0
  31. package/src/browser/shallow.ts +35 -0
  32. package/src/browser/types.ts +443 -0
  33. package/src/cache/__tests__/memory-segment-store.test.ts +487 -0
  34. package/src/cache/__tests__/memory-store.test.ts +484 -0
  35. package/src/cache/cache-scope.ts +565 -0
  36. package/src/cache/cf/__tests__/cf-cache-store.test.ts +361 -0
  37. package/src/cache/cf/cf-cache-store.ts +274 -0
  38. package/src/cache/cf/index.ts +19 -0
  39. package/src/cache/index.ts +52 -0
  40. package/src/cache/memory-segment-store.ts +150 -0
  41. package/src/cache/memory-store.ts +253 -0
  42. package/src/cache/types.ts +366 -0
  43. package/src/client.rsc.tsx +88 -0
  44. package/src/client.tsx +609 -0
  45. package/src/components/DefaultDocument.tsx +20 -0
  46. package/src/default-error-boundary.tsx +88 -0
  47. package/src/deps/browser.ts +8 -0
  48. package/src/deps/html-stream-client.ts +2 -0
  49. package/src/deps/html-stream-server.ts +2 -0
  50. package/src/deps/rsc.ts +10 -0
  51. package/src/deps/ssr.ts +2 -0
  52. package/src/errors.ts +259 -0
  53. package/src/handle.ts +120 -0
  54. package/src/handles/MetaTags.tsx +178 -0
  55. package/src/handles/index.ts +6 -0
  56. package/src/handles/meta.ts +247 -0
  57. package/src/href-client.ts +128 -0
  58. package/src/href.ts +139 -0
  59. package/src/index.rsc.ts +69 -0
  60. package/src/index.ts +84 -0
  61. package/src/loader.rsc.ts +204 -0
  62. package/src/loader.ts +47 -0
  63. package/src/network-error-thrower.tsx +21 -0
  64. package/src/outlet-context.ts +15 -0
  65. package/src/root-error-boundary.tsx +277 -0
  66. package/src/route-content-wrapper.tsx +198 -0
  67. package/src/route-definition.ts +1333 -0
  68. package/src/route-map-builder.ts +140 -0
  69. package/src/route-types.ts +148 -0
  70. package/src/route-utils.ts +89 -0
  71. package/src/router/__tests__/match-context.test.ts +104 -0
  72. package/src/router/__tests__/match-pipelines.test.ts +537 -0
  73. package/src/router/__tests__/match-result.test.ts +566 -0
  74. package/src/router/__tests__/on-error.test.ts +935 -0
  75. package/src/router/__tests__/pattern-matching.test.ts +577 -0
  76. package/src/router/error-handling.ts +287 -0
  77. package/src/router/handler-context.ts +60 -0
  78. package/src/router/loader-resolution.ts +326 -0
  79. package/src/router/manifest.ts +116 -0
  80. package/src/router/match-context.ts +261 -0
  81. package/src/router/match-middleware/background-revalidation.ts +236 -0
  82. package/src/router/match-middleware/cache-lookup.ts +261 -0
  83. package/src/router/match-middleware/cache-store.ts +250 -0
  84. package/src/router/match-middleware/index.ts +81 -0
  85. package/src/router/match-middleware/intercept-resolution.ts +268 -0
  86. package/src/router/match-middleware/segment-resolution.ts +174 -0
  87. package/src/router/match-pipelines.ts +214 -0
  88. package/src/router/match-result.ts +212 -0
  89. package/src/router/metrics.ts +62 -0
  90. package/src/router/middleware.test.ts +1355 -0
  91. package/src/router/middleware.ts +748 -0
  92. package/src/router/pattern-matching.ts +271 -0
  93. package/src/router/revalidation.ts +190 -0
  94. package/src/router/router-context.ts +299 -0
  95. package/src/router/types.ts +96 -0
  96. package/src/router.ts +3484 -0
  97. package/src/rsc/__tests__/helpers.test.ts +175 -0
  98. package/src/rsc/handler.ts +942 -0
  99. package/src/rsc/helpers.ts +64 -0
  100. package/src/rsc/index.ts +56 -0
  101. package/src/rsc/nonce.ts +18 -0
  102. package/src/rsc/types.ts +225 -0
  103. package/src/segment-system.tsx +405 -0
  104. package/src/server/__tests__/request-context.test.ts +171 -0
  105. package/src/server/context.ts +340 -0
  106. package/src/server/handle-store.ts +230 -0
  107. package/src/server/loader-registry.ts +174 -0
  108. package/src/server/request-context.ts +470 -0
  109. package/src/server/root-layout.tsx +10 -0
  110. package/src/server/tsconfig.json +14 -0
  111. package/src/server.ts +126 -0
  112. package/src/ssr/__tests__/ssr-handler.test.tsx +188 -0
  113. package/src/ssr/index.tsx +215 -0
  114. package/src/types.ts +1473 -0
  115. package/src/use-loader.tsx +346 -0
  116. package/src/vite/__tests__/expose-loader-id.test.ts +117 -0
  117. package/src/vite/expose-action-id.ts +344 -0
  118. package/src/vite/expose-handle-id.ts +209 -0
  119. package/src/vite/expose-loader-id.ts +357 -0
  120. package/src/vite/expose-location-state-id.ts +177 -0
  121. package/src/vite/index.ts +608 -0
  122. package/src/vite/version.d.ts +12 -0
  123. package/src/vite/virtual-entries.ts +109 -0
@@ -0,0 +1,271 @@
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
+ 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
+ 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
+ * Match a pathname against registered routes
147
+ *
148
+ * Note: Optional params that are absent in the path will have empty string value.
149
+ * Use the pattern definition to determine if a param is optional.
150
+ *
151
+ * Trailing slash handling (priority order):
152
+ * 1. Per-route `trailingSlash` config from route()
153
+ * 2. Pattern-based detection (pattern ending with `/`)
154
+ *
155
+ * Modes:
156
+ * - "never": Redirect to no trailing slash
157
+ * - "always": Redirect to with trailing slash
158
+ * - "ignore": Match both, no redirect
159
+ */
160
+ /**
161
+ * Result of a route match
162
+ */
163
+ export interface RouteMatchResult<TEnv = any> {
164
+ entry: RouteEntry<TEnv>;
165
+ routeKey: string;
166
+ params: Record<string, string>;
167
+ optionalParams: Set<string>;
168
+ redirectTo?: string;
169
+ }
170
+
171
+ export function findMatch<TEnv>(
172
+ pathname: string,
173
+ routesEntries: RouteEntry<TEnv>[]
174
+ ): RouteMatchResult<TEnv> | null {
175
+ const pathnameHasTrailingSlash = pathname.length > 1 && pathname.endsWith("/");
176
+ // Try alternate pathname for redirect matching
177
+ const alternatePathname = pathnameHasTrailingSlash
178
+ ? pathname.slice(0, -1)
179
+ : pathname + "/";
180
+
181
+ for (const entry of routesEntries) {
182
+ const routeEntries = Object.entries(entry.routes);
183
+
184
+ for (const [routeKey, pattern] of routeEntries) {
185
+ // Join prefix and pattern, handling edge cases
186
+ let fullPattern: string;
187
+ if (entry.prefix === "" || entry.prefix === "/") {
188
+ fullPattern = pattern;
189
+ } else if (pattern === "/" || pattern === "") {
190
+ fullPattern = entry.prefix;
191
+ } else {
192
+ fullPattern = entry.prefix + pattern;
193
+ }
194
+
195
+ const { regex, paramNames, optionalParams, hasTrailingSlash } = compilePattern(fullPattern);
196
+
197
+ // Get trailing slash mode for this route (per-route config or pattern-based)
198
+ const trailingSlashMode: TrailingSlashMode | undefined = entry.trailingSlash?.[routeKey];
199
+
200
+ // Try exact match first
201
+ const match = regex.exec(pathname);
202
+ if (match) {
203
+ const params: Record<string, string> = {};
204
+ paramNames.forEach((name, index) => {
205
+ params[name] = match[index + 1] ?? "";
206
+ });
207
+
208
+ // Check if trailing slash mode requires redirect even on exact match
209
+ if (trailingSlashMode === "always" && !pathnameHasTrailingSlash && pathname !== "/") {
210
+ // Mode says always have trailing slash, but pathname doesn't have it
211
+ return { entry, routeKey, params, optionalParams, redirectTo: pathname + "/" };
212
+ } else if (trailingSlashMode === "never" && pathnameHasTrailingSlash) {
213
+ // Mode says never have trailing slash, but pathname has it
214
+ return { entry, routeKey, params, optionalParams, redirectTo: pathname.slice(0, -1) };
215
+ }
216
+
217
+ return { entry, routeKey, params, optionalParams };
218
+ }
219
+
220
+ // Try alternate pathname (opposite trailing slash)
221
+ const altMatch = regex.exec(alternatePathname);
222
+ if (altMatch) {
223
+ const params: Record<string, string> = {};
224
+ paramNames.forEach((name, index) => {
225
+ params[name] = altMatch[index + 1] ?? "";
226
+ });
227
+
228
+ // Determine redirect behavior based on mode
229
+ if (trailingSlashMode === "ignore") {
230
+ // Match without redirect
231
+ return { entry, routeKey, params, optionalParams };
232
+ } else if (trailingSlashMode === "never") {
233
+ // Redirect to no trailing slash
234
+ if (pathnameHasTrailingSlash) {
235
+ return { entry, routeKey, params, optionalParams, redirectTo: alternatePathname };
236
+ }
237
+ return { entry, routeKey, params, optionalParams };
238
+ } else if (trailingSlashMode === "always") {
239
+ // Redirect to with trailing slash
240
+ if (!pathnameHasTrailingSlash) {
241
+ return { entry, routeKey, params, optionalParams, redirectTo: alternatePathname };
242
+ }
243
+ return { entry, routeKey, params, optionalParams };
244
+ } else {
245
+ // No explicit mode - use pattern-based detection
246
+ // Redirect to canonical form (what the pattern defines)
247
+ const canonicalPath = hasTrailingSlash ? alternatePathname : pathname.slice(0, -1);
248
+ return { entry, routeKey, params, optionalParams, redirectTo: canonicalPath };
249
+ }
250
+ }
251
+ }
252
+ }
253
+
254
+ return null;
255
+ }
256
+
257
+ /**
258
+ * Traverse from entry to bottom to top, yielding each EntryData
259
+ * e.g. {child -> parent -> grandparent ...}
260
+ */
261
+ export function* traverseBack(entry: EntryData): Generator<EntryData> {
262
+ let current: EntryData | null = entry;
263
+ const items = [] as EntryData[];
264
+ while (current !== null) {
265
+ items.push(current); // Move up to next parent
266
+ current = current.parent;
267
+ }
268
+ for (let i = items.length - 1; i >= 0; i--) {
269
+ yield items[i];
270
+ }
271
+ }
@@ -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
+ }