fix(sw): stop an update from blanking the editor, and give the site one entity - #201
Merged
Conversation
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>
This was referenced Aug 23, 2026
chaxus
enabled auto-merge (rebase)
August 23, 2026 07:45
Deploying document with
|
| 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 |
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
promoteWaitingWorkerruns when
register()resolves, a few hundred milliseconds before the editorinstance exists, so it saw "no document open" and promoted; by the time
controllerchangearrived the document was open, the reload was refused, andthe 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
waitingon almost every editor load with no newbuild 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-updateandsw-warmpass locally; the reload case is the one thatwent 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
#organizationand every pageafter that writes
manufacturer: { "@id": ... }.We did the opposite -- every page emitted its own anonymous
WebApplicationand
SoftwareSourceCode, so 154 pages described 154 unrelated tools thathappened to share a name.
Organization,WebSite,WebApplicationandSoftwareSourceCodenow have fixed @ids and reference each other;WebPage,FAQPage,HowToandBreadcrumbListget page-scoped ones. The app's url isthe 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, plusmax-snippet:-1, which liftsthe cap on what a search or AI summary may quote.
Three new per-page contracts; the one that checks every
@idreferenceresolves caught a real dangling reference on the documentation pages, which is
why
appStub()exists. Reverse-verified: stashingbin/build-pages.mjsturns441 red.
Considered and rejected:
hreflangpt->pt-BR. Apple declares it becauseit 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