From 696b96ac18b29b14cfae84f2ae9d3922de1035fa Mon Sep 17 00:00:00 2001 From: agent_coder Date: Sat, 11 Jul 2026 18:47:08 +0300 Subject: [PATCH] =?UTF-8?q?fix(client):=20=D0=B0=D0=B2=D1=82=D0=BE-=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=BA?= =?UTF-8?q?=D0=B0=20=D1=87=D0=B0=D0=BD=D0=BA=D0=BE=D0=B2=20=D0=B2=20=D0=BE?= =?UTF-8?q?=D0=BA=D0=BD=D0=B5=205=20=D0=BC=D0=B8=D0=BD=D1=83=D1=82=20?= =?UTF-8?q?=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20one-shot=20=D1=84=D0=BB?= =?UTF-8?q?=D0=B0=D0=B3=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit При 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) --- .../chunk-load-error-boundary.test.ts | 30 +++++++++++++++- .../components/chunk-load-error-boundary.tsx | 34 +++++++++++++++---- 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/apps/client/src/components/chunk-load-error-boundary.test.ts b/apps/client/src/components/chunk-load-error-boundary.test.ts index 78ecce6b..a01e4c1b 100644 --- a/apps/client/src/components/chunk-load-error-boundary.test.ts +++ b/apps/client/src/components/chunk-load-error-boundary.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { isChunkLoadError } from "./chunk-load-error-boundary"; +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 @@ -35,3 +35,31 @@ describe("isChunkLoadError", () => { 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); + }); +}); diff --git a/apps/client/src/components/chunk-load-error-boundary.tsx b/apps/client/src/components/chunk-load-error-boundary.tsx index c3e39a00..e28dfe26 100644 --- a/apps/client/src/components/chunk-load-error-boundary.tsx +++ b/apps/client/src/components/chunk-load-error-boundary.tsx @@ -2,7 +2,25 @@ import { ReactNode } from "react"; import { ErrorBoundary } from "react-error-boundary"; import { Button, Center, Stack, Text } from "@mantine/core"; -const RELOAD_FLAG = "chunk-reload-attempted"; +// sessionStorage key holding the epoch-ms timestamp of the last automatic reload. +const RELOAD_AT_KEY = "chunk-reload-at"; +// Allow at most one automatic reload per this window. A stale-deploy 404 is cured +// by a single reload, so anything inside the window is treated as a reload loop +// (permanently-broken chunk) and falls through to the manual UI. A window (rather +// than a one-shot flag) lets a SECOND deploy in the same tab's lifetime recover too. +const RELOAD_WINDOW_MS = 5 * 60 * 1000; + +// Pure window decision, unit-tested in isolation: auto-reload only if we have never +// auto-reloaded (lastReloadAt null/NaN) or the last one was strictly older than the +// window. Anything inside the window is suppressed to break an infinite reload loop. +export function shouldAutoReload( + now: number, + lastReloadAt: number | null, + windowMs: number, +): boolean { + if (lastReloadAt === null || Number.isNaN(lastReloadAt)) return true; + return now - lastReloadAt > windowMs; +} // Heuristic detection of a failed dynamic import. Since the code-splitting work, // every route (plus Aside / AiChatWindow) is React.lazy: when a new deploy @@ -24,12 +42,16 @@ export function isChunkLoadError(error: unknown): boolean { function handleError(error: unknown) { if (!isChunkLoadError(error)) return; // A stale-chunk 404 is cured by a full reload that re-fetches index.html and - // the new chunk manifest. Auto-reload once, guarding against a reload loop - // (e.g. a genuinely missing chunk) with a one-shot sessionStorage flag. If the - // flag is already set we fall through to the manual recovery UI below. + // the new chunk manifest. Auto-reload at most once per RELOAD_WINDOW_MS: this + // recovers across multiple deploys in a single tab's lifetime, yet a + // permanently-broken lazy chunk (which would loop) is stopped after the first + // reload and falls through to the manual recovery UI below. try { - if (sessionStorage.getItem(RELOAD_FLAG)) return; - sessionStorage.setItem(RELOAD_FLAG, "1"); + const raw = sessionStorage.getItem(RELOAD_AT_KEY); + const lastReloadAt = raw === null ? null : Number.parseInt(raw, 10); + const now = Date.now(); + if (!shouldAutoReload(now, lastReloadAt, RELOAD_WINDOW_MS)) return; + sessionStorage.setItem(RELOAD_AT_KEY, String(now)); } catch { // sessionStorage unavailable (private mode / disabled): skip the automatic // reload rather than risk an unguarded loop; the fallback UI still recovers.