From 02fad667f9aa45b5255843a7af558aaa13bdb6e4 Mon Sep 17 00:00:00 2001 From: 134 Date: Mon, 19 Oct 2026 17:29:54 +0800 Subject: [PATCH] fix(flow-board): prevent runtime recovery from reloading edits --- app/layout.tsx | 66 +++++++------ tests/flow-board/reload-recovery.spec.ts | 43 +++++++++ .../runtime-error-recovery.test.ts | 93 +++++++++++++++++++ 3 files changed, 176 insertions(+), 26 deletions(-) create mode 100644 tests/flow-board/reload-recovery.spec.ts create mode 100644 tests/integration/runtime-error-recovery.test.ts diff --git a/app/layout.tsx b/app/layout.tsx index fcee64a6a..aca22ee6f 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -94,18 +94,33 @@ const CHUNK_ERROR_RECOVERY_SCRIPT = `\ } } - function safeRemoveSessionStorage(key) { - try { - sessionStorage.removeItem(key); - return true; - } catch (e) { - return false; - } - } - - var RECOVERY_KEY = 'chunk-reload-attempted-v2'; + // Keep one attempt per route for the browser session. A cooldown alone can + // turn a recurring editor exception into an endless reload cycle. + var RECOVERY_KEY = 'chunk-reload-attempted-v3:' + window.location.pathname; var lastReloadAt = Number(safeGetSessionStorage(RECOVERY_KEY) || '0') || 0; - + var recoveryNoticeShown = false; + + function isFlowBoardEditing() { + var path = window.location.pathname; + return path === '/flow-board' || path.indexOf('/flow-board/') === 0; + } + + function showManualRecoveryNotice() { + if (recoveryNoticeShown) return; + recoveryNoticeShown = true; + function show() { + if (!document.body || !isFlowBoardEditing()) return; + var notice = document.createElement('div'); + notice.setAttribute('role', 'alert'); + notice.setAttribute('data-runtime-recovery-notice', 'true'); + notice.style.cssText = 'position:fixed;bottom:24px;left:50%;transform:translateX(-50%);z-index:2147483647;max-width:calc(100vw - 32px);padding:12px 16px;border:1px solid var(--border,#e4e4e7);border-radius:12px;background:var(--background,#fff);color:var(--foreground,#27272a);box-shadow:0 4px 20px #0002;font:14px/1.5 system-ui;'; + notice.textContent = '部分页面资源加载失败,请先保存白板后手动刷新页面。'; + document.body.appendChild(notice); + } + if (document.body) show(); + else window.addEventListener('DOMContentLoaded', show, { once: true }); + } + function shouldAutoRecover(message) { if (!message) return false; var normalized = String(message).toLowerCase(); @@ -117,28 +132,33 @@ const CHUNK_ERROR_RECOVERY_SCRIPT = `\ normalized.indexOf('error loading dynamically imported module') !== -1 || normalized.indexOf('importing a module script failed') !== -1 || normalized.indexOf('module script load failed') !== -1 || - normalized.indexOf('/_next/static/chunks/') !== -1 || - normalized.indexOf('failed to find server action') !== -1 || - normalized.indexOf('server components render') !== -1 || - normalized.indexOf('application error: a client-side exception') !== -1 + normalized.indexOf('failed to find server action') !== -1 ); } function triggerAutoRecover(reason) { + if (isFlowBoardEditing()) { + showManualRecoveryNotice(); + return; + } + if (lastReloadAt) return; var now = Date.now(); - if (lastReloadAt && now - lastReloadAt < 30000) return; + // If storage is blocked, a reload cannot remember its attempt and must not run. + if (!safeSetSessionStorage(RECOVERY_KEY, String(now))) return; lastReloadAt = now; - console.warn('[RuntimeRecover] 检测到前端运行时异常,自动刷新页面...', reason || ''); - safeSetSessionStorage(RECOVERY_KEY, String(now)); + console.warn('[RuntimeRecover] 检测到页面资源加载失败,尝试恢复一次...', reason || ''); setTimeout(function() { + if (isFlowBoardEditing()) { + showManualRecoveryNotice(); + return; + } window.location.reload(); }, 800); } - + window.addEventListener('error', function(event) { var message = ''; if (event && event.message) message += event.message; - if (event && event.filename) message += ' ' + event.filename; if (event && event.error && event.error.message) { message += ' ' + event.error.message; } @@ -157,12 +177,6 @@ const CHUNK_ERROR_RECOVERY_SCRIPT = `\ } }); - // 页面稳定一段时间后清除标记,避免旧资源异常无限刷新。 - window.addEventListener('load', function() { - setTimeout(function() { - safeRemoveSessionStorage(RECOVERY_KEY); - }, 120000); - }); })();`; export default function RootLayout({ diff --git a/tests/flow-board/reload-recovery.spec.ts b/tests/flow-board/reload-recovery.spec.ts new file mode 100644 index 000000000..fd1d1100c --- /dev/null +++ b/tests/flow-board/reload-recovery.spec.ts @@ -0,0 +1,43 @@ +import { expect, test } from "@playwright/test"; +import JSZip from "jszip"; + +test("runtime errors and missing chunks preserve the open flow board without navigation", async ({ page }) => { + await page.goto("/dev/flow-board-preview", { waitUntil: "commit" }); + const zip = new JSZip(); + zip.file("content.json", JSON.stringify([{ title: "刷新保护", rootTopic: { + id: "recovery-root", title: "保留正在编辑的主题", children: { attached: [{ id: "recovery-child", title: "已编辑的分支" }] }, + } }])); + await page.locator("input[accept*=xmind]").setInputFiles({ name: "recovery.xmind", mimeType: "application/octet-stream", buffer: await zip.generateAsync({ type: "nodebuffer" }) }); + const topic = page.locator(".drawnix").getByText("保留正在编辑的主题", { exact: true }); + await expect(topic).toBeVisible(); + await page.evaluate(() => document.fonts.ready); + // Exercise the production pathname with the isolated, non-persistent fixture. + await page.evaluate(() => window.history.replaceState(null, "", "/flow-board")); + let navigations = 0; + page.on("framenavigated", (frame) => { if (frame === page.mainFrame()) navigations += 1; }); + await page.evaluate(() => { + (window as any).__flowBoardRoot = document.querySelector(".drawnix"); + window.dispatchEvent(new ErrorEvent("error", { + message: "Cannot resolve a DOM node from Slate node", + filename: `${window.location.origin}/_next/static/chunks/flow-board.js`, + })); + }); + await page.waitForTimeout(1100); + expect(navigations).toBe(0); + await expect(topic).toBeVisible(); + await expect(page.locator("[data-runtime-recovery-notice]")).toHaveCount(0); + await page.evaluate(() => { + const error = { message: "ChunkLoadError: Loading chunk 123 failed" }; + window.dispatchEvent(new ErrorEvent("error", { message: error.message })); + window.dispatchEvent(new ErrorEvent("error", { message: error.message })); + }); + await page.waitForTimeout(1100); + expect(navigations).toBe(0); + await expect(page.locator("[data-runtime-recovery-notice]")).toHaveCount(1); + await expect(page.locator("[data-runtime-recovery-notice]")).toContainText("先保存白板后手动刷新"); + expect(await page.evaluate(() => (window as any).__flowBoardRoot === document.querySelector(".drawnix"))).toBe(true); + await topic.click(); + await page.keyboard.press("Tab"); + await expect(page.locator(".drawnix").getByText("已编辑的分支", { exact: true })).toBeVisible(); + await expect(page.locator(".xmind-topic-count")).toContainText("3"); +}); diff --git a/tests/integration/runtime-error-recovery.test.ts b/tests/integration/runtime-error-recovery.test.ts new file mode 100644 index 000000000..c12c97098 --- /dev/null +++ b/tests/integration/runtime-error-recovery.test.ts @@ -0,0 +1,93 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; +import { runInNewContext } from "node:vm"; + +const layout = readFileSync(new URL("../../app/layout.tsx", import.meta.url), "utf8"); +const script = layout.match(/const CHUNK_ERROR_RECOVERY_SCRIPT = `\\?\n([\s\S]*?)`;/)?.[1]; +assert(script, "Test the exact recovery script delivered by the root layout"); + +function browser(pathname = "/flow-board", options: { blockedStorage?: boolean; storage?: Map } = {}) { + const events = new Map void>(); + const timers: Array<() => void> = []; + const storage = options.storage || new Map(); + const notices: unknown[] = []; + let reloads = 0; + const location = { pathname, reload: () => { reloads += 1; } }; + const dom = { + body: { appendChild: (node: unknown) => notices.push(node) }, + createElement: () => ({ setAttribute() {}, style: {}, textContent: "" }), + }; + runInNewContext(script!, { + window: { location, addEventListener: (name: string, handler: (event?: unknown) => void) => events.set(name, handler) }, + document: dom, + sessionStorage: { + getItem: (key: string) => storage.get(key), + setItem: (key: string, value: string) => { if (options.blockedStorage) throw new Error("Blocked storage"); storage.set(key, value); }, + }, + setTimeout: (handler: () => void) => timers.push(handler), + console: { warn() {} }, + }); + return { + location, storage, notices, dom, + dispatch: (name: string, event?: unknown) => events.get(name)?.(event), + flush: () => { while (timers.length) timers.shift()!(); }, + reloads: () => reloads, + }; +} + +test("ordinary editor exceptions in bundled scripts never force a page reload", () => { + for (const path of ["/flow-board", "/"]) { + const page = browser(path); + for (let i = 0; i < 3; i += 1) { + page.dispatch("error", { message: "Cannot resolve a DOM node from Slate node", filename: "https://ai.example/_next/static/chunks/editor.js" }); + page.flush(); + } + assert.equal(page.reloads(), 0); + } +}); + +test("failed imports and rejected chunk promises never reload the flow board", () => { + const page = browser(); + page.dispatch("error", { message: "ChunkLoadError: Loading chunk 123 failed" }); + page.dispatch("unhandledrejection", { reason: new Error("Failed to fetch dynamically imported module") }); + page.flush(); + assert.equal(page.reloads(), 0); + assert.equal(page.notices.length, 1); +}); + +test("a genuinely missing chunk can recover a non-editor route only once", () => { + const page = browser("/"); + const error = { message: "ChunkLoadError: Loading chunk 123 failed" }; + page.dispatch("error", error); page.flush(); + page.dispatch("error", error); page.flush(); + page.dispatch("load"); page.flush(); + assert.equal(page.reloads(), 1); + const afterReload = browser("/", { storage: page.storage }); + afterReload.dispatch("error", error); afterReload.flush(); + assert.equal(afterReload.reloads(), 0); +}); + +test("blocked session storage cannot create a reload loop", () => { + const page = browser("/", { blockedStorage: true }); + page.dispatch("error", { message: "ChunkLoadError: Loading chunk 123 failed" }); + page.flush(); + assert.equal(page.reloads(), 0); +}); + +test("navigating into a flow board cancels an already scheduled reload", () => { + const page = browser("/"); + page.dispatch("error", { message: "ChunkLoadError: Loading chunk 123 failed" }); + page.location.pathname = "/flow-board"; + page.flush(); + assert.equal(page.reloads(), 0); + assert.equal(page.notices.length, 1); +}); + +test("server render failures do not masquerade as downloadable resource failures", () => { + const page = browser("/"); + page.dispatch("unhandledrejection", { reason: new Error("An error occurred in the Server Components render") }); + page.dispatch("error", { message: "Application error: a client-side exception has occurred", filename: "/_next/static/chunks/editor.js" }); + page.flush(); + assert.equal(page.reloads(), 0); +});