UNPKG

rwsdk

Version:

Build fast, server-driven webapps on Cloudflare with SSR, RSC, and realtime

457 lines (456 loc) 19.4 kB
import { beforeEach, describe, expect, it, vi } from "vitest"; import { createDefaultNavigationCacheStorage, evictOldGenerationCaches, getCachedNavigationResponse, onNavigationCommit, preloadFromLinkTags, preloadNavigationUrl, } from "./navigationCache"; describe("navigationCache", () => { let mockCacheStorage; let mockCache; let mockFetch; let mockSessionStorage; // Local type for requestIdleCallback to avoid depending on global declarations let mockRequestIdleCallback; beforeEach(() => { // Reset module state between tests vi.resetModules(); // Mock Cache mockCache = { put: vi.fn().mockResolvedValue(undefined), match: vi.fn().mockResolvedValue(undefined), delete: vi.fn().mockResolvedValue(true), add: vi.fn(), addAll: vi.fn(), keys: vi.fn(), matchAll: vi.fn(), }; // Mock CacheStorage mockCacheStorage = { open: vi.fn().mockResolvedValue(mockCache), delete: vi.fn().mockResolvedValue(true), keys: vi.fn().mockResolvedValue([]), has: vi.fn(), match: vi.fn(), }; // Mock fetch mockFetch = vi.fn().mockResolvedValue(new Response("test response", { status: 200, headers: { "content-type": "text/html" }, })); // Mock sessionStorage mockSessionStorage = { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn(), clear: vi.fn(), key: vi.fn(), length: 0, }; // Mock requestIdleCallback to execute callback asynchronously for testing let idleCallback = null; mockRequestIdleCallback = vi.fn((callback) => { idleCallback = callback; // Execute after current I/O callbacks setImmediate(() => { if (idleCallback) { idleCallback(); idleCallback = null; } }); return 1; }); // Setup global mocks globalThis.window = { isSecureContext: true, location: { origin: "https://example.com" }, caches: mockCacheStorage, fetch: mockFetch, sessionStorage: mockSessionStorage, crypto: { randomUUID: () => "test-uuid-123", }, }; globalThis.requestIdleCallback = mockRequestIdleCallback; }); describe("createDefaultNavigationCacheStorage", () => { it("should create a cache storage wrapper", async () => { const env = { isSecureContext: true, origin: "https://example.com", caches: mockCacheStorage, fetch: mockFetch, }; const storage = createDefaultNavigationCacheStorage(env); expect(storage).toBeDefined(); const cache = await storage.open("test-cache"); expect(mockCacheStorage.open).toHaveBeenCalledWith("test-cache"); expect(cache).toBeDefined(); }); it("should return undefined if no caches available", () => { const env = { isSecureContext: true, origin: "https://example.com", caches: undefined, fetch: mockFetch, }; const storage = createDefaultNavigationCacheStorage(env); expect(storage).toBeUndefined(); }); }); describe("preloadNavigationUrl", () => { it("should cache a successful response", async () => { const env = { isSecureContext: true, origin: "https://example.com", caches: mockCacheStorage, fetch: mockFetch, }; const url = new URL("https://example.com/test"); await preloadNavigationUrl(url, env); expect(mockFetch).toHaveBeenCalled(); expect(mockCacheStorage.open).toHaveBeenCalled(); expect(mockCache.put).toHaveBeenCalled(); }); it("should not cache error responses (status >= 400)", async () => { const errorFetch = vi .fn() .mockResolvedValue(new Response("error", { status: 404 })); const env = { isSecureContext: true, origin: "https://example.com", caches: mockCacheStorage, fetch: errorFetch, }; const url = new URL("https://example.com/test"); await preloadNavigationUrl(url, env); expect(errorFetch).toHaveBeenCalled(); expect(mockCache.put).not.toHaveBeenCalled(); }); it("should skip cross-origin URLs", async () => { const env = { isSecureContext: true, origin: "https://example.com", caches: mockCacheStorage, fetch: mockFetch, }; const url = new URL("https://other-origin.com/test"); await preloadNavigationUrl(url, env); expect(mockFetch).not.toHaveBeenCalled(); expect(mockCache.put).not.toHaveBeenCalled(); }); it("should add __rsc query parameter", async () => { const env = { isSecureContext: true, origin: "https://example.com", caches: mockCacheStorage, fetch: mockFetch, }; const url = new URL("https://example.com/test"); await preloadNavigationUrl(url, env); const fetchCall = mockFetch.mock.calls[0]; const request = fetchCall[0]; const requestUrl = new URL(request.url); expect(requestUrl.searchParams.has("__rsc")).toBe(true); }); it("should use custom cacheStorage when provided", async () => { const customCache = { put: vi.fn().mockResolvedValue(undefined), match: vi.fn().mockResolvedValue(undefined), }; const customStorage = { open: vi.fn().mockResolvedValue(customCache), delete: vi.fn().mockResolvedValue(true), keys: vi.fn().mockResolvedValue([]), }; const env = { isSecureContext: true, origin: "https://example.com", caches: mockCacheStorage, fetch: mockFetch, }; const url = new URL("https://example.com/test"); await preloadNavigationUrl(url, env, customStorage); expect(customStorage.open).toHaveBeenCalled(); expect(customCache.put).toHaveBeenCalled(); expect(mockCacheStorage.open).not.toHaveBeenCalled(); }); it("should handle errors gracefully", async () => { const errorFetch = vi.fn().mockRejectedValue(new Error("Network error")); const env = { isSecureContext: true, origin: "https://example.com", caches: mockCacheStorage, fetch: errorFetch, }; const url = new URL("https://example.com/test"); // Should not throw await expect(preloadNavigationUrl(url, env)).resolves.toBeUndefined(); }); }); describe("getCachedNavigationResponse", () => { it("should return cached response if found", async () => { const cachedResponse = new Response("cached", { status: 200 }); mockCache.match.mockResolvedValue(cachedResponse); const env = { isSecureContext: true, origin: "https://example.com", caches: mockCacheStorage, fetch: mockFetch, }; const url = new URL("https://example.com/test"); const result = await getCachedNavigationResponse(url, env); expect(result).toBe(cachedResponse); expect(mockCache.match).toHaveBeenCalled(); }); it("should return undefined if not cached", async () => { mockCache.match.mockResolvedValue(undefined); const env = { isSecureContext: true, origin: "https://example.com", caches: mockCacheStorage, fetch: mockFetch, }; const url = new URL("https://example.com/test"); const result = await getCachedNavigationResponse(url, env); expect(result).toBeUndefined(); }); it("should check global cacheStorage if not provided", async () => { const cachedResponse = new Response("cached", { status: 200 }); const customCache = { put: vi.fn(), match: vi.fn().mockResolvedValue(cachedResponse), }; const customStorage = { open: vi.fn().mockResolvedValue(customCache), delete: vi.fn(), keys: vi.fn().mockResolvedValue([]), }; // Set global cache storage globalThis.__rsc_cacheStorage = customStorage; const url = new URL("https://example.com/test"); const result = await getCachedNavigationResponse(url); expect(result).toBe(cachedResponse); expect(customStorage.open).toHaveBeenCalled(); // Cleanup delete globalThis.__rsc_cacheStorage; }); it("should add __rsc query parameter", async () => { const env = { isSecureContext: true, origin: "https://example.com", caches: mockCacheStorage, fetch: mockFetch, }; const url = new URL("https://example.com/test"); await getCachedNavigationResponse(url, env); const matchCall = mockCache.match.mock.calls[0]; const request = matchCall[0]; const requestUrl = new URL(request.url); expect(requestUrl.searchParams.has("__rsc")).toBe(true); }); it("should skip cross-origin URLs", async () => { const env = { isSecureContext: true, origin: "https://example.com", caches: mockCacheStorage, fetch: mockFetch, }; const url = new URL("https://other-origin.com/test"); const result = await getCachedNavigationResponse(url, env); expect(result).toBeUndefined(); expect(mockCache.match).not.toHaveBeenCalled(); }); it("should handle errors gracefully", async () => { mockCache.match.mockRejectedValue(new Error("Cache error")); const env = { isSecureContext: true, origin: "https://example.com", caches: mockCacheStorage, fetch: mockFetch, }; const url = new URL("https://example.com/test"); // Should not throw const result = await getCachedNavigationResponse(url, env); expect(result).toBeUndefined(); }); }); describe("evictOldGenerationCaches", () => { it("should delete old generation caches", async () => { const env = { isSecureContext: true, origin: "https://example.com", caches: mockCacheStorage, fetch: mockFetch, }; // Get the actual tabId that will be used const url = new URL("https://example.com/test"); await preloadNavigationUrl(url, env); const openCall = mockCacheStorage.open.mock.calls[0]; const cacheName = openCall[0]; const tabIdMatch = cacheName.match(/^rsc-prefetch:rwsdk:([^:]+):\d+$/); const tabId = tabIdMatch ? tabIdMatch[1] : "test-uuid-123"; // Increment generation to 2 by calling onNavigationCommit twice onNavigationCommit(env); onNavigationCommit(env); // Mock cache names matching the actual tabId const allCacheNames = [ `rsc-prefetch:rwsdk:${tabId}:0`, `rsc-prefetch:rwsdk:${tabId}:1`, `rsc-prefetch:rwsdk:${tabId}:2`, "rsc-prefetch:rwsdk:other-tab:0", ]; mockCacheStorage.keys.mockResolvedValue(allCacheNames); await evictOldGenerationCaches(env); // Wait for the cleanup to execute await new Promise((resolve) => setTimeout(resolve, 10)); // Should delete generations 0 and 1, but not 2 (current) or other-tab expect(mockCacheStorage.delete).toHaveBeenCalledWith(`rsc-prefetch:rwsdk:${tabId}:0`); expect(mockCacheStorage.delete).toHaveBeenCalledWith(`rsc-prefetch:rwsdk:${tabId}:1`); expect(mockCacheStorage.delete).not.toHaveBeenCalledWith(`rsc-prefetch:rwsdk:${tabId}:2`); expect(mockCacheStorage.delete).not.toHaveBeenCalledWith("rsc-prefetch:rwsdk:other-tab:0"); }); it("should use custom cacheStorage when provided", async () => { // Get the actual tabId that will be used const env = { isSecureContext: true, origin: "https://example.com", caches: mockCacheStorage, fetch: mockFetch, }; const url = new URL("https://example.com/test"); await preloadNavigationUrl(url, env); const openCall = mockCacheStorage.open.mock.calls[0]; const cacheName = openCall[0]; const tabIdMatch = cacheName.match(/^rsc-prefetch:rwsdk:([^:]+):\d+$/); const tabId = tabIdMatch ? tabIdMatch[1] : "test-uuid-123"; const customStorage = { open: vi.fn(), delete: vi.fn().mockResolvedValue(true), keys: vi .fn() .mockResolvedValue([ `rsc-prefetch:rwsdk:${tabId}:0`, `rsc-prefetch:rwsdk:${tabId}:1`, ]), }; // Increment generation so there are old caches to delete onNavigationCommit(); await evictOldGenerationCaches(undefined, customStorage); // Wait for the cleanup to execute await new Promise((resolve) => setTimeout(resolve, 10)); expect(customStorage.keys).toHaveBeenCalled(); expect(customStorage.delete).toHaveBeenCalled(); }); it("should handle errors gracefully", async () => { mockCacheStorage.keys.mockRejectedValue(new Error("Cache error")); const env = { isSecureContext: true, origin: "https://example.com", caches: mockCacheStorage, fetch: mockFetch, }; // Should not throw await expect(evictOldGenerationCaches(env)).resolves.toBeUndefined(); // Wait for requestIdleCallback to execute await new Promise((resolve) => setTimeout(resolve, 10)); }); }); describe("onNavigationCommit", () => { it("should increment generation and evict old caches", async () => { const env = { isSecureContext: true, origin: "https://example.com", caches: mockCacheStorage, fetch: mockFetch, }; mockCacheStorage.keys.mockResolvedValue([]); onNavigationCommit(env); // Wait for eviction to complete await new Promise((resolve) => setTimeout(resolve, 10)); expect(mockRequestIdleCallback).toHaveBeenCalled(); }); }); describe("preloadFromLinkTags", () => { it("should preload URLs from prefetch link tags", async () => { const env = { isSecureContext: true, origin: "https://example.com", caches: mockCacheStorage, fetch: mockFetch, }; // Create a mock document with prefetch links const mockDoc = { querySelectorAll: vi.fn().mockReturnValue([ { getAttribute: () => "/page1", }, { getAttribute: () => "/page2", }, ]), }; await preloadFromLinkTags(mockDoc, env); // Should have called preloadNavigationUrl for each link expect(mockFetch).toHaveBeenCalled(); }); it("should skip non-route-like hrefs (not starting with /)", async () => { const env = { isSecureContext: true, origin: "https://example.com", caches: mockCacheStorage, fetch: mockFetch, }; const mockDoc = { querySelectorAll: vi.fn().mockReturnValue([ { getAttribute: () => "https://example.com/page1", }, { getAttribute: () => "/page2", }, ]), }; await preloadFromLinkTags(mockDoc, env); // Should only preload /page2, not the absolute URL expect(mockFetch).toHaveBeenCalledTimes(1); }); it("should use custom cacheStorage when provided", async () => { const customCache = { put: vi.fn().mockResolvedValue(undefined), match: vi.fn(), }; const customStorage = { open: vi.fn().mockResolvedValue(customCache), delete: vi.fn(), keys: vi.fn().mockResolvedValue([]), }; const env = { isSecureContext: true, origin: "https://example.com", caches: mockCacheStorage, fetch: mockFetch, }; const mockDoc = { querySelectorAll: vi.fn().mockReturnValue([ { getAttribute: () => "/page1", }, ]), }; await preloadFromLinkTags(mockDoc, env, customStorage); expect(customStorage.open).toHaveBeenCalled(); expect(customCache.put).toHaveBeenCalled(); }); }); describe("cache name generation", () => { it("should generate cache names with correct format", async () => { const env = { isSecureContext: true, origin: "https://example.com", caches: mockCacheStorage, fetch: mockFetch, }; const url = new URL("https://example.com/test"); await preloadNavigationUrl(url, env); const openCall = mockCacheStorage.open.mock.calls[0]; const cacheName = openCall[0]; expect(cacheName).toMatch(/^rsc-prefetch:rwsdk:[^:]+:\d+$/); }); }); });