696b96ac18
При ChunkLoadError граница перезагружала страницу один раз, гейтируя булевым флагом в sessionStorage, который никогда не сбрасывался. Из-за этого ВТОРОЙ деплой за время жизни вкладки не давал авто-восстановления: пользователь застревал на битом чанке без перезагрузки. Заменяю one-shot флаг на счётчик по временному окну: не более одной авто-перезагрузки за 5 минут. В sessionStorage храню метку времени последней перезагрузки; на ChunkLoadError перезагружаемся только если прошлая была раньше окна (или её не было), иначе проваливаемся в ручной UI без перезагрузки. Это восстанавливает работу через несколько деплоев, но не даёт бесконечного цикла при навсегда битом lazy-чанке (сброс флага после успешного маунта отвергнут: оболочка монтируется, чанк 404 — и цикл). Решение об окне вынесено в чистый хелпер shouldAutoReload(now, lastReloadAt, windowMs) и покрыто юнит-тестами: никогда-не-грузили → можно; 6 минут назад → можно; 1 минуту назад → нельзя. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
66 lines
2.5 KiB
TypeScript
66 lines
2.5 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { isChunkLoadError, shouldAutoReload } from "./chunk-load-error-boundary";
|
|
|
|
// The detector decides whether a caught render error is a stale-deploy chunk-404
|
|
// (→ auto-reload to fetch the new manifest) vs a genuine app error (→ generic
|
|
// recovery UI, no reload). A false negative on a real chunk failure re-blanks the
|
|
// app; a false positive would auto-reload on an ordinary error. Pin both sides.
|
|
describe("isChunkLoadError", () => {
|
|
it("detects the ChunkLoadError name", () => {
|
|
expect(isChunkLoadError({ name: "ChunkLoadError", message: "x" })).toBe(true);
|
|
});
|
|
|
|
it.each([
|
|
"Failed to fetch dynamically imported module: https://x/assets/index-abc.js",
|
|
"error loading dynamically imported module",
|
|
"Importing a module script failed.",
|
|
])("detects the dynamic-import failure message %#", (message) => {
|
|
expect(isChunkLoadError({ name: "TypeError", message })).toBe(true);
|
|
});
|
|
|
|
it("is case-insensitive on the message", () => {
|
|
expect(
|
|
isChunkLoadError({ message: "FAILED TO FETCH DYNAMICALLY IMPORTED MODULE" }),
|
|
).toBe(true);
|
|
});
|
|
|
|
it.each([
|
|
null,
|
|
undefined,
|
|
{},
|
|
{ name: "TypeError", message: "Cannot read properties of undefined" },
|
|
{ message: "Network request failed" },
|
|
new Error("some ordinary render error"),
|
|
])("returns false for a non-chunk error %#", (err) => {
|
|
expect(isChunkLoadError(err)).toBe(false);
|
|
});
|
|
});
|
|
|
|
// The window gate replaces the old one-shot flag: it must permit recovery across
|
|
// several deploys in one tab (each > window apart) while still stopping an infinite
|
|
// reload loop when a lazy chunk is permanently broken (a second failure < window).
|
|
describe("shouldAutoReload", () => {
|
|
const WINDOW = 5 * 60 * 1000;
|
|
const NOW = 1_000_000_000_000;
|
|
|
|
it("allows a reload when we have never auto-reloaded", () => {
|
|
expect(shouldAutoReload(NOW, null, WINDOW)).toBe(true);
|
|
});
|
|
|
|
it("allows a reload when the last one was 6 minutes ago (outside the window)", () => {
|
|
expect(shouldAutoReload(NOW, NOW - 6 * 60 * 1000, WINDOW)).toBe(true);
|
|
});
|
|
|
|
it("blocks a reload when the last one was 1 minute ago (inside the window)", () => {
|
|
expect(shouldAutoReload(NOW, NOW - 1 * 60 * 1000, WINDOW)).toBe(false);
|
|
});
|
|
|
|
it("blocks a reload exactly at the window boundary (not strictly older)", () => {
|
|
expect(shouldAutoReload(NOW, NOW - WINDOW, WINDOW)).toBe(false);
|
|
});
|
|
|
|
it("allows a reload when the stored timestamp is unparseable (NaN)", () => {
|
|
expect(shouldAutoReload(NOW, NaN, WINDOW)).toBe(true);
|
|
});
|
|
});
|