fix(flow-board): prevent runtime recovery from reloading edits

This commit is contained in:
134 2026-10-19 17:29:54 +08:00
parent b9d3763730
commit 02fad667f9
3 changed files with 176 additions and 26 deletions

View File

@ -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({

View File

@ -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");
});

View File

@ -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<string, string> } = {}) {
const events = new Map<string, (event?: unknown) => void>();
const timers: Array<() => void> = [];
const storage = options.storage || new Map<string, string>();
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);
});