@rangojs/router 0.0.0-experimental.ede38110 → 0.0.0-experimental.f2d1a2f1

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 (66) hide show
  1. package/README.md +50 -20
  2. package/dist/vite/index.js +353 -49
  3. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  4. package/package.json +5 -3
  5. package/skills/breadcrumbs/SKILL.md +3 -1
  6. package/skills/hooks/SKILL.md +28 -20
  7. package/skills/links/SKILL.md +88 -16
  8. package/skills/loader/SKILL.md +35 -2
  9. package/skills/migrate-react-router/SKILL.md +1 -0
  10. package/skills/response-routes/SKILL.md +8 -0
  11. package/skills/streams-and-websockets/SKILL.md +283 -0
  12. package/skills/typesafety/SKILL.md +3 -1
  13. package/src/browser/app-shell.ts +52 -0
  14. package/src/browser/navigation-bridge.ts +51 -2
  15. package/src/browser/navigation-client.ts +33 -10
  16. package/src/browser/navigation-store.ts +25 -1
  17. package/src/browser/partial-update.ts +20 -1
  18. package/src/browser/prefetch/cache.ts +124 -26
  19. package/src/browser/prefetch/fetch.ts +114 -38
  20. package/src/browser/prefetch/queue.ts +36 -5
  21. package/src/browser/rango-state.ts +53 -13
  22. package/src/browser/react/Link.tsx +18 -13
  23. package/src/browser/react/NavigationProvider.tsx +50 -11
  24. package/src/browser/react/use-navigation.ts +30 -11
  25. package/src/browser/react/use-params.ts +11 -1
  26. package/src/browser/react/use-router.ts +8 -1
  27. package/src/browser/rsc-router.tsx +34 -6
  28. package/src/browser/types.ts +13 -0
  29. package/src/cache/cf/cf-cache-store.ts +5 -7
  30. package/src/index.rsc.ts +3 -0
  31. package/src/index.ts +3 -0
  32. package/src/outlet-context.ts +1 -1
  33. package/src/response-utils.ts +28 -0
  34. package/src/reverse.ts +3 -2
  35. package/src/route-definition/dsl-helpers.ts +16 -3
  36. package/src/route-definition/resolve-handler-use.ts +6 -0
  37. package/src/router/handler-context.ts +20 -3
  38. package/src/router/lazy-includes.ts +1 -1
  39. package/src/router/loader-resolution.ts +3 -0
  40. package/src/router/match-api.ts +3 -3
  41. package/src/router/middleware-types.ts +2 -22
  42. package/src/router/middleware.ts +32 -4
  43. package/src/router/pattern-matching.ts +60 -9
  44. package/src/router/trie-matching.ts +10 -4
  45. package/src/router/url-params.ts +49 -0
  46. package/src/router.ts +1 -2
  47. package/src/rsc/handler.ts +8 -4
  48. package/src/rsc/helpers.ts +69 -41
  49. package/src/rsc/progressive-enhancement.ts +2 -0
  50. package/src/rsc/response-route-handler.ts +14 -1
  51. package/src/rsc/rsc-rendering.ts +7 -0
  52. package/src/rsc/server-action.ts +2 -0
  53. package/src/server/request-context.ts +10 -42
  54. package/src/types/handler-context.ts +2 -34
  55. package/src/types/loader-types.ts +5 -6
  56. package/src/types/request-scope.ts +126 -0
  57. package/src/urls/response-types.ts +2 -10
  58. package/src/vite/debug.ts +86 -0
  59. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  60. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  61. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  62. package/src/vite/plugins/performance-tracks.ts +4 -6
  63. package/src/vite/rango.ts +49 -14
  64. package/src/vite/router-discovery.ts +161 -23
  65. package/src/vite/utils/banner.ts +1 -1
  66. package/src/vite/utils/package-resolution.ts +41 -1
@@ -98,25 +98,30 @@ export interface LinkProps extends Omit<
98
98
  */
99
99
  prefetch?: PrefetchStrategy;
100
100
  /**
101
- * Custom prefetch cache key for source-agnostic cache reuse.
102
- * When set, prefetch responses are cached independently of the current
103
- * page URL, so navigating to the same target from different source pages
104
- * reuses the cached prefetch.
101
+ * Opt-in override for the prefetch cache scope.
105
102
  *
106
- * - String: static group name (e.g., `"pages"`)
107
- * - Function: receives current URL (`window.location.href`), returns a
108
- * normalized source key
103
+ * The default cache is source-agnostic: one shared entry per target,
104
+ * keyed on Rango state + target URL. This is correct for routes whose
105
+ * response shape doesn't depend on where the user navigates from.
106
+ *
107
+ * Set `":source"` when this Link's response would legitimately differ
108
+ * based on the source page — typically when the target route (or one
109
+ * of its layouts) uses a custom `revalidate()` handler that reads
110
+ * `currentUrl` / `currentParams`, and the wildcard entry would
111
+ * therefore serve the wrong diff to a navigation from a different
112
+ * source.
113
+ *
114
+ * Intercept responses are auto-scoped to the source via a server-side
115
+ * tag, so `":source"` is only needed for custom revalidation logic.
109
116
  *
110
117
  * @example
111
118
  * ```tsx
112
- * // Static group all "pages" links share one cache entry per target
113
- * <Link to="/page/3" prefetch="hover" prefetchKey="pages" />
114
- *
115
- * // Normalize — strip trailing page number from source URL
116
- * <Link to="/page/3" prefetch="hover" prefetchKey={(from) => from.replace(/\/\d+$/, '')} />
119
+ * // Route uses a `revalidate()` that branches on currentUrl opt in
120
+ * // so prefetches don't bleed across source pages.
121
+ * <Link to="/dashboard" prefetch="hover" prefetchKey=":source" />
117
122
  * ```
118
123
  */
119
- prefetchKey?: string | ((from: string) => string);
124
+ prefetchKey?: ":source";
120
125
  /**
121
126
  * State to pass to history.pushState/replaceState.
122
127
  * Accessible via useLocationState() hook.
@@ -28,6 +28,7 @@ import { NonceContext } from "./nonce-context.js";
28
28
  import type { ResolvedThemeConfig, Theme } from "../../theme/types.js";
29
29
  import { cancelAllPrefetches } from "../prefetch/queue.js";
30
30
  import { handleNavigationEnd } from "../scroll-restoration.js";
31
+ import type { AppShellRef } from "../app-shell.js";
31
32
 
32
33
  /**
33
34
  * Process handles from an async generator, updating the event controller
@@ -133,15 +134,23 @@ export interface NavigationProviderProps {
133
134
  warmupEnabled?: boolean;
134
135
 
135
136
  /**
136
- * App version from server payload (stable, immutable).
137
- * Forwarded to context for cache key building.
137
+ * App version from server payload.
138
+ * Used only as a fallback when `appShellRef` is not supplied.
138
139
  */
139
140
  version?: string;
140
141
 
141
142
  /**
142
143
  * URL prefix for all routes (from createRouter({ basename })).
144
+ * Used only as a fallback when `appShellRef` is not supplied.
143
145
  */
144
146
  basename?: string;
147
+
148
+ /**
149
+ * Live app-shell ref. When provided, the context's `basename` and `version`
150
+ * properties become live getters that track app-switch updates without
151
+ * invalidating the memoized context value.
152
+ */
153
+ appShellRef?: AppShellRef;
145
154
  }
146
155
 
147
156
  /**
@@ -175,6 +184,7 @@ export function NavigationProvider({
175
184
  warmupEnabled,
176
185
  version,
177
186
  basename,
187
+ appShellRef,
178
188
  }: NavigationProviderProps): ReactNode {
179
189
  // Track current payload for rendering (this triggers re-renders)
180
190
  const [payload, setPayload] = useState(initialPayload);
@@ -196,18 +206,39 @@ export function NavigationProvider({
196
206
  await bridge.refresh();
197
207
  }, []);
198
208
 
199
- // Context value is stable (store, eventController, navigate, refresh never change)
200
- const contextValue = useMemo<NavigationStoreContextValue>(
201
- () => ({
209
+ // Context value is stable (store, eventController, navigate, refresh never
210
+ // change). When an appShellRef is supplied, `basename` and `version` are
211
+ // installed as live getters so app-switch transitions (which update the ref)
212
+ // propagate to consumers without forcing a tree-wide rerender.
213
+ const contextValue = useMemo<NavigationStoreContextValue>(() => {
214
+ if (appShellRef) {
215
+ const value = {
216
+ store,
217
+ eventController,
218
+ navigate,
219
+ refresh,
220
+ } as NavigationStoreContextValue;
221
+ Object.defineProperty(value, "basename", {
222
+ configurable: true,
223
+ enumerable: true,
224
+ get: () => appShellRef.get().basename,
225
+ });
226
+ Object.defineProperty(value, "version", {
227
+ configurable: true,
228
+ enumerable: true,
229
+ get: () => appShellRef.get().version,
230
+ });
231
+ return value;
232
+ }
233
+ return {
202
234
  store,
203
235
  eventController,
204
236
  navigate,
205
237
  refresh,
206
238
  version,
207
239
  basename,
208
- }),
209
- [],
210
- );
240
+ };
241
+ }, []);
211
242
 
212
243
  // Connection warmup: keep TLS alive after idle periods.
213
244
  // After 60s of no user interaction, marks connection as "cold".
@@ -345,8 +376,12 @@ export function NavigationProvider({
345
376
  metadata: update.metadata,
346
377
  });
347
378
 
348
- // Update route params
349
- eventController.setParams(update.metadata.params ?? {});
379
+ // Update route params. Only reset when the server actually sends a params
380
+ // map — an absent `params` field means "no change" (e.g., legacy action
381
+ // responses that omitted params). Explicit `{}` still clears correctly.
382
+ if (update.metadata.params !== undefined) {
383
+ eventController.setParams(update.metadata.params);
384
+ }
350
385
 
351
386
  // Update handle data progressively as it streams in
352
387
  if (update.metadata.handles) {
@@ -398,7 +433,11 @@ export function NavigationProvider({
398
433
  // Build the content tree
399
434
  let content = <RootErrorBoundary>{root}</RootErrorBoundary>;
400
435
 
401
- // Wrap with ThemeProvider when theme is enabled
436
+ // Wrap with ThemeProvider when theme is enabled. The ThemeProvider is
437
+ // document-lifetime: its config comes from the initial load and does NOT
438
+ // swap on cross-app transitions, because the ThemeProvider sits above the
439
+ // segment tree and a smooth (no-reload) app switch cannot safely remount
440
+ // it. A new theme config only takes effect on a full document load.
402
441
  if (themeConfig) {
403
442
  content = (
404
443
  <ThemeProvider config={themeConfig} initialTheme={initialTheme}>
@@ -53,6 +53,12 @@ export function useNavigation<T>(
53
53
  });
54
54
  const prevState = useRef(baseValue);
55
55
 
56
+ // Tracks whether the most recent setOptimisticValue call pinned the value
57
+ // to a non-idle state. Used to decide whether to emit a release update when
58
+ // returning to idle, so the optimistic store doesn't stay pinned if a
59
+ // parent transition (e.g. <Link> click) is still pending.
60
+ const optimisticPinnedRef = useRef(false);
61
+
56
62
  // useOptimistic allows immediate updates during transitions/actions
57
63
  const [value, setOptimisticValue] = useOptimistic(baseValue);
58
64
 
@@ -78,17 +84,30 @@ export function useNavigation<T>(
78
84
  if (!shallowEqual(nextSelected, prevState.current)) {
79
85
  prevState.current = nextSelected;
80
86
 
81
- // Always mirror into the optimistic store inside a transition.
82
- // useOptimistic returns the optimistic value while any transition that
83
- // includes a setOptimisticValue is pending. Calling it only when
84
- // entering "loading" left the optimistic store stuck on the previous
85
- // value when going back to "idle" — if a parent transition (e.g., a
86
- // <Link> click) was still pending, useOptimistic would keep returning
87
- // the stale loading value even after baseValue flipped to idle.
88
- // Updating in both directions keeps the optimistic store in sync.
89
- startTransition(() => {
90
- setOptimisticValue(nextSelected);
91
- });
87
+ // Check if any actions are in progress for optimistic updates
88
+ const hasInflightActions =
89
+ ctx.eventController.getInflightActions().size > 0;
90
+
91
+ const shouldPin = hasInflightActions || publicState.state !== "idle";
92
+
93
+ if (shouldPin) {
94
+ // Pin the optimistic store so the loading value shows immediately
95
+ // even if a parent transition (e.g. <Link> click) defers the
96
+ // urgent setBaseValue commit.
97
+ startTransition(() => {
98
+ setOptimisticValue(nextSelected);
99
+ });
100
+ optimisticPinnedRef.current = true;
101
+ } else if (optimisticPinnedRef.current) {
102
+ // Release a previously-pinned optimistic value. Without this,
103
+ // useOptimistic keeps returning the stale loading value while
104
+ // any parent transition is still pending, even after baseValue
105
+ // flipped to idle.
106
+ startTransition(() => {
107
+ setOptimisticValue(nextSelected);
108
+ });
109
+ optimisticPinnedRef.current = false;
110
+ }
92
111
 
93
112
  // Always update base state so UI reflects current state
94
113
  setBaseValue(nextSelected);
@@ -16,11 +16,21 @@ import { shallowEqual } from "./shallow-equal.js";
16
16
  * const params = useParams();
17
17
  * // { productId: "123" }
18
18
  *
19
+ * // Annotate the expected shape via a generic
20
+ * const { productId } = useParams<{ productId: string }>();
21
+ *
19
22
  * // With selector
20
23
  * const productId = useParams(p => p.productId);
21
24
  * ```
22
25
  */
23
- export function useParams(): Record<string, string>;
26
+ // `T extends object` (not `Record<string, string | undefined>`) so that
27
+ // interface shapes pass the constraint — interfaces lack an implicit
28
+ // index signature and would otherwise be rejected. The generic is a
29
+ // shape annotation, not a runtime check; the body always returns the
30
+ // underlying params map unchanged.
31
+ export function useParams<
32
+ T extends object = Record<string, string>,
33
+ >(): Readonly<T>;
24
34
  export function useParams<T>(
25
35
  selector: (params: Record<string, string>) => T,
26
36
  ): T;
@@ -13,6 +13,10 @@ import type { RouterInstance, RouterNavigateOptions } from "../types.js";
13
13
  * useRouter() do not re-render on navigation state changes.
14
14
  * For reactive navigation state, use useNavigation() instead.
15
15
  *
16
+ * Methods read `basename` from the live context on each call so that
17
+ * cross-app navigation (app-switch) sees the current app's basename
18
+ * rather than the one captured at mount time.
19
+ *
16
20
  * @example
17
21
  * ```tsx
18
22
  * const router = useRouter();
@@ -29,7 +33,10 @@ export function useRouter(): RouterInstance {
29
33
  throw new Error("useRouter must be used within NavigationProvider");
30
34
  }
31
35
 
32
- // Stable reference: ctx is itself stable (NavigationProvider memoizes with [])
36
+ // Stable reference: ctx itself is stable, and reads on each method call
37
+ // pick up live basename values from the context (backed by a live ref
38
+ // in NavigationProvider), so app-switch transitions are reflected without
39
+ // recreating this object.
33
40
  return useMemo<RouterInstance>(() => {
34
41
  /** Prefix a root-relative path with basename if not already prefixed. */
35
42
  function withBasename(url: string): string {
@@ -28,6 +28,7 @@ import {
28
28
  isInterceptSegment,
29
29
  splitInterceptSegments,
30
30
  } from "./intercept-utils.js";
31
+ import { createAppShellRef } from "./app-shell.js";
31
32
 
32
33
  // Vite HMR types are provided by vite/client
33
34
 
@@ -114,6 +115,13 @@ export interface BrowserAppContext {
114
115
  warmupEnabled?: boolean;
115
116
  /** App version for prefetch version mismatch detection */
116
117
  version?: string;
118
+ /**
119
+ * Live app-shell ref. Cross-app navigations replace its contents so the
120
+ * NavigationProvider and renderSegments pick up the target app's
121
+ * rootLayout, basename, and version without consumer rerenders. Theme,
122
+ * warmup, and prefetch TTL are document-lifetime (see AppShell).
123
+ */
124
+ appShellRef?: import("./app-shell.js").AppShellRef;
117
125
  }
118
126
 
119
127
  // Module-level state for the initialized app
@@ -204,13 +212,23 @@ export async function initBrowserApp(
204
212
  // Create composable utilities
205
213
  const client = createNavigationClient(deps);
206
214
 
207
- // Extract rootLayout and version from metadata for browser-side re-renders
208
- const rootLayout = initialPayload.metadata?.rootLayout;
215
+ // Capture the per-router app-shell so cross-app navigations can replace
216
+ // it atomically. rootLayout, basename, and version live here and are
217
+ // read through the ref at call time rather than closed over. Theme,
218
+ // warmup, and prefetch TTL are deliberately excluded — they are
219
+ // document-lifetime and stay stable across smooth cross-app transitions.
209
220
  const version = initialPayload.metadata?.version;
221
+ const appShellRef = createAppShellRef({
222
+ routerId: initialPayload.metadata?.routerId,
223
+ rootLayout: initialPayload.metadata?.rootLayout,
224
+ basename: initialPayload.metadata?.basename,
225
+ version,
226
+ });
210
227
 
211
228
  // Initialize the localStorage state key for cache invalidation.
212
- // Uses the build version so a new deploy automatically busts all cached prefetches.
213
- initRangoState(version ?? "0");
229
+ // The build version busts cached prefetches on deploy; the routerId
230
+ // namespaces the key so sibling apps on the same origin don't collide.
231
+ initRangoState(version ?? "0", initialPayload.metadata?.routerId);
214
232
  setAppVersion(version);
215
233
 
216
234
  // Initialize the in-memory prefetch cache TTL from server config.
@@ -220,11 +238,17 @@ export async function initBrowserApp(
220
238
  initPrefetchCache(prefetchCacheTTL);
221
239
  }
222
240
 
223
- // Create a bound renderSegments that includes rootLayout
241
+ // Create a bound renderSegments that reads rootLayout through the shell
242
+ // ref. On app switch the ref is updated before the tree re-renders, so
243
+ // the new app's Document (rootLayout) replaces the previous one.
224
244
  const renderSegments = (
225
245
  segments: ResolvedSegment[],
226
246
  options?: RenderSegmentsOptions,
227
- ) => baseRenderSegments(segments, { ...options, rootLayout });
247
+ ) =>
248
+ baseRenderSegments(segments, {
249
+ ...options,
250
+ rootLayout: appShellRef.get().rootLayout,
251
+ });
228
252
 
229
253
  // Lazy reference for navigation bridge — the action bridge is created first
230
254
  // but may need to trigger SPA navigation for action redirects.
@@ -256,6 +280,7 @@ export async function initBrowserApp(
256
280
  onUpdate: (update) => store.emitUpdate(update),
257
281
  renderSegments,
258
282
  version: version,
283
+ appShellRef,
259
284
  });
260
285
 
261
286
  // Connect action redirect → navigation bridge (now that both are initialized)
@@ -416,6 +441,7 @@ export async function initBrowserApp(
416
441
  initialTheme: effectiveInitialTheme,
417
442
  warmupEnabled: initialPayload.metadata?.warmupEnabled ?? true,
418
443
  version,
444
+ appShellRef,
419
445
  };
420
446
  browserAppContext = context;
421
447
 
@@ -481,6 +507,7 @@ export function RSCRouter(_props: RSCRouterProps): React.ReactElement {
481
507
  initialTheme,
482
508
  warmupEnabled,
483
509
  version,
510
+ appShellRef,
484
511
  } = getBrowserAppContext();
485
512
 
486
513
  // Signal that the React tree has hydrated. useEffect only fires after
@@ -501,6 +528,7 @@ export function RSCRouter(_props: RSCRouterProps): React.ReactElement {
501
528
  warmupEnabled={warmupEnabled}
502
529
  version={version}
503
530
  basename={initialPayload.metadata?.basename}
531
+ appShellRef={appShellRef}
504
532
  />
505
533
  );
506
534
  }
@@ -427,6 +427,12 @@ export interface NavigationStore {
427
427
  markCacheAsStale(): void;
428
428
  markCacheAsStaleAndBroadcast(): void;
429
429
  clearHistoryCache(): void;
430
+ /**
431
+ * Clear this tab's nav + prefetch caches without broadcasting or rotating
432
+ * shared state. Intended for app-switch transitions that affect only this
433
+ * tab's session.
434
+ */
435
+ clearHistoryCacheLocal(): void;
430
436
  broadcastCacheInvalidation(): void;
431
437
 
432
438
  // Cross-tab refresh callback (set by navigation bridge)
@@ -542,6 +548,13 @@ export interface NavigationBridge {
542
548
  registerLinkInterception(): () => void;
543
549
  /** Update the RSC version (e.g. after HMR). Clears prefetch cache. */
544
550
  updateVersion(newVersion: string): void;
551
+ /**
552
+ * Replace the active app-shell snapshot (rootLayout, basename, version)
553
+ * atomically. Used on cross-app navigations when the response's routerId
554
+ * indicates the user entered a different app. Theme, warmup, and prefetch
555
+ * TTL are document-lifetime and not part of the shell.
556
+ */
557
+ updateAppShell(next: import("./app-shell.js").AppShell): void;
545
558
  }
546
559
 
547
560
  /**
@@ -67,13 +67,11 @@ export const MAX_REVALIDATION_INTERVAL = 30;
67
67
  // Types
68
68
  // ============================================================================
69
69
 
70
- /**
71
- * Cloudflare Workers ExecutionContext (subset we need)
72
- */
73
- export interface ExecutionContext {
74
- waitUntil(promise: Promise<any>): void;
75
- passThroughOnException(): void;
76
- }
70
+ // Re-exported from the canonical home so cf-cache-store consumers keep
71
+ // importing `ExecutionContext` from this module without a second interface
72
+ // drifting over time.
73
+ export type { ExecutionContext } from "../../types/request-scope.js";
74
+ import type { ExecutionContext } from "../../types/request-scope.js";
77
75
 
78
76
  /**
79
77
  * Minimal Cloudflare KV Namespace interface.
package/src/index.rsc.ts CHANGED
@@ -172,6 +172,9 @@ export type { PublicRequestContext as RequestContext } from "./server/request-co
172
172
  import type { PublicRequestContext } from "./server/request-context.js";
173
173
  import type { DefaultEnv } from "./types/global-namespace.js";
174
174
 
175
+ // Shared base for every user-facing request context (mirrors index.ts).
176
+ export type { RequestScope, ExecutionContext } from "./types/request-scope.js";
177
+
175
178
  export const getRequestContext: <
176
179
  TEnv = DefaultEnv,
177
180
  >() => PublicRequestContext<TEnv> = _getRequestContextInternal;
package/src/index.ts CHANGED
@@ -264,6 +264,9 @@ export function transition(): never {
264
264
  // Request context type (safe for client)
265
265
  export type { PublicRequestContext as RequestContext } from "./server/request-context.js";
266
266
 
267
+ // Shared base for every user-facing request context.
268
+ export type { RequestScope, ExecutionContext } from "./types/request-scope.js";
269
+
267
270
  // Cookie store types (safe for client)
268
271
  export type {
269
272
  CookieStore,
@@ -1,4 +1,4 @@
1
- import { Context, createContext, type ReactNode } from "react";
1
+ import { type Context, createContext, type ReactNode } from "react";
2
2
  import type { ResolvedSegment } from "./types";
3
3
 
4
4
  export interface OutletContextValue {
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Runtime-neutral Response shape utilities.
3
+ *
4
+ * Kept at the src/ root so both `router/` and `rsc/` can depend on it
5
+ * without creating a cross-layer import cycle.
6
+ */
7
+
8
+ /**
9
+ * True when a Response represents a WebSocket upgrade handoff and must not
10
+ * be reconstructed or mutated:
11
+ *
12
+ * - Status 101 (Switching Protocols) is outside the standard Response
13
+ * constructor's 200–599 range, so `new Response(body, { status: 101 })`
14
+ * throws RangeError on Node/undici and any spec-compliant runtime.
15
+ * - Cloudflare's workerd attaches a non-standard `webSocket` property on
16
+ * the upgrade Response (e.g. from `acceptWebSocket`/`handleWebSocketUpgrade`
17
+ * or the `agents` library's `routeAgentRequest`). That property is dropped
18
+ * by a `new Response(...)` copy, breaking the upgrade even on workerd
19
+ * where the status range is relaxed.
20
+ *
21
+ * Callers should short-circuit header/body merges for these responses.
22
+ */
23
+ export function isWebSocketUpgradeResponse(response: Response): boolean {
24
+ return (
25
+ response.status === 101 ||
26
+ (response as unknown as { webSocket?: unknown }).webSocket != null
27
+ );
28
+ }
package/src/reverse.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { ExtractParams } from "./types.js";
2
2
  import type { SearchSchema, ResolveSearchSchema } from "./search-params.js";
3
3
  import { serializeSearchParams } from "./search-params.js";
4
+ import { encodePathSegment } from "./router/url-params.js";
4
5
 
5
6
  /**
6
7
  * Sanitize prefix string by removing leading slash
@@ -318,7 +319,7 @@ export function createReverse<TRoutes extends Record<string, string>>(
318
319
  hadOmittedOptional = true;
319
320
  return "";
320
321
  }
321
- return encodeURIComponent(value);
322
+ return encodePathSegment(value);
322
323
  },
323
324
  );
324
325
  // Second pass: required params (no trailing ?)
@@ -329,7 +330,7 @@ export function createReverse<TRoutes extends Record<string, string>>(
329
330
  if (value === undefined) {
330
331
  throw new Error(`Missing param "${key}" for route "${name}"`);
331
332
  }
332
- return encodeURIComponent(value);
333
+ return encodePathSegment(value);
333
334
  },
334
335
  );
335
336
  // Clean up slashes only when an optional param was actually omitted,
@@ -304,6 +304,15 @@ const cache: RouteHelpers<any, any>["cache"] = (
304
304
  return { name: namespace, type: "cache" } as CacheItem;
305
305
  }
306
306
 
307
+ // Inside a loader() use() callback, only the direct form — cache()/cache(opts)/
308
+ // cache("profile") — writes cache config to the loader entry. The wrapper
309
+ // form creates a structural cache boundary with its own children scope, which
310
+ // has no effect on the loader and would silently no-op.
311
+ invariant(
312
+ !(ctx.parent && (ctx.parent as any).type === "loader"),
313
+ "cache() wrapper form is not valid inside loader() use(). Use cache({...}) without children to configure the loader's cache.",
314
+ );
315
+
307
316
  // With children: create a cache entry (like layout with caching semantics)
308
317
  const namespace = `${ctx.namespace}.${cacheIndex}`;
309
318
  const cacheShortCode = store.getShortCode("cache");
@@ -750,8 +759,12 @@ const loaderFn: RouteHelpers<any, any>["loader"] = (loaderDef, use) => {
750
759
  revalidate: [] as ShouldRevalidateFn<any, any>[],
751
760
  };
752
761
 
753
- // If use() callback provided, run it to collect revalidation rules and cache config
754
- if (use && typeof use === "function") {
762
+ // Merge handler.use defaults (attached to the loader definition) with explicit use
763
+ const handlerUseFn = resolveHandlerUse(loaderDef);
764
+ const mergedUse = mergeHandlerUse(handlerUseFn, use, "loader");
765
+
766
+ // If any use callback is in effect, run it to collect revalidation rules and cache config
767
+ if (mergedUse) {
755
768
  // Temporarily set context for revalidate()/cache() calls to target this loader
756
769
  const originalParent = ctx.parent;
757
770
  // Create a temporary "parent" with type "loader" so cache() can detect it.
@@ -764,7 +777,7 @@ const loaderFn: RouteHelpers<any, any>["loader"] = (loaderDef, use) => {
764
777
  };
765
778
  ctx.parent = tempParent as EntryData;
766
779
 
767
- const result = use()?.flat(3);
780
+ const result = mergedUse()?.flat(3);
768
781
 
769
782
  // Copy cache config only if cache() was called during the use() callback.
770
783
  // The spread from originalParent may carry an inherited .cache from
@@ -21,6 +21,10 @@ export function resolveHandlerUse(handler: unknown): (() => any[]) | undefined {
21
21
  if (isStaticHandler(handler)) {
22
22
  return (handler as any).use;
23
23
  }
24
+ // Loader definitions from createLoader() — branded objects with optional .use
25
+ if (typeof handler === "object" && (handler as any).__brand === "loader") {
26
+ return (handler as any).use;
27
+ }
24
28
  // Plain handler function
25
29
  if (typeof handler === "function") {
26
30
  return (handler as any).use;
@@ -99,6 +103,8 @@ const MOUNT_SITE_ALLOWED_TYPES: Record<string, Set<string>> = {
99
103
  "when",
100
104
  "transition",
101
105
  ]),
106
+ // LoaderUseItem — only revalidate + cache can attach to a loader entry
107
+ loader: new Set(["revalidate", "cache"]),
102
108
  };
103
109
 
104
110
  /**
@@ -18,6 +18,8 @@ import { isInsideCacheScope } from "../server/context.js";
18
18
  import { NOCACHE_SYMBOL, assertNotInsideCacheExec } from "../cache/taint.js";
19
19
  import { isAutoGeneratedRouteName } from "../route-name.js";
20
20
  import { PRERENDER_PASSTHROUGH } from "../prerender.js";
21
+ import { encodePathSegment } from "./url-params.js";
22
+ import { fireAndForgetWaitUntil } from "../types/request-scope.js";
21
23
 
22
24
  /**
23
25
  * Strip internal _rsc* query params from a URL.
@@ -181,7 +183,7 @@ export function createReverseFunction(
181
183
  hadOmittedOptional = true;
182
184
  return "";
183
185
  }
184
- return encodeURIComponent(value);
186
+ return encodePathSegment(value);
185
187
  },
186
188
  );
187
189
  // Second pass: required params (no trailing ?)
@@ -192,7 +194,7 @@ export function createReverseFunction(
192
194
  if (value === undefined) {
193
195
  throw new Error(`Missing param "${key}" for route "${name}"`);
194
196
  }
195
- return encodeURIComponent(value);
197
+ return encodePathSegment(value);
196
198
  },
197
199
  );
198
200
  // Clean up slashes only when an optional param was actually omitted,
@@ -281,8 +283,12 @@ export function createHandlerContext<TEnv>(
281
283
  search: searchSchema ? resolvedSearchParams : {},
282
284
  pathname,
283
285
  url,
284
- originalUrl: new URL(request.url),
286
+ originalUrl: requestContext?.originalUrl ?? new URL(request.url),
285
287
  env: bindings,
288
+ waitUntil: requestContext
289
+ ? requestContext.waitUntil.bind(requestContext)
290
+ : fireAndForgetWaitUntil,
291
+ executionContext: requestContext?.executionContext,
286
292
  _variables: variables,
287
293
  get: ((keyOrVar: any) => {
288
294
  // Read-time guard: non-cacheable var inside cache() → throw.
@@ -387,6 +393,12 @@ export function createPrerenderContext<TEnv>(
387
393
  "Configure buildEnv in your rango() plugin options to enable build-time env access.",
388
394
  );
389
395
  },
396
+ // Build-time prerender has no live request. waitUntil is a true no-op
397
+ // (running fn() here would fire side effects during build, which is
398
+ // incorrect — these are meant to outlive the live response).
399
+ // executionContext is absent for the same reason.
400
+ waitUntil: () => {},
401
+ executionContext: undefined,
390
402
  _variables: variables,
391
403
  get: ((keyOrVar: any) => contextGet(variables, keyOrVar)) as any,
392
404
  set: ((keyOrVar: any, value: any) => {
@@ -476,6 +488,11 @@ export function createStaticContext<TEnv>(
476
488
  "Configure buildEnv in your rango() plugin options to enable build-time env access.",
477
489
  );
478
490
  },
491
+ // Static() handlers have no live request. waitUntil is a true no-op
492
+ // (running fn() here would fire side effects during build, which is
493
+ // incorrect). executionContext is absent for the same reason.
494
+ waitUntil: () => {},
495
+ executionContext: undefined,
479
496
  _variables: variables,
480
497
  get: ((keyOrVar: any) => contextGet(variables, keyOrVar)) as any,
481
498
  set: ((keyOrVar: any, value: any) => {
@@ -1,7 +1,7 @@
1
1
  import { registerRouteMap } from "../route-map-builder.js";
2
2
  import { extractStaticPrefix } from "./pattern-matching.js";
3
3
  import {
4
- EntryData,
4
+ type EntryData,
5
5
  RSCRouterContext,
6
6
  runWithPrefixes,
7
7
  getIsolatedLazyParent,
@@ -266,7 +266,10 @@ function createLoaderExecutor<TEnv>(
266
266
  search: (ctx as any).search,
267
267
  pathname: ctx.pathname,
268
268
  url: ctx.url,
269
+ originalUrl: ctx.originalUrl,
269
270
  env: ctx.env,
271
+ waitUntil: ctx.waitUntil.bind(ctx),
272
+ executionContext: ctx.executionContext,
270
273
  get: ((keyOrVar: any) =>
271
274
  contextGet(variables, keyOrVar)) as typeof ctx.get,
272
275
  use: ((item: LoaderDefinition<any, any> | Handle<any, any>) => {