diff --git a/apps/web/src/lib/lazy-with-retry.ts b/apps/web/src/lib/lazy-with-retry.ts new file mode 100644 index 00000000..96d95d92 --- /dev/null +++ b/apps/web/src/lib/lazy-with-retry.ts @@ -0,0 +1,34 @@ +import { type ComponentType, lazy } from "react"; + +function isChunkError(error: unknown): boolean { + if (!(error instanceof Error)) return false; + const msg = error.message.toLowerCase(); + return ( + msg.includes("dynamically imported module") || + msg.includes("loading chunk") || + msg.includes("loading css chunk") || + msg.includes("failed to fetch") + ); +} + +export async function retryDynamicImport( + importFn: () => Promise, + retries = 3, + delay = 1000, +): Promise { + for (let attempt = 1; attempt <= retries; attempt++) { + try { + return await importFn(); + } catch (error) { + if (!isChunkError(error) || attempt === retries) throw error; + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + throw new Error("unreachable"); +} + +export function lazyWithRetry>( + importFn: () => Promise<{ default: T }>, +): React.LazyExoticComponent { + return lazy(() => retryDynamicImport(importFn)); +} diff --git a/tests/unit/web/lazy-with-retry.test.ts b/tests/unit/web/lazy-with-retry.test.ts new file mode 100644 index 00000000..86b4af30 --- /dev/null +++ b/tests/unit/web/lazy-with-retry.test.ts @@ -0,0 +1,42 @@ +// @vitest-environment jsdom +import { describe, expect, it, vi } from "vitest"; + +import { retryDynamicImport } from "@/lib/lazy-with-retry"; + +describe("retryDynamicImport", () => { + it("resolves on first success", async () => { + const mod = { default: () => null }; + const importFn = vi.fn().mockResolvedValue(mod); + const result = await retryDynamicImport(importFn); + expect(result).toBe(mod); + expect(importFn).toHaveBeenCalledTimes(1); + }); + + it("retries on failure and resolves on eventual success", async () => { + const mod = { default: () => null }; + const importFn = vi + .fn() + .mockRejectedValueOnce(new TypeError("Failed to fetch dynamically imported module")) + .mockRejectedValueOnce(new TypeError("Failed to fetch dynamically imported module")) + .mockResolvedValue(mod); + const result = await retryDynamicImport(importFn, 3, 0); + expect(result).toBe(mod); + expect(importFn).toHaveBeenCalledTimes(3); + }); + + it("rejects after all retries exhausted", async () => { + const err = new TypeError("Failed to fetch dynamically imported module"); + const importFn = vi.fn().mockRejectedValue(err); + await expect(retryDynamicImport(importFn, 3, 0)).rejects.toThrow( + "Failed to fetch dynamically imported module", + ); + expect(importFn).toHaveBeenCalledTimes(3); + }); + + it("only retries chunk-related errors, not other errors", async () => { + const err = new Error("Some other error"); + const importFn = vi.fn().mockRejectedValue(err); + await expect(retryDynamicImport(importFn, 3, 0)).rejects.toThrow("Some other error"); + expect(importFn).toHaveBeenCalledTimes(1); + }); +});