@rangojs/router 0.0.0-experimental.121 → 0.0.0-experimental.124

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 (120) hide show
  1. package/dist/bin/rango.js +7 -2
  2. package/dist/vite/index.js +47 -6
  3. package/package.json +61 -21
  4. package/skills/cache-guide/SKILL.md +8 -6
  5. package/skills/caching/SKILL.md +148 -1
  6. package/skills/hooks/SKILL.md +38 -27
  7. package/skills/host-router/SKILL.md +16 -2
  8. package/skills/intercept/SKILL.md +4 -2
  9. package/skills/layout/SKILL.md +11 -6
  10. package/skills/loader/SKILL.md +6 -2
  11. package/skills/middleware/SKILL.md +4 -2
  12. package/skills/migrate-nextjs/SKILL.md +38 -16
  13. package/skills/parallel/SKILL.md +9 -4
  14. package/skills/rango/SKILL.md +27 -15
  15. package/skills/route/SKILL.md +4 -2
  16. package/skills/testing/SKILL.md +129 -0
  17. package/skills/testing/bindings.md +89 -0
  18. package/skills/testing/cache-prerender.md +98 -0
  19. package/skills/testing/client-components.md +122 -0
  20. package/skills/testing/e2e-parity.md +125 -0
  21. package/skills/testing/flight.md +89 -0
  22. package/skills/testing/handles.md +129 -0
  23. package/skills/testing/loader.md +128 -0
  24. package/skills/testing/middleware.md +99 -0
  25. package/skills/testing/render-handler.md +118 -0
  26. package/skills/testing/response-routes.md +95 -0
  27. package/skills/testing/reverse-and-types.md +84 -0
  28. package/skills/testing/server-actions.md +107 -0
  29. package/skills/testing/server-tree.md +128 -0
  30. package/skills/testing/setup.md +120 -0
  31. package/skills/use-cache/SKILL.md +9 -7
  32. package/src/browser/action-fence.ts +37 -0
  33. package/src/browser/cookie-name.ts +140 -0
  34. package/src/browser/invalidate-client-cache.ts +52 -0
  35. package/src/browser/navigation-bridge.ts +14 -1
  36. package/src/browser/navigation-client.ts +14 -1
  37. package/src/browser/navigation-store-handle.ts +39 -0
  38. package/src/browser/navigation-store.ts +26 -12
  39. package/src/browser/prefetch/fetch.ts +7 -0
  40. package/src/browser/rango-state.ts +176 -97
  41. package/src/browser/react/index.ts +0 -6
  42. package/src/browser/rsc-router.tsx +12 -4
  43. package/src/browser/server-action-bridge.ts +77 -15
  44. package/src/browser/types.ts +7 -1
  45. package/src/cache/cache-error.ts +104 -0
  46. package/src/cache/cache-policy.ts +95 -1
  47. package/src/cache/cache-runtime.ts +79 -13
  48. package/src/cache/cache-scope.ts +55 -4
  49. package/src/cache/cache-tag.ts +135 -0
  50. package/src/cache/cf/cf-cache-store.ts +2080 -224
  51. package/src/cache/cf/index.ts +15 -1
  52. package/src/cache/document-cache.ts +74 -7
  53. package/src/cache/index.ts +17 -0
  54. package/src/cache/memory-segment-store.ts +164 -14
  55. package/src/cache/tag-invalidation.ts +230 -0
  56. package/src/cache/types.ts +27 -0
  57. package/src/client.rsc.tsx +1 -1
  58. package/src/client.tsx +0 -6
  59. package/src/component-utils.ts +19 -0
  60. package/src/handle.ts +29 -9
  61. package/src/host/testing.ts +43 -14
  62. package/src/index.rsc.ts +29 -1
  63. package/src/index.ts +43 -1
  64. package/src/loader.rsc.ts +24 -3
  65. package/src/loader.ts +16 -2
  66. package/src/prerender.ts +24 -3
  67. package/src/router/basename.ts +14 -0
  68. package/src/router/match-handlers.ts +62 -20
  69. package/src/router/prerender-match.ts +6 -0
  70. package/src/router/router-interfaces.ts +7 -0
  71. package/src/router/router-options.ts +30 -0
  72. package/src/router/segment-resolution/loader-cache.ts +8 -17
  73. package/src/router/state-cookie-name.ts +33 -0
  74. package/src/router/telemetry.ts +99 -0
  75. package/src/router.ts +36 -7
  76. package/src/rsc/handler.ts +13 -1
  77. package/src/rsc/helpers.ts +19 -0
  78. package/src/rsc/progressive-enhancement.ts +2 -0
  79. package/src/rsc/response-route-handler.ts +8 -1
  80. package/src/rsc/rsc-rendering.ts +2 -0
  81. package/src/rsc/types.ts +2 -0
  82. package/src/runtime-env.ts +18 -0
  83. package/src/server/cookie-store.ts +52 -1
  84. package/src/server/request-context.ts +105 -2
  85. package/src/static-handler.ts +25 -3
  86. package/src/testing/cache-status.ts +166 -0
  87. package/src/testing/collect-handle.ts +63 -0
  88. package/src/testing/dispatch.ts +581 -0
  89. package/src/testing/dom.entry.ts +22 -0
  90. package/src/testing/e2e/fixture.ts +188 -0
  91. package/src/testing/e2e/index.ts +149 -0
  92. package/src/testing/e2e/matchers.ts +51 -0
  93. package/src/testing/e2e/page-helpers.ts +272 -0
  94. package/src/testing/e2e/parity.ts +387 -0
  95. package/src/testing/e2e/server.ts +195 -0
  96. package/src/testing/flight-matchers.ts +110 -0
  97. package/src/testing/flight-normalize.ts +38 -0
  98. package/src/testing/flight-runtime.d.ts +57 -0
  99. package/src/testing/flight-tree.ts +682 -0
  100. package/src/testing/flight.entry.ts +52 -0
  101. package/src/testing/flight.ts +234 -0
  102. package/src/testing/generated-routes.ts +223 -0
  103. package/src/testing/index.ts +119 -0
  104. package/src/testing/internal/context.ts +390 -0
  105. package/src/testing/internal/flight-client-globals.ts +30 -0
  106. package/src/testing/internal/seed-vars.ts +80 -0
  107. package/src/testing/render-handler.ts +360 -0
  108. package/src/testing/render-route.tsx +594 -0
  109. package/src/testing/run-loader.ts +474 -0
  110. package/src/testing/run-middleware.ts +231 -0
  111. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  112. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  113. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  114. package/src/testing/vitest-stubs/version.ts +5 -0
  115. package/src/testing/vitest.ts +305 -0
  116. package/src/types/cache-types.ts +13 -4
  117. package/src/types/error-types.ts +5 -1
  118. package/src/types/global-namespace.ts +11 -1
  119. package/src/types/handler-context.ts +16 -5
  120. package/src/browser/react/use-client-cache.ts +0 -58
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Cache Tag Invalidation API
3
+ *
4
+ * Two on-demand invalidation verbs, mirroring the distinction popularized by
5
+ * Next.js so consumers can pick the right consistency model:
6
+ *
7
+ * - updateTag(...tags): read-your-own-writes. Awaitable - resolves only after
8
+ * in-process invalidation across every configured store completes. Use in a
9
+ * Server Action and `await` it before the action re-renders, so the action's
10
+ * own response reflects the mutation.
11
+ *
12
+ * - revalidateTag(...tags): fire-and-forget via waitUntil - the response is not
13
+ * blocked. Use in Route Handlers / webhooks. NOTE: both verbs hard-purge; the
14
+ * only difference is awaitability. revalidateTag does NOT serve stale content -
15
+ * the next read after the invalidation lands is a hard miss that re-renders.
16
+ * (The name mirrors Next.js, where it is SWR; here it is background-purge.)
17
+ *
18
+ * Both fan out across the app-level store (ctx._cacheStore) and every explicit
19
+ * per-scope store from cache({ store }) registered for this handler
20
+ * (ctx._explicitTaggedStores), calling the store-level invalidateTags()
21
+ * primitive for each tag. A single configured store (the common case) owns its
22
+ * own tag index and distributed invalidation - there is no separate
23
+ * tag-invalidation store.
24
+ */
25
+
26
+ import { _getRequestContext } from "../server/request-context.js";
27
+ import { reportingAsync } from "./cache-error.js";
28
+ import { normalizeTags } from "./cache-tag.js";
29
+ import type { SegmentCacheStore } from "./types.js";
30
+
31
+ /**
32
+ * Collect every store that may hold entries tagged for this request's handler:
33
+ * the app-level store plus all explicit per-scope stores (deduplicated). Splits
34
+ * them into tag-capable (implement invalidateTags()) and not, so callers can
35
+ * warn about configured stores whose tagged entries will NOT be invalidated.
36
+ *
37
+ * `hasContext` reports whether an ALS request context existed at all. Without one
38
+ * (e.g. a queue consumer or cron job calling updateTag/revalidateTag) no stores
39
+ * are reachable, and the empty-capable case is a missing-context problem, not a
40
+ * store-config problem - callers branch on this to warn about the right cause.
41
+ */
42
+ function collectStores(): {
43
+ capable: SegmentCacheStore[];
44
+ incapable: number;
45
+ hasContext: boolean;
46
+ } {
47
+ const ctx = _getRequestContext();
48
+ const stores = new Set<SegmentCacheStore>();
49
+ if (ctx?._cacheStore) stores.add(ctx._cacheStore);
50
+ if (ctx?._explicitTaggedStores) {
51
+ for (const store of ctx._explicitTaggedStores) stores.add(store);
52
+ }
53
+ const capable: SegmentCacheStore[] = [];
54
+ let incapable = 0;
55
+ for (const store of stores) {
56
+ if (typeof store.invalidateTags === "function") capable.push(store);
57
+ else incapable++;
58
+ }
59
+ return { capable, incapable, hasContext: ctx != null };
60
+ }
61
+
62
+ /**
63
+ * Production-visible warning. A misconfigured store silently dropping
64
+ * invalidations is a data-correctness footgun, so this surfaces in every
65
+ * environment (not dev-only).
66
+ */
67
+ function warnNoTagStore(fn: string, tags: string[]): void {
68
+ console.warn(
69
+ `[${fn}] No tag-capable cache store is configured; tags ` +
70
+ `[${tags.join(", ")}] were not invalidated. The configured store must ` +
71
+ `implement invalidateTags() (the built-in MemorySegmentCacheStore and ` +
72
+ `CFCacheStore do).`,
73
+ );
74
+ }
75
+
76
+ /**
77
+ * Production-visible warning for the no-request-context case. Distinct from
78
+ * warnNoTagStore: the stores are not unreachable because they are misconfigured,
79
+ * but because there is no ALS request context to reach them through (e.g. a queue
80
+ * consumer or scheduled job). Naming the real cause keeps consumers from chasing
81
+ * a store-config red herring.
82
+ */
83
+ function warnNoRequestContext(fn: string, tags: string[]): void {
84
+ console.warn(
85
+ `[${fn}] Called outside a request context (e.g. from a queue consumer or ` +
86
+ `scheduled job); no cache stores are reachable and tags ` +
87
+ `[${tags.join(", ")}] were not invalidated. Invoke it within a request ` +
88
+ `(Server Action or route handler).`,
89
+ );
90
+ }
91
+
92
+ /**
93
+ * Production-visible warning for mixed-store configs: at least one configured
94
+ * store does not support tag invalidation, so its tagged entries (if any) are
95
+ * left stale even though other stores were invalidated.
96
+ */
97
+ function warnPartialTagStore(fn: string, incapable: number): void {
98
+ console.warn(
99
+ `[${fn}] ${incapable} configured cache store(s) do not implement ` +
100
+ `invalidateTags(); their tagged entries were NOT invalidated. Use a ` +
101
+ `tag-capable store (e.g. MemorySegmentCacheStore / CFCacheStore) for any ` +
102
+ `cache({ store }) boundary whose entries you invalidate by tag.`,
103
+ );
104
+ }
105
+
106
+ async function invalidateAcross(
107
+ stores: SegmentCacheStore[],
108
+ tags: string[],
109
+ ): Promise<void> {
110
+ // One invalidateTags(tags) call per store: the store receives the whole tag
111
+ // batch so it can do a single CDN purge request rather than one per tag.
112
+ //
113
+ // allSettled, not all: a store's invalidateTags() can reject (e.g. CFCacheStore
114
+ // surfaces a failed durable KV marker write). With Promise.all, the first
115
+ // rejection would short-circuit and the other stores' outcomes would go
116
+ // unobserved. Attempt every store, then surface a combined error so an awaited
117
+ // updateTag() still rejects (read-your-own-writes honesty) without masking the
118
+ // stores that did succeed.
119
+ const results = await Promise.allSettled(
120
+ stores.map((store) => store.invalidateTags!(tags)),
121
+ );
122
+ const errors = results
123
+ .filter((r): r is PromiseRejectedResult => r.status === "rejected")
124
+ .map((r) => r.reason);
125
+ if (errors.length > 0) {
126
+ const err = new Error(
127
+ `[tag invalidation] ${errors.length}/${stores.length} store(s) failed to ` +
128
+ `invalidate tags [${tags.join(", ")}]; their entries may still serve ` +
129
+ `stale data. Retry the invalidation.`,
130
+ );
131
+ (err as Error & { cause?: unknown }).cause = errors[0];
132
+ throw err;
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Immediately expire every cache entry tagged with any of `tags`, resolving
138
+ * once in-process invalidation across all configured stores completes.
139
+ *
140
+ * Read-your-own-writes: because the returned promise resolves before you return
141
+ * from a Server Action, awaiting it guarantees the action's own re-render (and
142
+ * any subsequent read) sees fresh data.
143
+ *
144
+ * @example
145
+ * ```typescript
146
+ * async function updateProduct(formData: FormData) {
147
+ * "use server";
148
+ * await db.updateProduct(formData);
149
+ * await updateTag("products"); // next render is fresh
150
+ * }
151
+ * ```
152
+ */
153
+ export async function updateTag(...tags: string[]): Promise<void> {
154
+ const valid = normalizeTags(tags);
155
+ if (valid.length === 0) return;
156
+
157
+ const { capable, incapable, hasContext } = collectStores();
158
+ if (capable.length === 0) {
159
+ if (hasContext) warnNoTagStore("updateTag", valid);
160
+ else warnNoRequestContext("updateTag", valid);
161
+ return;
162
+ }
163
+ if (incapable > 0) warnPartialTagStore("updateTag", incapable);
164
+
165
+ await invalidateAcross(capable, valid);
166
+ }
167
+
168
+ /**
169
+ * Invalidate every cache entry tagged with any of `tags` in the background,
170
+ * without blocking the current response (fire-and-forget via waitUntil).
171
+ *
172
+ * This is NOT stale-while-revalidate: like updateTag() it hard-purges, so the
173
+ * next read after the invalidation lands is a miss that re-renders fresh. The
174
+ * only difference from updateTag() is awaitability - revalidateTag() defers the
175
+ * purge off the response path and is not awaited.
176
+ *
177
+ * Use in Route Handlers / webhooks. For read-your-own-writes inside a Server
178
+ * Action, use updateTag() instead so the action's own response is fresh.
179
+ *
180
+ * Fire-and-forget: because this returns void and runs in the background, a
181
+ * failed durable marker write (e.g. a transient KV outage) is NOT surfaced to
182
+ * the caller. It IS reported - logged loudly and routed through the router's
183
+ * `onError` callback (phase `cache`, `metadata.category === "cache-invalidate"`)
184
+ * via reportingAsync - so the failure is observable in telemetry even though it
185
+ * cannot be awaited. If you need the invalidation to be CONFIRMED (and to retry
186
+ * on failure), use `await updateTag()` instead, which rejects when a store's
187
+ * durable write fails.
188
+ *
189
+ * @example
190
+ * ```typescript
191
+ * // route handler invoked by an external webhook
192
+ * export async function POST() {
193
+ * "use server";
194
+ * revalidateTag("products");
195
+ * return new Response("ok");
196
+ * }
197
+ * ```
198
+ */
199
+ export function revalidateTag(...tags: string[]): void {
200
+ const valid = normalizeTags(tags);
201
+ if (valid.length === 0) return;
202
+
203
+ const { capable, incapable, hasContext } = collectStores();
204
+ if (capable.length === 0) {
205
+ if (hasContext) warnNoTagStore("revalidateTag", valid);
206
+ else warnNoRequestContext("revalidateTag", valid);
207
+ return;
208
+ }
209
+ if (incapable > 0) warnPartialTagStore("revalidateTag", incapable);
210
+
211
+ const ctx = _getRequestContext();
212
+ // reportingAsync never rejects: it catches a failed durable write and routes
213
+ // it through reportCacheError (loud log + onError). This is the only place a
214
+ // revalidateTag failure can be observed, since it is not awaitable. Pass ctx
215
+ // explicitly - the run executes in a detached waitUntil where the ALS context
216
+ // is gone, so onError fires only if we hand it the captured context.
217
+ const run = () =>
218
+ reportingAsync(
219
+ () => invalidateAcross(capable, valid),
220
+ "cache-invalidate",
221
+ "[revalidateTag] background invalidation",
222
+ ctx,
223
+ );
224
+ if (ctx?.waitUntil) {
225
+ ctx.waitUntil(run);
226
+ } else {
227
+ // No request context (e.g. called outside ALS): best-effort background run.
228
+ void run();
229
+ }
230
+ }
@@ -136,12 +136,14 @@ export interface SegmentCacheStore<TEnv = unknown> {
136
136
  * @param response - Response to cache (will be cloned)
137
137
  * @param ttl - Time-to-live in seconds
138
138
  * @param swr - Optional stale-while-revalidate window in seconds
139
+ * @param tags - Optional cache tags for invalidation
139
140
  */
140
141
  putResponse?(
141
142
  key: string,
142
143
  response: Response,
143
144
  ttl: number,
144
145
  swr?: number,
146
+ tags?: string[],
145
147
  ): Promise<void>;
146
148
 
147
149
  // ============================================================================
@@ -167,6 +169,20 @@ export interface SegmentCacheStore<TEnv = unknown> {
167
169
  value: string,
168
170
  options?: CacheItemOptions,
169
171
  ): Promise<void>;
172
+
173
+ // ============================================================================
174
+ // Tag-based Invalidation (optional)
175
+ // ============================================================================
176
+
177
+ /**
178
+ * Invalidate every cache entry (segment, response, item) tagged with any of
179
+ * `tags`. Store-level primitive that the public updateTag()/revalidateTag()
180
+ * APIs delegate to. Receives ALL of one invalidation call's tags at once so
181
+ * stores can batch their work (e.g. a single CDN purge request rather than
182
+ * one per tag). Stores that do not support tags simply omit this method.
183
+ * @param tags - The cache tags to invalidate
184
+ */
185
+ invalidateTags?(tags: string[]): Promise<void>;
170
186
  }
171
187
 
172
188
  /**
@@ -181,6 +197,13 @@ export interface CacheItemResult {
181
197
  handles?: string;
182
198
  /** Whether the entry is stale and should be revalidated */
183
199
  shouldRevalidate: boolean;
200
+ /**
201
+ * The entry's cache tags (including runtime cacheTag() tags), surfaced on read
202
+ * so a "use cache" HIT can still contribute its tags to the request-scoped tag
203
+ * set used by document-level caching. On a hit the cached function is not
204
+ * re-run, so its runtime tags are only available here, not from re-execution.
205
+ */
206
+ tags?: string[];
184
207
  }
185
208
 
186
209
  /**
@@ -235,6 +258,10 @@ export interface CachedEntryData {
235
258
  handles: string;
236
259
  /** Expiration timestamp (ms since epoch) */
237
260
  expiresAt: number;
261
+ /** Cache tags for invalidation */
262
+ tags?: string[];
263
+ /** Timestamp (ms since epoch) when tags were attached, for distributed invalidation */
264
+ taggedAt?: number;
238
265
  }
239
266
 
240
267
  // ============================================================================
@@ -50,7 +50,7 @@ export { href } from "./href-client.js";
50
50
  // Mount context re-exports (useMount is client-only, but MountContext can be referenced)
51
51
  export { MountContext } from "./browser/react/mount-context.js";
52
52
 
53
- // Note: useNavigation, useAction, useClientCache are NOT re-exported here
53
+ // Note: useNavigation and useAction are NOT re-exported here
54
54
  // because they use client-side state and should only be used in client components
55
55
 
56
56
  // Handle API - for accumulating data across route segments
package/src/client.tsx CHANGED
@@ -358,12 +358,6 @@ export {
358
358
  type SegmentsState,
359
359
  } from "./browser/react/use-segments.js";
360
360
 
361
- // Client cache controls hook
362
- export {
363
- useClientCache,
364
- type ClientCacheControls,
365
- } from "./browser/react/use-client-cache.js";
366
-
367
361
  // Provider
368
362
  export {
369
363
  NavigationProvider,
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { ComponentType } from "react";
8
+ import { isUnderTestRunner } from "./runtime-env.js";
8
9
 
9
10
  /**
10
11
  * Symbol used by React to mark client component references.
@@ -48,11 +49,21 @@ export function isClientComponent(
48
49
  *
49
50
  * @param component - The component to check
50
51
  * @param name - Name to use in error message (e.g., "document")
52
+ * @param opts.allowServerInTest - When true AND running under a test runner
53
+ * (`isUnderTestRunner()`), relax ONLY the "use client" requirement: a server
54
+ * component is accepted. The plugin's "use client" transform does not run in a
55
+ * bare unit test, so a real exported `document` (almost every app sets one) has
56
+ * no client marker and would otherwise throw at `createRouter`, blocking
57
+ * `dispatch`/`assertGeneratedRoutesMatch` against the real router. The document
58
+ * reference is irrelevant to those (no Flight render). The "not a JSX element"
59
+ * guard still fires, and a real dev/build still throws (mirrors the runtime
60
+ * fallback-id gating in handle.ts/loader.ts).
51
61
  * @throws Error if the component is not a client component
52
62
  */
53
63
  export function assertClientComponent(
54
64
  component: ComponentType<unknown> | unknown,
55
65
  name: string,
66
+ opts?: { allowServerInTest?: boolean },
56
67
  ): asserts component is ComponentType<unknown> {
57
68
  if (typeof component !== "function") {
58
69
  throw new Error(
@@ -62,6 +73,14 @@ export function assertClientComponent(
62
73
  );
63
74
  }
64
75
 
76
+ // Under a test runner the "use client" transform did not run, so a real
77
+ // server-rendered `document` has no client marker; accept it (the reference is
78
+ // never serialized in dispatch/route-map checks). Outside a test runner this
79
+ // still throws — the build-time safety net is preserved.
80
+ if (opts?.allowServerInTest && isUnderTestRunner()) {
81
+ return;
82
+ }
83
+
65
84
  if (!isClientComponent(component)) {
66
85
  throw new Error(
67
86
  `${name} must be a client component with "use client" directive at the top of the file. ` +
package/src/handle.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { missingInjectedIdError } from "./missing-id-error.js";
2
+ import { isUnderTestRunner } from "./runtime-env.js";
2
3
 
3
4
  /**
4
5
  * Handle definition for accumulating data across route segments.
@@ -45,6 +46,11 @@ function defaultCollect<T>(segments: T[][]): T[] {
45
46
  // Used by useHandle() to recover collect when handle is deserialized from RSC prop.
46
47
  const collectRegistry = new Map<string, (segments: unknown[][]) => unknown>();
47
48
 
49
+ // Monotonic counter for runtime fallback ids (see createHandle). Module-scoped
50
+ // and deterministic, so each createHandle() call gets a stable, unique id within
51
+ // the process. Only used when no build id was injected (a bare unit test).
52
+ let runtimeHandleIdCounter = 0;
53
+
48
54
  /**
49
55
  * Look up a collect function from the registry by handle $$id.
50
56
  * Returns undefined if not registered (falls back to defaultCollect in useHandle).
@@ -95,10 +101,26 @@ export function createHandle<TData, TAccumulated = TData[]>(
95
101
  collect?: (segments: TData[][]) => TAccumulated,
96
102
  __injectedId?: string,
97
103
  ): Handle<TData, TAccumulated> {
98
- const handleId = __injectedId ?? "";
104
+ let handleId = __injectedId ?? "";
99
105
 
100
- if (!handleId && process.env.NODE_ENV === "development") {
101
- throw missingInjectedIdError("Handle", "createHandle");
106
+ // No build-injected id. Under a test runner: fall back to a synthetic id so the
107
+ // collect registers below and the handle is exercisable in tests (useHandle,
108
+ // collectHandle, renderRoute's `handles` run the REAL collect). Otherwise (dev
109
+ // or a real build) it means an UNSUPPORTED handler shape the plugin skipped —
110
+ // fail loud. The rich, stack-parsing diagnostic stays behind the NODE_ENV check
111
+ // so a production build folds it away and tree-shakes missing-id-error.ts out,
112
+ // shipping the small throw instead. isUnderTestRunner() is runtime-safe.
113
+ if (!handleId) {
114
+ if (isUnderTestRunner()) {
115
+ handleId = `__rango_runtime_handle_${runtimeHandleIdCounter++}`;
116
+ } else if (process.env.NODE_ENV !== "production") {
117
+ throw missingInjectedIdError("Handle", "createHandle");
118
+ } else {
119
+ throw new Error(
120
+ "[rango] Handle is missing $$id — the build plugin did not inject one. " +
121
+ "Export it as `export const X = createHandle(...)`.",
122
+ );
123
+ }
102
124
  }
103
125
 
104
126
  const collectFn =
@@ -107,12 +129,10 @@ export function createHandle<TData, TAccumulated = TData[]>(
107
129
 
108
130
  // Register collect in module-level registry so useHandle() can recover it
109
131
  // when the handle is deserialized from RSC props (toJSON strips collect).
110
- if (handleId) {
111
- collectRegistry.set(
112
- handleId,
113
- collectFn as (segments: unknown[][]) => unknown,
114
- );
115
- }
132
+ collectRegistry.set(
133
+ handleId,
134
+ collectFn as (segments: unknown[][]) => unknown,
135
+ );
116
136
 
117
137
  return {
118
138
  __brand: "handle" as const,
@@ -4,7 +4,7 @@
4
4
  * Helper functions for testing host routing.
5
5
  */
6
6
 
7
- import { matchPattern } from "./pattern-matcher.js";
7
+ import { matchPattern, parseRequest } from "./pattern-matcher.js";
8
8
 
9
9
  export interface CreateTestRequestOptions {
10
10
  host: string;
@@ -52,28 +52,57 @@ export function createTestRequest(options: CreateTestRequestOptions): Request {
52
52
  });
53
53
  }
54
54
 
55
+ // Try each pattern (a single pattern or any in an array) against the already
56
+ // parsed host + path. Shared by testPattern and matchesHost so the
57
+ // normalize-and-loop lives once.
58
+ function matchPatterns(
59
+ pattern: string | string[],
60
+ hostname: string,
61
+ pathname: string,
62
+ parts: string[],
63
+ ): boolean {
64
+ const patterns = Array.isArray(pattern) ? pattern : [pattern];
65
+ return patterns.some((p) => matchPattern(p, hostname, pathname, parts));
66
+ }
67
+
55
68
  /**
56
- * Test if a pattern matches a hostname
69
+ * Test if a pattern matches a hostname (and, for path-based patterns, a pathname).
70
+ *
71
+ * `pathname` defaults to `"/"`, so a host-only pattern works with two args. Pass
72
+ * the third arg to test a path-based pattern (`**.workers.dev/admin`,
73
+ * `localhost/shop`) — without it those patterns can never match.
57
74
  *
58
75
  * @example
59
76
  * ```ts
60
- * expect(testPattern('admin.*', 'admin.example.com')).toBe(true);
61
- * expect(testPattern(['*', 'www.*'], 'example.com')).toBe(true);
77
+ * expect(testPattern("admin.*", "admin.example.com")).toBe(true);
78
+ * expect(testPattern(["*", "www.*"], "example.com")).toBe(true);
79
+ * expect(testPattern("**.workers.dev/admin", "foo.workers.dev", "/admin")).toBe(true);
62
80
  * ```
63
81
  */
64
82
  export function testPattern(
65
83
  pattern: string | string[],
66
84
  hostname: string,
85
+ pathname: string = "/",
67
86
  ): boolean {
68
- const patterns = Array.isArray(pattern) ? pattern : [pattern];
69
- const parts = hostname.split(".");
70
- const pathname = "/";
71
-
72
- for (const p of patterns) {
73
- if (matchPattern(p, hostname, pathname, parts)) {
74
- return true;
75
- }
76
- }
87
+ return matchPatterns(pattern, hostname, pathname, hostname.split("."));
88
+ }
77
89
 
78
- return false;
90
+ /**
91
+ * Test if a pattern matches a `Request` — the hostname AND pathname are taken
92
+ * from the request URL (via the same `parseRequest` the host router uses), so a
93
+ * path-based pattern is tested against a real request without splitting the URL
94
+ * by hand.
95
+ *
96
+ * @example
97
+ * ```ts
98
+ * const req = new Request("https://foo.workers.dev/admin");
99
+ * expect(matchesHost("**.workers.dev/admin", req)).toBe(true);
100
+ * ```
101
+ */
102
+ export function matchesHost(
103
+ pattern: string | string[],
104
+ request: Request,
105
+ ): boolean {
106
+ const { hostname, pathname, parts } = parseRequest(request);
107
+ return matchPatterns(pattern, hostname, pathname, parts);
79
108
  }
package/src/index.rsc.ts CHANGED
@@ -184,11 +184,20 @@ export const getRequestContext: <
184
184
  export {
185
185
  cookies,
186
186
  headers,
187
+ invalidateClientCache,
188
+ keepClientCache,
187
189
  type CookieStore,
188
190
  type Cookie,
189
191
  type ReadonlyHeaders,
190
192
  } from "./server/cookie-store.js";
191
193
 
194
+ // Cache tag APIs (server-only)
195
+ // cacheTag: tag the current "use cache" entry at runtime.
196
+ // updateTag: read-your-own-writes invalidation (awaitable, for Server Actions).
197
+ // revalidateTag: background hard-purge invalidation (not awaited, for route handlers / webhooks).
198
+ export { cacheTag } from "./cache/cache-tag.js";
199
+ export { updateTag, revalidateTag } from "./cache/tag-invalidation.js";
200
+
192
201
  // Meta types
193
202
  export type { MetaDescriptor, MetaDescriptorBase } from "./router/types.js";
194
203
 
@@ -228,7 +237,26 @@ export {
228
237
  export { createConsoleSink } from "./router/telemetry.js";
229
238
  export { createOTelSink } from "./router/telemetry-otel.js";
230
239
  export type { OTelTracer, OTelSpan } from "./router/telemetry-otel.js";
231
- export type { TelemetrySink, TelemetryEvent } from "./router/telemetry.js";
240
+ // The full TelemetryEvent union PLUS its member types, so a consumer writing a
241
+ // TelemetrySink can annotate a per-`type` handler (or construct an event literal
242
+ // in a test) instead of only narrowing the opaque union.
243
+ export type {
244
+ TelemetrySink,
245
+ TelemetryEvent,
246
+ RequestStartEvent,
247
+ RequestEndEvent,
248
+ RequestErrorEvent,
249
+ LoaderStartEvent,
250
+ LoaderEndEvent,
251
+ LoaderErrorEvent,
252
+ HandlerErrorEvent,
253
+ CacheSegmentStatus,
254
+ CacheSegmentSignal,
255
+ CacheDecisionEvent,
256
+ RevalidationDecisionEvent,
257
+ RequestTimeoutEvent,
258
+ OriginCheckRejectedEvent,
259
+ } from "./router/telemetry.js";
232
260
 
233
261
  // Timeout types and error class
234
262
  export { RouterTimeoutError } from "./router/timeout.js";
package/src/index.ts CHANGED
@@ -217,6 +217,17 @@ export function headers(): never {
217
217
  throw serverOnlyStubError("headers");
218
218
  }
219
219
 
220
+ /**
221
+ * Client implementation of `invalidateClientCache()`. Unlike the server-only
222
+ * stubs above this is a REAL function under the `default` condition (it marks
223
+ * the client's caches stale); the `react-server` condition (index.rsc.ts)
224
+ * selects the server implementation that writes a rotated `Set-Cookie`.
225
+ */
226
+ export {
227
+ invalidateClientCache,
228
+ keepClientCache,
229
+ } from "./browser/invalidate-client-cache.js";
230
+
220
231
  /**
221
232
  * Error-throwing stub for server-only `createReverse` function.
222
233
  */
@@ -237,6 +248,18 @@ export function middleware(): never {
237
248
  export function revalidate(): never {
238
249
  throw serverOnlyStubError("revalidate");
239
250
  }
251
+ // Cache tag APIs are server-only (real implementations in index.rsc.ts). These
252
+ // stubs keep the named-export shape identical under the default/non-react-server
253
+ // condition so SSR/client/default bundles that encounter the import link cleanly.
254
+ export function cacheTag(): never {
255
+ throw serverOnlyStubError("cacheTag");
256
+ }
257
+ export function updateTag(): never {
258
+ throw serverOnlyStubError("updateTag");
259
+ }
260
+ export function revalidateTag(): never {
261
+ throw serverOnlyStubError("revalidateTag");
262
+ }
240
263
  export function loader(): never {
241
264
  throw serverOnlyStubError("loader");
242
265
  }
@@ -315,7 +338,26 @@ export {
315
338
  // who need the values in non-RSC contexts can import from
316
339
  // `@rangojs/router/server`.
317
340
  export type { OTelTracer, OTelSpan } from "./router/telemetry-otel.js";
318
- export type { TelemetrySink, TelemetryEvent } from "./router/telemetry.js";
341
+ // The full TelemetryEvent union PLUS its member types, so a consumer writing a
342
+ // TelemetrySink can annotate a per-`type` handler (or construct an event literal
343
+ // in a test) instead of only narrowing the opaque union.
344
+ export type {
345
+ TelemetrySink,
346
+ TelemetryEvent,
347
+ RequestStartEvent,
348
+ RequestEndEvent,
349
+ RequestErrorEvent,
350
+ LoaderStartEvent,
351
+ LoaderEndEvent,
352
+ LoaderErrorEvent,
353
+ HandlerErrorEvent,
354
+ CacheSegmentStatus,
355
+ CacheSegmentSignal,
356
+ CacheDecisionEvent,
357
+ RevalidationDecisionEvent,
358
+ RequestTimeoutEvent,
359
+ OriginCheckRejectedEvent,
360
+ } from "./router/telemetry.js";
319
361
 
320
362
  // Timeout types and error class
321
363
  export { RouterTimeoutError } from "./router/timeout.js";
package/src/loader.rsc.ts CHANGED
@@ -22,9 +22,14 @@ import {
22
22
  getFetchableLoader,
23
23
  } from "./server/fetchable-loader-store.js";
24
24
  import { missingInjectedIdError } from "./missing-id-error.js";
25
+ import { isUnderTestRunner } from "./runtime-env.js";
25
26
 
26
27
  export { getFetchableLoader };
27
28
 
29
+ // Counter for runtime-fallback loader ids assigned only in a bare unit test
30
+ // (no Vite plugin to inject one). Process-stable; never reached in a real build.
31
+ let runtimeLoaderIdCounter = 0;
32
+
28
33
  // Overload 1: With function only (not fetchable)
29
34
  export function createLoader<T>(
30
35
  fn: LoaderFn<T, Record<string, string | undefined>, any>,
@@ -51,10 +56,26 @@ export function createLoader<T>(
51
56
  ): LoaderDefinition<Awaited<T>, Record<string, string | undefined>> {
52
57
  // The $$id will be set on the returned object by Vite plugin
53
58
  // For fetchable loaders, __injectedId is also passed as a parameter
54
- const loaderId = __injectedId || "";
59
+ let loaderId = __injectedId || "";
55
60
 
56
- if (!loaderId && process.env.NODE_ENV === "development") {
57
- throw missingInjectedIdError("Loader", "createLoader");
61
+ // No build-injected id. Under a test runner: fall back to a synthetic id so the
62
+ // fn registers below and the loader is exercisable via runLoader(loaderHandle)
63
+ // (it recovers the fn from the registry by $$id). Otherwise (dev or a real
64
+ // build) it means an UNSUPPORTED shape (e.g. a namespace import
65
+ // `rango.createLoader(...)`) the plugin skipped — fail loud. The rich
66
+ // diagnostic stays behind the NODE_ENV check so production folds it away and
67
+ // ships the small throw. isUnderTestRunner() is runtime-safe. Mirrors createHandle.
68
+ if (!loaderId) {
69
+ if (isUnderTestRunner()) {
70
+ loaderId = `__rango_runtime_loader_${runtimeLoaderIdCounter++}`;
71
+ } else if (process.env.NODE_ENV !== "production") {
72
+ throw missingInjectedIdError("Loader", "createLoader");
73
+ } else {
74
+ throw new Error(
75
+ "[rango] Loader is missing $$id — the build plugin did not inject one. " +
76
+ "Export it as `export const X = createLoader(...)`.",
77
+ );
78
+ }
58
79
  }
59
80
 
60
81
  // If not fetchable, store fn in registry (for SSR ctx.use() resolution)