fix(web): reload app on bfcache restore so duplicated tabs show fresh data (#2717)
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
import { expect, test } from "../../fixtures/test-base";
|
||||
|
||||
test.describe("bfcache restore reload", () => {
|
||||
test("reloads the app when the page is restored from a frozen snapshot", async ({ testPage }) => {
|
||||
await testPage.goto("/");
|
||||
|
||||
// A normal load is not a restore: the document must not reload by itself.
|
||||
expect(await testPage.evaluate(() => performance.getEntriesByType("navigation")[0]?.type)).toBe(
|
||||
"navigate",
|
||||
);
|
||||
|
||||
// Chrome's "Duplicate tab" (and any bfcache restore) brings the page back
|
||||
// from a frozen snapshot and fires pageshow with persisted=true. The app
|
||||
// reloads so the no-store boot payload is re-fetched instead of showing
|
||||
// the frozen state. Arm the causal wait BEFORE dispatching: the frame
|
||||
// navigation IS the cause, so we wait on it rather than budgeting the
|
||||
// effect.
|
||||
const reloaded = testPage.waitForEvent("framenavigated");
|
||||
await testPage
|
||||
.evaluate(() => {
|
||||
window.dispatchEvent(new PageTransitionEvent("pageshow", { persisted: true }));
|
||||
})
|
||||
.catch(() => undefined); // the reload may tear down the evaluate context
|
||||
await reloaded;
|
||||
|
||||
// The fresh document reports the reload navigation type. Poll with the
|
||||
// default assertion timeout; the entry exists as soon as the frame
|
||||
// commits, and the catch tolerates the mid-navigation context teardown.
|
||||
await expect
|
||||
.poll(() =>
|
||||
testPage
|
||||
.evaluate(() => performance.getEntriesByType("navigation")[0]?.type)
|
||||
.catch(() => undefined),
|
||||
)
|
||||
.toBe("reload");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { installBfcacheRestoreReload } from "./bfcache-restore-reload";
|
||||
|
||||
const RESTORE_PROBE_KEY = "kandev.bfcacheRestoreProbe";
|
||||
|
||||
function createMemoryStorage() {
|
||||
const values = new Map<string, string>();
|
||||
return {
|
||||
storage: {
|
||||
setItem: vi.fn((key: string, value: string) => values.set(key, value)),
|
||||
},
|
||||
value: () => values.get(RESTORE_PROBE_KEY) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function createHarness({
|
||||
isDebug = () => false,
|
||||
storage = createMemoryStorage().storage,
|
||||
navigationType,
|
||||
injectNavigationType = true,
|
||||
}: {
|
||||
isDebug?: () => boolean;
|
||||
storage?: Pick<Storage, "setItem">;
|
||||
navigationType?: string;
|
||||
injectNavigationType?: boolean;
|
||||
} = {}) {
|
||||
const target = new EventTarget();
|
||||
const reload = vi.fn();
|
||||
const getNavigationType = vi.fn(() => navigationType);
|
||||
const cleanup = installBfcacheRestoreReload({
|
||||
target,
|
||||
reload,
|
||||
isDebug,
|
||||
getStorage: () => storage,
|
||||
...(injectNavigationType ? { getNavigationType } : {}),
|
||||
});
|
||||
|
||||
return {
|
||||
target,
|
||||
reload,
|
||||
storage,
|
||||
getNavigationType,
|
||||
cleanup,
|
||||
dispatch(persisted: boolean) {
|
||||
const event = new Event("pageshow");
|
||||
Object.defineProperty(event, "persisted", { value: persisted });
|
||||
target.dispatchEvent(event);
|
||||
return event;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("installBfcacheRestoreReload", () => {
|
||||
it("reloads when the page is restored from a frozen snapshot (persisted=true)", () => {
|
||||
const harness = createHarness();
|
||||
|
||||
harness.dispatch(true);
|
||||
|
||||
expect(harness.reload).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not reload on a cold back/forward traversal (persisted=false)", () => {
|
||||
// Regression for the round-1 finding: a history traversal not served
|
||||
// from bfcache (e.g. an open WebSocket made the no-store page ineligible)
|
||||
// loads fresh and must not be reloaded a second time, even when the
|
||||
// navigation entry reports type `back_forward`. The injected
|
||||
// navigation-type reader returns `back_forward` here, so reintroducing
|
||||
// the navigation-type reload fallback would fail this test.
|
||||
const harness = createHarness({ navigationType: "back_forward" });
|
||||
|
||||
harness.dispatch(false);
|
||||
|
||||
expect(harness.reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not reload on a cold back/forward traversal via the default reader (persisted=false)", () => {
|
||||
// Companion to the injected-seam case: a reintroduced fallback that calls
|
||||
// the module's DEFAULT readNavigationType (reading global performance)
|
||||
// must also fail. Stub global performance to report back_forward, install
|
||||
// without the injected reader, and assert no reload.
|
||||
vi.stubGlobal("performance", {
|
||||
// happy-dom's Event constructor calls performance.now(); keep it intact
|
||||
// while exposing the back_forward navigation entry.
|
||||
now: () => 0,
|
||||
getEntriesByType: (type: string) => (type === "navigation" ? [{ type: "back_forward" }] : []),
|
||||
});
|
||||
const harness = createHarness({ injectNavigationType: false });
|
||||
|
||||
harness.dispatch(false);
|
||||
|
||||
expect(harness.reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not reload on a fresh load (persisted=false)", () => {
|
||||
// navigationType: "navigate" distinguishes this from the manual-refresh
|
||||
// case below, so a reintroduced fallback keyed on either type fails.
|
||||
const harness = createHarness({ navigationType: "navigate" });
|
||||
|
||||
harness.dispatch(false);
|
||||
|
||||
expect(harness.reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not reload on a manual refresh (persisted=false)", () => {
|
||||
const harness = createHarness({ navigationType: "reload" });
|
||||
|
||||
harness.dispatch(false);
|
||||
|
||||
expect(harness.reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not reload when the event carries no persisted flag", () => {
|
||||
const harness = createHarness();
|
||||
|
||||
harness.target.dispatchEvent(new Event("pageshow"));
|
||||
|
||||
expect(harness.reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uninstall removes the listener", () => {
|
||||
const harness = createHarness();
|
||||
|
||||
harness.cleanup();
|
||||
harness.dispatch(true);
|
||||
|
||||
expect(harness.reload).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("installBfcacheRestoreReload debug probe", () => {
|
||||
it("records restore evidence before reloading in debug builds", () => {
|
||||
const memory = createMemoryStorage();
|
||||
const harness = createHarness({
|
||||
isDebug: () => true,
|
||||
storage: memory.storage,
|
||||
navigationType: "back_forward",
|
||||
});
|
||||
|
||||
harness.dispatch(true);
|
||||
|
||||
expect(harness.reload).toHaveBeenCalledOnce();
|
||||
expect(memory.value()).not.toBeNull();
|
||||
expect(JSON.parse(memory.value()!)).toMatchObject({
|
||||
persisted: true,
|
||||
navigationType: "back_forward",
|
||||
});
|
||||
expect(Number.isFinite(JSON.parse(memory.value()!).at)).toBe(true);
|
||||
});
|
||||
|
||||
it("records a persisted=false duplicate candidate without reloading", () => {
|
||||
// The merge gate must be able to distinguish "no pageshow at all" from
|
||||
// "pageshow with persisted=false": a duplicate that restores stale heap
|
||||
// state while reporting persisted=false (a cold back_forward traversal)
|
||||
// is recorded for diagnosis but never reloaded.
|
||||
const memory = createMemoryStorage();
|
||||
const harness = createHarness({
|
||||
isDebug: () => true,
|
||||
storage: memory.storage,
|
||||
navigationType: "back_forward",
|
||||
});
|
||||
|
||||
harness.dispatch(false);
|
||||
|
||||
expect(harness.reload).not.toHaveBeenCalled();
|
||||
expect(memory.value()).not.toBeNull();
|
||||
expect(JSON.parse(memory.value()!)).toMatchObject({
|
||||
persisted: false,
|
||||
navigationType: "back_forward",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not record non-candidates even in debug builds", () => {
|
||||
const memory = createMemoryStorage();
|
||||
const harness = createHarness({
|
||||
isDebug: () => true,
|
||||
storage: memory.storage,
|
||||
navigationType: "navigate",
|
||||
});
|
||||
|
||||
harness.dispatch(false);
|
||||
|
||||
expect(harness.reload).not.toHaveBeenCalled();
|
||||
expect(memory.value()).toBeNull();
|
||||
});
|
||||
|
||||
it("does not record evidence when debug is disabled", () => {
|
||||
const memory = createMemoryStorage();
|
||||
const harness = createHarness({ isDebug: () => false, storage: memory.storage });
|
||||
|
||||
harness.dispatch(true);
|
||||
|
||||
expect(harness.reload).toHaveBeenCalledOnce();
|
||||
expect(memory.value()).toBeNull();
|
||||
});
|
||||
|
||||
it("a failing probe never blocks the reload", () => {
|
||||
const harness = createHarness({
|
||||
isDebug: () => true,
|
||||
storage: {
|
||||
setItem: vi.fn(() => {
|
||||
throw new Error("storage unavailable");
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => harness.dispatch(true)).not.toThrow();
|
||||
expect(harness.reload).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("a throwing debug predicate never blocks the reload", () => {
|
||||
const harness = createHarness({
|
||||
isDebug: () => {
|
||||
throw new Error("debug flag unavailable");
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => harness.dispatch(true)).not.toThrow();
|
||||
expect(harness.reload).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
const PAGESHOW_EVENT = "pageshow";
|
||||
const RESTORE_PROBE_KEY = "kandev.bfcacheRestoreProbe";
|
||||
|
||||
type RestoreReloadTarget = Pick<EventTarget, "addEventListener" | "removeEventListener">;
|
||||
|
||||
interface RestoreReloadOptions {
|
||||
target?: RestoreReloadTarget;
|
||||
reload?: () => void;
|
||||
isDebug?: () => boolean;
|
||||
getStorage?: () => Pick<Storage, "setItem">;
|
||||
getNavigationType?: () => string | undefined;
|
||||
}
|
||||
|
||||
function readNavigationType(): string | undefined {
|
||||
try {
|
||||
const entry = performance.getEntriesByType("navigation")[0];
|
||||
return entry ? (entry as PerformanceNavigationTiming).type : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reloads the page when it is restored from a frozen browser snapshot
|
||||
* (back/forward cache restore, including Chrome's "Duplicate tab") instead of
|
||||
* being loaded fresh. A restored page keeps the frozen JS heap and DOM, so
|
||||
* state can be stale until a real load; the reload re-fetches the no-store
|
||||
* boot payload and reconnects the WebSocket.
|
||||
*
|
||||
* A restore is detected on `pageshow` when `event.persisted` is true. That is
|
||||
* the only reliable frozen-restore signal: the navigation type
|
||||
* `back_forward` also covers cold history traversals and session-restored
|
||||
* tabs, which load fresh and must NOT be reloaded a second time. Fresh loads
|
||||
* (`navigate`), manual refreshes (`reload`), and in-app SPA routing never
|
||||
* reload; a reload produces a fresh document and cannot loop.
|
||||
*
|
||||
* In debug builds (`window.__KANDEV_DEBUG`), restore evidence is written to
|
||||
* sessionStorage BEFORE the reload so a native Duplicate-tab run can be
|
||||
* verified after the fact: the reload destroys the document, and the
|
||||
* duplicated tab is a separate DevTools target, so the event data must be
|
||||
* persisted pre-reload. For duplicate/history candidates the actual
|
||||
* `persisted` value is recorded even when it is false, so the merge gate can
|
||||
* distinguish "no pageshow at all" from "pageshow with persisted=false". The
|
||||
* probe is diagnostics only, is skipped outside debug builds, and never
|
||||
* blocks the reload.
|
||||
*
|
||||
* Returns an uninstall function.
|
||||
*/
|
||||
export function installBfcacheRestoreReload({
|
||||
target = window,
|
||||
reload = () => window.location.reload(),
|
||||
isDebug = () => window.__KANDEV_DEBUG === true,
|
||||
getStorage = () => window.sessionStorage,
|
||||
getNavigationType = readNavigationType,
|
||||
}: RestoreReloadOptions = {}): () => void {
|
||||
const recordProbe = (persisted: boolean) => {
|
||||
try {
|
||||
if (!isDebug()) return;
|
||||
const navigationType = getNavigationType();
|
||||
// Only duplicate/history candidates are probed: a frozen restore, or
|
||||
// any document reached via a `back_forward` traversal. Fresh loads and
|
||||
// manual refreshes are not candidates and are never recorded.
|
||||
if (!persisted && navigationType !== "back_forward") return;
|
||||
getStorage().setItem(
|
||||
RESTORE_PROBE_KEY,
|
||||
JSON.stringify({ persisted, navigationType, at: Date.now() }),
|
||||
);
|
||||
} catch {
|
||||
// Best-effort diagnostics; never block the reload.
|
||||
}
|
||||
};
|
||||
|
||||
const handlePageshow = (event: Event) => {
|
||||
const persisted = (event as PageTransitionEvent).persisted === true;
|
||||
recordProbe(persisted);
|
||||
if (persisted) {
|
||||
reload();
|
||||
}
|
||||
};
|
||||
|
||||
target.addEventListener(PAGESHOW_EVENT, handlePageshow);
|
||||
return () => target.removeEventListener(PAGESHOW_EVENT, handlePageshow);
|
||||
}
|
||||
@@ -13,10 +13,12 @@ import type { BootPayload } from "./boot-payload";
|
||||
import { RootErrorBoundary, RouteErrorBoundary } from "./app-error-boundary";
|
||||
import { SpaRoutes } from "./spa-routes";
|
||||
import { installVitePreloadRecovery } from "./vite-preload-recovery";
|
||||
import { installBfcacheRestoreReload } from "./bfcache-restore-reload";
|
||||
import { markRenderingEngine } from "@/lib/browser/rendering-engine";
|
||||
import { applyTitlePrefix } from "@/lib/browser/document-title";
|
||||
|
||||
installVitePreloadRecovery();
|
||||
installBfcacheRestoreReload();
|
||||
markRenderingEngine(document.documentElement);
|
||||
|
||||
const AUTH_ROUTE_PATHS = new Set(["/login", "/setup", "/invite"]);
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
---
|
||||
spec: docs/specs/fix-duplicated-tab-stale-data/spec.md
|
||||
created: 2026-08-16
|
||||
status: in-progress
|
||||
---
|
||||
|
||||
# Implementation Plan: Reload on Frozen-Snapshot Restore
|
||||
|
||||
## Overview
|
||||
|
||||
Chrome's Duplicate tab restores the Kandev page from a frozen browser snapshot
|
||||
(back/forward-style restore; the duplicated tab's navigation type is
|
||||
`back_forward`) instead of performing a fresh load. The restored page keeps the
|
||||
frozen JS heap and DOM, so data that changed after the snapshot (e.g., a task
|
||||
archived since) shows as stale until a manual refresh.
|
||||
|
||||
Root cause chain, with evidence:
|
||||
|
||||
1. Fresh loads are always correct: the SPA shell and the boot-payload paths set
|
||||
`Cache-Control: no-store` (`apps/backend/internal/webapp/handler.go`,
|
||||
`dev_handler.go`), and the frontend data fetches pass `cache: "no-store"`
|
||||
(`apps/web/lib/api/domains/...`, `loadBootPayload` in
|
||||
`apps/web/src/boot-payload.ts`). A real navigation therefore re-reads
|
||||
current state.
|
||||
2. Tab duplicate is not a fresh load. Chromium tracks it as a `back_forward`
|
||||
navigation (bfcache-dev discussion,
|
||||
https://groups.google.com/a/chromium.org/g/bfcache-dev/c/Cs5ISWbKhKU), and
|
||||
Chrome has been rolling out bfcache admission for `Cache-Control: no-store`
|
||||
pages (https://developer.chrome.com/docs/web-platform/bfcache-ccns).
|
||||
3. The app has no restore-specific handling: no handler inspects
|
||||
`pageshow.persisted` (the generic `useForegroundRefresh` hook registers a
|
||||
`pageshow` listener in `apps/web/hooks/use-foreground-refresh.ts` but does
|
||||
not distinguish restores from focus/visibility events, and covers only
|
||||
subsets of surfaces).
|
||||
4. Verified in real Chrome (headless, via a test page): a bfcache restore
|
||||
fires `pageshow` with `persisted === true`, and the frozen timers resume.
|
||||
Platform guidance for this class of bug is to handle `pageshow` with
|
||||
`event.persisted === true` and refresh or reload the page (web.dev bfcache
|
||||
article, https://web.dev/articles/bfcache).
|
||||
|
||||
## Fix
|
||||
|
||||
Add a small, testable bootstrap module that reloads the page when it is
|
||||
restored from a frozen snapshot:
|
||||
|
||||
- `apps/web/src/bfcache-restore-reload.ts` exports
|
||||
`installBfcacheRestoreReload(options?)` returning an uninstall function. It
|
||||
listens for `pageshow` and reloads when `event.persisted === true`. That is
|
||||
the only reliable frozen-restore signal: the navigation type
|
||||
`back_forward` also covers cold history traversals and session-restored
|
||||
tabs, which load fresh and must NOT be reloaded a second time (a redundant
|
||||
reload would discard restored scroll/UI state on the common
|
||||
click-out-and-back flow). Fresh loads (`navigate`), manual refreshes
|
||||
(`reload`), and SPA soft navigations never reload.
|
||||
- The module follows the `apps/web/src/vite-preload-recovery.ts` pattern:
|
||||
injected `target` and `reload` for testability.
|
||||
- `apps/web/src/main.tsx` installs it at module scope next to
|
||||
`installVitePreloadRecovery()`, before React mounts.
|
||||
|
||||
No backend change. No new user-facing copy (no i18n impact).
|
||||
|
||||
## Tests
|
||||
|
||||
Unit tests, `apps/web/src/bfcache-restore-reload.test.ts` (vitest + happy-dom,
|
||||
mirroring `vite-preload-recovery.test.ts` conventions):
|
||||
|
||||
- `pageshow` with `persisted === true` triggers the reload.
|
||||
- `pageshow` with `persisted === false` does not reload — including on a cold
|
||||
`back_forward` traversal and on a manual refresh (regression: the
|
||||
navigation type must not be treated as a restore signal).
|
||||
- An event carrying no `persisted` flag does not reload.
|
||||
- The uninstall function removes the listener.
|
||||
|
||||
E2E test, `apps/web/e2e/tests/layout/bfcache-restore-reload.spec.ts`
|
||||
(Playwright + real backend fixture, per `apps/web/e2e/README.md`):
|
||||
|
||||
- Load the app; assert the navigation type is `navigate` and no reload fired.
|
||||
- Dispatch a real `pageshow` event with `persisted === true` from the page
|
||||
context; retry (navigation-race safe) until the navigation type becomes
|
||||
`reload` (the app reloaded through its own installed handler).
|
||||
- The synthetic-signal approach is deliberate: the e2e backend page holds an
|
||||
open WebSocket, which in current Chrome makes a `no-store` page ineligible
|
||||
for bfcache on back/forward navigations, so `page.goBack()` would reload
|
||||
even without the fix (false positive). The browser's restore machinery is
|
||||
covered by the real-Chrome manual verification below.
|
||||
|
||||
## Verification
|
||||
|
||||
- `cd apps && pnpm --filter @kandev/web test -- --run src/bfcache-restore-reload.test.ts`
|
||||
- `cd apps/web && pnpm run typecheck`
|
||||
- `cd apps && pnpm --filter @kandev/web lint` (changed files only)
|
||||
- E2E: `cd apps/web && pnpm e2e:run tests/layout/bfcache-restore-reload.spec.ts`
|
||||
- Real-Chrome manual verification (user or implementer, on a non-headless
|
||||
Chrome): open Kandev, archive a task, right-click the tab → Duplicate. The
|
||||
duplicated tab must reload and show the task as archived. This run is the
|
||||
MERGE BLOCKING gate (instrumentation checklist and decision rule in
|
||||
`task-01-reload-on-bfcache-restore.md`). If a current Chrome version
|
||||
restores without firing `pageshow` (no persisted signal), record it in the
|
||||
task Results and follow up with a WS-close-based fallback, gated on that
|
||||
evidence and subject to the restore-bounding caveat and regression-test
|
||||
requirement recorded in `task-01-reload-on-bfcache-restore.md`
|
||||
("WS-close fallback caveat").
|
||||
|
||||
## Implementation Waves And Parallel Candidates
|
||||
|
||||
Wave 1:
|
||||
|
||||
- [ ] [Task 01: Reload on bfcache restore](task-01-reload-on-bfcache-restore.md)
|
||||
|
||||
Sequential. Single task; no parallel candidates.
|
||||
@@ -0,0 +1,375 @@
|
||||
---
|
||||
id: "01-reload-on-bfcache-restore"
|
||||
title: "Reload on bfcache restore"
|
||||
status: in-progress
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/fix-duplicated-tab-stale-data/spec.md"
|
||||
---
|
||||
|
||||
# Task 01: Reload on BFCache Restore
|
||||
|
||||
## Acceptance
|
||||
|
||||
- `apps/web/src/bfcache-restore-reload.ts` exports `installBfcacheRestoreReload`
|
||||
which reloads the page on a `pageshow` event when `event.persisted === true`
|
||||
only (never on the `back_forward` navigation type alone, which also covers
|
||||
cold history traversals and session-restored tabs), and returns an
|
||||
uninstall function.
|
||||
- `apps/web/src/main.tsx` installs it at module scope next to
|
||||
`installVitePreloadRecovery()`, before React mounts.
|
||||
- Unit tests cover: persisted restore reloads; persisted=false does not reload
|
||||
(fresh load, manual refresh, and cold `back_forward` traversal regression);
|
||||
an event with no persisted flag does not reload; uninstall removes the
|
||||
listener.
|
||||
- E2E test dispatches a real persisted `pageshow` in the running app and
|
||||
asserts the page reloads (navigation type becomes `reload`); a normal load
|
||||
does not reload.
|
||||
- No user-facing copy added (no i18n ratchet impact).
|
||||
- MERGE BLOCKING: a real-Chrome native Duplicate-tab run is performed and
|
||||
recorded (see "Blocking verification" below). The automated suite proves
|
||||
the handler fires on `persisted === true`, which is the only event sequence
|
||||
consistent with the reported symptom (a cold duplicate load re-fetches the
|
||||
no-store boot payload and would show fresh data; only a frozen restore
|
||||
preserves the stale view, and frozen restores fire `pageshow` with
|
||||
`persisted === true`). The native-run record confirms that sequence on the
|
||||
user's Chrome before merge.
|
||||
|
||||
## Blocking verification (merge gate)
|
||||
|
||||
Run in the user's non-headless Chrome against a debug build of the affected
|
||||
Kandev (debug mode sets `window.__KANDEV_DEBUG`, which arms the pre-reload
|
||||
probe in `bfcache-restore-reload.ts`). The duplicated tab is a SEPARATE
|
||||
DevTools target and the handler reloads synchronously during its initial
|
||||
`pageshow`, so attaching DevTools afterward cannot observe the restore; the
|
||||
probe persists the evidence to sessionStorage BEFORE the reload.
|
||||
|
||||
1. In the SOURCE tab's DevTools console, clear any inherited probe from an
|
||||
earlier run and store the attempt start time in sessionStorage:
|
||||
`sessionStorage.removeItem("kandev.bfcacheRestoreProbe"); sessionStorage.setItem("kandev.bfcacheProbeStart", String(Date.now()));`
|
||||
(sessionStorage survives reloads and Chrome Duplicate copies the source
|
||||
tab's storage — including this helper key — so the duplicated tab can
|
||||
compare timestamps even though console variables are target-local.)
|
||||
2. Archive a task, then right-click the Kandev tab → **Duplicate**.
|
||||
3. In the duplicated tab's DevTools console, read the probe and check it is
|
||||
newer than this attempt in one self-contained, null-safe expression
|
||||
(console bindings are not carried over from the source tab):
|
||||
`((JSON.parse(sessionStorage.getItem("kandev.bfcacheRestoreProbe") ?? "null")?.at ?? -1) >= Number(sessionStorage.getItem("kandev.bfcacheProbeStart")))`
|
||||
The probe records `{ persisted, navigationType, at }` captured pre-reload;
|
||||
the comparison must be true for the record to belong to this run (a stale
|
||||
probe inherited from an earlier restore must not be attributed to it). If
|
||||
no `pageshow` fired, there is no probe entry and the expression evaluates
|
||||
false instead of throwing, so the no-probe outcome in step 6 can be
|
||||
recorded.
|
||||
4. Confirm the handler reloaded from inside the duplicated tab's console
|
||||
after the reload settles:
|
||||
`performance.getEntriesByType("navigation")[0]?.type` must be `"reload"`
|
||||
(a bfcache restore without the fix would report `"back_forward"`). The
|
||||
archived task must no longer be listed as active. (The duplicated tab is a
|
||||
separate DevTools target and reloads synchronously, so source-tab Network
|
||||
capture cannot observe its document request; the navigation-type and
|
||||
UI-state checks above are the executable proof.)
|
||||
5. Expected with this fix: the probe shows `persisted: true`, the duplicated
|
||||
tab reloads, and the task is shown as archived.
|
||||
6. If the duplicated tab fires NO `pageshow` (no probe entry) or shows a
|
||||
probe with `persisted: false` while still displaying stale data, the
|
||||
frozen-restore assumption is wrong for that Chrome version: record the
|
||||
observed sequence (including WebSocket close code/timing, if any), then
|
||||
design a fallback from that evidence — see the WS-close caveat below. Do
|
||||
not add a fallback without this evidence.
|
||||
|
||||
### WS-close fallback caveat (only if step 6 triggers)
|
||||
|
||||
The navigation type `back_forward` is fixed for the document lifetime, and
|
||||
`WebSocketClient.handleDisconnect` observes every later unintentional
|
||||
disconnect, so reloading on "unexpected WS close while the navigation type is
|
||||
`back_forward`" would also fire after a cold history/session restore followed
|
||||
by an ordinary network loss — recreating the round-1 false-positive class.
|
||||
Any WS-based fallback must be bounded to the restore/bootstrap context (e.g.
|
||||
only within a short window after the restore, or only when the sessionStorage
|
||||
probe from this handler is present) and must include a regression test that a
|
||||
routine disconnect after a cold `back_forward` load does NOT reload.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps && pnpm --filter @kandev/web test -- --run src/bfcache-restore-reload.test.ts
|
||||
cd apps/web && pnpm run typecheck
|
||||
cd apps && pnpm --filter @kandev/web lint
|
||||
cd apps/web && pnpm e2e:run tests/layout/bfcache-restore-reload.spec.ts
|
||||
```
|
||||
|
||||
Manual, on a real (non-headless) Chrome: archive a task, right-click the
|
||||
Kandev tab → Duplicate; the duplicated tab must reload and show the task as
|
||||
archived. Record the outcome in Results.
|
||||
|
||||
## Files Likely Touched
|
||||
|
||||
- `apps/web/src/bfcache-restore-reload.ts` (new)
|
||||
- `apps/web/src/bfcache-restore-reload.test.ts` (new)
|
||||
- `apps/web/src/main.tsx` (wire install call)
|
||||
- `apps/web/e2e/tests/layout/bfcache-restore-reload.spec.ts` (new)
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
|
||||
## Parallelism
|
||||
|
||||
Sequential. Single task; no parallel candidates.
|
||||
|
||||
## Inputs
|
||||
|
||||
- Repair spec: `docs/specs/fix-duplicated-tab-stale-data/spec.md`.
|
||||
- Existing pattern: `apps/web/src/vite-preload-recovery.ts` and its test
|
||||
(injected `target`/`reload`, defensive storage reads).
|
||||
- Bootstrap entry: `apps/web/src/main.tsx` (`installVitePreloadRecovery()`
|
||||
call site).
|
||||
|
||||
## Risks
|
||||
|
||||
- Chrome duplicate-tab restore event delivery may vary by version. Detection
|
||||
is `pageshow.persisted === true` only; a state-clone restore that fires no
|
||||
`pageshow` at all is not detectable by this handler. If a real-Chrome
|
||||
duplicate test shows no reload, follow up with the WS-close fallback
|
||||
described in the plan (reload on unexpected WS close when the navigation
|
||||
type is `back_forward`), gated on that evidence; do not add it
|
||||
speculatively.
|
||||
- happy-dom lacks `PageTransitionEvent`; tests define the `persisted` flag on
|
||||
a plain `Event`. The cold-`back_forward` regression is covered twice: the
|
||||
injected-seam case (harness `navigationType: "back_forward"` through the
|
||||
injected reader) and the default-reader case (module installed without the
|
||||
injected reader while global `performance` reports `back_forward`), so a
|
||||
reintroduced navigation-type fallback fails either way.
|
||||
- E2E must use the synthetic persisted-`pageshow` signal (see plan): with an
|
||||
open WebSocket, current Chrome does not bfcache `no-store` pages on
|
||||
back/forward, so `page.goBack()` would pass trivially. The reload assertion
|
||||
retries with `expect(...).toPass({ timeout: 15_000 })` (catching the
|
||||
destroyed execution context mid-navigation); no sleeps.
|
||||
|
||||
## Output Contract
|
||||
|
||||
Report the root-cause evidence summary, files changed, exact tests run
|
||||
(unit, typecheck, lint, E2E), the real-Chrome duplicate-verification outcome,
|
||||
task and plan status updates, and any follow-up decision on the WS-close
|
||||
fallback.
|
||||
|
||||
## Results
|
||||
|
||||
- Added `apps/web/src/bfcache-restore-reload.ts`: a `pageshow` handler that
|
||||
reloads the page when `event.persisted === true`, with an uninstall
|
||||
function (pattern: `vite-preload-recovery.ts`).
|
||||
- Wired `installBfcacheRestoreReload()` into `apps/web/src/main.tsx` at module
|
||||
scope next to `installVitePreloadRecovery()`, before React mounts.
|
||||
- Added `apps/web/src/bfcache-restore-reload.test.ts` (13 tests) and
|
||||
`apps/web/e2e/tests/layout/bfcache-restore-reload.spec.ts`.
|
||||
- Checks passed:
|
||||
- `cd apps && pnpm --filter @kandev/web test -- --run src/bfcache-restore-reload.test.ts`
|
||||
(13/13 tests, current HEAD).
|
||||
- `cd apps/web && pnpm run typecheck` (clean).
|
||||
- `cd apps/web && pnpm exec eslint src/bfcache-restore-reload.ts
|
||||
src/bfcache-restore-reload.test.ts src/main.tsx
|
||||
e2e/tests/layout/bfcache-restore-reload.spec.ts` (clean).
|
||||
- `cd apps/web && pnpm e2e:run tests/layout/bfcache-restore-reload.spec.ts`
|
||||
(1/1; the assertion arms a `framenavigated` causal wait before
|
||||
dispatching the restore signal, then asserts the reload navigation type
|
||||
with the default timeout — the earlier `expect(...).toPass({ timeout:
|
||||
15_000 })` effect-polling budget was removed per the E2E causal-wait
|
||||
convention).
|
||||
|
||||
### Adversarial review round 1 (50-luna-review-fix)
|
||||
|
||||
- Finding (P2, accepted): the initial version reloaded on the
|
||||
`back_forward` navigation type in addition to `persisted === true`, but
|
||||
`back_forward` identifies every history traversal, not only frozen
|
||||
restores. A cold back/forward load or session-restored tab (both load
|
||||
fresh) was therefore reloaded a second time, discarding restored scroll/UI
|
||||
state on the common click-out-and-back flow. Frozen restores always fire
|
||||
`pageshow` with `persisted === true`, so the fallback added false positives
|
||||
without covering any additional restore path.
|
||||
- Resolution: detection is now `persisted === true` only; the navigation-type
|
||||
fallback and its tests were removed; a regression test asserts no reload on
|
||||
a cold `back_forward` traversal. Spec, plan, and this file updated to match.
|
||||
|
||||
- Real-Chrome duplicate-tab verification (non-headless, user's environment)
|
||||
remains outstanding: archive a task, right-click the tab → Duplicate, expect
|
||||
the duplicated tab to reload and show the task as archived. If a Chrome
|
||||
version restores without firing `pageshow` at all (no persisted signal),
|
||||
add the plan-documented WS-close fallback gated on that evidence; none was
|
||||
needed for the signals observed here.
|
||||
|
||||
### Adversarial review round 2 (50-luna-review-fix)
|
||||
|
||||
- Finding 1 (minor, accepted): the cold-`back_forward` regression test only
|
||||
dispatched `persisted=false` without exposing a `back_forward` navigation
|
||||
entry, so it was behaviorally identical to the fresh-load test and a
|
||||
reintroduced navigation-type fallback would stay green under happy-dom (no
|
||||
matching navigation entry). Fixed: the test now stubs
|
||||
`performance.getEntriesByType("navigation")` to return a `back_forward`
|
||||
entry before dispatching `persisted=false`, and asserts no reload.
|
||||
- Finding 2 (nit, accepted): the plan and task still described the removed
|
||||
navigation-type reader and defensive Navigation Timing reads. Fixed: both
|
||||
documents now describe only injected `target`/`reload` and the
|
||||
plain-`Event` persisted test, retaining the separately gated WS-close
|
||||
fallback.
|
||||
- No production-code correctness issue found. The real-Chrome duplicate-tab
|
||||
run remains the outstanding evidence gate for the WS-close fallback
|
||||
decision.
|
||||
|
||||
### Adversarial review round 3 (50-luna-review-fix)
|
||||
|
||||
- Finding (nit, accepted): the durable plan/task record said the unit
|
||||
environment is jsdom and the E2E uses `expect.poll` with default timeouts,
|
||||
but `apps/web/vitest.config.ts` configures happy-dom and the implemented
|
||||
E2E uses `expect(...).toPass({ timeout: 15_000 })` (the reload teardown
|
||||
destroys the evaluate context, so the retry swallows those errors; 15s
|
||||
covers the reload plus a fresh app boot). Fixed: all jsdom references in
|
||||
the plan package now name happy-dom, and the E2E wait strategy is
|
||||
documented as `toPass`/15s.
|
||||
- No production correctness issue found (persisted-only semantics, bootstrap
|
||||
timing/StrictMode, listener lifecycle, reload-path interaction, option/cast
|
||||
safety, cold-`back_forward` stub, E2E positive/negative causality).
|
||||
|
||||
### Adversarial review round 4 (50-luna-review-fix)
|
||||
|
||||
- Finding (major, partially accepted): native Chrome Duplicate calls
|
||||
`contents->Clone()` + `CopyStateFrom(..., true)` + `LoadIfNecessary()`, and
|
||||
the cited Chromium discussion establishes only a `back_forward` navigation
|
||||
type, not `persisted=true`; the E2E synthesizes the signal, so all
|
||||
automated checks could pass while the reported path remains unfixed.
|
||||
- Response: the premise that native Duplicate is a cold "navigated clone" is
|
||||
inconsistent with the reported symptom. A cold duplicate load re-fetches
|
||||
the no-store boot payload (and the tasks/kanban fetches are `no-store`),
|
||||
so it would show FRESH data; the user's stale view can only come from a
|
||||
frozen restore (JS heap preserved), and frozen restores fire `pageshow`
|
||||
with `persisted === true`. `persisted === true` is therefore the correct
|
||||
signal for the observed bug, and the handler cannot fire on a cold load.
|
||||
Accepted part: the native-Duplicate event sequence on the user's Chrome
|
||||
version is unverified, and a Chrome build could in principle clone without
|
||||
firing `pageshow` at all. That verification is now a MERGE BLOCKING gate
|
||||
with an instrumentation checklist (pageshow.persisted, navigation type,
|
||||
boot-payload requests); if the observed sequence contradicts the
|
||||
assumption, the WS-close fallback is implemented gated on that evidence.
|
||||
- The real-Chrome duplicate-tab run remains the outstanding evidence gate
|
||||
and now blocks merge.
|
||||
|
||||
### Adversarial review round 5 (50-luna-review-fix)
|
||||
|
||||
- Finding 1 (major, accepted): the task/plan were marked done/complete while
|
||||
the merge gate was outstanding. Fixed: both are now `in-progress`; they are
|
||||
flipped to done/complete only after the native run is recorded.
|
||||
- Finding 2 (major, accepted): the gate checklist could not capture the
|
||||
evidence — `window.__KANDEV_DEBUG` is only a boolean, the duplicated tab is
|
||||
a separate DevTools target, and the synchronous reload destroys the
|
||||
pre-reload event/navigation data. Fixed: the module now writes a
|
||||
debug-gated probe (`kandev.bfcacheRestoreProbe`) to sessionStorage BEFORE
|
||||
reloading, recording `{ persisted: true, navigationType, at }`; the
|
||||
checklist reads the probe post-duplicate and corroborates with a fresh
|
||||
boot-payload request. Probe covered by 3 new unit tests (records in debug,
|
||||
silent in non-debug, never blocks the reload).
|
||||
- Finding 3 (minor, accepted): the WS-close fallback rule was not
|
||||
restore-specific — `back_forward` is fixed for the document lifetime and
|
||||
`handleDisconnect` observes every later unintentional disconnect, which
|
||||
would recreate the round-1 false-positive class after a cold restore plus
|
||||
ordinary network loss. Fixed: the fallback is now explicitly caveated (must
|
||||
be bounded to the restore/bootstrap context, e.g. a short post-restore
|
||||
window or the presence of this handler's sessionStorage probe, and must
|
||||
include a routine-disconnect-after-cold-back_forward regression test) and
|
||||
is only designed after the step-5 evidence.
|
||||
- Finding 4 (nit, accepted): the plan claimed no `pageshow` handlers existed
|
||||
anywhere, but `useForegroundRefresh` already registers one. Fixed: the
|
||||
claim now says no handler inspects `pageshow.persisted`.
|
||||
|
||||
### Adversarial review round 6 (50-luna-review-fix)
|
||||
|
||||
- Finding 1 (major, accepted): the probe could not capture the falsifying
|
||||
`persisted=false` case — the handler returned before recording and the
|
||||
payload hard-coded `persisted: true`. Fixed: the handler now records the
|
||||
ACTUAL `persisted` value for duplicate/history candidates (`persisted ===
|
||||
true`, or any `back_forward`-typed document) before any reload decision,
|
||||
and the record survives the reload in sessionStorage; a
|
||||
persisted=false/back_forward record-without-reload unit test was added.
|
||||
- Finding 2 (minor, accepted): `isDebug()` ran outside the best-effort
|
||||
guard, so a throwing predicate aborted the handler before `reload()`.
|
||||
Fixed: the predicate is inside the diagnostic try/catch; a throwing-`isDebug`
|
||||
test proves the reload still runs.
|
||||
- Finding 3 (minor, accepted): the gate read the fixed sessionStorage key
|
||||
without clearing it, and Chrome Duplicate copies the source tab's storage,
|
||||
so a stale probe could be misattributed. Fixed: the checklist now clears
|
||||
the key and records `t0` before Duplicate, and requires `at >= t0`.
|
||||
- Finding 4 (minor, accepted): Results recorded 6/6 for a file now containing
|
||||
12 tests. Fixed: re-ran the focused unit command on current HEAD (12/12)
|
||||
and updated the record.
|
||||
- Finding 5 (nit, accepted): the spec said the fix writes no client storage,
|
||||
but debug restores write the sessionStorage probe. Fixed: Persistence
|
||||
guarantees now document the debug-only diagnostic key, fields, lifetime,
|
||||
cleanup, and best-effort failure behavior.
|
||||
|
||||
### Adversarial review round 7 (50-luna-review-fix)
|
||||
|
||||
- Finding 1 (minor, accepted): the cold-`back_forward` regression test
|
||||
stubbed global `performance`, but the harness always injects the
|
||||
navigation-type reader, so the stub never reached the exercised code path
|
||||
and a reintroduced fallback reading the injected option would pass. Fixed:
|
||||
the test now passes `navigationType: "back_forward"` through the harness's
|
||||
injected reader (the actual seam the module uses) and the ineffective
|
||||
global stub and its cleanup were removed.
|
||||
- Finding 2 (minor, accepted): the gate's Network-capture step could not
|
||||
observe the duplicated tab — it is a separate DevTools target that reloads
|
||||
synchronously, so source-tab DevTools never records its document request.
|
||||
Fixed: step 4 now uses executable post-reload proof from the duplicated
|
||||
tab's own console — `performance.getEntriesByType("navigation")[0]?.type
|
||||
=== "reload"` — plus the archived-task UI check.
|
||||
- No production-handler correctness defect found.
|
||||
|
||||
### Adversarial review round 8 (50-luna-review-fix)
|
||||
- Finding 1 (minor, accepted): the cold-`back_forward` regression only
|
||||
covered a fallback using the injected navigation reader; a fallback calling
|
||||
the module's default `readNavigationType` (global performance) would stay
|
||||
green. Fixed: the harness gained `injectNavigationType: false`, and a
|
||||
companion test installs without the injected reader while stubbing global
|
||||
`performance` to report `back_forward` (with `performance.now` for
|
||||
happy-dom); the Risk text now describes both paths.
|
||||
- Finding 2 (minor, accepted): step 1 declared `const t0` in the source tab's
|
||||
console, which is target-local and unavailable in the duplicated tab.
|
||||
Fixed: the start time is stored in sessionStorage
|
||||
(`kandev.bfcacheProbeStart`, copied to the duplicate) and the duplicated
|
||||
tab compares with a self-contained null-safe one-liner
|
||||
(`((JSON.parse(sessionStorage.getItem("kandev.bfcacheRestoreProbe") ?? "null")?.at ?? -1) >= Number(sessionStorage.getItem("kandev.bfcacheProbeStart")))`),
|
||||
after a follow-up review caught that the first revision referenced an
|
||||
unbound `at`, and a later review caught that the expression threw on an
|
||||
absent probe.
|
||||
- Finding 3 (nit, accepted): the spec's client-storage contract only
|
||||
mentioned writes before reload, omitting the `persisted === false`
|
||||
`back_forward` diagnostic writes. Fixed: the contract now states the probe
|
||||
covers frozen restores AND persisted-false candidates, with only
|
||||
`persisted === true` causing a reload.
|
||||
- No production-handler correctness defect found.
|
||||
|
||||
### PR review fixup (CodeRabbit / Claude / Greptile, PR #2717)
|
||||
|
||||
- CodeRabbit P2 (plan.md:108): the Wave-1 checkbox marked Task 01 complete
|
||||
while the native gate is open. Fixed: unchecked; it stays `[ ]` until the
|
||||
native result is recorded.
|
||||
- CodeRabbit P2 (e2e:30): the reload assertion polled the navigation effect
|
||||
with a hand-picked 15s budget. Fixed: the E2E now arms a
|
||||
`framenavigated` causal wait before dispatching the restore signal and
|
||||
asserts the reload navigation type with the default timeout.
|
||||
- CodeRabbit + Claude (unit tests): "fresh load" and "manual refresh" tests
|
||||
exercised identical paths. Fixed: they now pass
|
||||
`navigationType: "navigate"` / `"reload"` through the harness, guarding a
|
||||
reintroduced fallback keyed on either type.
|
||||
- CodeRabbit (task-01:61): the gate's probe expression threw on an absent
|
||||
probe. Fixed: both step 3 and the round-8 record now use a null-safe
|
||||
expression that evaluates false (no-probe outcome recordable) instead of
|
||||
throwing.
|
||||
- CodeRabbit (plan/spec): the spec claimed native Duplicate "still delivers
|
||||
`persisted`" — unverified. Fixed: the failure-mode bullet and the
|
||||
Duplicate-tab scenario now mark that outcome pending until the native gate
|
||||
is recorded; LanguageTool wording nit ("in the meantime") fixed.
|
||||
- Claude suggestion: spec status `draft` → `building` (INDEX updated).
|
||||
- Not actionable here: the merge-blocking native-Chrome Duplicate-tab
|
||||
verification requires the user's non-headless Chrome (right-click →
|
||||
Duplicate cannot be driven via CDP/Playwright); it remains the documented
|
||||
gate and stays open in the task/plan records.
|
||||
@@ -58,6 +58,7 @@ Product-wide capabilities that are not tied to a single feature area.
|
||||
| [bounded-task-status-delivery](platform/bounded-task-status-delivery.md) | approved |
|
||||
| [diagnostic-logging](platform/diagnostic-logging.md) | approved |
|
||||
| [provider-error-recovery](platform/provider-error-recovery.md) | draft |
|
||||
| [duplicated-tab-stale-data](fix-duplicated-tab-stale-data/spec.md) | building |
|
||||
| [health-endpoint-version](health-endpoint-version/spec.md) | building |
|
||||
| [go-dev-launcher](go-dev-launcher/spec.md) | draft |
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
---
|
||||
status: building
|
||||
created: 2026-08-16
|
||||
owner: Kandev
|
||||
---
|
||||
|
||||
# Reload Kandev when a tab is restored from a frozen browser snapshot
|
||||
|
||||
## Why
|
||||
|
||||
Duplicating a Kandev tab in Chrome restores a frozen snapshot of the page
|
||||
(back/forward-style restore, not a fresh load; Chromium reports the duplicated
|
||||
tab's navigation type as `back_forward`). The restored page keeps the JS heap
|
||||
and DOM as they were when the snapshot was taken, and no boot payload is
|
||||
re-fetched.
|
||||
|
||||
Kandev's data-freshness model assumes fresh loads: the SPA shell and all data
|
||||
fetches are `Cache-Control: no-store`, so any real navigation re-reads current
|
||||
backend state. A restored page bypasses that model entirely. The existing
|
||||
foreground-refresh hooks (`useForegroundRefresh`) are best-effort, cover only
|
||||
subsets of surfaces, and do not distinguish restores from normal focus events,
|
||||
so a frozen restore can show stale data (for example, a task archived after
|
||||
the snapshot still appears in Active tasks) until the user manually refreshes.
|
||||
|
||||
Chrome has been rolling out back/forward cache (bfcache) admission for
|
||||
`Cache-Control: no-store` pages since 2024, so restores of this page are
|
||||
becoming more common. The platform guidance for this exact situation is to
|
||||
handle `pageshow` with `event.persisted === true` and refresh or reload the
|
||||
page.
|
||||
|
||||
## What
|
||||
|
||||
- When the Kandev page is restored from a frozen browser snapshot, the app
|
||||
performs a full reload so state is re-fetched from the backend.
|
||||
- A restore is detected on the `pageshow` event when `event.persisted ===
|
||||
true`. This is the only reliable frozen-restore signal: the navigation type
|
||||
`back_forward` also covers cold history traversals and session-restored
|
||||
tabs, which load fresh and must not be reloaded a second time.
|
||||
- Normal page loads are unaffected: a fresh navigation, a manual refresh,
|
||||
a cold back/forward traversal, and in-app SPA routing never trigger the
|
||||
reload.
|
||||
|
||||
## API surface
|
||||
|
||||
No backend, network, or public contract change. Observable behavior: after
|
||||
Chrome's Duplicate tab (or a back/forward restore) the page reloads once
|
||||
instead of showing frozen state. This is the same effect as the user's manual
|
||||
refresh, automated.
|
||||
|
||||
## Failure modes
|
||||
|
||||
- Restore signals unavailable (no `PageTransitionEvent.persisted`): the
|
||||
handler degrades to a no-op and stale data can still appear until a manual
|
||||
refresh. All browsers that implement bfcache deliver `persisted` on the
|
||||
restore.
|
||||
- Cold history traversals and session-restored tabs fire `pageshow` with
|
||||
`persisted` false even though their navigation type is `back_forward`; the
|
||||
handler ignores them because those pages load fresh and need no reload.
|
||||
- Reload loop: a reload produces a fresh document whose `pageshow.persisted`
|
||||
is `false`, so no recursive reload.
|
||||
- Open WebSocket at freeze time can make the page ineligible for bfcache on
|
||||
back/forward navigations in some Chrome versions; such navigations reload
|
||||
normally and are correct without the handler. Genuine restores deliver
|
||||
`persisted` on `pageshow`. Whether Chrome's native Duplicate-tab path is
|
||||
such a restore (vs. a state clone that fires no `pageshow` at all) is
|
||||
pending the native verification gate documented in the plan; the
|
||||
Duplicate-tab outcome in the scenarios below is marked pending until that
|
||||
evidence is recorded.
|
||||
|
||||
## Persistence guarantees
|
||||
|
||||
Server state remains the source of truth; the reload re-reads it. The fix
|
||||
writes no server state.
|
||||
|
||||
Client storage: in debug builds only (`window.__KANDEV_DEBUG`), a
|
||||
`pageshow` duplicate/history candidate writes a diagnostic record to
|
||||
sessionStorage under `kandev.bfcacheRestoreProbe` before any reload decision —
|
||||
`{ persisted, navigationType, at }`. Candidates are frozen restores
|
||||
(`persisted === true`, which then reload) and `persisted === false` documents
|
||||
reached via a `back_forward` traversal (diagnostic only, no reload), so a
|
||||
native Duplicate-tab run can be verified after the reload destroys the
|
||||
document. The write is best-effort (never blocks the reload), is skipped
|
||||
outside debug builds, and is scoped to the tab session: it is overwritten on
|
||||
the next candidate and cleared by the merge-gate checklist before each
|
||||
attempt. It contains no user data.
|
||||
|
||||
## Scenarios
|
||||
|
||||
- **GIVEN** a Kandev tab showing a task in Active tasks, **WHEN** the task is
|
||||
archived and the user duplicates the tab in Chrome, **THEN** the duplicated
|
||||
tab reloads and the task is not shown as active. *(Pending: the native
|
||||
Duplicate-tab event sequence is the outstanding verification gate; until it
|
||||
is recorded, this outcome is expected but not yet evidenced.)*
|
||||
- **GIVEN** a loaded Kandev page, **WHEN** the browser restores it from
|
||||
bfcache (back/forward navigation), **THEN** the page reloads with fresh
|
||||
data.
|
||||
- **GIVEN** a Kandev page loading normally, **WHEN** it loads, **THEN** no
|
||||
reload is triggered.
|
||||
- **GIVEN** a Kandev page, **WHEN** the user refreshes it manually, **THEN**
|
||||
no additional reload is triggered.
|
||||
- **GIVEN** a Kandev page reached by a cold back/forward traversal (loaded
|
||||
fresh, `pageshow.persisted` false), **WHEN** it loads, **THEN** no reload
|
||||
is triggered.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Incremental state reconciliation after restore. The reload is the
|
||||
reconciliation; WS reconnect/resubscribe continues to cover session-level
|
||||
data independently.
|
||||
- Reloading on tab freeze/resume (`resume` event). Backgrounded-tab resume is
|
||||
the normal tab-switch flow already handled by the existing
|
||||
foreground-refresh hooks.
|
||||
- Changing HTTP caching headers. The shell and data fetches already use
|
||||
`no-store`; bfcache is not the HTTP cache and cannot be disabled with
|
||||
headers.
|
||||
Reference in New Issue
Block a user