@rangojs/router 0.0.0-experimental.130 → 0.0.0-experimental.132

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 (54) hide show
  1. package/dist/bin/rango.js +69 -24
  2. package/dist/vite/index.js +182 -41
  3. package/package.json +6 -3
  4. package/src/browser/connection-warmup.ts +134 -0
  5. package/src/browser/event-controller.ts +5 -4
  6. package/src/browser/partial-update.ts +32 -16
  7. package/src/browser/react/NavigationProvider.tsx +6 -83
  8. package/src/browser/react/filter-segment-order.ts +17 -0
  9. package/src/browser/react/use-link-status.ts +10 -2
  10. package/src/browser/react/use-navigation.ts +10 -2
  11. package/src/build/route-types/ast-route-extraction.ts +15 -8
  12. package/src/build/route-types/include-resolution.ts +109 -21
  13. package/src/build/route-types/per-module-writer.ts +15 -2
  14. package/src/cache/cache-key-utils.ts +29 -13
  15. package/src/cache/cf/cf-cache-store.ts +129 -5
  16. package/src/decode-loader-results.ts +11 -1
  17. package/src/encode-kv.ts +49 -0
  18. package/src/handles/meta.ts +5 -1
  19. package/src/host/cookie-handler.ts +2 -21
  20. package/src/prerender/param-hash.ts +6 -5
  21. package/src/regex-escape.ts +8 -0
  22. package/src/route-definition/dsl-helpers.ts +6 -2
  23. package/src/router/error-handling.ts +32 -1
  24. package/src/router/handler-context.ts +6 -1
  25. package/src/router/instrument.ts +56 -14
  26. package/src/router/intercept-resolution.ts +16 -1
  27. package/src/router/loader-resolution.ts +49 -19
  28. package/src/router/match-middleware/background-revalidation.ts +6 -0
  29. package/src/router/match-middleware/cache-store.ts +6 -0
  30. package/src/router/middleware.ts +67 -27
  31. package/src/router/pattern-matching.ts +3 -9
  32. package/src/router/revalidation.ts +65 -23
  33. package/src/router/router-context.ts +1 -0
  34. package/src/router/router-options.ts +3 -3
  35. package/src/router/segment-resolution/fresh.ts +8 -9
  36. package/src/router/segment-resolution/helpers.ts +11 -10
  37. package/src/router/segment-resolution/loader-cache.ts +13 -0
  38. package/src/router/segment-resolution/revalidation.ts +4 -4
  39. package/src/router/segment-wrappers.ts +3 -0
  40. package/src/router/trie-matching.ts +74 -20
  41. package/src/router.ts +2 -2
  42. package/src/rsc/progressive-enhancement.ts +20 -0
  43. package/src/rsc/server-action.ts +124 -47
  44. package/src/search-params.ts +8 -6
  45. package/src/segment-system.tsx +7 -1
  46. package/src/server/cookie-parse.ts +32 -0
  47. package/src/server/handle-store.ts +14 -14
  48. package/src/server/request-context.ts +5 -26
  49. package/src/ssr/index.tsx +5 -4
  50. package/src/testing/render-handler.ts +11 -0
  51. package/src/vite/plugins/expose-id-utils.ts +77 -2
  52. package/src/vite/plugins/expose-ids/export-analysis.ts +30 -5
  53. package/src/vite/plugins/expose-ids/router-transform.ts +82 -12
  54. package/src/vite/utils/prerender-utils.ts +1 -3
@@ -112,10 +112,25 @@ export async function resolveInterceptEntry<TEnv>(
112
112
  };
113
113
  stale?: boolean;
114
114
  },
115
+ options?: {
116
+ /**
117
+ * Skip the intercept's middleware execution. Set ONLY by the post-response
118
+ * background re-render paths (proactive caching, stale background
119
+ * revalidation), whose sole purpose is to re-render the segment tree to
120
+ * populate the cache. The foreground request already ran the intercept
121
+ * middleware before the response was sent — it validated auth, set cookies,
122
+ * and wrote context vars into the request context's shared `_variables`,
123
+ * which the background render reuses. Re-running middleware here would fire
124
+ * its side effects a SECOND time, and a middleware that short-circuits with
125
+ * a Response would `throw` and silently abort the cache write. Never set on
126
+ * the foreground path.
127
+ */
128
+ skipMiddleware?: boolean;
129
+ },
115
130
  ): Promise<ResolvedSegment[]> {
116
131
  const segments: ResolvedSegment[] = [];
117
132
 
118
- if (interceptEntry.middleware.length > 0) {
133
+ if (!options?.skipMiddleware && interceptEntry.middleware.length > 0) {
119
134
  const requestCtx = getRequestContext();
120
135
  if (!requestCtx?.res) {
121
136
  throw new Error(
@@ -109,16 +109,40 @@ export function wrapLoaderWithErrorHandling<T>(
109
109
  };
110
110
  }
111
111
 
112
- // Render fallback on server
112
+ // Render fallback on server. The user ErrorBoundaryHandler may throw
113
+ // synchronously; if it does we must NOT let that rejection escape — the
114
+ // wrapped LoaderDataResult promise is contracted to never reject (see
115
+ // segment-resolution/fresh.ts `await Promise.all(...wrapped)`), and a
116
+ // rejection here would collapse the whole entry and discard healthy
117
+ // sibling loader data. On a fallback-render throw, fall back to the
118
+ // no-boundary result (fallback: null) so the client throws the ORIGINAL
119
+ // error, and the wrapped promise still resolves to a LoaderDataResult.
113
120
  let renderedFallback: ReactNode;
114
- if (typeof fallback === "function") {
115
- // ErrorBoundaryHandler - call with error info
116
- const props: ErrorBoundaryFallbackProps = {
121
+ try {
122
+ if (typeof fallback === "function") {
123
+ // ErrorBoundaryHandler - call with error info
124
+ const props: ErrorBoundaryFallbackProps = {
125
+ error: errorInfo,
126
+ };
127
+ renderedFallback = fallback(props);
128
+ } else {
129
+ renderedFallback = fallback;
130
+ }
131
+ } catch (fallbackError) {
132
+ debugLog("loader", "error boundary fallback render threw", {
133
+ segmentId,
134
+ message: errorInfo.message,
135
+ fallbackError:
136
+ fallbackError instanceof Error
137
+ ? fallbackError.message
138
+ : String(fallbackError),
139
+ });
140
+ return {
141
+ __loaderResult: true,
142
+ ok: false,
117
143
  error: errorInfo,
144
+ fallback: null,
118
145
  };
119
- renderedFallback = fallback(props);
120
- } else {
121
- renderedFallback = fallback;
122
146
  }
123
147
 
124
148
  debugLog("loader", "loader error wrapped with boundary fallback", {
@@ -542,18 +566,21 @@ export function setupBuildUse<TEnv>(ctx: HandlerContext<any, TEnv>): void {
542
566
  );
543
567
  }
544
568
 
545
- return (
546
- dataOrFn: unknown | Promise<unknown> | (() => Promise<unknown>),
547
- ) => {
548
- if (!store) return;
569
+ // Wrap with withDefer so ctx.use(Handle).defer(...) works on the build /
570
+ // prerender path, matching production setupLoaderAccess. Without it a
571
+ // prerender handler calling .defer() throws "defer is not a function".
572
+ return withDefer(
573
+ (dataOrFn: unknown | Promise<unknown> | (() => Promise<unknown>)) => {
574
+ if (!store) return;
549
575
 
550
- const valueOrPromise =
551
- typeof dataOrFn === "function"
552
- ? (dataOrFn as () => Promise<unknown>)()
553
- : dataOrFn;
576
+ const valueOrPromise =
577
+ typeof dataOrFn === "function"
578
+ ? (dataOrFn as () => Promise<unknown>)()
579
+ : dataOrFn;
554
580
 
555
- store.push(handle.$$id, segmentId, valueOrPromise);
556
- };
581
+ store.push(handle.$$id, segmentId, valueOrPromise);
582
+ },
583
+ );
557
584
  }
558
585
 
559
586
  // Loader case: not available during pre-rendering
@@ -581,8 +608,11 @@ export function setupLoaderAccessSilent<TEnv>(
581
608
 
582
609
  ctx.use = ((item: LoaderDefinition<any, any> | Handle<any, any>) => {
583
610
  if (isHandle(item)) {
584
- // Silent mode - return a no-op so handle data is not pushed during caching
585
- return (_dataOrFn: unknown) => {};
611
+ // Silent mode - return a no-op so handle data is not pushed during caching.
612
+ // Wrap with withDefer so ctx.use(Handle).defer(...) still resolves to a
613
+ // callable resolver (also a no-op here), matching production's push shape
614
+ // instead of throwing "defer is not a function".
615
+ return withDefer((_dataOrFn: unknown) => {});
586
616
  }
587
617
 
588
618
  return useLoader(item as LoaderDefinition<any, any>, null);
@@ -203,6 +203,12 @@ export function withBackgroundRevalidation<TEnv>(
203
203
  ctx.matched.params,
204
204
  freshHandlerContext,
205
205
  true,
206
+ undefined,
207
+ // Skip intercept middleware: this is a post-response background
208
+ // re-render to refresh a stale cached route. The foreground
209
+ // already ran the middleware; re-running it would double its side
210
+ // effects and a short-circuit Response would abort the write.
211
+ { skipMiddleware: true },
206
212
  ),
207
213
  );
208
214
  }
@@ -243,6 +243,12 @@ export function withCacheStore<TEnv>(
243
243
  proactiveHandlerContext,
244
244
  true, // belongsToRoute
245
245
  // No revalidationContext = render fresh
246
+ undefined,
247
+ // Skip intercept middleware: the foreground already ran it
248
+ // before the response was sent. Re-running here (post-response,
249
+ // background) would fire side effects twice and a short-circuit
250
+ // Response would silently abort this cache write.
251
+ { skipMiddleware: true },
246
252
  ),
247
253
  );
248
254
  }
@@ -1,6 +1,8 @@
1
1
  /// <reference types="vite/types/importMeta.d.ts" />
2
2
 
3
3
  import { contextGet, contextSet } from "../context-var.js";
4
+ import { escapeRegExp } from "../regex-escape.js";
5
+ import { parsePattern as parseRoutePattern } from "./pattern-matching.js";
4
6
  import { safeDecodeURIComponent } from "./url-params.js";
5
7
  import { fireAndForgetWaitUntil } from "../types/request-scope.js";
6
8
  import type {
@@ -48,7 +50,43 @@ function getMiddlewareMetricLabel<TEnv>(
48
50
  return `middleware:${scope}#${ordinal + 1}`;
49
51
  }
50
52
 
51
- export function parsePattern(pattern: string): {
53
+ /**
54
+ * Compile a middleware scope pattern to a regex + param names.
55
+ *
56
+ * Middleware scopes reuse the route pattern parser (`parsePattern` from
57
+ * pattern-matching.ts), so they support the same param forms as routes —
58
+ * optional (`:x?`), constrained (`:x(en|gb)`), and suffix (`:x.html`) — in
59
+ * addition to the trailing-`*` wildcard middleware relies on. Before this
60
+ * unification the middleware-side parser handled only static, bare `:param`,
61
+ * and trailing `*`, so e.g. `router.use("/:locale(en|gb)/*", mw)` silently
62
+ * named the param "locale(en|gb)" and never enforced the constraint.
63
+ *
64
+ * Middleware matching semantics deliberately differ from route matching, so we
65
+ * emit the regex here rather than route through `compilePattern`:
66
+ * - `*` alone matches every path (`/^.*$/`).
67
+ * - A trailing `*` segment is an OPTIONAL subtree match (`(?:/.*)?`): `/admin/*`
68
+ * matches `/admin`, `/admin/`, and `/admin/users`. It contributes no param
69
+ * name (unlike route wildcards, which capture `*`).
70
+ * - A NON-trailing `*` is also OPTIONAL (`(?:/.*)?`), matching zero-or-more
71
+ * intermediate segments: `/a/<star>/b` matches both `/a/b` and `/a/x/b`. This
72
+ * mirrors the pre-unification parser, which compiled every `*` part as
73
+ * optional regardless of position.
74
+ * - A pattern without a trailing `*` tolerates a trailing slash (`/?$`).
75
+ * - Constraints are baked into the regex as an alternation so `matchMiddleware`
76
+ * (a bare `regex.test`) enforces them without extra validation. Constraint
77
+ * values are matched against the raw (still URL-encoded) path segment, which
78
+ * matches the pre-unification middleware behavior (it never decoded for
79
+ * matching); the constraint string is regex-escaped so values like `en.gb`
80
+ * are treated literally.
81
+ *
82
+ * The route segment parser only recognizes `/`-prefixed segments, but the
83
+ * pre-unification middleware parser split on `/` and dropped empty parts, so a
84
+ * leading slash was irrelevant: `use("admin/*")` and `use("/admin/*")` scoped
85
+ * identically. Normalize a non-`*` pattern to have a leading slash before
86
+ * parsing so that behavior is preserved (without it, `parseRoutePattern("admin/*")`
87
+ * drops the static `admin` and the scope explodes to every path).
88
+ */
89
+ export function compileMiddlewarePattern(pattern: string): {
52
90
  regex: RegExp;
53
91
  paramNames: string[];
54
92
  } {
@@ -56,45 +94,47 @@ export function parsePattern(pattern: string): {
56
94
  return { regex: /^.*$/, paramNames: [] };
57
95
  }
58
96
 
97
+ const normalizedPattern = pattern.startsWith("/") ? pattern : `/${pattern}`;
98
+ const segments = parseRoutePattern(normalizedPattern);
59
99
  const paramNames: string[] = [];
60
100
  let regexStr = "^";
101
+ let hasTrailingWildcard = false;
61
102
 
62
- const parts = pattern.split("/").filter(Boolean);
63
-
64
- for (let i = 0; i < parts.length; i++) {
65
- const part = parts[i];
103
+ for (let i = 0; i < segments.length; i++) {
104
+ const segment = segments[i];
66
105
 
67
- if (part === "*") {
68
- // Wildcard - match rest of path
106
+ if (segment.type === "wildcard") {
107
+ // Optional subtree match (parity with the original middleware parser,
108
+ // which compiled every `*` as `(?:/.*)?`). A trailing `*` matches the
109
+ // subtree; a non-trailing `*` matches zero-or-more intermediate segments,
110
+ // so `/a/<star>/b` still matches `/a/b`.
69
111
  regexStr += "(?:/.*)?";
70
- } else if (part.startsWith(":")) {
71
- // Param
72
- const paramName = part.slice(1);
73
- paramNames.push(paramName);
74
- regexStr += "/([^/]+)";
112
+ if (i === segments.length - 1) {
113
+ hasTrailingWildcard = true;
114
+ }
115
+ } else if (segment.type === "param") {
116
+ paramNames.push(segment.value);
117
+ const suffixPattern = segment.suffix ? escapeRegExp(segment.suffix) : "";
118
+ const valuePattern = segment.constraint
119
+ ? `(${segment.constraint.map(escapeRegExp).join("|")})`
120
+ : "([^/]+)";
121
+ if (segment.optional) {
122
+ regexStr += `(?:/${valuePattern}${suffixPattern})?`;
123
+ } else {
124
+ regexStr += `/${valuePattern}${suffixPattern}`;
125
+ }
75
126
  } else {
76
- // Literal
77
- regexStr += "/" + escapeRegex(part);
127
+ // Static literal
128
+ regexStr += "/" + escapeRegExp(segment.value);
78
129
  }
79
130
  }
80
131
 
81
- // If pattern doesn't end with *, match exact or with trailing segments
82
- if (!pattern.endsWith("*")) {
83
- regexStr += "/?$";
84
- } else {
85
- regexStr += "$";
86
- }
132
+ // Without a trailing `*`, match exactly with an optional trailing slash.
133
+ regexStr += hasTrailingWildcard ? "$" : "/?$";
87
134
 
88
135
  return { regex: new RegExp(regexStr), paramNames };
89
136
  }
90
137
 
91
- /**
92
- * Escape special regex characters
93
- */
94
- function escapeRegex(str: string): string {
95
- return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
96
- }
97
-
98
138
  /**
99
139
  * Extract params from a pathname using a pattern's regex and param names.
100
140
  *
@@ -7,6 +7,7 @@
7
7
  import type { RouteEntry, TrailingSlashMode } from "../types";
8
8
  import type { EntryData } from "../server/context";
9
9
  import { debugLog, isRouterDebugEnabled } from "./logging.js";
10
+ import { escapeRegExp } from "../regex-escape.js";
10
11
  import { safeDecodeURIComponent } from "./url-params.js";
11
12
 
12
13
  /**
@@ -151,7 +152,7 @@ export function compilePattern(pattern: string): CompiledPattern {
151
152
  regexPattern += "/(.*)";
152
153
  } else if (segment.type === "param") {
153
154
  paramNames.push(segment.value);
154
- const suffixPattern = segment.suffix ? escapeRegex(segment.suffix) : "";
155
+ const suffixPattern = segment.suffix ? escapeRegExp(segment.suffix) : "";
155
156
  // Constrained params capture anything here; the allowed values are
156
157
  // checked post-decode in findMatch so URL-encoded constraint values
157
158
  // (e.g. `:lang(en GB)` via `/en%20GB`) still match.
@@ -169,7 +170,7 @@ export function compilePattern(pattern: string): CompiledPattern {
169
170
  }
170
171
  } else {
171
172
  // Static segment
172
- regexPattern += `/${escapeRegex(segment.value)}`;
173
+ regexPattern += `/${escapeRegExp(segment.value)}`;
173
174
  }
174
175
  }
175
176
 
@@ -229,13 +230,6 @@ function satisfiesConstraints(
229
230
  return true;
230
231
  }
231
232
 
232
- /**
233
- * Escape special regex characters in a string
234
- */
235
- function escapeRegex(str: string): string {
236
- return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
237
- }
238
-
239
233
  /**
240
234
  * Build the named-params record from a regex match. Optional segments that
241
235
  * didn't capture leave the corresponding group `undefined`; we skip those
@@ -231,29 +231,71 @@ export async function evaluateRevalidation<TEnv>(
231
231
  : undefined;
232
232
 
233
233
  for (const { name, fn } of revalidations) {
234
- const result = fn({
235
- currentParams: prevSegment?.params || prevParams, // Use segment params if available, else route params
236
- currentUrl: prevUrl,
237
- nextParams,
238
- nextUrl,
239
- defaultShouldRevalidate: currentSuggestion,
240
- context,
241
- // Segment metadata (which segment is being evaluated)
242
- segmentType: segment.type,
243
- layoutName: segment.layoutName,
244
- slotName: segment.slot,
245
- // Action context (only populated when triggered by server action)
246
- actionId: actionContext?.actionId,
247
- isAction: makeIsAction(actionContext?.actionId),
248
- actionUrl: actionContext?.actionUrl,
249
- actionResult: actionContext?.actionResult,
250
- formData: actionContext?.formData,
251
- method: request.method,
252
- routeName: toRouteName,
253
- fromRouteName,
254
- toRouteName,
255
- stale,
256
- });
234
+ let result: any;
235
+ try {
236
+ result = fn({
237
+ currentParams: prevSegment?.params || prevParams, // Use segment params if available, else route params
238
+ currentUrl: prevUrl,
239
+ nextParams,
240
+ nextUrl,
241
+ defaultShouldRevalidate: currentSuggestion,
242
+ context,
243
+ // Segment metadata (which segment is being evaluated)
244
+ segmentType: segment.type,
245
+ layoutName: segment.layoutName,
246
+ slotName: segment.slot,
247
+ // Action context (only populated when triggered by server action)
248
+ actionId: actionContext?.actionId,
249
+ isAction: makeIsAction(actionContext?.actionId),
250
+ actionUrl: actionContext?.actionUrl,
251
+ actionResult: actionContext?.actionResult,
252
+ formData: actionContext?.formData,
253
+ method: request.method,
254
+ routeName: toRouteName,
255
+ fromRouteName,
256
+ toRouteName,
257
+ stale,
258
+ });
259
+ } catch (error) {
260
+ // A thrown Response is control flow (e.g. `throw redirect(...)`), not a
261
+ // failure: re-throw it so the handler chokepoint (match-handlers.ts)
262
+ // turns it into the intended redirect/response. This mirrors how that
263
+ // catch special-cases `error instanceof Response`.
264
+ if (error instanceof Response) throw error;
265
+ // Fail open for genuine errors: a buggy user revalidate fn must not
266
+ // collapse the whole entry's loader batch into a failed partial render.
267
+ // Mirror the dynamic-tags fail-open in cache/cache-policy.ts: log and
268
+ // defer to the current default decision, leaving currentSuggestion
269
+ // unchanged. TODO: route through callOnError(phase "revalidation") once
270
+ // evaluateRevalidation is given the onError seam (today the error only
271
+ // reaches onError via the entry-collapse path in match-handlers.ts).
272
+ console.error(
273
+ `[revalidate] "${name}" threw for segment "${segment.id}"; using default decision:`,
274
+ error,
275
+ );
276
+ continue;
277
+ }
278
+
279
+ // The revalidate fn contract (handler-context.ts) is SYNCHRONOUS: it must
280
+ // return a boolean, a { defaultShouldRevalidate } object, or null/undefined.
281
+ // A Promise-returning (async) fn matches none of the decision branches below
282
+ // and silently falls through keeping the current default — a hard-to-find
283
+ // misuse. We do NOT await it (that would change the sync contract); instead
284
+ // we surface it as a dev-mode warning so the silent drop is diagnosable.
285
+ // Mirrors defer.ts: gated to dev, stripped from production builds.
286
+ if (
287
+ process.env.NODE_ENV !== "production" &&
288
+ result != null &&
289
+ typeof (result as { then?: unknown }).then === "function"
290
+ ) {
291
+ console.warn(
292
+ `[rango] revalidate fn "${name}" returned a Promise; revalidate ` +
293
+ `functions must be synchronous (return a boolean, ` +
294
+ `{ defaultShouldRevalidate }, or null/undefined). The async result ` +
295
+ `was IGNORED and the default (${currentSuggestion}) was kept. ` +
296
+ `Move async work into a loader instead.`,
297
+ );
298
+ }
257
299
 
258
300
  if (typeof result === "boolean") {
259
301
  debugLog("revalidation", "hard decision", {
@@ -154,6 +154,7 @@ export interface RouterContext<TEnv = any> {
154
154
  handlerContext: HandlerContext<any, TEnv>,
155
155
  belongsToRoute: boolean,
156
156
  revalidationContext?: RevalidationContext,
157
+ options?: { skipMiddleware?: boolean },
157
158
  ) => Promise<ResolvedSegment[]>;
158
159
 
159
160
  collectWithMarkers?: <T>(
@@ -579,9 +579,9 @@ export interface RangoOptions<TEnv = any> {
579
579
  * start/end/error, loader start/end/error, handler errors, cache decisions,
580
580
  * revalidation decisions, timeouts, origin rejections.
581
581
  *
582
- * This is the EVENT surface. Phase-duration SPANS (request/loader/render/ssr
583
- * timing wired into a tracing backend) come from the separate `tracing`
584
- * option below — a sink does not emit them, because async-context nesting
582
+ * This is the EVENT surface. Phase-duration SPANS (request/middleware/action/
583
+ * handler/loader/render/ssr timing wired into a tracing backend) come from the
584
+ * separate `tracing` option below — a sink does not emit them, because async-context nesting
585
585
  * cannot be faithfully reconstructed from after-the-fact start/end events.
586
586
  *
587
587
  * No-op when not configured (zero overhead).
@@ -30,7 +30,7 @@ import {
30
30
  import { applyViewTransitionDefault } from "./view-transition-default.js";
31
31
  import { getRouterContext } from "../router-context.js";
32
32
  import { observeStreamedHandler } from "./streamed-handler-telemetry.js";
33
- import { observePhase, PHASES } from "../instrument.js";
33
+ import { observeHandler } from "../instrument.js";
34
34
  import {
35
35
  track,
36
36
  RangoContext,
@@ -260,7 +260,7 @@ export async function resolveSegment<TEnv>(
260
260
  const doneRouteHandler = track(`handler:${entry.id}`, 2);
261
261
  if (entry.loading) {
262
262
  const result = handleHandlerResult(
263
- observePhase(PHASES.handler(entry.id), () => handler(context)),
263
+ observeHandler(entry.id, handler, context),
264
264
  );
265
265
  if (result instanceof Promise) {
266
266
  warnOnStreamedResponse(result, entry.id);
@@ -284,7 +284,7 @@ export async function resolveSegment<TEnv>(
284
284
  }
285
285
  } else {
286
286
  component = handleHandlerResult(
287
- await observePhase(PHASES.handler(entry.id), () => handler(context)),
287
+ await observeHandler(entry.id, handler, context),
288
288
  );
289
289
  doneRouteHandler();
290
290
  }
@@ -511,9 +511,7 @@ export async function resolveParallelEntry<TEnv>(
511
511
  if (hasLoadingFallback) {
512
512
  const result =
513
513
  typeof handler === "function"
514
- ? observePhase(PHASES.handler(`${parallelEntry.id}.${slot}`), () =>
515
- handler(context),
516
- )
514
+ ? observeHandler(`${parallelEntry.id}.${slot}`, handler, context)
517
515
  : handler;
518
516
  if (result instanceof Promise) {
519
517
  result.finally(doneParallelHandler).catch(() => {});
@@ -537,9 +535,10 @@ export async function resolveParallelEntry<TEnv>(
537
535
  } else {
538
536
  component =
539
537
  typeof handler === "function"
540
- ? await observePhase(
541
- PHASES.handler(`${parallelEntry.id}.${slot}`),
542
- () => handler(context),
538
+ ? await observeHandler(
539
+ `${parallelEntry.id}.${slot}`,
540
+ handler,
541
+ context,
543
542
  )
544
543
  : handler;
545
544
  doneParallelHandler();
@@ -23,7 +23,7 @@ import type { ResolvedSegment, ErrorInfo, HandlerContext } from "../../types";
23
23
  import type { SegmentResolutionDeps } from "../types.js";
24
24
  import { debugLog } from "../logging.js";
25
25
  import { tryStaticLookup } from "./static-store.js";
26
- import { observePhase, PHASES } from "../instrument.js";
26
+ import { observeHandler } from "../instrument.js";
27
27
  import type { TelemetrySink } from "../telemetry.js";
28
28
  import { resolveSink, safeEmit, getRequestId } from "../telemetry.js";
29
29
 
@@ -131,15 +131,16 @@ export async function resolveLayoutComponent<TEnv>(
131
131
  entry: EntryData,
132
132
  context: HandlerContext<any, TEnv>,
133
133
  ): Promise<ReactNode> {
134
- // rango.handler span for this layout/cache handler (the perf metric is owned
135
- // by the track("handler:<id>") at the call site; this adds the span only).
136
- return observePhase(PHASES.handler(entry.id), async () => {
137
- const component = await tryStaticHandler(entry, entry.shortCode);
138
- if (component !== undefined) return component;
139
- return typeof entry.handler === "function"
140
- ? handleHandlerResult(await entry.handler(context))
141
- : (entry.handler as ReactNode);
142
- });
134
+ // Static/prerender hit: no handler runs, so emit no rango.handler span.
135
+ const staticComponent = await tryStaticHandler(entry, entry.shortCode);
136
+ if (staticComponent !== undefined) return staticComponent;
137
+ const handler = entry.handler;
138
+ if (typeof handler !== "function") return handler as ReactNode;
139
+ // Wrap ONLY the handler call in the rango.handler span (the perf metric is owned
140
+ // by track("handler:<id>") at the call site). handleHandlerResult stays OUTSIDE
141
+ // the span so a handler that returns a Response (redirect control flow, which it
142
+ // rethrows) is not recorded as a span error — mirrors the route-handler sites.
143
+ return handleHandlerResult(await observeHandler(entry.id, handler, context));
143
144
  }
144
145
 
145
146
  // ---------------------------------------------------------------------------
@@ -175,6 +175,19 @@ export function resolveLoaderData<TEnv>(
175
175
  }
176
176
  const runMiss = internal._loaderCacheOriginalUse!;
177
177
 
178
+ // Dedup the cache read-through across repeated resolutions of the SAME
179
+ // loaderId in one request. An orphan layout with parallel slots inherits its
180
+ // parent route's loaders, so resolveOrphanLayout (fresh.ts) re-resolves the
181
+ // parent's loaders under a different shortCode — calling resolveLoaderData
182
+ // again for the same loaderId. The cache key (loader:{loaderId}:{host}
183
+ // {pathname}:{sortedParams}) does not include the shortCode and ctx/params
184
+ // are identical, so both resolutions produce the same data. Reuse the already
185
+ // in-flight dataPromise instead of issuing a second getItem/setItem (e.g. a
186
+ // second KV round-trip) for one logical cached loader. The shortCode only
187
+ // affects the emitted segmentId in resolveLoaders, not the cached value.
188
+ const existing = overrides.get(loaderId);
189
+ if (existing) return existing;
190
+
178
191
  const dataPromise = (async () => {
179
192
  const codec = await getCodec();
180
193
  const key = await resolveLoaderKey(
@@ -43,7 +43,7 @@ import {
43
43
  } from "./helpers.js";
44
44
  import { applyViewTransitionDefault } from "./view-transition-default.js";
45
45
  import { getRouterContext } from "../router-context.js";
46
- import { observeEvent, observePhase, PHASES } from "../instrument.js";
46
+ import { observeEvent, observeHandler } from "../instrument.js";
47
47
  import { observeStreamedHandler } from "./streamed-handler-telemetry.js";
48
48
  import {
49
49
  track,
@@ -794,14 +794,14 @@ export async function resolveEntryHandlerWithRevalidation<TEnv>(
794
794
  : routeEntry.handler;
795
795
  if (!routeEntry.loading) {
796
796
  const result = handleHandlerResult(
797
- await observePhase(PHASES.handler(entry.id), () => handler(context)),
797
+ await observeHandler(entry.id, handler, context),
798
798
  );
799
799
  doneHandler();
800
800
  return result;
801
801
  }
802
802
  if (!actionContext) {
803
803
  const result = handleHandlerResult(
804
- observePhase(PHASES.handler(entry.id), () => handler(context)),
804
+ observeHandler(entry.id, handler, context),
805
805
  );
806
806
  if (result instanceof Promise) {
807
807
  warnOnStreamedResponse(result, routeEntry.id);
@@ -827,7 +827,7 @@ export async function resolveEntryHandlerWithRevalidation<TEnv>(
827
827
  entryId: entry.id,
828
828
  });
829
829
  const actionResult = handleHandlerResult(
830
- await observePhase(PHASES.handler(entry.id), () => handler(context)),
830
+ await observeHandler(entry.id, handler, context),
831
831
  );
832
832
  doneHandler();
833
833
  return {
@@ -94,6 +94,7 @@ export interface SegmentWrappers<TEnv = any> {
94
94
  context: HandlerContext<any, TEnv>,
95
95
  belongsToRoute?: boolean,
96
96
  revalidationContext?: any,
97
+ options?: { skipMiddleware?: boolean },
97
98
  ) => Promise<ResolvedSegment[]>;
98
99
  resolveInterceptLoadersOnly: (
99
100
  interceptEntry: InterceptEntry,
@@ -245,6 +246,7 @@ export function createSegmentWrappers<TEnv = any>(
245
246
  context: HandlerContext<any, TEnv>,
246
247
  belongsToRoute: boolean = true,
247
248
  revalidationContext?: any,
249
+ options?: { skipMiddleware?: boolean },
248
250
  ): ReturnType<typeof _resolveInterceptEntry> {
249
251
  return _resolveInterceptEntry(
250
252
  interceptEntry,
@@ -254,6 +256,7 @@ export function createSegmentWrappers<TEnv = any>(
254
256
  belongsToRoute,
255
257
  segmentDeps,
256
258
  revalidationContext,
259
+ options,
257
260
  );
258
261
  }
259
262