Skip to content

fix(sw): stop an update from blanking the editor, and give the site one entity - #201

Merged
chaxus merged 5 commits into
mainfrom
sw-and-seo
Aug 23, 2026
Merged

fix(sw): stop an update from blanking the editor, and give the site one entity#201
chaxus merged 5 commits into
mainfrom
sw-and-seo

Conversation

@chaxus

@chaxus chaxus commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Two independent findings from the same session, kept as three commits.

1-2. A service-worker update could leave the editor blank

CI kept failing the @serial silent-update case with a 90s timeout, and the
screenshot was a blank white page. The trace says why: the editor iframe's own
document request came back -1, and nothing retries it.

Activating a worker terminates the outgoing one, and every fetch it still had
in flight fails -- including that iframe navigation. That is survivable only
because a reload follows. The reload did not follow: promoteWaitingWorker
runs when register() resolves, a few hundred milliseconds before the editor
instance exists, so it saw "no document open" and promoted; by the time
controllerchange arrived the document was open, the reload was refused, and
the page had already been torn in half.

The first commit makes the two decisions consistent -- whoever promotes owns
the reload. The second adds the half without which that becomes a reload on
every load: a page that is about to open a document does not promote at all.
The URL knows before the store does (?new= / ?file= / ?src= / ?open= /
?saved=, and ?embed=, where the host can push a document at any moment).
That matters because the vendored editor registers a worker of its own into
this scope, so ours sits in waiting on almost every editor load with no new
build anywhere -- "refuse the reload when a document is open" was doubling as
the brake on that. Those routes are what the silent heal exists for, and it
checks isUnseenBuild() first.

Reverse-verified both halves. autosave-recovery (three cases),
sw-silent-update and sw-warm pass locally; the reload case is the one that
went red with only the first half, on the Cloudflare Pages shard.

3. One entity for the site, instead of one per page

Read apple.com's markup and took the part that transfers. Apple never
redefines "Apple": it names the node once as #organization and every page
after that writes manufacturer: { "@id": ... }.

We did the opposite -- every page emitted its own anonymous WebApplication
and SoftwareSourceCode, so 154 pages described 154 unrelated tools that
happened to share a name. Organization, WebSite, WebApplication and
SoftwareSourceCode now have fixed @ids and reference each other; WebPage,
FAQPage, HowTo and BreadcrumbList get page-scoped ones. The app's url is
the site root everywhere (a per-page url made each translation look like a
separate product) and the language moved to WebPage.inLanguage.

Also from Apple: max-image-preview:large, plus max-snippet:-1, which lifts
the cap on what a search or AI summary may quote.

Three new per-page contracts; the one that checks every @id reference
resolves caught a real dangling reference on the documentation pages, which is
why appStub() exists. Reverse-verified: stashing bin/build-pages.mjs turns
441 red.

Considered and rejected: hreflang pt -> pt-BR. Apple declares it because
it runs separate pt and br sites; we have one Portuguese, and narrowing it
would drop readers in Portugal to the English x-default.

🤖 Generated with Claude Code

chaxus and others added 3 commits August 23, 2026 15:24
CI kept failing the @serial silent-update case with a 90s timeout, and the
screenshot was a blank white page. The trace says why: the editor iframe's own
document request came back -1, and nothing retries it.

Activating a worker terminates the outgoing one, and every fetch it still had
in flight fails -- including that iframe navigation. That is survivable only
because a reload follows. The reload did not follow.

promoteWaitingWorker runs the moment register() resolves, which on the editor
route is a few hundred milliseconds before the editor instance exists. It
therefore sees "no document open" and promotes. By the time controllerchange
arrives the document is open, shouldReloadOnControllerChange asks the same
question again, gets the other answer, and refuses -- with the page already
torn in half. wireServiceWorkerUpdates was dropping promoteWaitingWorker's
return value, so the page never knew it had asked for the swap itself.

It now reports it. index.ts keeps one flag for both promotion paths (the
ordinary update and the stale-build heal), renamed promotedFromThisTab, and a
tab that asked for the swap reloads regardless of what is open -- unsaved
edits still veto. Refusing the reload does not undo the swap; it only leaves
the reader on a blank editor.

Reverse-verified: stashing lib/sw-update.ts and index.ts turns all four new
unit cases red. sw-silent-update and sw-warm pass locally.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first half of this change turned every promotion into a reload, which the
Cloudflare Pages E2E caught: autosave-recovery's reload case went red on the
shard and stayed red on retry, while the same shard was green on a branch
without it.

CLAUDE.md already says why. A worker waiting is not a new version: the
vendored editor registers a worker of its own into this scope from inside the
iframe, so the scope's script alternates and ours sits in `waiting` on almost
every editor load with no new build anywhere. "Refuse the reload when a
document is open" was doubling as the brake on that. Removing it made those
routine swaps reload the page every time.

So the other half: a page that is about to open a document does not promote at
all. hasOpenDocument() reads the store, which fills a few hundred milliseconds
after register() resolves -- the URL knows sooner, and ?new= / ?file= / ?src= /
?open= / ?saved= all say a document is coming. Embed mode counts too, and for a
stronger reason: the host can push a document at any moment, and the reload
would throw away something the host page owns.

A worker left waiting on those routes is the case the silent heal exists for,
and that path checks isUnseenBuild() first, so the vendor's worker shuffling
cannot fool it.

Reverse-verified: stashing lib/sw-update.ts turns the three new
documentIsExpected cases red. autosave-recovery (all three), sw-silent-update
and sw-warm pass locally -- the reload case being the one that was red with
only the first half.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Read apple.com's markup and took the part that transfers. Apple never
redefines "Apple": it names the node once as #organization and every page
after that writes manufacturer: { "@id": ... }. Same for #website and each
product's #brand.

We did the opposite. Every page emitted its own anonymous WebApplication and
SoftwareSourceCode, so 154 pages described 154 unrelated tools that happened
to share a name. That costs little in ranking and a lot in the machines that
answer questions about the site -- an assistant that reads three of our pages
should come away with one editor.

Organization, WebSite, WebApplication and SoftwareSourceCode now have fixed
@ids and appear on every page as references to each other; WebPage, FAQPage,
HowTo and BreadcrumbList get page-scoped ones. The app's url is the site root
everywhere -- a per-page url made each translation look like a separate
product -- and the language moved to WebPage.inLanguage, where it belongs,
with the app and site carrying the list of all seven.

A documentation page is a page about the editor, not a listing of it, so it
ships a named stub rather than the full node with price and category. It has
to ship something: about: { "@id": ... } pointing at nothing is dropped, and
the page goes back to describing an anonymous application.

Also from Apple: max-image-preview:large on every page (plus max-snippet:-1,
which is ours -- it lifts the cap on what a search or AI summary may quote),
and sameAs moved onto the Organization where it identifies the publisher.

Three new contracts run per page: this page's WebPage node is this page, any
WebApplication is the shared entity, and every @id reference resolves to a
definition in the same graph with no duplicate ids. That third one caught a
real dangling reference on the documentation pages, which is why appStub()
exists. Reverse-verified: stashing bin/build-pages.mjs turns 441 red.

Considered and rejected: hreflang pt -> pt-BR. Apple declares it because it
runs separate pt and br sites; we have one Portuguese, and narrowing it would
drop readers in Portugal to the English x-default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 23, 2026

Copy link
Copy Markdown

Deploying document with  Cloudflare Pages  Cloudflare Pages

Latest commit: 860a8f7
Status: ✅  Deploy successful!
Preview URL: https://a422dc12.document-7hm.pages.dev
Branch Preview URL: https://sw-and-seo.document-7hm.pages.dev

View logs

chaxus and others added 2 commits August 23, 2026 15:59
Still red in CI, same case, same blank page. Lining up the network timeline
says why both earlier attempts missed:

  25.68  the reload's document, served by the old worker
  25.74  GET /sw.js -- bytes differ, install starts
  25.79  install precaches / /index.html /editor /editor.html
  25.83  the controller already reports e2e-next (50ms after the reload)
  25.96  GET .../documenteditor/main/index.html -> -1

The swap lands 50ms into the reload and 170ms before it kills that request --
and promoteWaitingWorker never ran, because documentIsExpected('?new=docx')
already stopped it. We are not the ones swapping.

Three things do, and only one involves this page: sw.js calls skipWaiting()
itself when activating would not discard vendor assets, another tab promotes
through the landing page, and the browser activates a waiting worker on its
own once the clients the old one controlled are gone -- which a reload
arranges. So "did this tab ask for the swap" is the wrong question and it
misses exactly the cases that happen; promotedFromThisTab and the onPromoted
plumbing are gone.

The condition that is left is the one that was always the real one: unsaved
work. The swap has happened either way, refusing the reload does not undo it,
and a page torn mid-load has nothing unsaved in it -- so the two never
collide.

documentIsExpected stays. It answers a different question: do not push a
promotion into a page that is opening a document, and never into an embedded
one, where the host can hand over a document at any moment.

Reverse-verified: stashing lib/sw-update.ts turns both reload cases and the
three documentIsExpected cases red. The reload case deliberately still passes
hasOpenDocument, because that input is what the old rule answered false to;
without it the case is green against either implementation.

Honest caveat: the blank page has never reproduced locally -- all three
implementations pass here, and only CI goes red. The evidence is the timeline
above and the stronger invariant it argues for: repair the tear whoever caused
it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nged

The previous commit reloaded on any controller change, and three E2E shards
said no. A controller changing is routine: the vendored editor registers a
worker of its own into this scope, ours is re-installed and left waiting on
ordinary loads, and the browser activates it by itself at the next navigation
-- same build, same caches, nothing behind it. Measured at 80ms into a plain
reload of a fresh profile, which is why sw-vendor-cache-first died with
"Execution context was destroyed" in the middle of its probe.

So the condition is two things, and both are load-bearing:

  - the new controller is a build this browser has not been running, judged
    the way healStaleController judges it -- by the runtime cache, named after
    the vendor tree's content. The cache names are snapshotted at boot: read
    them when the swap happens and the incoming worker has already created its
    own, and asking the outgoing worker instead is asking something a swap may
    already have terminated;
  - and there are no unsaved edits.

Not "is a document open", which is what left a torn page unrepaired, and not
"did this tab ask for the swap", which misses the cases that actually happen.

Reverse-verified in both directions, locally, which the earlier attempts could
not manage: pinning isNewBuild false turns sw-silent-update red, pinning it
true turns sw-vendor-cache-first red. With the real value, sw-silent-update,
sw-vendor-cache-first, sw-warm, autosave-recovery and font-cache pass -- eight
cases.

Tried and dropped: having sw.js skip its install-time takeover when a core
cache under this build's name already exists, so that a same-build re-install
stops swapping the controller mid-load. Its unit case reverse-verified, but
the local suite is green with and without it, and it has a real cost -- a
rollback to a build this browser has run would no longer be delivered, which
is the font revert's own scenario. Unproven benefit, delicate path, out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@chaxus
chaxus merged commit 22d7518 into main Aug 23, 2026
18 checks passed
@chaxus
chaxus deleted the sw-and-seo branch August 23, 2026 08:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant