@rangojs/router 0.0.0-experimental.83 → 0.0.0-experimental.8332dbe4

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 (100) hide show
  1. package/README.md +112 -17
  2. package/dist/vite/index.js +1197 -454
  3. package/package.json +4 -2
  4. package/skills/breadcrumbs/SKILL.md +3 -1
  5. package/skills/handler-use/SKILL.md +2 -0
  6. package/skills/hooks/SKILL.md +30 -2
  7. package/skills/i18n/SKILL.md +276 -0
  8. package/skills/intercept/SKILL.md +25 -0
  9. package/skills/layout/SKILL.md +2 -0
  10. package/skills/links/SKILL.md +234 -16
  11. package/skills/loader/SKILL.md +70 -3
  12. package/skills/middleware/SKILL.md +2 -0
  13. package/skills/migrate-nextjs/SKILL.md +3 -1
  14. package/skills/migrate-react-router/SKILL.md +4 -0
  15. package/skills/parallel/SKILL.md +9 -0
  16. package/skills/rango/SKILL.md +2 -0
  17. package/skills/response-routes/SKILL.md +8 -0
  18. package/skills/route/SKILL.md +24 -0
  19. package/skills/server-actions/SKILL.md +739 -0
  20. package/skills/streams-and-websockets/SKILL.md +283 -0
  21. package/skills/typesafety/SKILL.md +9 -1
  22. package/skills/view-transitions/SKILL.md +212 -0
  23. package/src/browser/app-shell.ts +52 -0
  24. package/src/browser/event-controller.ts +44 -4
  25. package/src/browser/navigation-bridge.ts +113 -6
  26. package/src/browser/navigation-store.ts +25 -1
  27. package/src/browser/partial-update.ts +44 -10
  28. package/src/browser/prefetch/cache.ts +16 -0
  29. package/src/browser/rango-state.ts +53 -13
  30. package/src/browser/react/NavigationProvider.tsx +64 -16
  31. package/src/browser/react/filter-segment-order.ts +51 -7
  32. package/src/browser/react/index.ts +3 -0
  33. package/src/browser/react/use-params.ts +8 -5
  34. package/src/browser/react/use-reverse.ts +99 -0
  35. package/src/browser/react/use-router.ts +8 -1
  36. package/src/browser/react/use-segments.ts +11 -8
  37. package/src/browser/rsc-router.tsx +34 -6
  38. package/src/browser/types.ts +19 -0
  39. package/src/build/route-trie.ts +2 -1
  40. package/src/cache/cf/cf-cache-store.ts +5 -7
  41. package/src/client.rsc.tsx +3 -0
  42. package/src/client.tsx +5 -1
  43. package/src/href-client.ts +4 -1
  44. package/src/index.rsc.ts +3 -0
  45. package/src/index.ts +3 -0
  46. package/src/outlet-context.ts +1 -1
  47. package/src/response-utils.ts +28 -0
  48. package/src/reverse.ts +62 -39
  49. package/src/route-definition/dsl-helpers.ts +16 -3
  50. package/src/route-definition/helpers-types.ts +6 -1
  51. package/src/route-definition/resolve-handler-use.ts +6 -0
  52. package/src/router/handler-context.ts +21 -41
  53. package/src/router/lazy-includes.ts +1 -1
  54. package/src/router/loader-resolution.ts +3 -0
  55. package/src/router/match-api.ts +4 -3
  56. package/src/router/match-handlers.ts +1 -0
  57. package/src/router/match-result.ts +21 -2
  58. package/src/router/middleware-types.ts +14 -25
  59. package/src/router/middleware.ts +54 -7
  60. package/src/router/pattern-matching.ts +101 -17
  61. package/src/router/revalidation.ts +15 -1
  62. package/src/router/segment-resolution/fresh.ts +8 -0
  63. package/src/router/segment-resolution/revalidation.ts +128 -100
  64. package/src/router/substitute-pattern-params.ts +56 -0
  65. package/src/router/trie-matching.ts +18 -13
  66. package/src/router/url-params.ts +49 -0
  67. package/src/router.ts +1 -2
  68. package/src/rsc/handler.ts +8 -4
  69. package/src/rsc/progressive-enhancement.ts +2 -0
  70. package/src/rsc/response-route-handler.ts +11 -10
  71. package/src/rsc/rsc-rendering.ts +3 -0
  72. package/src/rsc/server-action.ts +2 -0
  73. package/src/rsc/types.ts +6 -0
  74. package/src/segment-system.tsx +60 -9
  75. package/src/server/request-context.ts +10 -42
  76. package/src/ssr/index.tsx +5 -1
  77. package/src/types/handler-context.ts +12 -39
  78. package/src/types/loader-types.ts +5 -6
  79. package/src/types/request-scope.ts +126 -0
  80. package/src/types/segments.ts +17 -0
  81. package/src/urls/response-types.ts +2 -10
  82. package/src/vite/debug.ts +184 -0
  83. package/src/vite/discovery/discover-routers.ts +31 -3
  84. package/src/vite/discovery/gate-state.ts +171 -0
  85. package/src/vite/discovery/prerender-collection.ts +48 -1
  86. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  87. package/src/vite/plugins/cjs-to-esm.ts +5 -0
  88. package/src/vite/plugins/client-ref-dedup.ts +16 -0
  89. package/src/vite/plugins/client-ref-hashing.ts +16 -4
  90. package/src/vite/plugins/expose-action-id.ts +52 -28
  91. package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
  92. package/src/vite/plugins/expose-internal-ids.ts +516 -486
  93. package/src/vite/plugins/performance-tracks.ts +17 -9
  94. package/src/vite/plugins/use-cache-transform.ts +56 -43
  95. package/src/vite/plugins/version-injector.ts +37 -11
  96. package/src/vite/rango.ts +49 -14
  97. package/src/vite/router-discovery.ts +498 -52
  98. package/src/vite/utils/banner.ts +1 -1
  99. package/src/vite/utils/package-resolution.ts +41 -1
  100. package/src/vite/utils/prerender-utils.ts +5 -4
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import type { TrieNode, TrieLeaf } from "../build/route-trie.js";
9
+ import { safeDecodeURIComponent } from "./url-params.js";
9
10
 
10
11
  export interface TrieMatchResult {
11
12
  /** Route name */
@@ -14,7 +15,9 @@ export interface TrieMatchResult {
14
15
  sp: string;
15
16
  /** Matched route params */
16
17
  params: Record<string, string>;
17
- /** Optional param names (absent params have empty string value) */
18
+ /** Optional param names declared on the route. Absent params are omitted
19
+ * from `params` (read as `undefined`), matching the
20
+ * `ExtractParams<"/:locale?/...">` type. */
18
21
  optionalParams?: string[];
19
22
  /** Ancestry shortCodes for layout pruning */
20
23
  ancestry: string[];
@@ -173,20 +176,25 @@ function validateAndBuild(
173
176
  originalPathname: string,
174
177
  pathnameHasTrailingSlash: boolean,
175
178
  ): TrieMatchResult | null {
176
- // Build named params by zipping leaf.pa with positional paramValues
179
+ // Build named params by zipping leaf.pa with positional paramValues.
180
+ // Params are URL-decoded at this boundary so ctx.params holds the values
181
+ // apps expect (matching Express/React Router) and round-trip cleanly
182
+ // through ctx.reverse.
177
183
  const params: Record<string, string> = {};
178
184
  if (leaf.pa) {
179
185
  for (let i = 0; i < leaf.pa.length && i < paramValues.length; i++) {
180
- params[leaf.pa[i]] = paramValues[i];
186
+ params[leaf.pa[i]] = safeDecodeURIComponent(paramValues[i]);
181
187
  }
182
188
  }
183
189
 
184
190
  // Add wildcard param (wildcard leaves have pn from TrieNode.w type)
185
191
  if (wildcardValue !== undefined && "pn" in leaf) {
186
- params[(leaf as TrieLeaf & { pn: string }).pn] = wildcardValue;
192
+ params[(leaf as TrieLeaf & { pn: string }).pn] =
193
+ safeDecodeURIComponent(wildcardValue);
187
194
  }
188
195
 
189
- // Validate constraints
196
+ // Validate constraints against decoded values so constraint lists can be
197
+ // written in decoded form (e.g. ["en-GB", "en US"]).
190
198
  if (leaf.cv) {
191
199
  for (const paramName in leaf.cv) {
192
200
  const allowed = leaf.cv[paramName]!;
@@ -197,14 +205,11 @@ function validateAndBuild(
197
205
  }
198
206
  }
199
207
 
200
- // Fill in empty strings for optional params that weren't matched
201
- if (leaf.op) {
202
- for (const name of leaf.op) {
203
- if (!(name in params)) {
204
- params[name] = "";
205
- }
206
- }
207
- }
208
+ // Optional params that weren't matched are left absent from `params` so
209
+ // `ctx.params.locale` reads as `undefined`, matching the
210
+ // `ExtractParams<"/:locale?/...">` type (`{ locale?: string }`). Both
211
+ // internal consumers — the constraint check above and `reverse()`
212
+ // already treat missing/undefined as the absent form.
208
213
 
209
214
  // Trailing slash handling
210
215
  const tsMode = leaf.ts as "never" | "always" | "ignore" | undefined;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * URL param encode/decode at the route boundary.
3
+ *
4
+ * Extraction (decode): regex/trie matchers keep param values URL-encoded;
5
+ * `safeDecodeURIComponent` turns them back into raw strings so `ctx.params`
6
+ * matches the contract apps expect (Express/React Router/Fastify/Koa) and
7
+ * round-trips through reverse stay stable. Malformed %-encoding is
8
+ * preserved as-is so a broken URL doesn't crash matching.
9
+ *
10
+ * Reversal (encode): `encodePathSegment` escapes only what RFC 3986
11
+ * requires for a path segment — `/`, `?`, `#`, space, control chars,
12
+ * non-ASCII — and leaves pchar sub-delims (`@ : $ & + , ; =` and friends)
13
+ * readable. `encodeURIComponent` over-encodes for path segments, which
14
+ * makes generated URLs harder for humans to read in the address bar
15
+ * (e.g. mailbox IDs like `ivo@example.com` would become
16
+ * `ivo%40example.com` even though `@` is path-legal).
17
+ */
18
+
19
+ export function safeDecodeURIComponent(raw: string): string {
20
+ if (raw === "" || raw.indexOf("%") === -1) return raw;
21
+ try {
22
+ return decodeURIComponent(raw);
23
+ } catch {
24
+ return raw;
25
+ }
26
+ }
27
+
28
+ // encodeURIComponent over-encodes for path segments. After running it,
29
+ // un-encode the pchar sub-delims + (`:` / `@`) so the resulting URL
30
+ // keeps human-readable characters that are legal in a path segment.
31
+ // Everything dangerous — `/ ? # %` and space/control/non-ASCII — stays
32
+ // encoded.
33
+ const PATH_SAFE_ESCAPES: Record<string, string> = {
34
+ "%3A": ":",
35
+ "%40": "@",
36
+ "%24": "$",
37
+ "%26": "&",
38
+ "%2B": "+",
39
+ "%2C": ",",
40
+ "%3B": ";",
41
+ "%3D": "=",
42
+ };
43
+
44
+ export function encodePathSegment(value: string): string {
45
+ return encodeURIComponent(value).replace(
46
+ /%(?:3A|40|24|26|2B|2C|3B|3D)/gi,
47
+ (match) => PATH_SAFE_ESCAPES[match.toUpperCase()] ?? match,
48
+ );
49
+ }
package/src/router.ts CHANGED
@@ -22,8 +22,7 @@ import type { UrlPatterns } from "./urls.js";
22
22
  import type { UrlBuilder } from "./urls/pattern-types.js";
23
23
  import { urls } from "./urls.js";
24
24
  import {
25
- EntryData,
26
- InterceptSelectorContext,
25
+ type EntryData,
27
26
  getContext,
28
27
  RSCRouterContext,
29
28
  type MetricsStore,
@@ -31,6 +31,7 @@ import {
31
31
  interceptRedirectForPartial,
32
32
  buildRouteMiddlewareEntries,
33
33
  } from "./helpers.js";
34
+ import { isWebSocketUpgradeResponse } from "../response-utils.js";
34
35
  import {
35
36
  handleResponseRoute,
36
37
  type ResponseRouteMatch,
@@ -56,6 +57,7 @@ import {
56
57
  getRouterTrie,
57
58
  } from "../route-map-builder.js";
58
59
  import type { HandlerContext } from "./handler-context.js";
60
+ import type { SegmentCacheStore } from "../cache/types.js";
59
61
  import { buildRouterTrieFromUrlpatterns } from "./manifest-init.js";
60
62
  import { handleProgressiveEnhancement } from "./progressive-enhancement.js";
61
63
  import {
@@ -352,7 +354,7 @@ export function createRSCHandler<
352
354
  // Resolve cache store configuration
353
355
  // Priority: options.cache (handler override) > router.cache (router default)
354
356
  // Store is enabled only if: config provided, enabled, and no ?__no_cache query param
355
- let cacheStore = undefined;
357
+ let cacheStore: SegmentCacheStore | undefined;
356
358
  const cacheOption = options.cache ?? router.cache;
357
359
  if (cacheOption && !url.searchParams.has("__no_cache")) {
358
360
  const cacheConfig =
@@ -533,7 +535,9 @@ export function createRSCHandler<
533
535
  }
534
536
 
535
537
  const fullTiming = timingParts.join(", ");
536
- if (fullTiming) response.headers.set("Server-Timing", fullTiming);
538
+ if (fullTiming && !isWebSocketUpgradeResponse(response)) {
539
+ response.headers.set("Server-Timing", fullTiming);
540
+ }
537
541
 
538
542
  return response;
539
543
  });
@@ -804,7 +808,7 @@ export function createRSCHandler<
804
808
  );
805
809
  }
806
810
  const response = responseOutcome.result;
807
- if (plan.negotiated) {
811
+ if (plan.negotiated && !isWebSocketUpgradeResponse(response)) {
808
812
  response.headers.append("Vary", "Accept");
809
813
  }
810
814
  return response;
@@ -1014,7 +1018,7 @@ export function createRSCHandler<
1014
1018
  nonce,
1015
1019
  );
1016
1020
  }
1017
- if (negotiated) {
1021
+ if (negotiated && !isWebSocketUpgradeResponse(response)) {
1018
1022
  response.headers.append("Vary", "Accept");
1019
1023
  }
1020
1024
  return response;
@@ -248,6 +248,7 @@ export async function handleProgressiveEnhancement<TEnv>(
248
248
  segments: match.segments,
249
249
  matched: match.matched,
250
250
  diff: match.diff,
251
+ resolvedIds: match.resolvedIds,
251
252
  params: match.params,
252
253
  isPartial: false,
253
254
  rootLayout: ctx.router.rootLayout,
@@ -354,6 +355,7 @@ async function renderPeErrorBoundary<TEnv>(
354
355
  segments: errorResult.segments,
355
356
  matched: errorResult.matched,
356
357
  diff: errorResult.diff,
358
+ resolvedIds: errorResult.resolvedIds,
357
359
  params: errorResult.params,
358
360
  isPartial: false,
359
361
  isError: true,
@@ -28,6 +28,7 @@ import {
28
28
  buildRouteMiddlewareEntries,
29
29
  mergeStubHeadersAndFinalize,
30
30
  } from "./helpers.js";
31
+ import { isWebSocketUpgradeResponse } from "../response-utils.js";
31
32
 
32
33
  export interface ResponseRouteMatch {
33
34
  responseType: string;
@@ -79,10 +80,13 @@ export async function handleResponseRoute<TEnv>(
79
80
  env,
80
81
  searchParams: cleanUrl.searchParams,
81
82
  url: cleanUrl,
83
+ originalUrl: reqCtx.originalUrl,
82
84
  pathname: url.pathname,
83
85
  reverse: createReverseFunction(handlerCtx.getRequiredRouteMap()),
84
86
  get: ((keyOrVar: any) => contextGet(variables, keyOrVar)) as any,
85
87
  header: (name: string, value: string) => reqCtx.header(name, value),
88
+ waitUntil: reqCtx.waitUntil.bind(reqCtx),
89
+ executionContext: reqCtx.executionContext,
86
90
  _responseType: preview.responseType,
87
91
  };
88
92
  // Brand with taint symbol so "use cache" detects it as request-scoped
@@ -97,17 +101,12 @@ export async function handleResponseRoute<TEnv>(
97
101
  // so that stub headers (cookies, custom headers set via ctx.header()) are included.
98
102
  // Use Headers (not Record<string, string>) to preserve duplicate entries like Set-Cookie.
99
103
  const rewrapResponse = (result: Response) => {
100
- // WebSocket upgrades can't flow through `new Response()`: status 101 is
101
- // outside the constructor's 200-599 range, and the Cloudflare-specific
102
- // `webSocket` property would be lost on reconstruction. 204/205/304
103
- // are intentionally NOT short-circuited — they're valid for the
104
- // constructor and must honor ctx.setStatus() overrides.
105
- const hasWebSocket =
106
- (result as unknown as { webSocket?: unknown }).webSocket != null;
107
- if (hasWebSocket || result.status === 101) {
104
+ // 204/205/304 are NOT short-circuited they're valid for the Response
105
+ // constructor and must honor ctx.setStatus() overrides. Only upgrade
106
+ // responses (status 101 / `webSocket` property) bypass reconstruction.
107
+ if (isWebSocketUpgradeResponse(result)) {
108
108
  return mergeStubHeadersAndFinalize(result);
109
109
  }
110
-
111
110
  const headers = new Headers();
112
111
  result.headers.forEach((value, key) => {
113
112
  if (key.toLowerCase() === "set-cookie") {
@@ -208,7 +207,9 @@ export async function handleResponseRoute<TEnv>(
208
207
  // Wrap callHandler to append Vary: Accept on content-negotiated responses
209
208
  const callHandlerWithVary = async () => {
210
209
  const response = await callHandler();
211
- if (preview.negotiated) {
210
+ if (preview.negotiated && !isWebSocketUpgradeResponse(response)) {
211
+ // Skip Vary on upgrade responses: headers are semantically immutable
212
+ // on some runtimes, and Vary is meaningless for a 101 response.
212
213
  response.headers.append("Vary", "Accept");
213
214
  }
214
215
  return response;
@@ -59,6 +59,7 @@ export async function handleRscRendering<TEnv>(
59
59
  segments: match.segments,
60
60
  matched: match.matched,
61
61
  diff: match.diff,
62
+ resolvedIds: match.resolvedIds,
62
63
  params: match.params,
63
64
  isPartial: false,
64
65
  rootLayout: ctx.router.rootLayout,
@@ -81,6 +82,7 @@ export async function handleRscRendering<TEnv>(
81
82
  segments: result.segments,
82
83
  matched: result.matched,
83
84
  diff: result.diff,
85
+ resolvedIds: result.resolvedIds,
84
86
  params: result.params,
85
87
  isPartial: true,
86
88
  slots: result.slots,
@@ -144,6 +146,7 @@ export async function handleRscRendering<TEnv>(
144
146
  segments: match.segments,
145
147
  matched: match.matched,
146
148
  diff: match.diff,
149
+ resolvedIds: match.resolvedIds,
147
150
  params: match.params,
148
151
  isPartial: false,
149
152
  rootLayout: ctx.router.rootLayout,
@@ -213,6 +213,7 @@ export async function executeServerAction<TEnv>(
213
213
  isPartial: true,
214
214
  matched: errorResult.matched,
215
215
  diff: errorResult.diff,
216
+ resolvedIds: errorResult.resolvedIds,
216
217
  params: errorResult.params,
217
218
  isError: true,
218
219
  handles: handleStore.stream(),
@@ -324,6 +325,7 @@ export async function revalidateAfterAction<TEnv>(
324
325
  isPartial: true,
325
326
  matched: matchResult.matched,
326
327
  diff: matchResult.diff,
328
+ resolvedIds: matchResult.resolvedIds,
327
329
  params: matchResult.params,
328
330
  slots: matchResult.slots,
329
331
  handles: handleStore.stream(),
package/src/rsc/types.ts CHANGED
@@ -26,6 +26,12 @@ export interface RscPayload {
26
26
  isError?: boolean;
27
27
  matched?: string[];
28
28
  diff?: string[];
29
+ /**
30
+ * All segment ids re-resolved on the server, including null-component
31
+ * ones excluded from `segments`/`diff`. Drives client-side handle-bucket
32
+ * cleanup. Superset of `diff`. See MatchResult.resolvedIds.
33
+ */
34
+ resolvedIds?: string[];
29
35
  /** Merged route params from the matched route */
30
36
  params?: Record<string, string>;
31
37
  slots?: Record<string, SlotState>;
@@ -131,6 +131,47 @@ export interface RenderSegmentsOptions {
131
131
  rootLayout?: ComponentType<RootLayoutProps>;
132
132
  }
133
133
 
134
+ function createViewTransitionBoundary(
135
+ transition: NonNullable<ResolvedSegment["transition"]>,
136
+ children: ReactNode,
137
+ ): ReactNode {
138
+ return createElement(ReactViewTransition, {
139
+ ...transition,
140
+ children,
141
+ });
142
+ }
143
+
144
+ function wrapDefaultOutletContent(
145
+ content: ReactNode,
146
+ transition: NonNullable<ResolvedSegment["transition"]>,
147
+ ): ReactNode {
148
+ if (!React.isValidElement(content)) {
149
+ return createViewTransitionBoundary(transition, content);
150
+ }
151
+
152
+ const props = content.props as any;
153
+
154
+ if (content.type === MountContextProvider) {
155
+ return React.cloneElement(content, {
156
+ children: wrapDefaultOutletContent(props.children, transition),
157
+ } as any);
158
+ }
159
+
160
+ if (content.type === OutletProvider && props.segment?.type === "layout") {
161
+ return React.cloneElement(content, {
162
+ content: wrapDefaultOutletContent(props.content, transition),
163
+ } as any);
164
+ }
165
+
166
+ if (content.type === LoaderBoundary && props.segment?.type === "layout") {
167
+ return React.cloneElement(content, {
168
+ outletContent: wrapDefaultOutletContent(props.outletContent, transition),
169
+ } as any);
170
+ }
171
+
172
+ return createViewTransitionBoundary(transition, content);
173
+ }
174
+
134
175
  /**
135
176
  * Render segments into a React tree with proper layout nesting
136
177
  *
@@ -273,17 +314,27 @@ export async function renderSegments(
273
314
  // in transitions without adding custom animation classes. Named element-level
274
315
  // <ViewTransition> components inside (with name/share props) morph independently
275
316
  // from the parent's default cross-fade.
276
- if (ReactViewTransition && node.segment.transition) {
277
- nodeContent = createElement(ReactViewTransition, {
278
- ...node.segment.transition,
279
- children: nodeContent,
280
- });
281
- }
282
-
283
- // Common props for OutletProvider
284
- const outletContent: ReactNode =
317
+ //
318
+ // For layouts, wrap the outlet content (what `<Outlet />` renders) rather
319
+ // than the layout component itself. Parallel slots like `<ParallelOutlet
320
+ // name="@modal" />` read from a separate context channel and end up as
321
+ // siblings of the VT in the rendered tree, so modal mounts don't trigger a
322
+ // subtree update on the layout-level VT — which would otherwise make
323
+ // React's commit walker fire `document.startViewTransition` and apply
324
+ // view-transition-names to the underlying main subtree (cover/title/etc.).
325
+ let outletContent: ReactNode =
285
326
  node.segment.type === "layout" ? content : null;
286
327
 
328
+ const transition = node.segment.transition;
329
+
330
+ if (ReactViewTransition && transition) {
331
+ if (node.segment.type === "layout") {
332
+ outletContent = wrapDefaultOutletContent(outletContent, transition);
333
+ } else {
334
+ nodeContent = createViewTransitionBoundary(transition, nodeContent);
335
+ }
336
+ }
337
+
287
338
  // Prepare loader data if there are loaders
288
339
  const loaderIds = loaderEntries.map((loader) => loader.loaderId!);
289
340
  const loaderDataPromise = getMemoizedLoaderPromise(loaderEntries);
@@ -37,6 +37,8 @@ import { track, type MetricsStore } from "./context.js";
37
37
  import { getFetchableLoader } from "./fetchable-loader-store.js";
38
38
  import type { SegmentCacheStore } from "../cache/types.js";
39
39
  import type { Theme, ResolvedThemeConfig } from "../theme/types.js";
40
+ import type { ExecutionContext, RequestScope } from "../types/request-scope.js";
41
+ import { fireAndForgetWaitUntil } from "../types/request-scope.js";
40
42
  import { THEME_COOKIE } from "../theme/constants.js";
41
43
  import type { LocationStateEntry } from "../browser/react/location-state-shared.js";
42
44
  import { NOCACHE_SYMBOL, assertNotInsideCacheExec } from "../cache/taint.js";
@@ -58,22 +60,7 @@ import { isAutoGeneratedRouteName } from "../route-name.js";
58
60
  export interface RequestContext<
59
61
  TEnv = DefaultEnv,
60
62
  TParams = Record<string, string>,
61
- > {
62
- /** Platform bindings (Cloudflare env, etc.) */
63
- env: TEnv;
64
- /** Original HTTP request */
65
- request: Request;
66
- /** Parsed URL (with internal `_rsc*` params stripped) */
67
- url: URL;
68
- /**
69
- * The original request URL with all parameters intact, including
70
- * internal `_rsc*` transport params.
71
- */
72
- originalUrl: URL;
73
- /** URL pathname */
74
- pathname: string;
75
- /** URL search params (with internal `_rsc*` params stripped, same as `url.searchParams`) */
76
- searchParams: URLSearchParams;
63
+ > extends RequestScope<TEnv> {
77
64
  /** @internal Shared variable backing store for ctx.get()/ctx.set(). */
78
65
  _variables: Record<string, any>;
79
66
  /** Get a variable set by middleware */
@@ -159,20 +146,6 @@ export interface RequestContext<
159
146
  import("../cache/profile-registry.js").CacheProfile
160
147
  >;
161
148
 
162
- /**
163
- * Schedule work to run after the response is sent.
164
- * On Cloudflare Workers, uses ctx.waitUntil().
165
- * On Node.js, runs as fire-and-forget.
166
- *
167
- * @example
168
- * ```typescript
169
- * ctx.waitUntil(async () => {
170
- * await cacheStore.set(key, data, ttl);
171
- * });
172
- * ```
173
- */
174
- waitUntil(fn: () => Promise<void>): void;
175
-
176
149
  /**
177
150
  * Register a callback to run when the response is created.
178
151
  * Callbacks are sync and receive the response. They can:
@@ -498,13 +471,7 @@ export function requireRequestContext<
498
471
  return getRequestContext<TEnv>();
499
472
  }
500
473
 
501
- /**
502
- * Cloudflare Workers ExecutionContext (subset we need)
503
- */
504
- export interface ExecutionContext {
505
- waitUntil(promise: Promise<any>): void;
506
- passThroughOnException(): void;
507
- }
474
+ export type { ExecutionContext };
508
475
 
509
476
  /**
510
477
  * Options for creating a request context
@@ -768,16 +735,14 @@ export function createRequestContext<TEnv>(
768
735
 
769
736
  waitUntil(fn: () => Promise<void>): void {
770
737
  if (executionContext?.waitUntil) {
771
- // Cloudflare Workers: use native waitUntil
772
738
  executionContext.waitUntil(fn());
773
739
  } else {
774
- // Node.js / dev: fire-and-forget with error logging
775
- fn().catch((err) =>
776
- console.error("[waitUntil] Background task failed:", err),
777
- );
740
+ fireAndForgetWaitUntil(fn);
778
741
  }
779
742
  },
780
743
 
744
+ executionContext,
745
+
781
746
  _onResponseCallbacks: [],
782
747
 
783
748
  onResponse(callback: (response: Response) => Response): void {
@@ -1043,7 +1008,10 @@ export function createUseFunction<TEnv>(
1043
1008
  search: (ctx as any).search ?? {},
1044
1009
  pathname: ctx.pathname,
1045
1010
  url: ctx.url,
1011
+ originalUrl: ctx.originalUrl,
1046
1012
  env: ctx.env as any,
1013
+ waitUntil: ctx.waitUntil.bind(ctx),
1014
+ executionContext: ctx.executionContext,
1047
1015
  get: ctx.get as any,
1048
1016
  use: (<TDep, TDepParams = any>(
1049
1017
  dep: LoaderDefinition<TDep, TDepParams>,
package/src/ssr/index.tsx CHANGED
@@ -162,9 +162,13 @@ function createSsrEventController(opts: {
162
162
  }): EventController {
163
163
  const location = new URL(opts.pathname, "http://localhost");
164
164
  let params = opts.params ?? {};
165
+ const rawMatched = opts.matched ?? [];
165
166
  const handleState = {
166
167
  data: opts.handleData ?? {},
167
- segmentOrder: filterSegmentOrder(opts.matched ?? []),
168
+ segmentOrder: filterSegmentOrder(rawMatched),
169
+ routeSegmentIds: rawMatched.filter(
170
+ (id) => !id.includes(".@") && !/D\d+\./.test(id),
171
+ ),
168
172
  };
169
173
  const state: DerivedNavigationState = {
170
174
  state: "idle",
@@ -20,6 +20,7 @@ import type {
20
20
  } from "./route-config.js";
21
21
  import type { LoaderDefinition } from "./loader-types.js";
22
22
  import type { UseItems, HandlerUseItem } from "../route-types.js";
23
+ import type { RequestScope } from "./request-scope.js";
23
24
 
24
25
  // Re-export MiddlewareFn for internal/advanced use
25
26
  export type { MiddlewareFn } from "../router/middleware.js";
@@ -195,7 +196,7 @@ export type HandlerContext<
195
196
  TEnv = DefaultEnv,
196
197
  TSearch extends SearchSchema = {},
197
198
  TRouteMap = never,
198
- > = {
199
+ > = RequestScope<TEnv> & {
199
200
  /**
200
201
  * Route parameters extracted from the URL pattern.
201
202
  * Type-safe when using Handler<"/path/:param"> or Handler<{ param: string }>.
@@ -215,44 +216,11 @@ export type HandlerContext<
215
216
  * changing build semantics (e.g., skip expensive operations in dev).
216
217
  */
217
218
  dev: boolean;
218
- /**
219
- * The original incoming Request object (transport URL intact).
220
- * Use `ctx.url` / `ctx.searchParams` for application logic — those have
221
- * internal `_rsc*` params stripped. `ctx.request` preserves the raw URL
222
- * for cases where you need original headers, method, or body.
223
- */
224
- request: Request;
225
- /**
226
- * Query parameters from the URL (system params like `_rsc*` are filtered).
227
- * Always a standard URLSearchParams instance.
228
- */
229
- searchParams: URLSearchParams;
230
219
  /**
231
220
  * Typed search parameters parsed from URL query string via the route's
232
221
  * search schema. Empty object when no schema is defined.
233
222
  */
234
223
  search: {} extends TSearch ? {} : ResolveSearchSchema<TSearch>;
235
- /**
236
- * The pathname portion of the request URL.
237
- */
238
- pathname: string;
239
- /**
240
- * The full URL object (with internal `_rsc*` params stripped).
241
- * Use this for application logic — routing, link generation, display.
242
- */
243
- url: URL;
244
- /**
245
- * The original request URL with all parameters intact, including
246
- * internal `_rsc*` transport params. Use `ctx.url` for application
247
- * logic — this is only needed for advanced cases like debugging
248
- * or custom cache keying.
249
- */
250
- originalUrl: URL;
251
- /**
252
- * Platform bindings (DB, KV, secrets, etc.).
253
- * Access resources like `ctx.env.DB`, `ctx.env.KV`.
254
- */
255
- env: TEnv;
256
224
  /**
257
225
  * Type-safe getter for middleware variables.
258
226
  * Preferred way to read middleware-injected variables.
@@ -503,13 +471,16 @@ export type RevalidateParams<TParams = GenericParams, TEnv = any> = Parameters<
503
471
  * **Return Types:**
504
472
  * - `boolean` - Hard decision: immediately returns this value (short-circuits)
505
473
  * - `{ defaultShouldRevalidate: boolean }` - Soft decision: updates suggestion for next revalidator
474
+ * - `void` / `null` / `undefined` - Defer to the current suggestion (no opinion); the
475
+ * loop continues to the next revalidator without changing the running default
506
476
  *
507
477
  * **Execution Flow:**
508
478
  * 1. Start with built-in `defaultShouldRevalidate` (true if params changed)
509
479
  * 2. Execute global revalidators first, then route-specific
510
480
  * 3. Hard decision (boolean): stop immediately and use that value
511
481
  * 4. Soft decision (object): update suggestion and continue to next revalidator
512
- * 5. If all return soft decisions: use the final suggestion
482
+ * 5. Defer (`void` / `null` / `undefined`): leave suggestion unchanged and continue
483
+ * 6. If no hard decision was returned: use the final running suggestion
513
484
  *
514
485
  * @param args.currentParams - Previous route params (generic by default, can be narrowed)
515
486
  * @param args.currentUrl - Previous URL
@@ -521,7 +492,8 @@ export type RevalidateParams<TParams = GenericParams, TEnv = any> = Parameters<
521
492
  * @param args.formData - Form data from action (future support)
522
493
  * @param args.formMethod - HTTP method from action (future support)
523
494
  *
524
- * @returns Hard decision (boolean) or soft suggestion (object)
495
+ * @returns Hard decision (boolean), soft suggestion (object), or defer
496
+ * (`void` / `null` / `undefined`) to keep the running suggestion as-is.
525
497
  *
526
498
  * @example
527
499
  * ```typescript
@@ -546,8 +518,9 @@ export type RevalidateParams<TParams = GenericParams, TEnv = any> = Parameters<
546
518
  * a segment (layout, route, parallel slot, or loader) should be re-rendered.
547
519
  *
548
520
  * Return `true` to re-render, `false` to skip (keep client's current version),
549
- * or `{ defaultShouldRevalidate: boolean }` to override the default for
550
- * downstream segments.
521
+ * `{ defaultShouldRevalidate: boolean }` to update the running suggestion for
522
+ * downstream revalidators, or nothing (`void` / `null` / `undefined`) to defer
523
+ * to the current suggestion without changing it.
551
524
  *
552
525
  * @example
553
526
  * ```ts
@@ -647,7 +620,7 @@ export type ShouldRevalidateFn<TParams = GenericParams, TEnv = any> = (args: {
647
620
  * action that may have mutated backend state.
648
621
  */
649
622
  stale?: boolean;
650
- }) => boolean | { defaultShouldRevalidate: boolean };
623
+ }) => boolean | { defaultShouldRevalidate: boolean } | null | void;
651
624
 
652
625
  // MiddlewareFn is imported from "../router/middleware.js" and re-exported
653
626
 
@@ -3,11 +3,13 @@ import type { Handle } from "../handle.js";
3
3
  import type { MiddlewareFn } from "../router/middleware.js";
4
4
  import type { ScopedReverseFunction } from "../reverse.js";
5
5
  import type { SearchSchema, ResolveSearchSchema } from "../search-params.js";
6
+ import type { UseItems, LoaderUseItem } from "../route-types.js";
6
7
  import type {
7
8
  DefaultEnv,
8
9
  DefaultReverseRouteMap,
9
10
  DefaultVars,
10
11
  } from "./global-namespace.js";
12
+ import type { RequestScope } from "./request-scope.js";
11
13
 
12
14
  /**
13
15
  * Context passed to loader functions during execution
@@ -39,7 +41,7 @@ export type LoaderContext<
39
41
  TEnv = DefaultEnv,
40
42
  TBody = unknown,
41
43
  TSearch extends SearchSchema = {},
42
- > = {
44
+ > = RequestScope<TEnv> & {
43
45
  params: TParams;
44
46
  /**
45
47
  * Route params extracted from the URL pattern match (server-side only).
@@ -48,12 +50,7 @@ export type LoaderContext<
48
50
  * resource scoping.
49
51
  */
50
52
  routeParams: Record<string, string>;
51
- request: Request;
52
- searchParams: URLSearchParams;
53
53
  search: {} extends TSearch ? {} : ResolveSearchSchema<TSearch>;
54
- pathname: string;
55
- url: URL;
56
- env: TEnv;
57
54
  get: {
58
55
  <T>(contextVar: ContextVar<T>): T | undefined;
59
56
  } & (<K extends keyof DefaultVars>(key: K) => DefaultVars[K]);
@@ -207,4 +204,6 @@ export type LoaderDefinition<
207
204
  __brand: "loader";
208
205
  $$id: string; // Injected by Vite plugin (exposeInternalIds) - unique identifier
209
206
  fn?: LoaderFn<T, TParams, any>; // Optional - server-side only, stored in registry for RSC
207
+ /** Composable default DSL items merged when the loader is mounted. */
208
+ use?: () => UseItems<LoaderUseItem>;
210
209
  };