Skip to content

Commit 388da2b

Browse files
chaxusclaude
andcommitted
fix(pwa): a new build's service worker waits until no document is open before taking over (no mixed-version editor after a deploy); reload once after the switch
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 4342ba5 commit 388da2b

7 files changed

Lines changed: 233 additions & 29 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ notes. Entries describe what users experience, not internal refactors.
4141

4242
### Fixed
4343

44+
- A new release deployed while you have a document open no longer swaps
45+
parts of the editor underneath it: the update now waits until nothing is
46+
open (or the next visit), then takes over and refreshes once. Previously
47+
the old page could lazy-load pieces of the new build into the running
48+
session, a plausible cause of sporadic "An error occurred during the work
49+
with the document" dialogs right after deploys.
4450
- "Excel" files that are really an HTML table (the usual export of web
4551
systems, saved as .xls/.xlsx) now open and save as a real workbook instead
4652
of failing to load.

‎docs/changelogs/2026-08-15-v9-regression-campaign.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,3 +115,10 @@ gh workflow run nightly-corpus.yml -f limit=300 # 手动触发夜间
115115
与 `test-results-<port>/`——两会话共用 dist/test-results 会互相清空
116116
(这就是本晚"页面 60s 起不来 / 报告少行"的来源,非产品缺陷)。
117117
- 跨浏览器与视觉部分见上文"现状数字"。
118+
- **SW 升级策略(用户 P0 的最可能机制)**:原 sw.js `install` 即 `skipWaiting()`,
119+
`activate` 立刻删旧缓存并 claim;页面开着文档时不重载(保护未保存编辑),
120+
但此后懒加载的 sdk-all.js / x2t.wasm.gz / 字体 / 拼写引擎全来自**新构建**,
121+
与已加载的旧构建混装——今天连续部署几十次,"编辑标题时弹致命框"与此吻合。
122+
改为:新 SW 等待;页面无文档时发 `SKIP_WAITING` → controllerchange → 重载一次;
123+
有文档时保持旧 SW 直到下次访问(`lib/sw-update.ts` + `test/unit/sw-update.test.ts`
124+
含 sw.js 契约哨兵)。CHANGELOG 已记为用户可见修复。

‎docs/test-matrix.md‎

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -44,17 +44,17 @@ embed-save-default.spec、`HX` = html-as-xls.spec、`FN` = filename-matrix.spec
4444

4545
## C. 运行环境
4646

47-
| 环境 | 覆盖 |
48-
| ----------------------- | -------------------------------------------------------------------------------------------------------- |
49-
| 全新 profile 冷启动 | 每个 Playwright 用例即冷 profile |
50-
| SW 已控制页面(老用户) | `SW` = sw-warm.spec(第二次加载由 SW 控制页面与编辑器 frame,打开+保存往返);"SW 缓存旧构建"升级路径 ⬜ |
51-
| standalone 主站 | AS(加载 / manifest);编辑器路径 ⬜ |
52-
| embed(iframe) | ER / EA / 其余全部 |
53-
| Docker 镜像 | 同套 E2E(`test:e2e:docker`) |
54-
| Chromium | 全部 |
55-
| WebKit / Firefox | ⬜ |
56-
| 视觉基线(L3) | ⬜ |
57-
| 性能预算(L4) | ⬜(corpus 报告已含 load/save 耗时,未设阈值) |
47+
| 环境 | 覆盖 |
48+
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
49+
| 全新 profile 冷启动 | 每个 Playwright 用例即冷 profile |
50+
| SW 已控制页面(老用户) | `SW` = sw-warm.spec(第二次加载由 SW 控制页面与编辑器 frame,打开+保存往返);"SW 缓存旧构建"升级路径:策略改为新 SW 等待、无文档时才 SKIP_WAITING + 重载(`lib/sw-update.ts`,单测 `test/unit/sw-update.test.ts` + sw.js 契约);双构建端到端 ⬜ |
51+
| standalone 主站 | AS(加载 / manifest);编辑器路径 ⬜ |
52+
| embed(iframe) | ER / EA / 其余全部 |
53+
| Docker 镜像 | 同套 E2E(`test:e2e:docker`) |
54+
| Chromium | 全部 |
55+
| WebKit / Firefox | ⬜ |
56+
| 视觉基线(L3) | ⬜ |
57+
| 性能预算(L4) | ⬜(corpus 报告已含 load/save 耗时,未设阈值) |
5858

5959
## D. 交互入口(策略第 9 节)
6060

@@ -69,11 +69,11 @@ embed-save-default.spec、`HX` = html-as-xls.spec、`FN` = filename-matrix.spec
6969

7070
## E. escape 表(用户报出而矩阵未抓)
7171

72-
| 日期 | 现象 | 缺的维度 | 处置 |
73-
| ---------- | -------------------------------- | -------------------------- | ---------------------------------------------------------------------------------------- |
74-
| 2026-08-15 | 真实 35 页 PPTX 编辑标题弹致命框 | C. SW 已控制页面(旧构建) | 未复现于当前构建;等用户清 SW 复测;"SW 控制页面"维度已补 sw-warm.spec,"旧构建升级"待补 |
75-
| 2026-08-15 | 插图后保存主线程假死 | A. 插图后保存 | 已修 + ER 用例 |
76-
| 2026-08-15 | Save 按钮常灰 | A. 键盘编辑→Save 按钮 | 已修 + ER 用例 |
72+
| 日期 | 现象 | 缺的维度 | 处置 |
73+
| ---------- | -------------------------------- | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
74+
| 2026-08-15 | 真实 35 页 PPTX 编辑标题弹致命框 | C. SW 已控制页面(旧构建) | 未复现于当前构建;最可能机制=部署时页面开着文档、旧 SW 被立即替换、旧页面懒加载到新构建分片(新旧混装)——已改 SW 等待策略;sw-warm.spec 覆盖 SW 控制页面 |
75+
| 2026-08-15 | 插图后保存主线程假死 | A. 插图后保存 | 已修 + ER 用例 |
76+
| 2026-08-15 | Save 按钮常灰 | A. 键盘编辑→Save 按钮 | 已修 + ER 用例 |
7777

7878
## F. 集成禁区(API 枚举发现,任何集成路径都不得调用)
7979

‎index.ts‎

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { shouldReloadOnControllerChange, wireServiceWorkerUpdates } from './lib/sw-update';
12
import { getAllQueryString } from 'ranuts/utils';
23
import { View } from 'ranui/builder';
34
import { initEmbedApi } from './lib/embed-api';
@@ -183,22 +184,22 @@ if (documentUrl) {
183184

184185
// Register Service Worker for PWA
185186
if ('serviceWorker' in navigator) {
186-
// Whether a SW was already controlling this page when it started. Each deploy
187-
// rebuilds sw.js with a fresh CACHE_VERSION; the new SW skipWaiting()s and
188-
// claims clients, firing `controllerchange`. If a SW was ALREADY in control at
189-
// startup, that event means a *new build* took over — not the first install —
190-
// so we can reload once to swap the stale assets for the fresh ones. Without
191-
// this, the current page keeps rendering the previously-cached build until the
192-
// user manually refreshes (the "refresh once more and it's fixed" symptom).
187+
// Update policy lives in lib/sw-update.ts: a new build's worker waits until
188+
// no document is open, then takes over and the page reloads once.
193189
const hadController = !!navigator.serviceWorker.controller;
194190
let reloadingForUpdate = false;
191+
const hasOpenDocument = () => Boolean(getDocmentObj().fileName);
195192

196193
navigator.serviceWorker.addEventListener('controllerchange', () => {
197-
// Guard 1: only on a real update (not first install). Guard 2: reload once.
198-
// Guard 3: never while a document is open — a reload would discard unsaved
199-
// edits. On the landing page fileName is empty, so the reload is invisible.
200-
if (!hadController || reloadingForUpdate) return;
201-
if (getDocmentObj().fileName) return;
194+
if (
195+
!shouldReloadOnControllerChange({
196+
hadController,
197+
alreadyReloading: reloadingForUpdate,
198+
hasOpenDocument: hasOpenDocument(),
199+
})
200+
) {
201+
return;
202+
}
202203
reloadingForUpdate = true;
203204
window.location.reload();
204205
});
@@ -208,6 +209,7 @@ if ('serviceWorker' in navigator) {
208209
.register('./sw.js')
209210
.then((registration) => {
210211
console.log('SW registered: ', registration);
212+
wireServiceWorkerUpdates(registration, hasOpenDocument);
211213
// Check for updates on every page load. Firefox rejects the update
212214
// when the registration changed since it was scheduled (a benign race
213215
// right after register()); swallow it so it never surfaces as an

‎lib/sw-update.ts‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/**
2+
* Service-worker update policy.
3+
*
4+
* Each deploy ships a sw.js with a fresh CACHE_VERSION. The new worker
5+
* installs and then WAITS (sw.js no longer calls skipWaiting() on install):
6+
* activating it deletes the previous build's caches, and a page that keeps
7+
* running the old build with a document open would from then on lazy-load
8+
* pieces of the new build (sdk-all.js, x2t.wasm.gz, fonts) into an old
9+
* session -- a mixed-version editor. So the page promotes a waiting worker
10+
* only when no document is open, and reloads once it takes control.
11+
*/
12+
13+
export type SwLike = {
14+
state: string;
15+
postMessage: (msg: unknown) => unknown;
16+
addEventListener: (t: 'statechange', cb: () => void) => void;
17+
};
18+
export type RegistrationLike = {
19+
waiting: SwLike | null;
20+
installing: SwLike | null;
21+
addEventListener: (t: 'updatefound', cb: () => void) => void;
22+
};
23+
24+
export const SKIP_WAITING_MESSAGE = { type: 'SKIP_WAITING' } as const;
25+
26+
/** Tell a waiting worker to take over -- only if nothing is open. Returns whether it did. */
27+
export function promoteWaitingWorker(reg: RegistrationLike, hasOpenDocument: () => boolean): boolean {
28+
if (!reg.waiting || hasOpenDocument()) return false;
29+
reg.waiting.postMessage(SKIP_WAITING_MESSAGE);
30+
return true;
31+
}
32+
33+
/**
34+
* Wire a registration: promote an already-waiting worker now, and promote
35+
* future ones as soon as they finish installing (both gated on "no document
36+
* open"). A worker that stays waiting because a document is open activates on
37+
* the next visit, when the landing page calls this again.
38+
*/
39+
export function wireServiceWorkerUpdates(reg: RegistrationLike, hasOpenDocument: () => boolean): void {
40+
promoteWaitingWorker(reg, hasOpenDocument);
41+
reg.addEventListener('updatefound', () => {
42+
const installing = reg.installing;
43+
if (!installing) return;
44+
installing.addEventListener('statechange', () => {
45+
if (installing.state === 'installed') promoteWaitingWorker(reg, hasOpenDocument);
46+
});
47+
});
48+
}
49+
50+
/**
51+
* Whether a controllerchange should reload the page: only when a worker was
52+
* already in control at startup (so this is an update, not the first
53+
* install), only once, and never with a document open (unsaved edits).
54+
*/
55+
export function shouldReloadOnControllerChange(state: {
56+
hadController: boolean;
57+
alreadyReloading: boolean;
58+
hasOpenDocument: boolean;
59+
}): boolean {
60+
return state.hadController && !state.alreadyReloading && !state.hasOpenDocument;
61+
}

‎public/sw.js‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,18 @@ self.addEventListener('install', (event) => {
4848
return cache.addAll(ASSETS_TO_CACHE);
4949
}),
5050
);
51-
self.skipWaiting();
51+
// No skipWaiting() here. Activating immediately deletes the previous
52+
// build's runtime cache while pages of that build are still open; a page
53+
// with a document open is deliberately not reloaded on controllerchange,
54+
// so its later lazy loads (sdk-all.js, x2t.wasm.gz, fonts, spellcheck)
55+
// would come from the NEW build and run mixed with the old one. The page
56+
// asks for the switch (SKIP_WAITING below) only when no document is open.
57+
});
58+
59+
self.addEventListener('message', (event) => {
60+
if (event.data && event.data.type === 'SKIP_WAITING') {
61+
self.skipWaiting();
62+
}
5263
});
5364

5465
// Activate event: Clean up caches from every previous version

‎test/unit/sw-update.test.ts‎

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { readFileSync } from 'node:fs';
2+
import { resolve } from 'node:path';
3+
import { describe, expect, it, vi } from 'vitest';
4+
import {
5+
type SwLike,
6+
SKIP_WAITING_MESSAGE,
7+
promoteWaitingWorker,
8+
shouldReloadOnControllerChange,
9+
wireServiceWorkerUpdates,
10+
} from '../../lib/sw-update';
11+
12+
/**
13+
* Deploy-while-editing safety (matrix section C, "SW 缓存旧构建 -> 升级").
14+
* A new build's worker must not take over while a document is open: it would
15+
* delete the old build's caches and the still-running old page would lazy-load
16+
* new-build pieces into its session (mixed-version editor).
17+
*/
18+
type FakeWorker = SwLike & {
19+
postMessage: ReturnType<typeof vi.fn<(msg: unknown) => unknown>>;
20+
listeners: Array<() => void>;
21+
};
22+
const worker = (state = 'installed'): FakeWorker => {
23+
const w: FakeWorker = {
24+
state,
25+
postMessage: vi.fn<(msg: unknown) => unknown>(),
26+
listeners: [],
27+
addEventListener: (_t, cb) => {
28+
w.listeners.push(cb);
29+
},
30+
};
31+
return w;
32+
};
33+
const registration = (waiting: ReturnType<typeof worker> | null = null) => {
34+
const r = {
35+
waiting: waiting as SwLike | null,
36+
installing: null as SwLike | null,
37+
updateListeners: [] as Array<() => void>,
38+
addEventListener: (_t: 'updatefound', cb: () => void) => {
39+
r.updateListeners.push(cb);
40+
},
41+
};
42+
return r;
43+
};
44+
45+
describe('promoteWaitingWorker', () => {
46+
it('sends SKIP_WAITING to a waiting worker when no document is open', () => {
47+
const w = worker();
48+
expect(promoteWaitingWorker(registration(w), () => false)).toBe(true);
49+
expect(w.postMessage).toHaveBeenCalledWith(SKIP_WAITING_MESSAGE);
50+
});
51+
52+
it('never promotes while a document is open', () => {
53+
const w = worker();
54+
expect(promoteWaitingWorker(registration(w), () => true)).toBe(false);
55+
expect(w.postMessage).not.toHaveBeenCalled();
56+
});
57+
58+
it('is a no-op without a waiting worker', () => {
59+
expect(promoteWaitingWorker(registration(null), () => false)).toBe(false);
60+
});
61+
});
62+
63+
describe('wireServiceWorkerUpdates', () => {
64+
it('promotes a worker that finishes installing later (landing page, nothing open)', () => {
65+
const r = registration();
66+
wireServiceWorkerUpdates(r, () => false);
67+
const w = worker('installing');
68+
r.installing = w;
69+
r.updateListeners.forEach((cb) => cb());
70+
w.state = 'installed';
71+
r.waiting = w;
72+
w.listeners.forEach((cb) => cb());
73+
expect(w.postMessage).toHaveBeenCalledWith(SKIP_WAITING_MESSAGE);
74+
});
75+
76+
it('leaves the new worker waiting while a document is open', () => {
77+
const r = registration();
78+
wireServiceWorkerUpdates(r, () => true);
79+
const w = worker('installing');
80+
r.installing = w;
81+
r.updateListeners.forEach((cb) => cb());
82+
w.state = 'installed';
83+
r.waiting = w;
84+
w.listeners.forEach((cb) => cb());
85+
expect(w.postMessage).not.toHaveBeenCalled();
86+
});
87+
});
88+
89+
describe('shouldReloadOnControllerChange', () => {
90+
it('reloads once on an update with nothing open', () => {
91+
expect(
92+
shouldReloadOnControllerChange({ hadController: true, alreadyReloading: false, hasOpenDocument: false }),
93+
).toBe(true);
94+
});
95+
it('does not reload on first install, twice, or with a document open', () => {
96+
expect(
97+
shouldReloadOnControllerChange({ hadController: false, alreadyReloading: false, hasOpenDocument: false }),
98+
).toBe(false);
99+
expect(
100+
shouldReloadOnControllerChange({ hadController: true, alreadyReloading: true, hasOpenDocument: false }),
101+
).toBe(false);
102+
expect(
103+
shouldReloadOnControllerChange({ hadController: true, alreadyReloading: false, hasOpenDocument: true }),
104+
).toBe(false);
105+
});
106+
});
107+
108+
describe('public/sw.js keeps the waiting contract', () => {
109+
const src = readFileSync(resolve(__dirname, '../../public/sw.js'), 'utf8');
110+
it('does not skipWaiting on install and honors the SKIP_WAITING message', () => {
111+
const install = src
112+
.slice(src.indexOf("addEventListener('install'"), src.indexOf("addEventListener('message'"))
113+
.replace(/\/\/.*$/gm, '');
114+
expect(install).not.toMatch(/skipWaiting\(\)/);
115+
expect(src).toMatch(/type === 'SKIP_WAITING'[\s\S]*skipWaiting\(\)/);
116+
});
117+
});

0 commit comments

Comments
 (0)