Skip to content

Commit 75a4de0

Browse files
chaxusclaude
andcommitted
fix(editor): move a tab off an older build by itself, and stop flashing the home panel
Two things a user hit after the font sweep shipped. **Refreshing did not get them the new build.** A deploy whose vendored tree changed leaves its worker waiting -- activating it deletes the caches the outgoing build is still reading. The page promotes it only when no document is open, and the landing page only when no editor window is open at all, so someone who goes straight to /editor and keeps a document open is never asked: reloading serves the old build back. With that build's fonts deleted from the origin by the same deploy, their screen stayed garbled for a day after the revert had shipped. The page can fix this itself, because the navigation is network-first: the HTML and bundle a stale tab loads are already the new ones, only the vendored tree underneath is not. healStaleController() promotes the waiting worker and reloads once, at boot, silently -- no prompt, at most once per tab (sessionStorage), never over unsaved edits. No interruption was the ask. Deciding *when* to do that took three attempts, and the two failures are worth keeping: - The vendored editor registers a worker of its own into the same scope from inside its iframe (/document_editor_service_worker.js, an empty stub here). A scope holds one registration, so the script alternates and `waiting` is almost always non-null -- and whoever promotes it can hand the whole origin to an empty worker. Both promotion paths now check the script URL. - Asking the *controller* which build it is looked reasonable and was not: a busy worker misses the timeout, silence read as "it is old", and tabs reloaded themselves mid-run. The full suite failed 2 of 138 on this branch while the same machine ran the baseline green; disabling the wiring made it 122 green and two minutes faster. The evidence is local instead: sw.js names its runtime cache after the vendor hash, so "does this browser hold that build's cache" answers the question without a conversation. Silence now means do nothing. **The home-state panel flashed during load.** It is built visible and only hidden once a document takes over, and `?saved=` adds two dynamic imports before that -- long enough on a cold load to read "View/Edit Document / New Word / New Excel / New PowerPoint" under the spinner. When the URL already names a document, the panel is never painted. Tests, each reverse-validated: sw-silent-update.spec.ts deploys a second build by rewriting the vendor stamp in the served sw.js and requires the next load to be on it with no notice element; entry-paths samples the panel from first paint; unit cases cover the script-URL guard, the cache-name evidence and the once-per-tab heal. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 86f325f commit 75a4de0

13 files changed

Lines changed: 830 additions & 17 deletions

‎CHANGELOG.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,15 @@ notes. Entries describe what users experience, not internal refactors.
8484

8585
### Fixed
8686

87+
- **A new version now reaches you by itself.** An update used to wait until no
88+
document was open anywhere, which for anyone who goes straight to the editor
89+
meant never -- reloading served the same old build back, and when that build
90+
referred to files a deploy had removed, the page was not merely stale but
91+
broken. A tab still on an older build now moves onto the new one on its next
92+
load, without asking and without interrupting anything: it happens while the
93+
page is still loading, once, and never over unsaved edits.
94+
- The four home-screen buttons ("View/Edit Document", "New Word"...) no longer
95+
flash behind the spinner while a document named in the URL is loading.
8796
- Chinese, Japanese and Korean text in an exported PDF came out blank. The
8897
export writes its own list of font files, and that list had been pointing at
8998
files that were not in the bundle.

‎CLAUDE.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,25 @@ docs/explorations/2026-08-19-ci-e2e-sharding.md。
510510
落地页那侧的提升要覆盖三种到达方式:已经 `waiting`、`installing` 中途、
511511
以及 `updatefound` 时已经 `installed`(`statechange` 只报此后的迁移,
512512
漏掉这一支等于整页生命周期内再没人提升它)。
513+
**第三条路(2026-08-22 起):静默自愈**。前两条都到不了"直接进 /editor 且一直开着
514+
文档"的用户——他们刷新只会再拿到旧构建,被 revert 的版本因此在用户屏幕上留了一天
515+
(而它引用的字体已被删除,表现为乱码)。`healStaleController()`(`lib/sw-update.ts`)
516+
在启动时把这类标签页悄悄换过去:等待中的 worker 是我们自己的、且是这台浏览器没跑过
517+
的构建,就 skipWaiting 并 reload 一次。**每个标签页只做一次**(sessionStorage),
518+
**有未保存改动时不做**,不弹任何提示(用户明确要求别打扰)。可行的前提是导航
519+
network-first——卡住的标签页跑的其实已经是新 bundle。
520+
**判据是"本地有没有那个构建的运行时缓存",不是"问当前 worker 它是谁"**:问过一版,
521+
并发下对方 1s 内回不上来,沉默被当成"它旧了",页面自己刷新,E2E 稳定挂 2 条。
522+
缓存名是 `document-editor-runtime-<vendorVersion>`,找不到同名的才算新构建;问不到
523+
或本地还没有运行时缓存,一律不动。检测用 `onWaitingWorker()`,覆盖那三种到达方式。
524+
**注意"有 worker 在等"不等于"有新版本"**:厂商的编辑器 iframe 会往同一个 scope
525+
注册它自己的 worker(`/document_editor_service_worker.js`,本仓库是空 stub),
526+
一个 scope 只有一个 registration,于是脚本在我们的 sw.js 与它之间来回换,`waiting`
527+
几乎永远非空。两处都据此加了判断:**提升前比对脚本 URL**(不是自己的就不碰——
528+
否则会把整个源站交给那个空 worker,vendor 树不再 cache-first,编辑器可能直接加载
529+
失败),**提示前比对构建**(`isNewerBuild()` 用 `VERSION` 消息问两端的
530+
`vendorVersion`,不同才算新版本;vendor 没变的部署压根不会等待)。见
531+
docs/explorations/2026-08-22-stale-build-and-panel-flash.md。
513532
见 docs/explorations/2026-08-20-service-worker-update-never-promoted.md。
514533
4. **站点页面统一 ran 设计体系**:所有用户可见页面(落地页、demo 页如
515534
`public/embed-demo.html`、404 等)必须使用 ranui 组件/设计 token
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
# 用户刷新拿不到新版本,以及加载时露出来的那排按钮
2+
3+
日期:2026-08-22
4+
相关:`lib/sw-update.ts`、`lib/update-prompt.ts`、`index.ts`、`styles/base.css`
5+
前情:[SW 更新从不提升](2026-08-20-service-worker-update-never-promoted.md)、
6+
[字体替换](2026-08-22-font-substitution-solved.md)
7+
8+
## 起因
9+
10+
字体替换(PR #184)上线并实测通过之后,用户仍然看到满屏乱码:
11+
12+
```
13+
Qjgbc rgjc / Qjgbc qs`rgjc / Ajgai rm_bb l mrcq
14+
```
15+
16+
把 PR #170(当天早上被 revert 的那版)的 `public/` 检出到本地跑同一个页面,得到
17+
**逐字相同**的乱码 —— 所以那不是当前线上,是**缓存在浏览器里的旧构建**。用户用无痕
18+
窗口打开是正常的,普通窗口**刷新也没用**。
19+
20+
## 一、为什么刷新没用
21+
22+
`sw.js` 在 vendor 变化时刻意不 `skipWaiting()`:激活会删掉上一版 vendor 缓存,而
23+
仍在跑旧版的页面之后惰性加载 sdk-all.js / 字体就会混版。所以提升等待中的 worker
24+
需要有人主动请求,而现有的两条路都到不了这个用户:
25+
26+
| 谁负责提升 | 条件 | 为什么没生效 |
27+
| --------------------------------- | ------------------------------------- | ---------------------------------------------------------------------------- |
28+
| 编辑器页(`lib/sw-update.ts`) | **没有文档打开** | 编辑器页几乎总是带着文档打开(`?new=` / `?file=` / `?saved=`),条件永远为假 |
29+
| 落地页(`public/sw-register.js`) | **没有编辑器窗口**(`editors === 0`) | 用户直接进 `/editor`,根本没经过落地页 |
30+
31+
于是"直接进编辑器、开着文档"的用户没有任何出路:刷新只会让旧 worker 再服务一次旧
32+
构建。这不是理论问题——被 revert 掉的构建就这样在用户屏幕上留了一整天,而它引用的
33+
字体已经从源站删掉了,于是显示为乱码。
34+
35+
## 二、修法:让页面自己悄悄换过去
36+
37+
用户的要求是"尽量不要打扰,别弹提示"。这条路走得通,因为**卡住的标签页跑的其实是新
38+
代码**:导航请求走 network-first,HTML 和 app bundle 都是新的;旧的只有 worker 从
39+
自己缓存里 cache-first 喂出来的那棵 vendor 树。所以新代码有机会自己把这件事办了。
40+
41+
`healStaleController()`(`lib/sw-update.ts`)在启动时做三件事:
42+
43+
1. 等待中的 worker 得是**我们自己的**(比对脚本 URL,理由见下一节);
44+
2. 它得是**这台浏览器没跑过的构建**;
45+
3. 满足前两条就 `postMessage(SKIP_WAITING)`,接管后 reload 一次。
46+
47+
**每个标签页只做一次**(sessionStorage 记住,杜绝 reload 循环),**有未保存改动时
48+
不做**(`shouldReloadOnControllerChange` 里判),而这一切发生在启动那一刻——那时用户
49+
还没输入任何东西,代价就是加载多一秒。
50+
51+
### "有 worker 在等"根本不等于"有新版本"
52+
53+
第一版通知在 E2E 里每次都弹,还带崩了两条用例。查下去发现一件之前没人注意的事:
54+
55+
**厂商的编辑器 iframe 会往同一个 scope 里注册它自己的 worker**
56+
(`/document_editor_service_worker.js`,本仓库把它换成了一个空 stub)。一个 scope
57+
只有一个 registration,于是这个 scope 的脚本在"我们的 sw.js"和"厂商的 stub"之间
58+
来回换:
59+
60+
```
61+
打开 / active=/sw.js waiting=null
62+
打开 /editor active=/sw.js waiting=/document_editor_service_worker.js
63+
再刷新一次 active=/sw.js waiting=/sw.js ← 我们自己又被装了一遍
64+
```
65+
66+
两个后果,都真实存在:
67+
68+
1. **谁去提升等待中的 worker,就可能把整个源站交给那个空 stub**——
69+
`promoteWaitingWorker()`(编辑器页)与 `maybePromote()`(落地页)原本都只看
70+
"有没有 waiting"。空 worker 接管之后 vendor 树不再 cache-first,编辑器甚至可能
71+
直接加载失败。这是本轮之前就存在的隐患,现在两处都比对脚本 URL,不是自己的就不碰。
72+
2. **"有 waiting"永远为真**,所以判据不能是它。
73+
74+
### 判据不是"问一句",是"看证据"
75+
76+
第二版去问:给等待中的 worker 和**当前控制页面的 worker** 各发一条 `VERSION` 消息
77+
(`sw.js` 新增的 handler,回 `cacheVersion` / `vendorVersion`),版本不同才算新构建;
78+
控制方要是不回话,就当它老到还没有这个 handler。
79+
80+
**这一版是错的,而且错得很典型**:并发跑五个 WASM 编辑器时,控制方经常在 1 秒超时内
81+
回不过来。沉默被读成"它是旧的",页面于是自己 skipWaiting + reload,测试跑到一半整页
82+
刷新——表现为"编辑器没起来"。全量 E2E 稳定挂 2 条,而基线(同一台机器、同样并发)
83+
121 条全绿;把这段接线关掉,122 条全绿且快了两分半。
84+
85+
第三版不问对方,看**本地证据**:`sw.js` 的运行时缓存是按 vendor 内容哈希命名的
86+
(`document-editor-runtime-<vendorVersion>`),所以"这台浏览器有没有那个构建的缓存"
87+
本身就是答案。只问等待中的 worker(它是新代码,一定答得上),拿到 vendorVersion 后
88+
在本地 `caches.keys()` 里找:
89+
90+
- 找得到 → 这个构建这台机器跑过 → 什么都不做(就是上面那种"同构建被重装");
91+
- 找不到 → 没跑过 → 换过去;
92+
- 它不回话,或者本地压根还没有运行时缓存 → **什么都不做**。宁可这一次加载还用旧的,
93+
下一次再说:默认值取"沉默 = 不动",正是第二版翻车的教训。
94+
95+
## 三、加载时露出来的那排按钮
96+
97+
同一张截图里还有第二个问题:`?new=docx&saved=<id>` 加载期间,屏幕中间是
98+
"View/Edit Document / New Word / New Excel / New PowerPoint"。
99+
100+
根因不是 CSS,是顺序:`createControlPanel()` 在 boot 时就把面板**可见地**建出来,
101+
只有等文档接管后才 `hideControlPanel()`。实测:
102+
103+
```
104+
?new=docx t+0ms opacity=0 (几乎看不见)
105+
?new=docx&saved=<id> t+0ms opacity=1 t+200ms 才开始淡出
106+
```
107+
108+
`?saved=` 那条路要先动态 import `history/store` 与 `history/recovery` 再决定开什么,
109+
于是可见窗口被拉长;线上冷加载更长,就成了截图里那样。
110+
111+
修法:**URL 已经指明要打开什么时,面板从一开始就不渲染**。index.ts 在建面板之前
112+
读一次 query(`?file` / `?src` / `?new` / `?saved` / `?open=local`),给 body 加
113+
`opening-document`,CSS 据此 `display: none`;真的回到主页状态时
114+
`showControlPanel()` 再把这个类摘掉。
115+
116+
## 用例与反向验证
117+
118+
| 用例 | 反向验证 |
119+
| ------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------- |
120+
| `test/unit/sw-update.test.ts` 的 `onWaitingWorker` 四条(三种到达方式 + 没有新 worker) | 删掉 `updatefound` 那一支 → "reports one that turns up after the page has loaded" 变红 |
121+
| `test/unit/update-prompt.test.ts` 五条(出现一次、点刷新会 SKIP_WAITING、接管后 reload 一次、超时兜底、忽略不动 worker) | — |
122+
| `test/e2e/entry-paths.spec.ts` 的"the home-state panel never shows while a named document loads"(从首帧起连采 12 次) | 去掉 `opening-document` 那一行 → 用例报 `the panel was painted while the document was loading` |
123+
124+
## 给下一个人
125+
126+
- 面板默认可见这件事本身是个坑:任何新的"启动时决定开什么"的入口(新的 query 参数、
127+
新的恢复路径)都要记得进 `opensSomething` 的判断,否则又会闪一下。
128+
- **不要用"对方回不回话"当判据。** 超时只说明它忙,不说明它旧。这条一次就够贵了。
129+
- 静默 reload 只在启动那一刻做,且有未保存改动时不做。要是以后想扩到"用户用到一半
130+
也自动换",先想清楚丢的是谁的东西。
131+
- **已经卡在旧构建上的用户,这段代码也救得了他们**——导航是 network-first,他们下一次
132+
刷新拿到的 HTML 和 bundle 就是新的,这段逻辑随之运行。真正救不了的是"从不刷新"的
133+
标签页。
134+
- **删除 vendor 资源的部署仍然要额外小心**:老客户端的注册表指着已经不存在的文件,
135+
在它自愈之前那一屏就是坏的(本轮就是字体乱码)。删文件的那次部署,最好同时接受
136+
"所有老标签页会自动刷新一次"。

‎index.ts‎

Lines changed: 63 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { shouldReloadOnControllerChange, wireServiceWorkerUpdates } from './lib/sw-update';
1+
import {
2+
healStaleController,
3+
onWaitingWorker,
4+
shouldReloadOnControllerChange,
5+
wireServiceWorkerUpdates,
6+
} from './lib/sw-update';
27
import { getAllQueryString } from 'ranuts/utils';
38
import { View } from 'ranui/builder';
49
import { initEmbedApi } from './lib/embed-api';
@@ -13,7 +18,7 @@ import 'ranui/button';
1318
import 'ranui/card';
1419
import 'ranui/select';
1520
import { initWebMcp } from './lib/web-mcp';
16-
import { installUnsavedChangesGuard } from './lib/unsaved-guard';
21+
import { hasUnsavedChanges, installUnsavedChangesGuard } from './lib/unsaved-guard';
1722
import { initDocumentHistory } from './lib/history';
1823
import '@khmyznikov/pwa-install';
1924
import './styles/base.css';
@@ -75,6 +80,18 @@ window.onOpenDocument = onOpenDocument;
7580
window.hideControlPanel = hideControlPanel;
7681
window.showControlPanel = showControlPanel;
7782

83+
// If the URL already says what to open, the home-state panel must never be
84+
// painted: it is built visible and only hidden once the document takes over,
85+
// and on a cold load that gap is long enough to read (a `?saved=` restore adds
86+
// two dynamic imports before it). Deciding here, before the panel exists,
87+
// keeps "View/Edit Document / New Word / New Excel / New PowerPoint" off the
88+
// loading screen instead of racing to remove it afterwards.
89+
const params = getAllQueryString();
90+
const opensSomething = Boolean(
91+
params['file'] || params['src'] || params['new'] || params['saved'] || params['open'] === 'local',
92+
);
93+
if (opensSomething) document.body.classList.add('opening-document');
94+
7895
// Initialize UI components
7996
createControlPanel();
8097

@@ -88,7 +105,7 @@ createControlPanel();
88105
// ?file=https://example.com/doc.docx
89106
// ?src=https://example.com/doc.docx
90107
// ?file=doc1.docx&src=doc2.xlsx (will use file: doc1.docx)
91-
const { file, src, readonly, agent } = getAllQueryString();
108+
const { file, src, readonly, agent } = params;
92109
const documentUrl = file || src;
93110
// Pure preview mode: ?readonly=true (also accepts ?readonly=1 or bare ?readonly).
94111
// Opens the document with editing/download disabled (#25, #85, #87).
@@ -116,12 +133,12 @@ window.addEventListener('message', (event: MessageEvent) => {
116133
// into a new file (skipping the landing hero). The localized homepages use it —
117134
// e.g. /zh-CN/ links to `/?locale=zh-CN&new=docx`, so i18n (which reads ?locale)
118135
// boots the editor UI in Chinese and drops the user directly into editing.
119-
const newExtRaw = getAllQueryString()['new'];
136+
const newExtRaw = params['new'];
120137
const newExt = typeof newExtRaw === 'string' ? newExtRaw.replace(/^\./, '').toLowerCase() : '';
121138
const createNewOnLoad = ['docx', 'xlsx', 'pptx'].includes(newExt) && !documentUrl;
122139
// `?open=local`: a static landing page (e.g. /zh-CN/) stashed a picked file in
123140
// IndexedDB via public/open-local.js — take it out and open it on boot.
124-
const openParam = getAllQueryString()['open'];
141+
const openParam = params['open'];
125142
const openLocalOnLoad = openParam === 'local' && !documentUrl && !createNewOnLoad;
126143
// `?saved=<id>`: which of this browser's saved documents to open. Every
127144
// editing session stamps its own id here (see lib/history/session.ts), so a
@@ -135,7 +152,7 @@ const openLocalOnLoad = openParam === 'local' && !documentUrl && !createNewOnLoa
135152
// would open an empty editor. It is also why it is not called `id`: what makes
136153
// it meaningful is not that it is an identifier, it is that the document is
137154
// saved on this device.
138-
const savedParam = getAllQueryString()['saved'] ?? '';
155+
const savedParam = params['saved'] ?? '';
139156

140157
// Landing hero orchestration. Only the bare homepage (no ?file/?src/?new, not
141158
// embedded) shows the crawlable hero. If a document is about to load or be
@@ -216,6 +233,10 @@ if ('serviceWorker' in navigator) {
216233
// no document is open, then takes over and the page reloads once.
217234
const hadController = !!navigator.serviceWorker.controller;
218235
let reloadingForUpdate = false;
236+
// Set when this tab asked an older worker to step aside (see below): the
237+
// reload that follows is the point, so it is not blocked by having a
238+
// document open the way an ordinary update is.
239+
let healingStaleBuild = false;
219240
const hasOpenDocument = () => Boolean(getDocmentObj().fileName);
220241

221242
navigator.serviceWorker.addEventListener('controllerchange', () => {
@@ -224,6 +245,8 @@ if ('serviceWorker' in navigator) {
224245
hadController,
225246
alreadyReloading: reloadingForUpdate,
226247
hasOpenDocument: hasOpenDocument(),
248+
healingStaleBuild,
249+
hasUnsavedChanges: hasUnsavedChanges(),
227250
})
228251
) {
229252
return;
@@ -232,12 +255,45 @@ if ('serviceWorker' in navigator) {
232255
window.location.reload();
233256
});
234257

258+
// The script we register, absolute: the vendored editor registers one of its
259+
// own into this same scope from inside the iframe, and only this URL says
260+
// which waiting worker is a new build of ours.
261+
const ownScriptURL = new URL('./sw.js', window.location.href).href;
262+
235263
window.addEventListener('load', () => {
236264
navigator.serviceWorker
237265
.register('./sw.js')
238266
.then((registration) => {
239267
console.log('SW registered: ', registration);
240-
wireServiceWorkerUpdates(registration, hasOpenDocument);
268+
wireServiceWorkerUpdates(registration, hasOpenDocument, ownScriptURL);
269+
// Promotion above is refused while a document is open, and this page
270+
// is usually opened with one (?new=, ?file=, ?saved=). Without an
271+
// offer, such a visitor never leaves the build their worker cached --
272+
// reloading serves it again. Ask instead of deciding for them: the
273+
// reload happens on click, so the mixed-version hazard the waiting
274+
// exists to prevent cannot happen either.
275+
// A tab whose worker is an older build heals itself, quietly. The
276+
// navigation is network-first, so this page and its bundle are already
277+
// the new ones; it is the vendored tree underneath that is still being
278+
// served from the outgoing build's cache, and a registry that no longer
279+
// matches the origin is how a reverted build kept rendering garbled
280+
// text long after the revert had shipped. Promote and reload once --
281+
// at boot there is nothing typed to lose, and no one has to be told.
282+
onWaitingWorker(
283+
registration,
284+
(waiting) => {
285+
void healStaleController({
286+
registration,
287+
waiting,
288+
controller: navigator.serviceWorker.controller,
289+
hadController,
290+
storage: window.sessionStorage,
291+
}).then((started) => {
292+
if (started) healingStaleBuild = true;
293+
});
294+
},
295+
ownScriptURL,
296+
);
241297
// Check for updates on every page load. Firefox rejects the update
242298
// when the registration changed since it was scheduled (a benign race
243299
// right after register()); swallow it so it never surfaces as an

0 commit comments

Comments
 (0)