fix(deploy): variant C — reload по in-app навигации вместо visibilitychange + лог-след + CHANGELOG (#481, ревью)

Правки по ревью #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>
This commit is contained in:
2026-07-11 03:01:07 +03:00
parent 1e4925007d
commit 83f792ca39
8 changed files with 319 additions and 52 deletions
+1
View File
@@ -463,6 +463,7 @@ Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirro
- The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, schema, `canonicalizeFootnotes`) — editor schema changes often need to be made in `editor-ext`, not just the client. Server-side markdown import/export no longer lives in `editor-ext`: it goes through the canonical converter (#345, see below). The ProseMirror↔Markdown converter and its Docmost schema mirror now live in a SINGLE package, `@docmost/prosemirror-markdown` (#293), consumed by `mcp`, `git-sync`, and `apps/server` (#345) — do NOT reintroduce a per-package copy. `editor-ext` is the upstream source of the Tiptap schema; the package's `docmost-schema.ts` mirrors it and a serializer-contract test (`packages/prosemirror-markdown/test/serializer-contract.test.ts`) guards the boundary (every schema node must have a converter case), so a drift surfaces as a failing test rather than silent divergence. For the converter's property-testing and counterexample→fixture process (P1–P4 invariants, the `PROPERTY_SEED`/`PROPERTY_NUM_RUNS` knobs, and the nightly fuzz workflow), see `packages/prosemirror-markdown/README.md`. - The editor is Tiptap; shared node/mark extensions live in `packages/editor-ext` and are imported by **both the client and the server** (collaboration, schema, `canonicalizeFootnotes`) — editor schema changes often need to be made in `editor-ext`, not just the client. Server-side markdown import/export no longer lives in `editor-ext`: it goes through the canonical converter (#345, see below). The ProseMirror↔Markdown converter and its Docmost schema mirror now live in a SINGLE package, `@docmost/prosemirror-markdown` (#293), consumed by `mcp`, `git-sync`, and `apps/server` (#345) — do NOT reintroduce a per-package copy. `editor-ext` is the upstream source of the Tiptap schema; the package's `docmost-schema.ts` mirrors it and a serializer-contract test (`packages/prosemirror-markdown/test/serializer-contract.test.ts`) guards the boundary (every schema node must have a converter case), so a drift surfaces as a failing test rather than silent divergence. For the converter's property-testing and counterexample→fixture process (P1–P4 invariants, the `PROPERTY_SEED`/`PROPERTY_NUM_RUNS` knobs, and the nightly fuzz workflow), see `packages/prosemirror-markdown/README.md`.
- API access goes through `apps/client/src/lib/api-client.ts` (axios). The `@` alias maps to `apps/client/src`. - API access goes through `apps/client/src/lib/api-client.ts` (axios). The `@` alias maps to `apps/client/src`.
- Runtime config is injected at build time by `vite.config.ts` via `define` (`APP_URL`, `COLLAB_URL`, `APP_VERSION`, …) — these come from the root `.env`, not from `import.meta.env`. - Runtime config is injected at build time by `vite.config.ts` via `define` (`APP_URL`, `COLLAB_URL`, `APP_VERSION`, …) — these come from the root `.env`, not from `import.meta.env`.
- The build also emits `client/dist/version.json` (`{"version": …}`) from a small `vite.config.ts` plugin using the **same** `appVersion` that feeds `define.APP_VERSION`, so the file and the baked-in bundle version are identical by construction. The server reads it at startup (`ws.gateway.ts` via `readClientBuildVersion`/`resolveClientDistPath`) and announces it to each socket on connect (`app-version` event) so a tab left open across a redeploy can guard-reload before hitting a stale chunk (version-coherence). No runtime env / Dockerfile change — the file already ships in `client/dist`; missing/empty file ⇒ feature inert.
## Conventions ## Conventions
+12
View File
@@ -117,6 +117,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- **Open tabs pick up a new deploy on their own.** After the server is
redeployed while a tab is left open for hours, the tab now learns the new
build version over the existing WebSocket (announced per-connect, so a natural
reconnect delivers it) and shows a "A new version is available" banner with an
Update button. To avoid dropping a half-written comment or form, the tab is
not reloaded when you merely switch away from it; instead it auto-reloads at
the next safe point — the next in-app navigation (or immediately if you click
Update) — before it can hit a stale lazy-loaded chunk. At most one automatic
reload happens per browser session (shared with the existing chunk-load
recovery), so a permanent version skew degrades to the banner rather than a
reload loop. When the build carries no version info the feature stays inert.
- **Place several images side by side in a row.** A new "Inline (side by - **Place several images side by side in a row.** A new "Inline (side by
side)" alignment mode in the image bubble menu renders consecutive inline side)" alignment mode in the image bubble menu renders consecutive inline
images as a row that wraps onto the next line on narrow screens. The row is images as a row that wraps onto the next line on narrow screens. The row is
@@ -1,7 +1,11 @@
import { ReactNode } from "react"; import { ReactNode } from "react";
import { ErrorBoundary } from "react-error-boundary"; import { ErrorBoundary } from "react-error-boundary";
import { Button, Center, Stack, Text } from "@mantine/core"; import { Button, Center, Stack, Text } from "@mantine/core";
import { hasAutoReloaded, markAutoReloaded } from "@/lib/reload-guard"; import {
hasAutoReloaded,
markAutoReloaded,
recordReloadBreadcrumb,
} from "@/lib/reload-guard";
// Heuristic detection of a failed dynamic import. Since the code-splitting work, // Heuristic detection of a failed dynamic import. Since the code-splitting work,
// every route (plus Aside / AiChatWindow) is React.lazy: when a new deploy // every route (plus Aside / AiChatWindow) is React.lazy: when a new deploy
@@ -30,6 +34,9 @@ function handleError(error: unknown) {
// fall through to the manual recovery UI below rather than risk a loop. // fall through to the manual recovery UI below rather than risk a loop.
if (hasAutoReloaded()) return; if (hasAutoReloaded()) return;
if (!markAutoReloaded()) return; if (!markAutoReloaded()) return;
// Trace before the reload clears the console (same diagnostic breadcrumb the
// proactive version-coherence path writes, tagged with this path).
recordReloadBreadcrumb({ path: "chunk-boundary" });
window.location.reload(); window.location.reload();
} }
@@ -1,4 +1,6 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; 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. // Mocks for the dirty shell's side-effecting collaborators.
vi.mock("@mantine/notifications", () => ({ vi.mock("@mantine/notifications", () => ({
@@ -8,12 +10,15 @@ vi.mock("@/i18n.ts", () => ({ default: { t: (k: string) => k } }));
vi.mock("@/lib/reload-guard", () => ({ vi.mock("@/lib/reload-guard", () => ({
hasAutoReloaded: vi.fn(() => false), hasAutoReloaded: vi.fn(() => false),
markAutoReloaded: vi.fn(() => true), markAutoReloaded: vi.fn(() => true),
recordReloadBreadcrumb: vi.fn(),
takeReloadBreadcrumb: vi.fn(() => null),
})); }));
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import { hasAutoReloaded, markAutoReloaded } from "@/lib/reload-guard"; import { hasAutoReloaded, markAutoReloaded } from "@/lib/reload-guard";
import { import {
triggerGuardedReload, triggerGuardedReload,
useVersionReloadOnNavigation,
__resetGuardedReloadForTests, __resetGuardedReloadForTests,
} from "./guarded-reload"; } from "./guarded-reload";
@@ -28,9 +33,28 @@ const mockMarkAutoReloaded = markAutoReloaded as unknown as ReturnType<
let reload: ReturnType<typeof vi.fn>; let reload: ReturnType<typeof vi.fn>;
let visibility: DocumentVisibilityState; let visibility: DocumentVisibilityState;
function setVisibility(state: DocumentVisibilityState) { // Test harness mounted inside a router: it installs the navigation hook and
visibility = state; // exposes `navigate` so a test can drive an in-app router navigation.
document.dispatchEvent(new Event("visibilitychange")); 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(() => { beforeEach(() => {
@@ -55,10 +79,11 @@ beforeEach(() => {
}); });
afterEach(() => { afterEach(() => {
cleanup();
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
describe("triggerGuardedReload", () => { describe("triggerGuardedReload (variant C)", () => {
it("noop when versions match: no banner, no reload", () => { it("noop when versions match: no banner, no reload", () => {
triggerGuardedReload("test-A"); triggerGuardedReload("test-A");
expect(show).not.toHaveBeenCalled(); expect(show).not.toHaveBeenCalled();
@@ -72,16 +97,9 @@ describe("triggerGuardedReload", () => {
expect(reload).not.toHaveBeenCalled(); expect(reload).not.toHaveBeenCalled();
}); });
it("hidden tab on a real mismatch reloads immediately, no banner", () => { it("real mismatch shows the banner but does NOT reload immediately", () => {
visibility = "hidden"; mountHarness();
triggerGuardedReload("test-B"); triggerGuardedReload("test-B");
expect(reload).toHaveBeenCalledTimes(1);
expect(show).not.toHaveBeenCalled();
});
it("visible tab on a real mismatch shows the banner and arms a reload on hidden", () => {
triggerGuardedReload("test-B");
// Banner shown, no immediate reload.
expect(show).toHaveBeenCalledTimes(1); expect(show).toHaveBeenCalledTimes(1);
expect(show.mock.calls[0][0]).toMatchObject({ expect(show.mock.calls[0][0]).toMatchObject({
id: "app-version-reload", id: "app-version-reload",
@@ -89,30 +107,83 @@ describe("triggerGuardedReload", () => {
withCloseButton: true, withCloseButton: true,
}); });
expect(reload).not.toHaveBeenCalled(); expect(reload).not.toHaveBeenCalled();
});
// Going to the background triggers the guarded auto-reload. it("reloads EXACTLY ONCE on the first in-app navigation after a mismatch", () => {
setVisibility("hidden"); 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); expect(reload).toHaveBeenCalledTimes(1);
}); });
it("banner-only (auto-reload already spent): banner, never auto-reload", () => { it("does NOT reload on a 2nd mismatch after the first was armed/consumed (one-shot)", () => {
mockHasAutoReloaded.mockReturnValue(true); 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"); triggerGuardedReload("test-B");
expect(show).toHaveBeenCalledTimes(1);
expect(reload).not.toHaveBeenCalled(); expect(reload).not.toHaveBeenCalled();
// Even backgrounding must not reload (no listener was armed). visibility = "hidden";
setVisibility("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(); expect(reload).not.toHaveBeenCalled();
}); });
it("does NOT reload when the flag write fails; falls back to the banner", () => { it("does NOT reload when the flag write fails; falls back to the banner", () => {
mockMarkAutoReloaded.mockReturnValue(false); mockMarkAutoReloaded.mockReturnValue(false);
visibility = "hidden"; mountHarness();
triggerGuardedReload("test-B"); triggerGuardedReload("test-B");
navigateTo("/next");
expect(reload).not.toHaveBeenCalled(); expect(reload).not.toHaveBeenCalled();
// performAutoReload falls back to showing the banner. // performAutoReload falls back to showing the banner (initial + fallback).
expect(show).toHaveBeenCalledTimes(1); expect(show).toHaveBeenCalled();
}); });
it("is idempotent within a tab-load: repeated emits do not stack banners", () => { it("is idempotent within a tab-load: repeated emits do not stack banners", () => {
@@ -1,23 +1,43 @@
import { useEffect, useRef } from "react";
import { useLocation } from "react-router-dom";
import { Button } from "@mantine/core"; import { Button } from "@mantine/core";
import { notifications } from "@mantine/notifications"; import { notifications } from "@mantine/notifications";
import i18n from "@/i18n.ts"; import i18n from "@/i18n.ts";
import { hasAutoReloaded, markAutoReloaded } from "@/lib/reload-guard"; import {
hasAutoReloaded,
markAutoReloaded,
recordReloadBreadcrumb,
takeReloadBreadcrumb,
} from "@/lib/reload-guard";
import { decideVersionAction } from "@/features/user/version-coherence"; import { decideVersionAction } from "@/features/user/version-coherence";
// Dirty shell around the pure `decideVersionAction`: it reads globals // Dirty shell around the pure `decideVersionAction`: it reads globals
// (APP_VERSION, document.visibilityState), touches sessionStorage via the // (APP_VERSION), touches sessionStorage via the shared reload-guard, drives the
// shared reload-guard, and drives the Mantine notification. Kept separate from // Mantine notification, and arms the router-navigation reload hook. Kept
// the pure module so the decision stays unit-testable without a DOM. // separate from the pure module so the decision stays unit-testable without a
// DOM.
// One fixed id so repeated app-version signals (e.g. every reconnect) update a // One fixed id so repeated app-version signals (e.g. every reconnect) update a
// single banner instead of stacking a new one each time. // single banner instead of stacking a new one each time.
const BANNER_ID = "app-version-reload"; const BANNER_ID = "app-version-reload";
// Module-level idempotency for the current tab-load: once a mismatch has been // Module-level idempotency for the current tab-load: once a mismatch has been
// handled we don't re-arm the visibility listener or re-show the banner on // handled we don't re-arm the navigation reload or re-show the banner on
// subsequent app-version emits. // subsequent app-version emits.
let handled = false; let handled = false;
// Variant C: on a real mismatch we do NOT reload the tab when it merely goes to
// the background (that would silently drop a half-written comment/form). Instead
// we arm a one-shot reload for the NEXT in-app router navigation — a point where
// the user is already leaving the current page, so an in-app navigation would
// discard that unsaved component-state anyway and the reload adds no extra loss.
let pendingNavReload = false;
// Remembered from the last detected mismatch for the pre-reload breadcrumb and
// the (already-visible) banner.
let lastServerVersion = "";
let lastClientVersion = "";
// Read the build version baked into THIS bundle. The `typeof` guard avoids a // Read the build version baked into THIS bundle. The `typeof` guard avoids a
// ReferenceError where the `APP_VERSION` global is absent (e.g. under vitest, // ReferenceError where the `APP_VERSION` global is absent (e.g. under vitest,
// where Vite's `define` did not run) — an unknown client version makes the // where Vite's `define` did not run) — an unknown client version makes the
@@ -35,6 +55,16 @@ function performAutoReload(): void {
showReloadBanner(); showReloadBanner();
return; return;
} }
// Trace right before the reload (which clears the console): a persistent
// breadcrumb + a log line so the auto-reload is observable in a field report.
recordReloadBreadcrumb({
path: "proactive",
serverVersion: lastServerVersion,
clientVersion: lastClientVersion,
});
console.warn(
`[version-coherence] auto-reloading: client=${lastClientVersion} -> server=${lastServerVersion}`,
);
window.location.reload(); window.location.reload();
} }
@@ -54,13 +84,15 @@ function showReloadBanner(): void {
/** /**
* Handle a server `app-version` announcement: compare it to this bundle's * Handle a server `app-version` announcement: compare it to this bundle's
* version and, on a real mismatch, do a guarded reload. * version and, on a real mismatch, show the banner and arm a guarded reload for
* the next in-app navigation (variant C).
* *
* - hidden tab → reload immediately (nobody is looking). * - real mismatch (first this session) → banner + arm navigation reload. The
* - visible tab → show the banner AND self-reload the moment the * banner's "Update" button reloads immediately (same one-shot guard). The tab
* tab goes to the background (or on the button). * is NOT reloaded on visibility change.
* - auto-reload already used / storage error → banner only (no auto-reload), * - auto-reload already used / storage error → banner only (no arm), so there is
* so there is at most one automatic reload per session (loop safety). * at most one automatic reload per session (loop safety).
* - in sync / unknown version → noop (fail-safe).
*/ */
export function triggerGuardedReload( export function triggerGuardedReload(
rawServerVersion: string | undefined | null, rawServerVersion: string | undefined | null,
@@ -79,11 +111,14 @@ export function triggerGuardedReload(
}); });
if (action === "noop") return; if (action === "noop") return;
// Idempotent per tab-load: don't stack banners or re-arm the listener across // Idempotent per tab-load: don't re-arm or re-stack the banner across repeated
// repeated emits (reconnects) once we've already acted. // emits (reconnects) once we've already acted.
if (handled) return; if (handled) return;
handled = true; handled = true;
lastServerVersion = serverVersion;
lastClientVersion = clientVersion;
if (action === "banner") { if (action === "banner") {
// Entered banner-only (permanent skew, node oscillation, or spent // Entered banner-only (permanent skew, node oscillation, or spent
// auto-reload). Log for diagnosability; show the manual banner. // auto-reload). Log for diagnosability; show the manual banner.
@@ -95,22 +130,58 @@ export function triggerGuardedReload(
return; return;
} }
// action === "reload" // action === "reload" (variant C): show the banner and defer the auto-reload
if (document.visibilityState === "hidden") { // to the next in-app navigation instead of reloading now / on visibility.
// Covers tabs that are already backgrounded at the moment the signal
// arrives — reload them right away.
performAutoReload();
return;
}
showReloadBanner(); showReloadBanner();
const onHidden = () => { pendingNavReload = true;
if (document.visibilityState === "hidden") performAutoReload();
};
document.addEventListener("visibilitychange", onHidden, { once: true });
} }
// Test-only: reset the module-level idempotency latch between cases. /**
* Consume the armed one-shot navigation reload, if any. Called by
* `useVersionReloadOnNavigation` on each in-app router navigation.
*/
export function consumeNavigationReload(): void {
if (!pendingNavReload) return;
pendingNavReload = false;
performAutoReload();
}
/**
* Hook (mounted inside the Router) that fires the armed one-shot reload on the
* NEXT in-app router navigation after a version mismatch. Skips the initial
* render so it only reacts to real navigations, not the first location.
*/
export function useVersionReloadOnNavigation(): void {
const location = useLocation();
const firstRender = useRef(true);
useEffect(() => {
if (firstRender.current) {
firstRender.current = false;
return;
}
consumeNavigationReload();
}, [location.key]);
}
/**
* Surface (log once) the breadcrumb left by an auto-reload in the previous page
* load — the reload cleared the console, so this makes a "tab reloaded itself"
* report diagnosable. Call once on app startup.
*/
export function surfacePreviousReloadBreadcrumb(): void {
const crumb = takeReloadBreadcrumb();
if (!crumb) return;
console.info(
`[version-coherence] previous auto-reload: path=${crumb.path} ` +
`client=${crumb.clientVersion ?? ""} -> server=${crumb.serverVersion ?? ""} ` +
`at=${new Date(crumb.at).toISOString()}`,
);
}
// Test-only: reset module-level latches between cases.
export function __resetGuardedReloadForTests(): void { export function __resetGuardedReloadForTests(): void {
handled = false; handled = false;
pendingNavReload = false;
lastServerVersion = "";
lastClientVersion = "";
} }
@@ -13,7 +13,11 @@ import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
import { Error404 } from "@/components/ui/error-404.tsx"; import { Error404 } from "@/components/ui/error-404.tsx";
import { queryClient } from "@/main.tsx"; import { queryClient } from "@/main.tsx";
import { makeConnectHandler } from "@/features/user/connect-resync.ts"; import { makeConnectHandler } from "@/features/user/connect-resync.ts";
import { triggerGuardedReload } from "@/features/user/guarded-reload.tsx"; import {
triggerGuardedReload,
useVersionReloadOnNavigation,
surfacePreviousReloadBreadcrumb,
} from "@/features/user/guarded-reload.tsx";
import type { AppVersionSocketPayload } from "@/features/user/version-coherence.ts"; import type { AppVersionSocketPayload } from "@/features/user/version-coherence.ts";
export function UserProvider({ children }: React.PropsWithChildren) { export function UserProvider({ children }: React.PropsWithChildren) {
@@ -24,6 +28,16 @@ export function UserProvider({ children }: React.PropsWithChildren) {
// fetch collab token on load // fetch collab token on load
const { data: collab } = useCollabToken(); const { data: collab } = useCollabToken();
// version-coherence: fire the armed one-shot reload on the next in-app
// navigation (variant C — a safe point, not on tab backgrounding).
useVersionReloadOnNavigation();
// Surface any breadcrumb left by an auto-reload in the previous page load
// (the reload cleared the console) so a field report stays diagnosable.
useEffect(() => {
surfacePreviousReloadBreadcrumb();
}, []);
useEffect(() => { useEffect(() => {
if (isLoading || isError) { if (isLoading || isError) {
return; return;
+44 -1
View File
@@ -1,5 +1,10 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { hasAutoReloaded, markAutoReloaded } from "./reload-guard"; import {
hasAutoReloaded,
markAutoReloaded,
recordReloadBreadcrumb,
takeReloadBreadcrumb,
} from "./reload-guard";
const FLAG = "chunk-reload-attempted"; const FLAG = "chunk-reload-attempted";
@@ -51,4 +56,42 @@ describe("reload-guard", () => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
} }
}); });
it("records and then takes a breadcrumb once (cleared on read)", () => {
recordReloadBreadcrumb({
path: "proactive",
serverVersion: "test-B",
clientVersion: "test-A",
});
const crumb = takeReloadBreadcrumb();
expect(crumb).toMatchObject({
path: "proactive",
serverVersion: "test-B",
clientVersion: "test-A",
});
expect(typeof crumb?.at).toBe("number");
// Cleared on read → a second take returns null.
expect(takeReloadBreadcrumb()).toBeNull();
});
it("takeReloadBreadcrumb returns null when nothing was recorded", () => {
expect(takeReloadBreadcrumb()).toBeNull();
});
it("recordReloadBreadcrumb swallows a storage-write error (diagnostics only)", () => {
vi.stubGlobal("sessionStorage", {
getItem: () => null,
setItem: () => {
throw new Error("storage disabled");
},
removeItem: () => {},
});
try {
expect(() =>
recordReloadBreadcrumb({ path: "chunk-boundary" }),
).not.toThrow();
} finally {
vi.unstubAllGlobals();
}
});
}); });
+48
View File
@@ -40,3 +40,51 @@ export function markAutoReloaded(): boolean {
return false; return false;
} }
} }
// Diagnostic breadcrumb for an automatic reload. Written right before
// window.location.reload() (which clears the console) and read back on the next
// page load, so a "the tab reloaded itself / it's looping" field report is
// diagnosable: which path fired (proactive version-coherence vs the reactive
// chunk-load boundary) and which version pair triggered it. sessionStorage
// survives a same-tab reload, unlike the console.
const RELOAD_BREADCRUMB_KEY = "reload-breadcrumb";
export type ReloadBreadcrumb = {
path: "proactive" | "chunk-boundary";
serverVersion?: string;
clientVersion?: string;
at: number;
};
/**
* Persist a best-effort breadcrumb just before an automatic reload. Failures
* (storage unavailable) are swallowed — this is diagnostics only and must never
* block or alter the reload decision.
*/
export function recordReloadBreadcrumb(
entry: Omit<ReloadBreadcrumb, "at">,
): void {
try {
sessionStorage.setItem(
RELOAD_BREADCRUMB_KEY,
JSON.stringify({ ...entry, at: Date.now() }),
);
} catch {
// best-effort diagnostics only
}
}
/**
* Read and clear the breadcrumb left by an auto-reload in the previous page
* load. Cleared on read so it surfaces exactly once per reload.
*/
export function takeReloadBreadcrumb(): ReloadBreadcrumb | null {
try {
const raw = sessionStorage.getItem(RELOAD_BREADCRUMB_KEY);
if (!raw) return null;
sessionStorage.removeItem(RELOAD_BREADCRUMB_KEY);
return JSON.parse(raw) as ReloadBreadcrumb;
} catch {
return null;
}
}