fix(flow-board): prevent runtime recovery from reloading edits
This commit is contained in:
parent
b9d3763730
commit
02fad667f9
@ -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({
|
||||
|
||||
43
tests/flow-board/reload-recovery.spec.ts
Normal file
43
tests/flow-board/reload-recovery.spec.ts
Normal 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");
|
||||
});
|
||||
93
tests/integration/runtime-error-recovery.test.ts
Normal file
93
tests/integration/runtime-error-recovery.test.ts
Normal 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);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user