rwsdk 1.0.0-beta.41 → 1.0.0-beta.42
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.
- package/dist/lib/e2e/dev.mjs +14 -10
- package/dist/lib/e2e/index.d.mts +1 -0
- package/dist/lib/e2e/index.mjs +1 -0
- package/dist/lib/e2e/release.mjs +9 -4
- package/dist/lib/e2e/testHarness.d.mts +9 -4
- package/dist/lib/e2e/testHarness.mjs +20 -2
- package/dist/runtime/client/client.d.ts +10 -3
- package/dist/runtime/client/client.js +76 -13
- package/dist/runtime/client/navigation.d.ts +6 -2
- package/dist/runtime/client/navigation.js +29 -17
- package/dist/runtime/client/navigationCache.d.ts +68 -0
- package/dist/runtime/client/navigationCache.js +294 -0
- package/dist/runtime/client/navigationCache.test.d.ts +1 -0
- package/dist/runtime/client/navigationCache.test.js +456 -0
- package/dist/runtime/client/types.d.ts +25 -3
- package/dist/runtime/client/types.js +7 -1
- package/dist/runtime/lib/realtime/client.js +17 -1
- package/dist/runtime/render/normalizeActionResult.js +8 -1
- package/package.json +2 -1
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createDefaultNavigationCacheStorage, evictOldGenerationCaches, getCachedNavigationResponse, onNavigationCommit, preloadFromLinkTags, preloadNavigationUrl, } from "./navigationCache";
|
|
3
|
+
describe("navigationCache", () => {
|
|
4
|
+
let mockCacheStorage;
|
|
5
|
+
let mockCache;
|
|
6
|
+
let mockFetch;
|
|
7
|
+
let mockSessionStorage;
|
|
8
|
+
// Local type for requestIdleCallback to avoid depending on global declarations
|
|
9
|
+
let mockRequestIdleCallback;
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
// Reset module state between tests
|
|
12
|
+
vi.resetModules();
|
|
13
|
+
// Mock Cache
|
|
14
|
+
mockCache = {
|
|
15
|
+
put: vi.fn().mockResolvedValue(undefined),
|
|
16
|
+
match: vi.fn().mockResolvedValue(undefined),
|
|
17
|
+
delete: vi.fn().mockResolvedValue(true),
|
|
18
|
+
add: vi.fn(),
|
|
19
|
+
addAll: vi.fn(),
|
|
20
|
+
keys: vi.fn(),
|
|
21
|
+
matchAll: vi.fn(),
|
|
22
|
+
};
|
|
23
|
+
// Mock CacheStorage
|
|
24
|
+
mockCacheStorage = {
|
|
25
|
+
open: vi.fn().mockResolvedValue(mockCache),
|
|
26
|
+
delete: vi.fn().mockResolvedValue(true),
|
|
27
|
+
keys: vi.fn().mockResolvedValue([]),
|
|
28
|
+
has: vi.fn(),
|
|
29
|
+
match: vi.fn(),
|
|
30
|
+
};
|
|
31
|
+
// Mock fetch
|
|
32
|
+
mockFetch = vi.fn().mockResolvedValue(new Response("test response", {
|
|
33
|
+
status: 200,
|
|
34
|
+
headers: { "content-type": "text/html" },
|
|
35
|
+
}));
|
|
36
|
+
// Mock sessionStorage
|
|
37
|
+
mockSessionStorage = {
|
|
38
|
+
getItem: vi.fn().mockReturnValue(null),
|
|
39
|
+
setItem: vi.fn(),
|
|
40
|
+
removeItem: vi.fn(),
|
|
41
|
+
clear: vi.fn(),
|
|
42
|
+
key: vi.fn(),
|
|
43
|
+
length: 0,
|
|
44
|
+
};
|
|
45
|
+
// Mock requestIdleCallback to execute callback asynchronously for testing
|
|
46
|
+
let idleCallback = null;
|
|
47
|
+
mockRequestIdleCallback = vi.fn((callback) => {
|
|
48
|
+
idleCallback = callback;
|
|
49
|
+
// Execute after current I/O callbacks
|
|
50
|
+
setImmediate(() => {
|
|
51
|
+
if (idleCallback) {
|
|
52
|
+
idleCallback();
|
|
53
|
+
idleCallback = null;
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
return 1;
|
|
57
|
+
});
|
|
58
|
+
// Setup global mocks
|
|
59
|
+
globalThis.window = {
|
|
60
|
+
isSecureContext: true,
|
|
61
|
+
location: { origin: "https://example.com" },
|
|
62
|
+
caches: mockCacheStorage,
|
|
63
|
+
fetch: mockFetch,
|
|
64
|
+
sessionStorage: mockSessionStorage,
|
|
65
|
+
crypto: {
|
|
66
|
+
randomUUID: () => "test-uuid-123",
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
globalThis.requestIdleCallback = mockRequestIdleCallback;
|
|
70
|
+
});
|
|
71
|
+
describe("createDefaultNavigationCacheStorage", () => {
|
|
72
|
+
it("should create a cache storage wrapper", async () => {
|
|
73
|
+
const env = {
|
|
74
|
+
isSecureContext: true,
|
|
75
|
+
origin: "https://example.com",
|
|
76
|
+
caches: mockCacheStorage,
|
|
77
|
+
fetch: mockFetch,
|
|
78
|
+
};
|
|
79
|
+
const storage = createDefaultNavigationCacheStorage(env);
|
|
80
|
+
expect(storage).toBeDefined();
|
|
81
|
+
const cache = await storage.open("test-cache");
|
|
82
|
+
expect(mockCacheStorage.open).toHaveBeenCalledWith("test-cache");
|
|
83
|
+
expect(cache).toBeDefined();
|
|
84
|
+
});
|
|
85
|
+
it("should return undefined if no caches available", () => {
|
|
86
|
+
const env = {
|
|
87
|
+
isSecureContext: true,
|
|
88
|
+
origin: "https://example.com",
|
|
89
|
+
caches: undefined,
|
|
90
|
+
fetch: mockFetch,
|
|
91
|
+
};
|
|
92
|
+
const storage = createDefaultNavigationCacheStorage(env);
|
|
93
|
+
expect(storage).toBeUndefined();
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
describe("preloadNavigationUrl", () => {
|
|
97
|
+
it("should cache a successful response", async () => {
|
|
98
|
+
const env = {
|
|
99
|
+
isSecureContext: true,
|
|
100
|
+
origin: "https://example.com",
|
|
101
|
+
caches: mockCacheStorage,
|
|
102
|
+
fetch: mockFetch,
|
|
103
|
+
};
|
|
104
|
+
const url = new URL("https://example.com/test");
|
|
105
|
+
await preloadNavigationUrl(url, env);
|
|
106
|
+
expect(mockFetch).toHaveBeenCalled();
|
|
107
|
+
expect(mockCacheStorage.open).toHaveBeenCalled();
|
|
108
|
+
expect(mockCache.put).toHaveBeenCalled();
|
|
109
|
+
});
|
|
110
|
+
it("should not cache error responses (status >= 400)", async () => {
|
|
111
|
+
const errorFetch = vi
|
|
112
|
+
.fn()
|
|
113
|
+
.mockResolvedValue(new Response("error", { status: 404 }));
|
|
114
|
+
const env = {
|
|
115
|
+
isSecureContext: true,
|
|
116
|
+
origin: "https://example.com",
|
|
117
|
+
caches: mockCacheStorage,
|
|
118
|
+
fetch: errorFetch,
|
|
119
|
+
};
|
|
120
|
+
const url = new URL("https://example.com/test");
|
|
121
|
+
await preloadNavigationUrl(url, env);
|
|
122
|
+
expect(errorFetch).toHaveBeenCalled();
|
|
123
|
+
expect(mockCache.put).not.toHaveBeenCalled();
|
|
124
|
+
});
|
|
125
|
+
it("should skip cross-origin URLs", async () => {
|
|
126
|
+
const env = {
|
|
127
|
+
isSecureContext: true,
|
|
128
|
+
origin: "https://example.com",
|
|
129
|
+
caches: mockCacheStorage,
|
|
130
|
+
fetch: mockFetch,
|
|
131
|
+
};
|
|
132
|
+
const url = new URL("https://other-origin.com/test");
|
|
133
|
+
await preloadNavigationUrl(url, env);
|
|
134
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
135
|
+
expect(mockCache.put).not.toHaveBeenCalled();
|
|
136
|
+
});
|
|
137
|
+
it("should add __rsc query parameter", async () => {
|
|
138
|
+
const env = {
|
|
139
|
+
isSecureContext: true,
|
|
140
|
+
origin: "https://example.com",
|
|
141
|
+
caches: mockCacheStorage,
|
|
142
|
+
fetch: mockFetch,
|
|
143
|
+
};
|
|
144
|
+
const url = new URL("https://example.com/test");
|
|
145
|
+
await preloadNavigationUrl(url, env);
|
|
146
|
+
const fetchCall = mockFetch.mock.calls[0];
|
|
147
|
+
const request = fetchCall[0];
|
|
148
|
+
const requestUrl = new URL(request.url);
|
|
149
|
+
expect(requestUrl.searchParams.has("__rsc")).toBe(true);
|
|
150
|
+
});
|
|
151
|
+
it("should use custom cacheStorage when provided", async () => {
|
|
152
|
+
const customCache = {
|
|
153
|
+
put: vi.fn().mockResolvedValue(undefined),
|
|
154
|
+
match: vi.fn().mockResolvedValue(undefined),
|
|
155
|
+
};
|
|
156
|
+
const customStorage = {
|
|
157
|
+
open: vi.fn().mockResolvedValue(customCache),
|
|
158
|
+
delete: vi.fn().mockResolvedValue(true),
|
|
159
|
+
keys: vi.fn().mockResolvedValue([]),
|
|
160
|
+
};
|
|
161
|
+
const env = {
|
|
162
|
+
isSecureContext: true,
|
|
163
|
+
origin: "https://example.com",
|
|
164
|
+
caches: mockCacheStorage,
|
|
165
|
+
fetch: mockFetch,
|
|
166
|
+
};
|
|
167
|
+
const url = new URL("https://example.com/test");
|
|
168
|
+
await preloadNavigationUrl(url, env, customStorage);
|
|
169
|
+
expect(customStorage.open).toHaveBeenCalled();
|
|
170
|
+
expect(customCache.put).toHaveBeenCalled();
|
|
171
|
+
expect(mockCacheStorage.open).not.toHaveBeenCalled();
|
|
172
|
+
});
|
|
173
|
+
it("should handle errors gracefully", async () => {
|
|
174
|
+
const errorFetch = vi.fn().mockRejectedValue(new Error("Network error"));
|
|
175
|
+
const env = {
|
|
176
|
+
isSecureContext: true,
|
|
177
|
+
origin: "https://example.com",
|
|
178
|
+
caches: mockCacheStorage,
|
|
179
|
+
fetch: errorFetch,
|
|
180
|
+
};
|
|
181
|
+
const url = new URL("https://example.com/test");
|
|
182
|
+
// Should not throw
|
|
183
|
+
await expect(preloadNavigationUrl(url, env)).resolves.toBeUndefined();
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
describe("getCachedNavigationResponse", () => {
|
|
187
|
+
it("should return cached response if found", async () => {
|
|
188
|
+
const cachedResponse = new Response("cached", { status: 200 });
|
|
189
|
+
mockCache.match.mockResolvedValue(cachedResponse);
|
|
190
|
+
const env = {
|
|
191
|
+
isSecureContext: true,
|
|
192
|
+
origin: "https://example.com",
|
|
193
|
+
caches: mockCacheStorage,
|
|
194
|
+
fetch: mockFetch,
|
|
195
|
+
};
|
|
196
|
+
const url = new URL("https://example.com/test");
|
|
197
|
+
const result = await getCachedNavigationResponse(url, env);
|
|
198
|
+
expect(result).toBe(cachedResponse);
|
|
199
|
+
expect(mockCache.match).toHaveBeenCalled();
|
|
200
|
+
});
|
|
201
|
+
it("should return undefined if not cached", async () => {
|
|
202
|
+
mockCache.match.mockResolvedValue(undefined);
|
|
203
|
+
const env = {
|
|
204
|
+
isSecureContext: true,
|
|
205
|
+
origin: "https://example.com",
|
|
206
|
+
caches: mockCacheStorage,
|
|
207
|
+
fetch: mockFetch,
|
|
208
|
+
};
|
|
209
|
+
const url = new URL("https://example.com/test");
|
|
210
|
+
const result = await getCachedNavigationResponse(url, env);
|
|
211
|
+
expect(result).toBeUndefined();
|
|
212
|
+
});
|
|
213
|
+
it("should check global cacheStorage if not provided", async () => {
|
|
214
|
+
const cachedResponse = new Response("cached", { status: 200 });
|
|
215
|
+
const customCache = {
|
|
216
|
+
put: vi.fn(),
|
|
217
|
+
match: vi.fn().mockResolvedValue(cachedResponse),
|
|
218
|
+
};
|
|
219
|
+
const customStorage = {
|
|
220
|
+
open: vi.fn().mockResolvedValue(customCache),
|
|
221
|
+
delete: vi.fn(),
|
|
222
|
+
keys: vi.fn().mockResolvedValue([]),
|
|
223
|
+
};
|
|
224
|
+
// Set global cache storage
|
|
225
|
+
globalThis.__rsc_cacheStorage = customStorage;
|
|
226
|
+
const url = new URL("https://example.com/test");
|
|
227
|
+
const result = await getCachedNavigationResponse(url);
|
|
228
|
+
expect(result).toBe(cachedResponse);
|
|
229
|
+
expect(customStorage.open).toHaveBeenCalled();
|
|
230
|
+
// Cleanup
|
|
231
|
+
delete globalThis.__rsc_cacheStorage;
|
|
232
|
+
});
|
|
233
|
+
it("should add __rsc query parameter", async () => {
|
|
234
|
+
const env = {
|
|
235
|
+
isSecureContext: true,
|
|
236
|
+
origin: "https://example.com",
|
|
237
|
+
caches: mockCacheStorage,
|
|
238
|
+
fetch: mockFetch,
|
|
239
|
+
};
|
|
240
|
+
const url = new URL("https://example.com/test");
|
|
241
|
+
await getCachedNavigationResponse(url, env);
|
|
242
|
+
const matchCall = mockCache.match.mock.calls[0];
|
|
243
|
+
const request = matchCall[0];
|
|
244
|
+
const requestUrl = new URL(request.url);
|
|
245
|
+
expect(requestUrl.searchParams.has("__rsc")).toBe(true);
|
|
246
|
+
});
|
|
247
|
+
it("should skip cross-origin URLs", async () => {
|
|
248
|
+
const env = {
|
|
249
|
+
isSecureContext: true,
|
|
250
|
+
origin: "https://example.com",
|
|
251
|
+
caches: mockCacheStorage,
|
|
252
|
+
fetch: mockFetch,
|
|
253
|
+
};
|
|
254
|
+
const url = new URL("https://other-origin.com/test");
|
|
255
|
+
const result = await getCachedNavigationResponse(url, env);
|
|
256
|
+
expect(result).toBeUndefined();
|
|
257
|
+
expect(mockCache.match).not.toHaveBeenCalled();
|
|
258
|
+
});
|
|
259
|
+
it("should handle errors gracefully", async () => {
|
|
260
|
+
mockCache.match.mockRejectedValue(new Error("Cache error"));
|
|
261
|
+
const env = {
|
|
262
|
+
isSecureContext: true,
|
|
263
|
+
origin: "https://example.com",
|
|
264
|
+
caches: mockCacheStorage,
|
|
265
|
+
fetch: mockFetch,
|
|
266
|
+
};
|
|
267
|
+
const url = new URL("https://example.com/test");
|
|
268
|
+
// Should not throw
|
|
269
|
+
const result = await getCachedNavigationResponse(url, env);
|
|
270
|
+
expect(result).toBeUndefined();
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
describe("evictOldGenerationCaches", () => {
|
|
274
|
+
it("should delete old generation caches", async () => {
|
|
275
|
+
const env = {
|
|
276
|
+
isSecureContext: true,
|
|
277
|
+
origin: "https://example.com",
|
|
278
|
+
caches: mockCacheStorage,
|
|
279
|
+
fetch: mockFetch,
|
|
280
|
+
};
|
|
281
|
+
// Get the actual tabId that will be used
|
|
282
|
+
const url = new URL("https://example.com/test");
|
|
283
|
+
await preloadNavigationUrl(url, env);
|
|
284
|
+
const openCall = mockCacheStorage.open.mock.calls[0];
|
|
285
|
+
const cacheName = openCall[0];
|
|
286
|
+
const tabIdMatch = cacheName.match(/^rsc-prefetch:rwsdk:([^:]+):\d+$/);
|
|
287
|
+
const tabId = tabIdMatch ? tabIdMatch[1] : "test-uuid-123";
|
|
288
|
+
// Increment generation to 2 by calling onNavigationCommit twice
|
|
289
|
+
onNavigationCommit(env);
|
|
290
|
+
onNavigationCommit(env);
|
|
291
|
+
// Mock cache names matching the actual tabId
|
|
292
|
+
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",
|
|
297
|
+
];
|
|
298
|
+
mockCacheStorage.keys.mockResolvedValue(allCacheNames);
|
|
299
|
+
await evictOldGenerationCaches(env);
|
|
300
|
+
// Wait for the cleanup to execute
|
|
301
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
302
|
+
// 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");
|
|
307
|
+
});
|
|
308
|
+
it("should use custom cacheStorage when provided", async () => {
|
|
309
|
+
// Get the actual tabId that will be used
|
|
310
|
+
const env = {
|
|
311
|
+
isSecureContext: true,
|
|
312
|
+
origin: "https://example.com",
|
|
313
|
+
caches: mockCacheStorage,
|
|
314
|
+
fetch: mockFetch,
|
|
315
|
+
};
|
|
316
|
+
const url = new URL("https://example.com/test");
|
|
317
|
+
await preloadNavigationUrl(url, env);
|
|
318
|
+
const openCall = mockCacheStorage.open.mock.calls[0];
|
|
319
|
+
const cacheName = openCall[0];
|
|
320
|
+
const tabIdMatch = cacheName.match(/^rsc-prefetch:rwsdk:([^:]+):\d+$/);
|
|
321
|
+
const tabId = tabIdMatch ? tabIdMatch[1] : "test-uuid-123";
|
|
322
|
+
const customStorage = {
|
|
323
|
+
open: vi.fn(),
|
|
324
|
+
delete: vi.fn().mockResolvedValue(true),
|
|
325
|
+
keys: vi
|
|
326
|
+
.fn()
|
|
327
|
+
.mockResolvedValue([
|
|
328
|
+
`rsc-prefetch:rwsdk:${tabId}:0`,
|
|
329
|
+
`rsc-prefetch:rwsdk:${tabId}:1`,
|
|
330
|
+
]),
|
|
331
|
+
};
|
|
332
|
+
// Increment generation so there are old caches to delete
|
|
333
|
+
onNavigationCommit();
|
|
334
|
+
await evictOldGenerationCaches(undefined, customStorage);
|
|
335
|
+
// Wait for the cleanup to execute
|
|
336
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
337
|
+
expect(customStorage.keys).toHaveBeenCalled();
|
|
338
|
+
expect(customStorage.delete).toHaveBeenCalled();
|
|
339
|
+
});
|
|
340
|
+
it("should handle errors gracefully", async () => {
|
|
341
|
+
mockCacheStorage.keys.mockRejectedValue(new Error("Cache error"));
|
|
342
|
+
const env = {
|
|
343
|
+
isSecureContext: true,
|
|
344
|
+
origin: "https://example.com",
|
|
345
|
+
caches: mockCacheStorage,
|
|
346
|
+
fetch: mockFetch,
|
|
347
|
+
};
|
|
348
|
+
// Should not throw
|
|
349
|
+
await expect(evictOldGenerationCaches(env)).resolves.toBeUndefined();
|
|
350
|
+
// Wait for requestIdleCallback to execute
|
|
351
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
describe("onNavigationCommit", () => {
|
|
355
|
+
it("should increment generation and evict old caches", async () => {
|
|
356
|
+
const env = {
|
|
357
|
+
isSecureContext: true,
|
|
358
|
+
origin: "https://example.com",
|
|
359
|
+
caches: mockCacheStorage,
|
|
360
|
+
fetch: mockFetch,
|
|
361
|
+
};
|
|
362
|
+
mockCacheStorage.keys.mockResolvedValue([]);
|
|
363
|
+
onNavigationCommit(env);
|
|
364
|
+
// Wait for eviction to complete
|
|
365
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
366
|
+
expect(mockRequestIdleCallback).toHaveBeenCalled();
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
describe("preloadFromLinkTags", () => {
|
|
370
|
+
it("should preload URLs from prefetch link tags", async () => {
|
|
371
|
+
const env = {
|
|
372
|
+
isSecureContext: true,
|
|
373
|
+
origin: "https://example.com",
|
|
374
|
+
caches: mockCacheStorage,
|
|
375
|
+
fetch: mockFetch,
|
|
376
|
+
};
|
|
377
|
+
// Create a mock document with prefetch links
|
|
378
|
+
const mockDoc = {
|
|
379
|
+
querySelectorAll: vi.fn().mockReturnValue([
|
|
380
|
+
{
|
|
381
|
+
getAttribute: () => "/page1",
|
|
382
|
+
},
|
|
383
|
+
{
|
|
384
|
+
getAttribute: () => "/page2",
|
|
385
|
+
},
|
|
386
|
+
]),
|
|
387
|
+
};
|
|
388
|
+
await preloadFromLinkTags(mockDoc, env);
|
|
389
|
+
// Should have called preloadNavigationUrl for each link
|
|
390
|
+
expect(mockFetch).toHaveBeenCalled();
|
|
391
|
+
});
|
|
392
|
+
it("should skip non-route-like hrefs (not starting with /)", async () => {
|
|
393
|
+
const env = {
|
|
394
|
+
isSecureContext: true,
|
|
395
|
+
origin: "https://example.com",
|
|
396
|
+
caches: mockCacheStorage,
|
|
397
|
+
fetch: mockFetch,
|
|
398
|
+
};
|
|
399
|
+
const mockDoc = {
|
|
400
|
+
querySelectorAll: vi.fn().mockReturnValue([
|
|
401
|
+
{
|
|
402
|
+
getAttribute: () => "https://example.com/page1",
|
|
403
|
+
},
|
|
404
|
+
{
|
|
405
|
+
getAttribute: () => "/page2",
|
|
406
|
+
},
|
|
407
|
+
]),
|
|
408
|
+
};
|
|
409
|
+
await preloadFromLinkTags(mockDoc, env);
|
|
410
|
+
// Should only preload /page2, not the absolute URL
|
|
411
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
412
|
+
});
|
|
413
|
+
it("should use custom cacheStorage when provided", async () => {
|
|
414
|
+
const customCache = {
|
|
415
|
+
put: vi.fn().mockResolvedValue(undefined),
|
|
416
|
+
match: vi.fn(),
|
|
417
|
+
};
|
|
418
|
+
const customStorage = {
|
|
419
|
+
open: vi.fn().mockResolvedValue(customCache),
|
|
420
|
+
delete: vi.fn(),
|
|
421
|
+
keys: vi.fn().mockResolvedValue([]),
|
|
422
|
+
};
|
|
423
|
+
const env = {
|
|
424
|
+
isSecureContext: true,
|
|
425
|
+
origin: "https://example.com",
|
|
426
|
+
caches: mockCacheStorage,
|
|
427
|
+
fetch: mockFetch,
|
|
428
|
+
};
|
|
429
|
+
const mockDoc = {
|
|
430
|
+
querySelectorAll: vi.fn().mockReturnValue([
|
|
431
|
+
{
|
|
432
|
+
getAttribute: () => "/page1",
|
|
433
|
+
},
|
|
434
|
+
]),
|
|
435
|
+
};
|
|
436
|
+
await preloadFromLinkTags(mockDoc, env, customStorage);
|
|
437
|
+
expect(customStorage.open).toHaveBeenCalled();
|
|
438
|
+
expect(customCache.put).toHaveBeenCalled();
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
describe("cache name generation", () => {
|
|
442
|
+
it("should generate cache names with correct format", async () => {
|
|
443
|
+
const env = {
|
|
444
|
+
isSecureContext: true,
|
|
445
|
+
origin: "https://example.com",
|
|
446
|
+
caches: mockCacheStorage,
|
|
447
|
+
fetch: mockFetch,
|
|
448
|
+
};
|
|
449
|
+
const url = new URL("https://example.com/test");
|
|
450
|
+
await preloadNavigationUrl(url, env);
|
|
451
|
+
const openCall = mockCacheStorage.open.mock.calls[0];
|
|
452
|
+
const cacheName = openCall[0];
|
|
453
|
+
expect(cacheName).toMatch(/^rsc-prefetch:rwsdk:[^:]+:\d+$/);
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
});
|
|
@@ -1,13 +1,35 @@
|
|
|
1
1
|
import type { CallServerCallback } from "react-server-dom-webpack/client.browser";
|
|
2
2
|
export type { HydrationOptions } from "react-dom/client";
|
|
3
3
|
export type { CallServerCallback } from "react-server-dom-webpack/client.browser";
|
|
4
|
-
export type
|
|
4
|
+
export type RscActionResponse<Result> = {
|
|
5
5
|
node: React.ReactNode;
|
|
6
6
|
actionResult: Result;
|
|
7
7
|
};
|
|
8
|
+
export type ActionResponseData = {
|
|
9
|
+
status: number;
|
|
10
|
+
headers: {
|
|
11
|
+
location: string | null;
|
|
12
|
+
};
|
|
13
|
+
};
|
|
14
|
+
export type ActionResponseMeta = {
|
|
15
|
+
__rw_action_response: ActionResponseData;
|
|
16
|
+
};
|
|
17
|
+
export declare function isActionResponse(value: unknown): value is ActionResponseMeta;
|
|
8
18
|
export type TransportContext = {
|
|
9
|
-
setRscPayload: <Result>(v: Promise<
|
|
19
|
+
setRscPayload: <Result>(v: Promise<RscActionResponse<Result>>) => void;
|
|
10
20
|
handleResponse?: (response: Response) => boolean;
|
|
21
|
+
/**
|
|
22
|
+
* Optional callback invoked after a new RSC payload has been committed on the client.
|
|
23
|
+
* This is useful for features like client-side navigation that want to run logic
|
|
24
|
+
* after hydration/updates, e.g. warming navigation caches.
|
|
25
|
+
*/
|
|
26
|
+
onHydrationUpdate?: () => void;
|
|
27
|
+
/**
|
|
28
|
+
* Optional callback invoked when an action returns a Response.
|
|
29
|
+
* Return true to signal that the response has been handled and
|
|
30
|
+
* default behaviour (e.g. redirects) should be skipped.
|
|
31
|
+
*/
|
|
32
|
+
onActionResponse?: (actionResponse: ActionResponseData) => boolean | void;
|
|
11
33
|
};
|
|
12
34
|
export type Transport = (context: TransportContext) => CallServerCallback;
|
|
13
|
-
export type CreateCallServer = (context: TransportContext) => <Result>(id: null | string, args: null | unknown[]) => Promise<Result>;
|
|
35
|
+
export type CreateCallServer = (context: TransportContext) => <Result>(id: null | string, args: null | unknown[], source?: "action" | "navigation") => Promise<Result>;
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// prettier-ignore
|
|
4
4
|
import { initClient } from "../../client/client";
|
|
5
5
|
// prettier-ignore
|
|
6
|
+
import { isActionResponse, } from "../../client/types";
|
|
7
|
+
// prettier-ignore
|
|
6
8
|
import { createFromReadableStream } from "react-server-dom-webpack/client.browser";
|
|
7
9
|
// prettier-ignore
|
|
8
10
|
import { MESSAGE_TYPE } from "./shared";
|
|
@@ -103,7 +105,21 @@ export const realtimeTransport = ({ key = DEFAULT_KEY, handleResponse, }) => (tr
|
|
|
103
105
|
callServer: realtimeCallServer,
|
|
104
106
|
});
|
|
105
107
|
transportContext.setRscPayload(rscPayload);
|
|
106
|
-
|
|
108
|
+
const rawActionResult = (await rscPayload).actionResult;
|
|
109
|
+
if (isActionResponse(rawActionResult)) {
|
|
110
|
+
const actionResponse = rawActionResult.__rw_action_response;
|
|
111
|
+
const handledByHook = transportContext.onActionResponse?.(actionResponse) === true;
|
|
112
|
+
if (!handledByHook) {
|
|
113
|
+
const location = actionResponse.headers["location"];
|
|
114
|
+
const isRedirect = actionResponse.status >= 300 && actionResponse.status < 400;
|
|
115
|
+
if (location && isRedirect) {
|
|
116
|
+
window.location.href = location;
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return rawActionResult;
|
|
121
|
+
}
|
|
122
|
+
return rawActionResult;
|
|
107
123
|
}
|
|
108
124
|
catch (err) {
|
|
109
125
|
throw err;
|
|
@@ -34,7 +34,14 @@ function rechunkStream(stream, maxChunkSize = 28) {
|
|
|
34
34
|
}
|
|
35
35
|
export const normalizeActionResult = (actionResult) => {
|
|
36
36
|
if (actionResult instanceof Response) {
|
|
37
|
-
return
|
|
37
|
+
return {
|
|
38
|
+
__rw_action_response: {
|
|
39
|
+
status: actionResult.status,
|
|
40
|
+
headers: {
|
|
41
|
+
location: actionResult.headers.get("location"),
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
};
|
|
38
45
|
}
|
|
39
46
|
if (actionResult instanceof ReadableStream) {
|
|
40
47
|
return rechunkStream(actionResult);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rwsdk",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.42",
|
|
4
4
|
"description": "Build fast, server-driven webapps on Cloudflare with SSR, RSC, and realtime",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
],
|
|
16
16
|
"scripts": {
|
|
17
17
|
"build": "tsc --build --clean && tsc",
|
|
18
|
+
"build:watch": "npm run build -- --watch",
|
|
18
19
|
"release": "./scripts/release.sh",
|
|
19
20
|
"test": "vitest --run",
|
|
20
21
|
"debug:sync": "tsx ./src/scripts/debug-sync.mts",
|