feat(pwa): prompt before applying a new version (issue #42) - #95
Merged
Conversation
The service worker ran in autoUpdate mode: a new deploy's worker activated silently and only took effect on the next full navigation. A long-lived tab or the installed PWA kept running the old bundle indefinitely and never found out, so fixes we ship didn't reach open clients. Switch to prompt mode. A new worker installs and waits; <UpdateNotice> (mounted once in the dashboard shell) shows a polite role="status" notice with a Reload button, and only that click activates the worker and reloads. Nothing reloads on its own — admin forms exist, and an unrequested reload eats input. Dismissal lasts for the page load only; a later update shows the notice again. Open clients learn about deploys because the registration is re-checked hourly and whenever the document becomes visible again (the "phone unlocks, app resumes" case). The component also shows on a wolke:sw-need-refresh CustomEvent — the documented seam the e2e suite triggers, since Playwright cannot build a second worker version against one embedded binary. Covered by Vitest (states, both locales, axe, the polling helper) and by e2e/update-notice.spec.ts at all six matrix viewports.
… an effect react-hooks/set-state-in-effect (CI lint) flagged the effect that cleared the dismissal when needRefresh rose. Counting the updates this page load has heard about, and recording the count a dismissal was made at, gets the same behaviour — re-show on the next update, persist nothing — with no render-then-correct pass.
tibroc
commented
Aug 28, 2026
tibroc
left a comment
Member
Author
There was a problem hiding this comment.
Supervisor review: approve. Two recorded nits, non-blocking: (1) onRegisteredSW discards startUpdateChecks' teardown, so a DashboardShell remount would leak the hourly interval — latent since the shell mounts once per page load; (2) after dismissing a real update, a later deploy in the same tab doesn't re-show the notice (needRefresh is already true; only the seam increments the count) — benign, but the dismissal comment claims otherwise. Fine to fix opportunistically later.
This was referenced Aug 28, 2026
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.
Closes the second half of #42 (the install hint shipped earlier). Implements
docs/specs/pwa-update-notification.md— that spec file lives on the branch ofPR #93 and isn't on
mainyet, so it isn't part of this diff.What changed
registerType: 'autoUpdate'→'prompt'. A new deploy's worker installs andwaits; nothing reloads on its own.
UpdateNotice(mounted once inDashboardShell) owns registration viauseRegisterSWand shows a calm, bottom-leftrole="status"notice —"Neue Version verfügbar." + Neu laden + a dismiss ×.
main.tsxno longerregisters the worker itself (comment there points at the new owner).
registration.update()every 60 min (aconstant, not config) and on every
visibilitychange→ visible.re-shows the notice.
prefers-reduced-motionreset neutralises it. 44px targets at phone widths,compact sizes above the
md:breakpoint.cover the assistant launcher; asserted in e2e.
README line under Deployment → Notes.
The e2e seam
window.dispatchEvent(new CustomEvent('wolke:sw-need-refresh'))shows the samenotice. Playwright can't produce a second worker version against one embedded
binary, so this is how the viewport suite sees the real component in the real
layout. It's honest production code: with no worker waiting, Reload falls back
to
location.reload().Tests
UpdateNotice.test.tsx,pwa-update.test.ts): hidden by default;shown on
needRefresh; Reload callsupdateServiceWorker(true); dismiss hideswithout touching storage; re-shows on the next update; both locales; axe clean;
the seam and its plain-reload fallback; polling interval, visibility check,
teardown, and a rejected check. Mutation-checked (forcing the component to
render nothing fails 7 of them).
e2e/update-notice.spec.ts, 3 tests × all six viewports):appears on trigger,
expectViewportHealthyin the open state, doesn'toverlap the top-bar nav or the launcher corner, dismiss restores a clean
layout.
Gates:
tsc --noEmit(app + e2e),vitest run238 passed,make e2e133passed / 6 skipped,
go test -race ./...clean, embedded build green.internal/web/distis untracked and stayed that way.The first push failed CI's
npm run lintonreact-hooks/set-state-in-effect:the notice cleared its dismissal from an effect watching
needRefresh. (I firstmisread that as a pre-existing
full-icon.tsxerror —git stashhad left thenew untracked files in place, so the "unchanged" comparison was worthless.) The
follow-up commit derives it instead: count the updates this page load has heard
about, remember the count a dismissal was made at. Same behaviour, no
render-then-correct pass. Lint is now 0 errors / 8 pre-existing warnings, and
all gates were re-run green after the fix.
Manual verification of the real SW update path (spec §3)
Driven against the embedded binary on
:8472with the real mock IdP session —build, load, confirm the worker controls the page, then change a user-visible
string,
make build, restart the server, and watch the untouched tab.the tab with no interaction; clicking Neu laden swapped
index-DYB9gIZ6.js→index-DC2lmAju.jsand the new build's string was onthe page. ✅
after the deploy the tab sat idle with no notice, then a
visibilitychangeproduced a waiting worker and the notice; Reload landed
index-jJWePKAY.js→index-CorI8Yds.jswith the new string. ✅Reported honestly: Playwright never puts a page into the hidden state —
bringToFrontfires novisibilitychange, headless or headed, andPage.setWebLifecycleStatedoesn't either — so in that second run the browser'sfiring of the event was dispatched by the harness. Everything downstream (the
production listener, the real registration, the update fetch over HTTP, the
waiting worker, the notice, the reload) was real. A genuine phone-unlock resume
is the one link not exercised end-to-end here. Chrome DevTools MCP failed to
attach in this environment ("Target closed"), which is why this ran through
Playwright rather than an interactive browser session.
Desktop install UI is still deliberately left to the browser (unchanged from the
install-hint PR).
Left unmerged for supervisor review.