68409a8ae9
Правки по ревью #499. Владелец выбрал вариант C по вопросу потери несохранённого ввода. F1 (reload по навигации): убрал ветку visibilitychange→reload И немедленный reload скрытой-при-получении вкладки — visibility больше не влияет на авто-reload вообще. На реальном mismatch: баннер (кнопка «Обновить» — немедленный reload под тем же one-shot гардом) + модульный флаг pendingNavReload. Хук useVersionReloadOnNavigation (useLocation + useEffect по location.key, пропуск первого рендера через useRef; react-router v7 BrowserRouter компонентный, объекта роутера нет — подписка изнутри дерева) на СЛЕДУЮЩЕЙ навигации зовёт consumeNavigationReload → performAutoReload (mark-перед-reload, при spent/непишущемся storage — только баннер). Скрытая вкладка идёт тем же путём (C единообразно). chunk-load-error-boundary reload не тронут — остаётся бэкапом для не-навигирующей вкладки. Так reload в безопасной точке (юзер и так уходит со страницы), а не когда свернул с недописанным комментарием. F2 (лог-след): общий recordReloadBreadcrumb/takeReloadBreadcrumb (sessionStorage, переживает reload). performAutoReload пишет breadcrumb {path:proactive,server, client} + console.warn перед reload; chunk-boundary пишет {path:chunk-boundary}; surfacePreviousReloadBreadcrumb на маунте UserProvider логирует прошлый след. F3 (CHANGELOG + AGENTS.md): user-facing запись про version-coherence + строка про артефакт client/dist/version.json. Тесты: vitest version-coherence+reload-guard+guarded-reload 23 (переписан на MemoryRouter+useNavigate: reload РОВНО раз на первой навигации после mismatch, НЕ на 2-й навигации, НЕ на 2-м mismatch one-shot, НЕ от ухода в фон; скрытая вкладка — только на навигации; Update — сразу; banner-only/write-fail — никогда). Шире: features/user 34. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
196 lines
5.7 KiB
TypeScript
196 lines
5.7 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
import { render, act, cleanup } from "@testing-library/react";
|
|
import { MemoryRouter, useNavigate } from "react-router-dom";
|
|
|
|
// Mocks for the dirty shell's side-effecting collaborators.
|
|
vi.mock("@mantine/notifications", () => ({
|
|
notifications: { show: vi.fn() },
|
|
}));
|
|
vi.mock("@/i18n.ts", () => ({ default: { t: (k: string) => k } }));
|
|
vi.mock("@/lib/reload-guard", () => ({
|
|
hasAutoReloaded: vi.fn(() => false),
|
|
markAutoReloaded: vi.fn(() => true),
|
|
recordReloadBreadcrumb: vi.fn(),
|
|
takeReloadBreadcrumb: vi.fn(() => null),
|
|
}));
|
|
|
|
import { notifications } from "@mantine/notifications";
|
|
import { hasAutoReloaded, markAutoReloaded } from "@/lib/reload-guard";
|
|
import {
|
|
triggerGuardedReload,
|
|
useVersionReloadOnNavigation,
|
|
__resetGuardedReloadForTests,
|
|
} from "./guarded-reload";
|
|
|
|
const show = notifications.show as unknown as ReturnType<typeof vi.fn>;
|
|
const mockHasAutoReloaded = hasAutoReloaded as unknown as ReturnType<
|
|
typeof vi.fn
|
|
>;
|
|
const mockMarkAutoReloaded = markAutoReloaded as unknown as ReturnType<
|
|
typeof vi.fn
|
|
>;
|
|
|
|
let reload: ReturnType<typeof vi.fn>;
|
|
let visibility: DocumentVisibilityState;
|
|
|
|
// Test harness mounted inside a router: it installs the navigation hook and
|
|
// exposes `navigate` so a test can drive an in-app router navigation.
|
|
let doNavigate: (to: string) => void;
|
|
function Harness() {
|
|
useVersionReloadOnNavigation();
|
|
const navigate = useNavigate();
|
|
doNavigate = navigate;
|
|
return null;
|
|
}
|
|
|
|
function mountHarness() {
|
|
render(
|
|
<MemoryRouter initialEntries={["/start"]}>
|
|
<Harness />
|
|
</MemoryRouter>,
|
|
);
|
|
}
|
|
|
|
function navigateTo(path: string) {
|
|
act(() => {
|
|
doNavigate(path);
|
|
});
|
|
}
|
|
|
|
beforeEach(() => {
|
|
__resetGuardedReloadForTests();
|
|
vi.clearAllMocks();
|
|
mockHasAutoReloaded.mockReturnValue(false);
|
|
mockMarkAutoReloaded.mockReturnValue(true);
|
|
|
|
vi.stubGlobal("APP_VERSION", "test-A");
|
|
|
|
reload = vi.fn();
|
|
Object.defineProperty(window, "location", {
|
|
configurable: true,
|
|
value: { reload },
|
|
});
|
|
|
|
visibility = "visible";
|
|
Object.defineProperty(document, "visibilityState", {
|
|
configurable: true,
|
|
get: () => visibility,
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup();
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
describe("triggerGuardedReload (variant C)", () => {
|
|
it("noop when versions match: no banner, no reload", () => {
|
|
triggerGuardedReload("test-A");
|
|
expect(show).not.toHaveBeenCalled();
|
|
expect(reload).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("noop when the server version is empty (fail-safe)", () => {
|
|
triggerGuardedReload("");
|
|
triggerGuardedReload(undefined);
|
|
expect(show).not.toHaveBeenCalled();
|
|
expect(reload).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("real mismatch shows the banner but does NOT reload immediately", () => {
|
|
mountHarness();
|
|
triggerGuardedReload("test-B");
|
|
expect(show).toHaveBeenCalledTimes(1);
|
|
expect(show.mock.calls[0][0]).toMatchObject({
|
|
id: "app-version-reload",
|
|
autoClose: false,
|
|
withCloseButton: true,
|
|
});
|
|
expect(reload).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("reloads EXACTLY ONCE on the first in-app navigation after a mismatch", () => {
|
|
mountHarness();
|
|
triggerGuardedReload("test-B");
|
|
expect(reload).not.toHaveBeenCalled();
|
|
|
|
navigateTo("/next");
|
|
expect(reload).toHaveBeenCalledTimes(1);
|
|
|
|
// A second navigation must NOT reload again (one-shot was consumed).
|
|
navigateTo("/again");
|
|
expect(reload).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("does NOT reload on a 2nd mismatch after the first was armed/consumed (one-shot)", () => {
|
|
mountHarness();
|
|
triggerGuardedReload("test-B");
|
|
navigateTo("/next");
|
|
expect(reload).toHaveBeenCalledTimes(1);
|
|
|
|
// Another app-version mismatch arrives (reconnect): must not re-arm.
|
|
triggerGuardedReload("test-C");
|
|
navigateTo("/again");
|
|
expect(reload).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("does NOT reload merely from the tab going to the background", () => {
|
|
mountHarness();
|
|
triggerGuardedReload("test-B");
|
|
expect(reload).not.toHaveBeenCalled();
|
|
|
|
visibility = "hidden";
|
|
act(() => {
|
|
document.dispatchEvent(new Event("visibilitychange"));
|
|
});
|
|
expect(reload).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("a hidden-at-receipt tab is also NOT reloaded immediately (variant C uniform); reloads on next navigation", () => {
|
|
visibility = "hidden";
|
|
mountHarness();
|
|
triggerGuardedReload("test-B");
|
|
expect(reload).not.toHaveBeenCalled();
|
|
expect(show).toHaveBeenCalledTimes(1);
|
|
|
|
navigateTo("/next");
|
|
expect(reload).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("the banner's Update button reloads immediately", () => {
|
|
triggerGuardedReload("test-B");
|
|
const message = show.mock.calls[0][0].message as {
|
|
props: { onClick: () => void };
|
|
};
|
|
message.props.onClick();
|
|
expect(reload).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("banner-only (auto-reload already spent): banner, never auto-reload on navigation", () => {
|
|
mockHasAutoReloaded.mockReturnValue(true);
|
|
mountHarness();
|
|
triggerGuardedReload("test-B");
|
|
expect(show).toHaveBeenCalledTimes(1);
|
|
|
|
navigateTo("/next");
|
|
expect(reload).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does NOT reload when the flag write fails; falls back to the banner", () => {
|
|
mockMarkAutoReloaded.mockReturnValue(false);
|
|
mountHarness();
|
|
triggerGuardedReload("test-B");
|
|
navigateTo("/next");
|
|
expect(reload).not.toHaveBeenCalled();
|
|
// performAutoReload falls back to showing the banner (initial + fallback).
|
|
expect(show).toHaveBeenCalled();
|
|
});
|
|
|
|
it("is idempotent within a tab-load: repeated emits do not stack banners", () => {
|
|
triggerGuardedReload("test-B");
|
|
triggerGuardedReload("test-B");
|
|
triggerGuardedReload("test-C");
|
|
expect(show).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|