rwsdk 1.0.0-beta.44 → 1.0.0-beta.45

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.
@@ -19,7 +19,7 @@ export declare const fetchTransport: Transport;
19
19
  * - `onCaughtError`: Handler for errors caught by error boundaries
20
20
  * - `onRecoverableError`: Handler for recoverable errors
21
21
  * @param handleResponse - Custom response handler for navigation errors (navigation GETs)
22
- * @param onHydrationUpdate - Callback invoked after a new RSC payload has been committed on the client
22
+ * @param onHydrated - Callback invoked after a new RSC payload has been committed on the client
23
23
  * @param onActionResponse - Optional hook invoked when an action returns a Response;
24
24
  * return true to signal that the response has been handled and
25
25
  * default behaviour (e.g. redirects) should be skipped
@@ -64,10 +64,10 @@ export declare const fetchTransport: Transport;
64
64
  * },
65
65
  * });
66
66
  */
67
- export declare const initClient: ({ transport, hydrateRootOptions, handleResponse, onHydrationUpdate, onActionResponse, }?: {
67
+ export declare const initClient: ({ transport, hydrateRootOptions, handleResponse, onHydrated, onActionResponse, }?: {
68
68
  transport?: Transport;
69
69
  hydrateRootOptions?: HydrationOptions;
70
70
  handleResponse?: (response: Response) => boolean;
71
- onHydrationUpdate?: () => void;
71
+ onHydrated?: () => void;
72
72
  onActionResponse?: (actionResponse: ActionResponseData) => boolean | void;
73
73
  }) => Promise<void>;
@@ -111,7 +111,7 @@ export const fetchTransport = (transportContext) => {
111
111
  * - `onCaughtError`: Handler for errors caught by error boundaries
112
112
  * - `onRecoverableError`: Handler for recoverable errors
113
113
  * @param handleResponse - Custom response handler for navigation errors (navigation GETs)
114
- * @param onHydrationUpdate - Callback invoked after a new RSC payload has been committed on the client
114
+ * @param onHydrated - Callback invoked after a new RSC payload has been committed on the client
115
115
  * @param onActionResponse - Optional hook invoked when an action returns a Response;
116
116
  * return true to signal that the response has been handled and
117
117
  * default behaviour (e.g. redirects) should be skipped
@@ -156,11 +156,11 @@ export const fetchTransport = (transportContext) => {
156
156
  * },
157
157
  * });
158
158
  */
159
- export const initClient = async ({ transport = fetchTransport, hydrateRootOptions, handleResponse, onHydrationUpdate, onActionResponse, } = {}) => {
159
+ export const initClient = async ({ transport = fetchTransport, hydrateRootOptions, handleResponse, onHydrated, onActionResponse, } = {}) => {
160
160
  const transportContext = {
161
161
  setRscPayload: () => { },
162
162
  handleResponse,
163
- onHydrationUpdate,
163
+ onHydrated,
164
164
  onActionResponse,
165
165
  };
166
166
  let transportCallServer = transport(transportContext);
@@ -198,7 +198,7 @@ export const initClient = async ({ transport = fetchTransport, hydrateRootOption
198
198
  React.useEffect(() => {
199
199
  if (!streamData)
200
200
  return;
201
- transportContext.onHydrationUpdate?.();
201
+ transportContext.onHydrated?.();
202
202
  }, [streamData]);
203
203
  return (_jsx(_Fragment, { children: streamData
204
204
  ? React.use(streamData).node
@@ -29,8 +29,8 @@ export declare function navigate(href: string, options?: NavigateOptions): Promi
29
29
  * // Basic usage
30
30
  * import { initClient, initClientNavigation } from "rwsdk/client";
31
31
  *
32
- * const { handleResponse, onHydrationUpdate } = initClientNavigation();
33
- * initClient({ handleResponse, onHydrationUpdate });
32
+ * const { handleResponse, onHydrated } = initClientNavigation();
33
+ * initClient({ handleResponse, onHydrated });
34
34
  *
35
35
  * @example
36
36
  * // With custom scroll behavior
@@ -58,5 +58,5 @@ export declare function navigate(href: string, options?: NavigateOptions): Promi
58
58
  */
59
59
  export declare function initClientNavigation(opts?: ClientNavigationOptions): {
60
60
  handleResponse: (response: Response) => boolean;
61
- onHydrationUpdate: () => void;
61
+ onHydrated: () => void;
62
62
  };
@@ -78,8 +78,8 @@ function saveScrollPosition(x, y) {
78
78
  * // Basic usage
79
79
  * import { initClient, initClientNavigation } from "rwsdk/client";
80
80
  *
81
- * const { handleResponse, onHydrationUpdate } = initClientNavigation();
82
- * initClient({ handleResponse, onHydrationUpdate });
81
+ * const { handleResponse, onHydrated } = initClientNavigation();
82
+ * initClient({ handleResponse, onHydrated });
83
83
  *
84
84
  * @example
85
85
  * // With custom scroll behavior
@@ -134,9 +134,9 @@ export function initClientNavigation(opts = {}) {
134
134
  if (opts.cacheStorage && typeof globalThis !== "undefined") {
135
135
  globalThis.__rsc_cacheStorage = opts.cacheStorage;
136
136
  }
137
- function onHydrationUpdate() {
137
+ function onHydrated() {
138
138
  // After each RSC hydration/update, increment generation and evict old caches,
139
- // then warm the navigation cache based on any <link rel="prefetch"> tags
139
+ // then warm the navigation cache based on any <link rel="x-prefetch"> tags
140
140
  // rendered for the current location.
141
141
  onNavigationCommit(undefined, opts.cacheStorage);
142
142
  void preloadFromLinkTags(undefined, undefined, opts.cacheStorage);
@@ -144,6 +144,6 @@ export function initClientNavigation(opts = {}) {
144
144
  // Return callbacks for use with initClient
145
145
  return {
146
146
  handleResponse,
147
- onHydrationUpdate,
147
+ onHydrated,
148
148
  };
149
149
  }
@@ -57,7 +57,7 @@ export declare function evictOldGenerationCaches(env?: NavigationCacheEnvironmen
57
57
  */
58
58
  export declare function onNavigationCommit(env?: NavigationCacheEnvironment, cacheStorage?: NavigationCacheStorage): void;
59
59
  /**
60
- * Scan the document for `<link rel="prefetch" href="...">` elements that point
60
+ * Scan the document for `<link rel="x-prefetch" href="...">` elements that point
61
61
  * to same-origin paths and prefetch their RSC navigation responses into the
62
62
  * Cache API.
63
63
  *
@@ -5,30 +5,22 @@ function getOrInitializeCacheState() {
5
5
  if (cacheState) {
6
6
  return cacheState;
7
7
  }
8
- // Get or generate tabId
9
- let tabId;
10
- if (typeof window !== "undefined" && window.sessionStorage) {
8
+ let tabId = null;
9
+ if (typeof window !== "undefined") {
11
10
  try {
12
- const stored = sessionStorage.getItem(TAB_ID_STORAGE_KEY);
13
- if (stored) {
14
- tabId = stored;
15
- }
16
- else {
17
- tabId = crypto.randomUUID();
11
+ tabId = sessionStorage.getItem(TAB_ID_STORAGE_KEY);
12
+ if (!tabId) {
13
+ tabId = `${Date.now()}-${Math.floor(Math.random() * 1000000)}`;
18
14
  sessionStorage.setItem(TAB_ID_STORAGE_KEY, tabId);
19
15
  }
20
16
  }
21
17
  catch {
22
- // Fallback to in-memory tabId if sessionStorage is unavailable
23
- tabId = crypto.randomUUID();
18
+ // sessionStorage might be unavailable
19
+ tabId = tabId || `${Date.now()}-${Math.floor(Math.random() * 1000000)}`;
24
20
  }
25
21
  }
26
- else {
27
- // Fallback for non-browser environments
28
- tabId = crypto.randomUUID();
29
- }
30
22
  cacheState = {
31
- tabId,
23
+ tabId: tabId || "1",
32
24
  generation: 0,
33
25
  buildId: BUILD_ID,
34
26
  };
@@ -36,7 +28,7 @@ function getOrInitializeCacheState() {
36
28
  }
37
29
  function getCurrentCacheName() {
38
30
  const state = getOrInitializeCacheState();
39
- return `rsc-prefetch:${state.buildId}:${state.tabId}:${state.generation}`;
31
+ return `rsc-x-prefetch:${state.buildId}:${state.tabId}:${state.generation}`;
40
32
  }
41
33
  function incrementGeneration() {
42
34
  const state = getOrInitializeCacheState();
@@ -135,6 +127,9 @@ export async function preloadNavigationUrl(rawUrl, env, cacheStorage) {
135
127
  const request = new Request(url.toString(), {
136
128
  method: "GET",
137
129
  redirect: "manual",
130
+ headers: {
131
+ "x-prefetch": "true",
132
+ },
138
133
  });
139
134
  const cacheName = getCurrentCacheName();
140
135
  const cache = await storage.open(cacheName);
@@ -221,7 +216,7 @@ export async function evictOldGenerationCaches(env, cacheStorage) {
221
216
  try {
222
217
  // List all cache names
223
218
  const cacheNames = await storage.keys();
224
- const prefix = `rsc-prefetch:${buildId}:${tabId}:`;
219
+ const prefix = `rsc-x-prefetch:${buildId}:${tabId}:`;
225
220
  // Find all caches for this tab
226
221
  const tabCaches = cacheNames.filter((name) => name.startsWith(prefix));
227
222
  // Delete caches with generation numbers less than current
@@ -256,11 +251,16 @@ export async function evictOldGenerationCaches(env, cacheStorage) {
256
251
  * as complete and prepare for the next navigation cycle.
257
252
  */
258
253
  export function onNavigationCommit(env, cacheStorage) {
254
+ const runtimeEnv = env ?? getBrowserNavigationCacheEnvironment();
255
+ const storage = cacheStorage ?? createDefaultNavigationCacheStorage(runtimeEnv);
256
+ if (!storage) {
257
+ return;
258
+ }
259
259
  incrementGeneration();
260
- void evictOldGenerationCaches(env, cacheStorage);
260
+ void evictOldGenerationCaches(env, storage);
261
261
  }
262
262
  /**
263
- * Scan the document for `<link rel="prefetch" href="...">` elements that point
263
+ * Scan the document for `<link rel="x-prefetch" href="...">` elements that point
264
264
  * to same-origin paths and prefetch their RSC navigation responses into the
265
265
  * Cache API.
266
266
  *
@@ -272,7 +272,7 @@ export async function preloadFromLinkTags(doc = document, env, cacheStorage) {
272
272
  if (typeof doc === "undefined") {
273
273
  return;
274
274
  }
275
- const links = Array.from(doc.querySelectorAll('link[rel="prefetch"][href]'));
275
+ const links = Array.from(doc.querySelectorAll('link[rel="x-prefetch"][href]'));
276
276
  await Promise.all(links.map((link) => {
277
277
  const href = link.getAttribute("href");
278
278
  if (!href) {
@@ -148,6 +148,19 @@ describe("navigationCache", () => {
148
148
  const requestUrl = new URL(request.url);
149
149
  expect(requestUrl.searchParams.has("__rsc")).toBe(true);
150
150
  });
151
+ it("should add x-prefetch header", async () => {
152
+ const env = {
153
+ isSecureContext: true,
154
+ origin: "https://example.com",
155
+ caches: mockCacheStorage,
156
+ fetch: mockFetch,
157
+ };
158
+ const url = new URL("https://example.com/test");
159
+ await preloadNavigationUrl(url, env);
160
+ const fetchCall = mockFetch.mock.calls[0];
161
+ const request = fetchCall[0];
162
+ expect(request.headers.get("x-prefetch")).toBe("true");
163
+ });
151
164
  it("should use custom cacheStorage when provided", async () => {
152
165
  const customCache = {
153
166
  put: vi.fn().mockResolvedValue(undefined),
@@ -283,27 +296,27 @@ describe("navigationCache", () => {
283
296
  await preloadNavigationUrl(url, env);
284
297
  const openCall = mockCacheStorage.open.mock.calls[0];
285
298
  const cacheName = openCall[0];
286
- const tabIdMatch = cacheName.match(/^rsc-prefetch:rwsdk:([^:]+):\d+$/);
299
+ const tabIdMatch = cacheName.match(/^rsc-x-prefetch:rwsdk:([^:]+):\d+$/);
287
300
  const tabId = tabIdMatch ? tabIdMatch[1] : "test-uuid-123";
288
301
  // Increment generation to 2 by calling onNavigationCommit twice
289
302
  onNavigationCommit(env);
290
303
  onNavigationCommit(env);
291
304
  // Mock cache names matching the actual tabId
292
305
  const allCacheNames = [
293
- `rsc-prefetch:rwsdk:${tabId}:0`,
294
- `rsc-prefetch:rwsdk:${tabId}:1`,
295
- `rsc-prefetch:rwsdk:${tabId}:2`,
296
- "rsc-prefetch:rwsdk:other-tab:0",
306
+ `rsc-x-prefetch:rwsdk:${tabId}:0`,
307
+ `rsc-x-prefetch:rwsdk:${tabId}:1`,
308
+ `rsc-x-prefetch:rwsdk:${tabId}:2`,
309
+ "rsc-x-prefetch:rwsdk:other-tab:0",
297
310
  ];
298
311
  mockCacheStorage.keys.mockResolvedValue(allCacheNames);
299
312
  await evictOldGenerationCaches(env);
300
313
  // Wait for the cleanup to execute
301
314
  await new Promise((resolve) => setTimeout(resolve, 10));
302
315
  // Should delete generations 0 and 1, but not 2 (current) or other-tab
303
- expect(mockCacheStorage.delete).toHaveBeenCalledWith(`rsc-prefetch:rwsdk:${tabId}:0`);
304
- expect(mockCacheStorage.delete).toHaveBeenCalledWith(`rsc-prefetch:rwsdk:${tabId}:1`);
305
- expect(mockCacheStorage.delete).not.toHaveBeenCalledWith(`rsc-prefetch:rwsdk:${tabId}:2`);
306
- expect(mockCacheStorage.delete).not.toHaveBeenCalledWith("rsc-prefetch:rwsdk:other-tab:0");
316
+ expect(mockCacheStorage.delete).toHaveBeenCalledWith(`rsc-x-prefetch:rwsdk:${tabId}:0`);
317
+ expect(mockCacheStorage.delete).toHaveBeenCalledWith(`rsc-x-prefetch:rwsdk:${tabId}:1`);
318
+ expect(mockCacheStorage.delete).not.toHaveBeenCalledWith(`rsc-x-prefetch:rwsdk:${tabId}:2`);
319
+ expect(mockCacheStorage.delete).not.toHaveBeenCalledWith("rsc-x-prefetch:rwsdk:other-tab:0");
307
320
  });
308
321
  it("should use custom cacheStorage when provided", async () => {
309
322
  // Get the actual tabId that will be used
@@ -317,7 +330,7 @@ describe("navigationCache", () => {
317
330
  await preloadNavigationUrl(url, env);
318
331
  const openCall = mockCacheStorage.open.mock.calls[0];
319
332
  const cacheName = openCall[0];
320
- const tabIdMatch = cacheName.match(/^rsc-prefetch:rwsdk:([^:]+):\d+$/);
333
+ const tabIdMatch = cacheName.match(/^rsc-x-prefetch:rwsdk:([^:]+):\d+$/);
321
334
  const tabId = tabIdMatch ? tabIdMatch[1] : "test-uuid-123";
322
335
  const customStorage = {
323
336
  open: vi.fn(),
@@ -325,8 +338,8 @@ describe("navigationCache", () => {
325
338
  keys: vi
326
339
  .fn()
327
340
  .mockResolvedValue([
328
- `rsc-prefetch:rwsdk:${tabId}:0`,
329
- `rsc-prefetch:rwsdk:${tabId}:1`,
341
+ `rsc-x-prefetch:rwsdk:${tabId}:0`,
342
+ `rsc-x-prefetch:rwsdk:${tabId}:1`,
330
343
  ]),
331
344
  };
332
345
  // Increment generation so there are old caches to delete
@@ -367,14 +380,14 @@ describe("navigationCache", () => {
367
380
  });
368
381
  });
369
382
  describe("preloadFromLinkTags", () => {
370
- it("should preload URLs from prefetch link tags", async () => {
383
+ it("should preload URLs from x-prefetch link tags", async () => {
371
384
  const env = {
372
385
  isSecureContext: true,
373
386
  origin: "https://example.com",
374
387
  caches: mockCacheStorage,
375
388
  fetch: mockFetch,
376
389
  };
377
- // Create a mock document with prefetch links
390
+ // Create a mock document with x-prefetch links
378
391
  const mockDoc = {
379
392
  querySelectorAll: vi.fn().mockReturnValue([
380
393
  {
@@ -450,7 +463,7 @@ describe("navigationCache", () => {
450
463
  await preloadNavigationUrl(url, env);
451
464
  const openCall = mockCacheStorage.open.mock.calls[0];
452
465
  const cacheName = openCall[0];
453
- expect(cacheName).toMatch(/^rsc-prefetch:rwsdk:[^:]+:\d+$/);
466
+ expect(cacheName).toMatch(/^rsc-x-prefetch:rwsdk:[^:]+:\d+$/);
454
467
  });
455
468
  });
456
469
  });
@@ -23,7 +23,7 @@ export type TransportContext = {
23
23
  * This is useful for features like client-side navigation that want to run logic
24
24
  * after hydration/updates, e.g. warming navigation caches.
25
25
  */
26
- onHydrationUpdate?: () => void;
26
+ onHydrated?: () => void;
27
27
  /**
28
28
  * Optional callback invoked when an action returns a Response.
29
29
  * Return true to signal that the response has been handled and
@@ -411,7 +411,9 @@ export function route(path, handler) {
411
411
  if (!normalizedPath.startsWith("/")) {
412
412
  normalizedPath = "/" + normalizedPath;
413
413
  }
414
- if (!normalizedPath.endsWith("/")) {
414
+ // Special case: wildcard route "*" should normalize to "/*" (not "/*/")
415
+ // to allow it to match the root path "/"
416
+ if (normalizedPath !== "/*" && !normalizedPath.endsWith("/")) {
415
417
  normalizedPath = normalizedPath + "/";
416
418
  }
417
419
  // Normalize custom method keys to lowercase
@@ -34,6 +34,11 @@ describe("matchPath", () => {
34
34
  it("should match empty wildcard", () => {
35
35
  expect(matchPath("/files/*/", "/files//")).toEqual({ $0: "" });
36
36
  });
37
+ it("should match wildcard route against root path", () => {
38
+ // Wildcard route should match the root path "/"
39
+ // This tests the regression where route("*") stopped matching "/"
40
+ expect(matchPath("/*", "/")).toEqual({ $0: "" });
41
+ });
37
42
  // Test case 4: Paths with both parameters and wildcards
38
43
  it("should match paths with both parameters and wildcards", () => {
39
44
  expect(matchPath("/products/:productId/*/", "/products/abc/details/more/")).toEqual({ productId: "abc", $0: "details/more" });
@@ -541,6 +546,28 @@ describe("defineRoutes - Request Handling Behavior", () => {
541
546
  expect(response.status).toBe(404);
542
547
  expect(await response.text()).toBe("Not Found");
543
548
  });
549
+ it("should match wildcard route against root path", async () => {
550
+ // Regression test: wildcard route should match the root path "/"
551
+ const executionOrder = [];
552
+ const WildcardPage = () => {
553
+ executionOrder.push("WildcardPage");
554
+ return React.createElement("div", {}, "Wildcard Page");
555
+ };
556
+ const router = defineRoutes([route("*", WildcardPage)]);
557
+ const deps = createMockDependencies();
558
+ deps.mockRequestInfo.request = new Request("http://localhost:3000/");
559
+ deps.mockRequestInfo.path = "/";
560
+ const request = new Request("http://localhost:3000/");
561
+ await router.handle({
562
+ request,
563
+ renderPage: deps.mockRenderPage,
564
+ getRequestInfo: deps.getRequestInfo,
565
+ onError: deps.onError,
566
+ runWithRequestInfoOverrides: deps.mockRunWithRequestInfoOverrides,
567
+ rscActionHandler: deps.mockRscActionHandler,
568
+ });
569
+ expect(executionOrder).toEqual(["WildcardPage"]);
570
+ });
544
571
  });
545
572
  describe("Multiple Render Blocks with SSR Configuration", () => {
546
573
  it("should short-circuit on first matching render block and not apply later configurations", async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rwsdk",
3
- "version": "1.0.0-beta.44",
3
+ "version": "1.0.0-beta.45",
4
4
  "description": "Build fast, server-driven webapps on Cloudflare with SSR, RSC, and realtime",
5
5
  "type": "module",
6
6
  "bin": {