Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,11 @@ notes. Entries describe what users experience, not internal refactors.
broken. A tab still on an older build now moves onto the new one on its next
load, without asking and without interrupting anything: it happens while the
page is still loading, once, and never over unsaved edits.
- **An update could leave the editor blank.** When a new version took over
while the page was still starting up, the request for the editor itself was
cancelled mid-flight and nothing asked for it again -- an empty white page
that reloading was the only way out of. The page now always reloads itself
after it switches versions, which is what repairs it.
- The four home-screen buttons ("View/Edit Document", "New Word"...) no longer
flash behind the spinner while a document named in the URL is loading.
- Chinese, Japanese and Korean text in an exported PDF came out blank. The
Expand Down
14 changes: 14 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,20 @@ docs/explorations/2026-08-19-ci-e2e-sharding.md。
`DEPLOY_COUPLED`(由 `hosting-contract.test.ts` / `sw-routing.test.ts` 钉住)。
`open-local.js` / `landing-prefetch.js` 同理,2026-08-20 起补齐——它们从路由
拆分起一直漏在 SWR 上,改这两个文件的部署,落地页会一直跑旧的那份。
**谁提升谁负责 reload**(2026-08-23):`promoteWaitingWorker` 在 `register()` 一
resolve 就跑,比编辑器实例存在早几百毫秒,于是它看到"没有文档打开"并提升;等
`controllerchange` 到达时文档已经打开,`shouldReloadOnControllerChange` 重新问一遍
就拒绝 reload——而那次交接已经终止了旧 worker,它手上 in-flight 的 fetch 全部失败,
其中就有编辑器 iframe 自己的文档请求,没人重试,标签页永远白屏。所以
`wireServiceWorkerUpdates` 有 `onPromoted` 回调,`index.ts` 用
`promotedFromThisTab` 标志位统一两条提升路径,见到它就 reload(只有未保存改动
一票否决)。别把这个回调当可选参数省掉。**同时必须有另一半**:`hasOpenDocument()`
要把 URL 也算进去(`documentIsExpected`:`?new=` / `?file=` / `?src=` / `?open=` /
`?saved=` / `?embed=`),否则——因为厂商 worker 的来回切换让我们的 sw.js 几乎每次
加载都在 `waiting`——上面那条规则会变成"每次加载都刷新一次"(实测把
`autosave-recovery` 的 reload 用例打红)。这两条路由上的等待交给静默自愈,
它会先 `isUnseenBuild()` 确认真是新构建。见
docs/explorations/2026-08-23-promotion-without-reload-blank-editor.md。
落地页那侧的提升要覆盖三种到达方式:已经 `waiting`、`installing` 中途、
以及 `updatefound` 时已经 `installed`(`statechange` 只报此后的迁移,
漏掉这一支等于整页生命周期内再没人提升它)。
Expand Down
102 changes: 102 additions & 0 deletions docs/explorations/2026-08-23-promotion-without-reload-blank-editor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# 谁提升了 worker,谁就得负责那次 reload

日期:2026-08-23
现场:CI run 32620431825,`E2E shard 1` 的 @serial 那趟挂在
`sw-silent-update.spec.ts:92` 的 `settleEditor`,90s 超时。这条用例在本地 16s 通过,
在 CI 已经是第二次红了。

## 不是慢,是白屏

失败截图是**纯白页**。把 trace 拆开看,时间轴很清楚:

```
24.1 page.reload() ← 测试写完新 sw.js 之后的那次刷新
24.2 console: SW registered
24.3 console: Creating new editor instance
24.35 GET /web-apps/apps/documenteditor/main/index.html → status -1(失败)
... 之后 90 秒什么都没有
114.3 超时
```

编辑器 iframe 自己的文档请求**失败了**,而且没有任何东西会重试它。页面就停在
"创建了编辑器实例、iframe 永远空着"的半成品状态。

请求为什么会失败:那一刻 service worker 换了人。激活新 worker 会终止旧 worker,
旧 worker 手上所有 in-flight 的 fetch 事件一起失败——其中就有这个 iframe 的导航请求。

**这本来不是问题**,因为换 worker 之后页面会 reload 一次,重新加载就好了。
问题是那次 reload 没有发生。

## 提升的判据和 reload 的判据,问的是同一个问题的两个时刻

`index.ts` 里两件事:

```ts
wireServiceWorkerUpdates(registration, hasOpenDocument, ownScriptURL);
// ...
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (!shouldReloadOnControllerChange({ ..., hasOpenDocument: hasOpenDocument() })) return;
window.location.reload();
});
```

`promoteWaitingWorker` 在 `register()` 一 resolve 就跑,而在编辑器路由上,
**那比编辑器实例存在早几百毫秒**(trace 里 SW registered 在 24.2、
Creating new editor instance 在 24.3)。于是它看到"没有文档打开",提升。

等 `controllerchange` 真的到达时,文档已经打开了。`shouldReloadOnControllerChange`
重新问一遍 `hasOpenDocument()`,答案变成了 true,于是**拒绝 reload**——
可这时候页面早就被那次交接撕成两半了。

`promoteWaitingWorker` 的返回值一直被 `wireServiceWorkerUpdates` 丢掉,
所以页面从来不知道"是我自己让它换的"。

CLAUDE.md 里记着这条路径上相反方向的那个 bug("打开流程排在 SW 注册之前,
`hasOpenDocument()` 永远为真,于是等待中的 worker 从不被提升")。修好之后
竞态翻了个面:注册有时候赢,提升发生了,而修复它的 reload 被拒。

## 改法

**谁提升谁负责 reload。** `wireServiceWorkerUpdates` 多一个 `onPromoted` 回调,
真的发出 `SKIP_WAITING` 时通知调用方;`index.ts` 把标志位从 `healingStaleBuild`
改名成 `promotedFromThisTab`,两条路径(普通更新提升、静默自愈)共用它;
`shouldReloadOnControllerChange` 见到这个标志就 reload,**不再问有没有文档打开**——
只有未保存改动仍然一票否决。

理由不是偏好而是修复:交接已经发生了,拒绝 reload 不会把它撤回来,
只会把标签页留在白屏上。

## 只补第一半会变成"每次加载都刷新一次"

第一版只做了上面那件事,`E2E (Cloudflare Pages semantics)` 的
`autosave-recovery` "a reload comes back to the same document" 立刻红(重试也红,
同一分片在没有这个改动的另一个 PR 上是绿的)。

原因 CLAUDE.md 里其实写着:**"有 worker 在等"不等于"有新版本"**。厂商的编辑器 iframe
会往同一个 scope 注册它自己的 worker,一个 scope 只有一个 registration,于是脚本在
我们的 sw.js 与它之间来回换——**编辑器路由上我们的 worker 几乎每次加载都躺在
`waiting` 里,跟有没有新构建无关**。原先那条"有文档打开就不 reload"顺带当了刹车;
把它拆掉,每一次这种交接都变成一次刷新。

所以第二半:**文档在路上的时候根本不要提升**。`hasOpenDocument()` 读的是 store,
而 store 要等编辑器实例建好才有值——比 `register()` resolve 晚几百毫秒。URL 早就知道了:
`?new=` / `?file=` / `?src=` / `?open=` / `?saved=` 任意一个在,就是"这一页要开文档"。
`?embed=`/`?embedded=` 也算,而且理由更硬:嵌入模式下宿主随时可能推一个文档进来,
那次 reload 扔掉的是宿主页面的东西。

这些路由上等待的 worker 就老老实实等着——这不是丢失更新,正是静默自愈存在的那个场景,
而自愈这条路**会先用 `isUnseenBuild()` 确认真的是另一个构建**才交接,不会被厂商 worker
的来回切换骗到。

## 用例与反向验证

`test/unit/sw-update.test.ts` 新增四条:`onPromoted` 在到达时提升、在稍后安装后提升、
留在 waiting 时不报;以及那条竞态本身——"在提升与交接之间打开的文档也要 reload"。

另加三条钉住 `documentIsExpected`:认得每个会挂文档的路由、把 embed 也算进去、
没东西可开的页面照常接更新。

反向验证:`git stash` 掉 `lib/sw-update.ts` 与 `index.ts`,前四条同时变红
(两条因为 `onPromoted` 不存在,两条因为标志位读不到);单独 stash `lib/sw-update.ts`,
`documentIsExpected` 三条变红。E2E `autosave-recovery`(三条)+ `sw-silent-update` +
`sw-warm` 本地全绿——其中 `autosave-recovery` 的 reload 那条正是只补第一半时红的那条。
30 changes: 22 additions & 8 deletions index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
documentIsExpected,
healStaleController,
onWaitingWorker,
shouldReloadOnControllerChange,
Expand Down Expand Up @@ -233,19 +234,24 @@ if ('serviceWorker' in navigator) {
// no document is open, then takes over and the page reloads once.
const hadController = !!navigator.serviceWorker.controller;
let reloadingForUpdate = false;
// Set when this tab asked an older worker to step aside (see below): the
// reload that follows is the point, so it is not blocked by having a
// document open the way an ordinary update is.
let healingStaleBuild = false;
const hasOpenDocument = () => Boolean(getDocmentObj().fileName);
// Set when this tab told a waiting worker to take over -- either the
// ordinary update below or the stale-build heal. The reload that follows is
// then repair, not a preference: the swap kills whatever the outgoing worker
// was still fetching, so it is not blocked by having a document open.
let promotedFromThisTab = false;
// "Is a document open?" is the wrong tense at boot: the store fills in a few
// hundred milliseconds after register() resolves, so a page opening a
// document answers "no" for exactly as long as it takes to promote a worker
// into the middle of its own load. The URL already knows.
const hasOpenDocument = () => Boolean(getDocmentObj().fileName) || documentIsExpected(window.location.search);

navigator.serviceWorker.addEventListener('controllerchange', () => {
if (
!shouldReloadOnControllerChange({
hadController,
alreadyReloading: reloadingForUpdate,
hasOpenDocument: hasOpenDocument(),
healingStaleBuild,
promotedFromThisTab,
hasUnsavedChanges: hasUnsavedChanges(),
})
) {
Expand All @@ -265,7 +271,15 @@ if ('serviceWorker' in navigator) {
.register('./sw.js')
.then((registration) => {
console.log('SW registered: ', registration);
wireServiceWorkerUpdates(registration, hasOpenDocument, ownScriptURL);
// The fourth argument is the whole point: promotion happens here, a
// few hundred milliseconds before the editor instance exists, so it
// sees "nothing open" and promotes -- and the controllerchange that
// follows arrives after the document is open. Without knowing this tab
// asked for it, the reload is refused and the tab is left on a blank
// editor whose iframe request the swap aborted.
wireServiceWorkerUpdates(registration, hasOpenDocument, ownScriptURL, () => {
promotedFromThisTab = true;
});
// Promotion above is refused while a document is open, and this page
// is usually opened with one (?new=, ?file=, ?saved=). Without an
// offer, such a visitor never leaves the build their worker cached --
Expand All @@ -289,7 +303,7 @@ if ('serviceWorker' in navigator) {
hadController,
storage: window.sessionStorage,
}).then((started) => {
if (started) healingStaleBuild = true;
if (started) promotedFromThisTab = true;
});
},
ownScriptURL,
Expand Down
60 changes: 55 additions & 5 deletions lib/sw-update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,32 @@ export function isOwnWorker(reg: RegistrationLike, worker: SwLike, ownScriptURL?

export const SKIP_WAITING_MESSAGE = { type: 'SKIP_WAITING' } as const;

/**
* Is this page going to have a document in it?
*
* `hasOpenDocument()` reads the store, which is empty until the editor
* instance exists -- a few hundred milliseconds after `register()` resolves.
* Asking it that early answers "nothing open" about a page whose whole purpose
* is to open something, and the promotion that follows lands in the middle of
* the editor booting.
*
* The URL knows sooner. Every route that mounts a document says so in its
* query string, and embed mode says the host may push one at any moment --
* which is also why an embedded editor must never promote: the reload that
* follows would throw away a document the host page owns.
*
* On these routes a waiting worker simply stays waiting. That is not a lost
* update: it is the ordinary case the silent heal exists for, and unlike this
* path the heal checks that the waiting worker is genuinely a different build
* before it swaps -- which matters, because the vendored editor registers a
* worker of its own into this scope from inside the iframe, so ours is left
* `waiting` on almost every editor load with no new build in sight.
*/
export function documentIsExpected(search: string): boolean {
const params = new URLSearchParams(search);
return ['new', 'file', 'src', 'open', 'saved', 'embed', 'embedded'].some((key) => params.has(key));
}

/** Tell a waiting worker to take over -- only if nothing is open. Returns whether it did. */
export function promoteWaitingWorker(
reg: RegistrationLike,
Expand All @@ -68,18 +94,27 @@ export function promoteWaitingWorker(
* future ones as soon as they finish installing (both gated on "no document
* open"). A worker that stays waiting because a document is open activates on
* the next visit, when the landing page calls this again.
*
* `onPromoted` fires when this tab actually told a worker to take over, and
* the caller must not drop it: the swap tears down whatever the outgoing
* worker was still fetching, so the reload afterwards is not a nicety. See
* shouldReloadOnControllerChange for what happened when it was dropped.
*/
export function wireServiceWorkerUpdates(
reg: RegistrationLike,
hasOpenDocument: () => boolean,
ownScriptURL?: string,
onPromoted?: () => void,
): void {
promoteWaitingWorker(reg, hasOpenDocument, ownScriptURL);
const promote = (): void => {
if (promoteWaitingWorker(reg, hasOpenDocument, ownScriptURL)) onPromoted?.();
};
promote();
reg.addEventListener('updatefound', () => {
const installing = reg.installing;
if (!installing) return;
installing.addEventListener('statechange', () => {
if (installing.state === 'installed') promoteWaitingWorker(reg, hasOpenDocument, ownScriptURL);
if (installing.state === 'installed') promote();
});
});
}
Expand Down Expand Up @@ -118,17 +153,32 @@ export function onWaitingWorker(
* Whether a controllerchange should reload the page: only when a worker was
* already in control at startup (so this is an update, not the first
* install), only once, and never with a document open (unsaved edits).
*
* Unless this tab is the one that asked for the swap -- then it reloads
* regardless of what is open, short of unsaved edits. That is not a
* preference, it is repair. Activating a worker terminates the outgoing one,
* and every request it still had in flight fails: on the editor route that is
* the vendored iframe's own document, which nothing retries, so the tab is
* left staring at a blank editor forever.
*
* The two decisions used to be made at different times against a predicate
* that changes underneath them. `promoteWaitingWorker` runs the moment
* `register()` resolves, which on the editor route is a few hundred
* milliseconds BEFORE the editor instance exists -- so "no document open" is
* true and it promotes. By the time the swap lands, the document is open, and
* the reload was refused. The page had already been torn in half by then.
* Whoever promotes owns the reload.
*/
export function shouldReloadOnControllerChange(state: {
hadController: boolean;
alreadyReloading: boolean;
hasOpenDocument: boolean;
/** This tab asked an older worker to step aside; the reload is the point. */
healingStaleBuild?: boolean;
/** This tab told a waiting worker to take over -- an update or a heal. */
promotedFromThisTab?: boolean;
hasUnsavedChanges?: boolean;
}): boolean {
if (!state.hadController || state.alreadyReloading) return false;
if (state.healingStaleBuild) return !state.hasUnsavedChanges;
if (state.promotedFromThisTab) return !state.hasUnsavedChanges;
return !state.hasOpenDocument;
}

Expand Down
Loading
Loading