Skip to content

fix: keep PWA app shell and its files consistent after a rebuild - #24807

Merged
Artur- merged 9 commits into
mainfrom
pwa-after-rebuild
Nov 10, 2026
Merged

Artur- merged 9 commits into
mainfrom
pwa-after-rebuild

Conversation

@Artur-

@Artur- Artur- commented Jun 25, 2026 •

Copy link
Copy Markdown
Member

Review risk: two-way door — only the generated PWA service worker (sw.ts) and the dev server's service worker update change; affects apps using @PWA with offline support, reverting is a one-file change.

Fixes #24800

Summary

After a frontend rebuild, an installed PWA could show a permanent blank page on the next reload. This happened when the service worker answered the reload with its precached app shell from the previous build. In development mode, the worker fetched every /VAADIN/ file network-first, so the old shell's entry script was requested from the server, which no longer had it. The worker then passed the server's 404 through and never checked the precache.

Removing only skipWaiting() was not enough: the blank page still appeared, and a reload no longer recovered it because the new worker stayed in "waiting".

What changed

  • In development mode (which includes dev-bundle builds, since those are built in development mode), /VAADIN/ files that are in the precache are now served from the precache instead of network-first. The files have a content hash in their name, so this is the same cache-first handling production builds already use. Non-precached /VAADIN/ URLs (e.g. the Vite dev server's modules) are still network-first.
  • Removed self.skipWaiting(). A worker from a new build now waits until the previous build's pages are closed. Activating right away removed the previous build's files from the precache while those pages could still load from it (e.g. lazy route chunks in another open window). clientsClaim() is kept so the first install still controls the page without a reload.
  • With the Vite dev server, editing sw.ts still takes effect in the open page. After the update check, the dev server asks the rebuilt worker to skip waiting through a new Vaadin.ServiceWorker.skipWaiting message. The worker handles that message only in development builds (including dev bundles); a production worker ignores it.
  • Workbox removes outdated precache entries only when a worker activates. Because activation now waits, every build deployed while the app stays open adds its files to the precache. They are removed once the newest worker activates.

Update behavior (changed default)

How a new service worker reaches users changes with this PR, and this should be mentioned in the release notes and the PWA docs:

  • A new worker activates only after every window and tab of the app is closed. Reloading a single tab does not activate it, because the old page is still a client of the old worker during the navigation.
  • Users who keep an installed PWA open stay on the old worker and its precache after a redeploy. Online, pages loaded from the network still get the new build's index.html and files on reload. Offline, or when the old worker serves the precached app shell, they get the previous build until the app is closed.
  • If a broken service worker is deployed, a fixed deploy cannot replace it while any window of the app stays open.
  • Flow does not offer a production API to activate a waiting worker early in this PR: the Vaadin.ServiceWorker.skipWaiting message is handled only in development builds. Whether Flow should provide a supported "new version available, reload" mechanism is left as a follow-up decision.

Test summary

  • Reproduced with a real @PWA app in dev-bundle mode, driven by headless WebKit (Playwright) and Chromium: install the worker, rebuild with a frontend change and restart the server, then click in the still-open page.
  • With the worker's app-shell fallback forced: unpatched main gives a 404 on the old indexhtml-*.js and a blank page. With this change, the app loads.
  • Without forcing anything, in desktop Chrome: pause the server while it rebuilds the dev bundle (e.g. a debugger breakpoint in the dev bundle build task) and reload the open page. On main the old shell's bundle files fail with no-response from the NetworkFirst route and the page stays blank. With this change they come from the precache and the app loads.
  • Normal online path with this change: clicking after the session expires reloads the page into the new build.
  • Dev-server hot update of sw.ts, in Chromium: the rebuilt worker activates and takes over the open page. Without the message it stays in "waiting".
  • New dev-bundle variant of the PWA test module, where the bundle files are actually precached. Its tests run against the real generated service worker in Chrome:
    • A precached bundle file that the server answers with 404 (as after a rebuild) is still served from the precache.
    • A newly installed worker stays in "waiting" while the page is open. With a dev bundle it takes over once it gets the Vaadin.ServiceWorker.skipWaiting message; in production it keeps waiting after the message.
  • Both tests fail against the previous sw.ts. The precache test runs only with a dev bundle, and fails there if the bundle scripts are not precached. The waiting test also runs in production. It is skipped with the Vite dev server, which does not serve sw.js with a query string.
  • Not covered automatically: the dev server sending the message after a hot edit of sw.ts.
  • The earlier model-based IT is removed because it did not exercise the real service worker.

Artur- added 2 commits June 24, 2026 21:22
Adds a failing integration test for #24800: an installed PWA goes blank
after a frontend bundle rebuild because the service worker shipped by the
rebuild takes over an already-open page mid-session (skipWaiting +
clientsClaim) and then 404s the old-hash bundle the loaded page still
references.

A faithful reproduction needs two frontend builds with different bundle
hashes, which a single-build IT cannot produce, so the test drives a model
service worker (swrebuild/sw-model.js) that mirrors the policy of the
generated sw.ts. Whether a freshly installed worker takes over mid-session
is read from the real generated sw.js, so the test turns green
automatically once the platform stops taking over mid-session. The
assertion is made on the still-open page (no navigation), so the result is
deterministic.

The test currently fails, demonstrating the bug.
A service worker generated by a new frontend build called
self.skipWaiting() and therefore activated while pages from the previous
build were still open. It took those pages over mid-session and then 404'd
the old-hash bundle they still reference (the previous build's files are
gone from both the new precache and the redeployed server), leaving the
installed PWA on a permanent blank screen.

Removing self.skipWaiting() lets the new worker stay in "waiting" until the
previous build's pages close, so control is handed over cleanly between
page loads instead of mid-load. clientsClaim() is kept so the first page
load is still controlled without a reload (offline-on-first-load).

Also corrects the reproduction IT to detect the takeover policy via the
self.skipWaiting() call rather than the bare "skipWaiting" token, which
also appears in a workbox doc comment in the bundled service worker.

Fixes #24800
@Artur-

Artur- commented Jun 25, 2026

Copy link
Copy Markdown
Member Author

skipWaiting has been there since #4334. It's not 100% clear to me what the impact of this is

@sonarqubecloud

Copy link
Copy Markdown

@github-actions

github-actions Bot commented Jun 25, 2026 •

Copy link
Copy Markdown
Contributor

Test Results

 1 668 files  + 4   1 753 suites  +4   2h 4m 59s ⏱️ -25s
12 910 tests +10  12 834 ✅ +6  76 💤 +4  0 ❌ ±0 
13 543 runs  +10  13 467 ✅ +6  76 💤 +4  0 ❌ ±0 

Results for commit 413503a. ± Comparison against base commit fdcaa49.

♻️ This comment has been updated with latest results.

@Artur-

Artur- commented Jun 25, 2026

Copy link
Copy Markdown
Member Author

Maybe @vursen has some knowledge about this? The test here seems to be worthless and automated testing of what happens when you rebuild the service worker is difficult

@vursen

vursen commented Jun 25, 2026 •

Copy link
Copy Markdown
Contributor

Maybe @vursen has some knowledge about this?

Unfortunately, I don't know much about this skipWaiting call. I just brought it over unchanged when I refactored the service workers to support Vite.

@joelpop

joelpop commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

The issue remains with self.skipWaiting() gone.

The sequence I use is:

  1. remove the PWA from the iOS Home Screen
  2. redeploy the app
  3. add the PWA back to the iOS Home Screen
  4. open the PWA from the iOS Home Screen
  5. start monitoring the PWA console log
  6. make a frontend code change
  7. redeploy the app — the bundle rebuilds in my desktop browser (the PWA page does not change)
  8. capture the PWA console log before tapping anything
  9. tap something in the PWA app (the page goes blank)
  10. capture the PWA console log

The only way to recover from this has been to delete the PWA from the iOS Home Screen and re-add it. With the "fix" in place, "killing" the PWA and restarting it now works, so that's a little better.

Here is the server console log of the redeployment:

2026-06-25T11:54:35.105-06:00  INFO 14220 --- [Mobile Showcase] [           main] .e.DevToolsPropertyDefaultsPostProcessor : Devtools property defaults active! Set 'spring.devtools.add-properties' to 'false' to disable
2026-06-25T11:54:35.105-06:00  INFO 14220 --- [Mobile Showcase] [           main] .e.DevToolsPropertyDefaultsPostProcessor : For additional web related logging consider setting the 'logging.level.web' property to 'DEBUG'
2026-06-25T11:54:35.764-06:00  INFO 14220 --- [Mobile Showcase] [           main] o.s.boot.tomcat.TomcatWebServer          : Tomcat initialized with port 8080 (http)
2026-06-25T11:54:35.776-06:00  INFO 14220 --- [Mobile Showcase] [           main] o.apache.catalina.core.StandardService   : Starting service [Tomcat]
2026-06-25T11:54:35.776-06:00  INFO 14220 --- [Mobile Showcase] [           main] o.apache.catalina.core.StandardEngine    : Starting Servlet engine: [Apache Tomcat/11.0.21]
2026-06-25 11:54:35 JRebel: Directory '/Users/joel/workspaces/joelpop/mobile-showcase/target/classes' will be monitored for changes.
2026-06-25T11:54:35.814-06:00  INFO 14220 --- [Mobile Showcase] [           main] b.w.c.s.WebApplicationContextInitializer : Root WebApplicationContext: initialization completed in 709 ms
2026-06-25T11:54:37.768-06:00  INFO 14220 --- [Mobile Showcase] [           main] c.v.f.s.VaadinServletContextInitializer  : Search for subclasses and classes with annotations took 1768 ms
2026-06-25T11:54:37.876-06:00  INFO 14220 --- [Mobile Showcase] [           main] c.v.b.d.startup.DevModeStartupListener   : Starting dev-mode updaters in /Users/joel/workspaces/joelpop/mobile-showcase folder.
2026-06-25T11:54:37.898-06:00  INFO 14220 --- [Mobile Showcase] [           main] c.v.f.s.f.s.FullDependenciesScanner      : Visited 118 classes. Took 12 ms.
2026-06-25T11:54:37.899-06:00  INFO 14220 --- [Mobile Showcase] [           main] c.v.f.s.frontend.BundleValidationUtil    : Checking if a development mode bundle build is needed
2026-06-25T11:54:37.982-06:00  INFO 14220 --- [Mobile Showcase] [           main] c.v.f.s.frontend.BundleValidationUtil    : Detected changed content for frontend files:
 - app-headroom.ts

2026-06-25T11:54:37.983-06:00  INFO 14220 --- [Mobile Showcase] [           main] c.v.f.s.frontend.BundleValidationUtil    : A development mode bundle build is needed
2026-06-25T11:54:37.988-06:00  INFO 14220 --- [Mobile Showcase] [           main] c.v.f.s.frontend.TaskRunDevBundleBuild   : Creating a new development mode bundle. This can take a while but will only run when the project setup is changed, addons are added or frontend files are modified
2026-06-25T11:54:38.017-06:00  INFO 14220 --- [Mobile Showcase] [in-dev-server-1] c.v.f.s.frontend.TaskUpdatePackages      : Running `npm install` to resolve and optionally download frontend dependencies. This may take a moment, please stand by...
2026-06-25T11:54:38.213-06:00  INFO 14220 --- [Mobile Showcase] [in-dev-server-1] c.v.flow.server.frontend.NodeResolver    : The globally installed Node.js version 25.x is newer than the maximum supported version 24.x and may not be compatible. Using Node.js from /Users/joel/.vaadin.
2026-06-25T11:54:38.280-06:00  INFO 14220 --- [Mobile Showcase] [in-dev-server-1] c.v.flow.server.frontend.NodeResolver    : Node v24.16.0 is already installed in /Users/joel/.vaadin
2026-06-25T11:54:38.349-06:00  INFO 14220 --- [Mobile Showcase] [           main] c.v.f.s.DefaultDeploymentConfiguration   : 
Vaadin is running in DEVELOPMENT mode - do not use for production deployments.
2026-06-25 11:54:38 JRebel: Vaadin Live Reload enabled.
2026-06-25T11:54:38.432-06:00  INFO 14220 --- [Mobile Showcase] [           main] o.s.boot.tomcat.TomcatWebServer          : Tomcat started on port 8080 (http) with context path '/'
2026-06-25T11:54:38.435-06:00  INFO 14220 --- [Mobile Showcase] [           main] com.example.application.Application      : Started Application in 3.665 seconds (process running for 4.627)
2026-06-25T11:54:38.482-06:00  INFO 14220 --- [Mobile Showcase] [in-dev-server-1] c.v.f.s.frontend.TaskUpdatePackages      : using '/Users/joel/.vaadin/node-v24.16.0/bin/node /Users/joel/.vaadin/node-v24.16.0/lib/node_modules/npm/bin/npm-cli.js --no-update-notifier --no-audit --scripts-prepend-node-path=true --ignore-scripts install' for frontend package installation
2026-06-25T11:54:38.727-06:00  INFO 14220 --- [Mobile Showcase] [nio-8080-exec-2] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring DispatcherServlet 'dispatcherServlet'
2026-06-25T11:54:38.728-06:00  INFO 14220 --- [Mobile Showcase] [nio-8080-exec-2] o.s.web.servlet.DispatcherServlet        : Initializing Servlet 'dispatcherServlet'
2026-06-25T11:54:38.728-06:00  INFO 14220 --- [Mobile Showcase] [nio-8080-exec-2] o.s.web.servlet.DispatcherServlet        : Completed initialization in 0 ms
2026-06-25T11:54:42.329-06:00  INFO 14220 --- [Mobile Showcase] [in-dev-server-1] c.v.f.s.frontend.TaskUpdatePackages      : Frontend dependencies resolved successfully.
2026-06-25T11:54:42.991-06:00  INFO 14220 --- [Mobile Showcase] [in-dev-server-1] c.v.f.s.frontend.TaskCopyFrontendFiles   : Copying frontend resources from jar files ...
2026-06-25T11:54:43.039-06:00  INFO 14220 --- [Mobile Showcase] [in-dev-server-1] c.v.f.s.frontend.TaskCopyFrontendFiles   : Visited 22 resources. Took 47 ms.
WARNING: A restricted method in java.lang.System has been called
WARNING: java.lang.System::load has been called by com.sun.jna.Native in an unnamed module (file:/Users/joel/.m2/repository/net/java/dev/jna/jna/5.18.1/jna-5.18.1.jar)
WARNING: Use --enable-native-access=ALL-UNNAMED to avoid a warning for callers in this module
WARNING: Restricted methods will be blocked in a future release unless native access is enabled

2026-06-25T11:54:52.198-06:00  INFO 14220 --- [Mobile Showcase] [in-dev-server-1] c.v.f.s.frontend.TaskRunDevBundleBuild   : Development frontend bundle built
2026-06-25T11:54:55.673-06:00  INFO 14220 --- [Mobile Showcase] [in-dev-server-2] c.v.b.d.DevModeHandlerManagerImpl        : Application running at http://localhost:8080/

Here is the PWA console log after the redeployment but before tapping anything:

[Debug] Sending heartbeat request... (FlowClient-sfGnReEW.js, line 5301)
[Debug] Scheduling heartbeat in 300 seconds (FlowClient-sfGnReEW.js, line 786)
[Debug] Sending heartbeat request... (FlowClient-sfGnReEW.js, line 5301)
[Debug] Scheduling heartbeat in 300 seconds (FlowClient-sfGnReEW.js, line 786)
[Debug] Sending heartbeat request... (FlowClient-sfGnReEW.js, line 5301)
[Debug] Scheduling heartbeat in 300 seconds (FlowClient-sfGnReEW.js, line 786)
[Warning] Websocket closed, reason: Normal closure; the connection successfully completed whatever purpose for which it was created. - wasClean: true (vaadinPush.js, line 3342)
[Error] WebSocket connection to 'ws://localhost:8080/VAADIN/push?v-r=push&debug_window&token=56d5c631-ddae-400f-8c74-01467185363b&X-Atmosphere-tracking-id=ffac45bc-87c2-4529-b286-4bbc3e5505bf&X-Atmosphere-Framework=4.0.1&X-Atmosphere-Transport=websocket&X-Atmosphere-TrackMessageSize=true&Content-Type=application/json;%20charset=UTF-8&X-atmo-protocol=true' failed: Could not connect to the server.
[Warning] Websocket closed, reason: Connection was closed abnormally (that is, with no close frame being sent). - wasClean: false (vaadinPush.js, line 3342)
[Debug] Sending heartbeat request... (FlowClient-sfGnReEW.js, line 5301)
[Error] Failed to load resource: the server responded with a status of 404 () (localhost, line 0)
[Warning] Heartbeat request returned 404 (FlowClient-sfGnReEW.js, line 780)
[Debug] Scheduling heartbeat in 300 seconds (FlowClient-sfGnReEW.js, line 786)

(I normally don't wait long enough here to get this 404—but that's immaterial.)

Here is the PWA console log after tapping something in the app:

[Debug] Thu Jun 25 2026 11:59:40 GMT-0600 (Mountain Daylight Time) Atmosphere: unload event (vaadinPush.js, line 3342)
[Error] Failed to load resource: the server responded with a status of 404 () (indexhtml-CgqZaI-V.js, line 0)
[Debug] Vaadin push loaded (vaadinPush.js, line 3506)
[Warning] The resource http://localhost:8080/VAADIN/build/commonjsHelpers-CUmg6egw.js was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it wasn't preloaded for nothing.

—

According to Claude Code:

The blank screen has two independent paths, and PR #24807 only fixes one of them:

With skipWaiting Without skipWaiting
Which SW controls the page SW_B (new, activated mid-session) SW_A (old, still in control)
indexhtml-CteMfDdg.js in precache? No (SW_B has bundle B's manifest) Yes (SW_A precached bundle A)
Served from precache? No No — networkFirst for VAADIN/* intercepts first, gets 404, doesn't fall back to precache
Result 404 → blank 404 → blank

The real fix needs to change the VAADIN/* routing strategy so that when networkFirst gets a 404 (file no longer exists on the server), it falls back to the precache as a second option. Removing skipWaiting alone isn't sufficient — it changes which SW handles the request but doesn't change the fact that precached files can't be recovered once the server 404s them.

Before (skipWaiting present): SW_B activated mid-session, claimed the page, and started serving from bundle B's manifest. Bundle A's lazy-loaded chunks were gone from both the server and SW_B's precache. Killing and relaunching still left SW_B in control — same broken state — requiring a full uninstall to clear the SW registration.

After (skipWaiting removed): SW_B installs but waits. The blank screen still happens when bundle A's JS requests a chunk that's gone from the server (and networkFirst doesn't fall back to SW_A's precache). But killing the PWA removes all clients → SW_B activates → relaunch → SW_B serves bundle B correctly.

So the PR narrows the recovery from "uninstall + reinstall" to "kill + relaunch," which is a meaningful improvement. The remaining issue — blank on the first tap after redeploy without killing first — requires a separate fix: the VAADIN/* route needs to fall back to the precache when the network returns 404, so SW_A can still serve its own precached bundle A chunks while it's in control.

So, the PR is a partial fix, it changes the recovery UX, but there's a second defect in the networkFirst fallback path.

In development mode the service worker sent every /VAADIN/ request
network-first, so a precached app shell from the previous build loaded
its entry script from the server, which no longer had it, and the page
stayed blank. Precached dev bundle files are now served from the
precache.

The dev server update of an edited sw.ts now asks the rebuilt worker to
skip waiting, as the generated worker no longer does that by itself.

Removes the model-based IT, which did not exercise the real service
worker.
@totally-not-ai totally-not-ai Bot changed the title fix: do not skipWaiting in the generated service worker fix: keep PWA app shell and its files consistent after a rebuild Oct 16, 2026
@totally-not-ai

Copy link
Copy Markdown
Contributor

Pushed 1095b5e on top of this PR, which addresses the remaining blank page reported above. (It replaces #26464, now closed.)

Root cause of the remaining blank page: in development mode (which includes dev-bundle builds), sw.ts registered a networkFirst route for every /VAADIN/ URL, ahead of the precache route. When the old worker served the precached app shell of the previous build, its indexhtml-<hash>.js was requested from the server, which no longer had it after the rebuild. networkFirst passed the 404 through and never looked in the precache, so the page stayed blank. This happens with or without skipWaiting(); removing it only changed which worker produced the 404.

What the new commit does:

  • The dev-mode /VAADIN/ networkFirst route now skips URLs that are in the precache manifest (!isManifestEntryURL(url)), so precached dev bundle files are served from the precache. They have a content hash in their name, so this is the same cache-first handling production builds already get. Non-precached /VAADIN/ URLs (e.g. Vite dev server modules) stay network-first.
  • Keeps the removal of self.skipWaiting() from this PR, with a shorter comment: a worker from a new build waits until the previous build's pages are closed, so it does not drop the previous build's files from the precache while those pages still load from it (e.g. lazy chunks in another open window). clientsClaim() is kept.
  • Since the worker no longer skips waiting by itself, editing sw.ts with the Vite dev server would otherwise not take effect in the open page. vite-devmode.ts now posts a new Vaadin.ServiceWorker.skipWaiting message to the installed worker after the update check, and sw.ts calls self.skipWaiting() when it receives it.
  • Removes the model-based ServiceWorkerRebuildIT and its swrebuild/ fixtures, since they did not exercise the real service worker. No automated test is added for now.

Verified manually with a real @PWA app in dev-bundle mode, driven by headless WebKit (Playwright) and Chromium: install the worker, rebuild with a frontend change and restart the server, then click in the still-open page. With the app-shell fallback forced, unpatched main gives a 404 on the old indexhtml-*.js and a blank page; with this change the app loads. The normal online path reloads into the new build, and a hot update of sw.ts activates the rebuilt worker in Chromium.

Trade-off: Workbox only cleans outdated precache entries on activation, so builds deployed while the app stays open accumulate in the precache until the newest worker activates.

@joelpop could you retry your iOS sequence with this branch? The first tap after a redeploy should no longer give a blank page without killing the app first.

@Artur-
Artur- marked this pull request as ready for review October 16, 2026 05:53
@github-actions

Copy link
Copy Markdown
Contributor

A new build's worker now waits for a message instead of activating itself

sequenceDiagram
    participant Vite as vite-devmode.ts hot.on("sw-js-update")
    participant Reg as ServiceWorkerRegistration
    participant Worker as New worker (sw.ts)

    Vite->>Reg: registration.update()
    Reg->>Worker: installs new build
    Worker-->>Worker: reaches state "installed", stays waiting (no skipWaiting call)
    Note over Worker: previously self.skipWaiting() ran here unconditionally
    Vite->>Worker: activateWaitingServiceWorker() watches statechange (new)
    Vite->>Worker: postMessage Vaadin.ServiceWorker.skipWaiting (new)
    Worker->>Worker: message handler calls self.skipWaiting() (new)
    Worker->>Worker: activates, clientsClaim(), drops old precache entries
    Note over Worker: without this message the worker stays "waiting" until every open window closes
Loading

sw.ts no longer calls self.skipWaiting() on install, so a new build's worker stays in the "waiting" state until the previous build's pages are closed — this is the behavioral change the PR description calls out, meant to stop a new worker from claiming pages mid-load. For the Vite dev server case, vite-devmode.ts's sw-js-update handler now calls the new activateWaitingServiceWorker(), which waits for the worker to reach installed and then sends it a Vaadin.ServiceWorker.skipWaiting message, handled by a new branch in sw.ts's message listener that calls self.skipWaiting() explicitly.

Diagram Bot draws the mechanism this pull request touches; it does not review the change. Verify it against the diff.

Generated by Diagram Bot for #24807 · agent · 50 AIC · ⊞ 9.2K · ◷

@vaadin-review-bot vaadin-review-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the changes — left 1 comment.

Finding
🧪 Neither changed behavior is reachable from any existing test module, so a regression would go unnoticed.

// Precached files of a dev bundle are left to the precache route: after a
// rebuild the server no longer has them, but the app shell served from the
// precache still references them.
registerRoute(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧪 Neither changed behavior is reachable from any existing test module, so a regression would go unnoticed.

All PWA IT modules (flow-tests/test-pwa, flow-tests/test-frontend/vite-pwa*) inherit vaadin.frontend.hotdeploy=true from flow-tests/pom.xml. In hotdeploy mode the service-worker plugin never runs injectManifestToSWPlugin, so self.__WB_MANIFEST stays undefined and manifestEntries holds only the runtime entries (icons, manifest, offline page) — none under /VAADIN/. The new !isManifestEntryURL(url) term is therefore always true there, and the dev-bundle mode the fix targets has no PWA test module at all.

The same applies to the lifecycle change: nothing asserts that a new worker waits, or that the Vaadin.ServiceWorker.skipWaiting message activates it. Reverting either hunk would keep CI green.

sw.ts:201 · tests

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added coverage in a106210 and the follow-up commit. There is now a dev-bundle variant of the PWA test module (flow-tests/test-pwa/pom-devbundle.xml, hotdeploy=false), so the bundle files really are in the precache manifest. ServiceWorkerUpdateIT runs against the real generated service worker:

  • precachedBundleFile_removedFromServer_servedFromPrecache: a test filter answers /VAADIN/build/* with 404 while a cookie is set, simulating the server after a rebuild. The precached indexhtml-*.js must still come back 200 through the worker.
  • newServiceWorker_waitsUntilSkipWaitingRequested: registers the worker script under another URL to install a new worker. The new worker must stay in waiting while the page is open, then activate and take control once it receives Vaadin.ServiceWorker.skipWaiting.

Against the previous sw.ts, both fail (404 instead of 200, and the new worker activates right away). The precache test runs only in the dev-bundle module, which sets vaadin.test.devBundle for failsafe. There it fails, rather than skipping, if no bundle script is precached. With the Vite dev server nothing is precached, and in production the files are served from the precache anyway. The waiting test passes in all three variants. Still not covered: the vite-devmode.ts side that sends the message after a hot sw.ts edit.

totally-not-ai Bot and others added 4 commits October 16, 2026 06:16
Adds a dev bundle variant of the PWA test module, where the bundle
files are precached, and tests that a precached bundle file the server
no longer has is still served from the precache, and that a new
service worker waits until it is asked to skip waiting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The precache test now runs only in the dev bundle module, where it
fails instead of skipping when the bundle scripts are not precached.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Vite dev server proxy answers sw.js with a query string with 404,
so a new worker cannot be installed that way in the hot deploy module.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@platosha
platosha self-requested a review October 16, 2026 07:32
Production service workers no longer activate a waiting worker on
request, so a new build's worker always waits until the previous
build's pages are closed. The dev server still uses the message to
apply an edited sw.ts to the open page.
@sonarqubecloud

sonarqubecloud Bot commented Nov 5, 2026

Copy link
Copy Markdown

@caalador caalador left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks ok, but would be good to have it tested on iOS with the latest changes.

Just to note: the first update after upgrading will still hit the bug. Users' devices run the previous service worker, which still calls skipWaiting() and lacks the route fix. The fix takes effect only for rebuilds after the fixed version has been deployed once.

@github-actions

github-actions Bot commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

📦 Snapshot published

25.4.pwa-after-rebuild-SNAPSHOT — built from 413503a (run).

Built without running tests. Look at the checks on this pull request before relying on it.

Every new commit on this branch republishes it while the snapshot build label is there. Add -U to pick the newest build up.

How to use it
<repository>
  <id>vaadin-snapshots</id>
  <url>https://maven.vaadin.com/vaadin-prereleases</url>
  <snapshots><enabled>true</enabled></snapshots>
</repository>

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>com.vaadin</groupId>
      <artifactId>flow-bom</artifactId>
      <version>25.4.pwa-after-rebuild-SNAPSHOT</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
    <!-- After flow-bom, so that its Flow version wins -->
    <dependency>
      <groupId>com.vaadin</groupId>
      <artifactId>vaadin-bom</artifactId>
      <version>25.4-SNAPSHOT</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

Replace the application's own vaadin-bom import with these two rather than adding them next to it: with two vaadin-bom imports, the first one decides the platform version. flow-bom has to come before vaadin-bom: Maven takes the first version it finds for an artifact, so the other order leaves the platform's own Flow version in use. 25.4-SNAPSHOT is the unreleased platform snapshot of the same minor version as this one, so this also moves the application off its released platform version.

@Artur-

Artur- commented Nov 6, 2026

Copy link
Copy Markdown
Member Author

I tried to first reproduce the original problem with 25.3.2 but cannot, so it is difficult to test the change. What I tried was

  1. Start an app with a custom element that says "One"
  2. Open on iOS
  3. Add to home screen and open it
    • The view says "One"
  4. Change the frontend file so the custom element renders "Two"
  5. Restart the development server, which rebuilds the dev bundle
  6. Click on a button in the open iOS app
    • The screen goes blank, then it refreshes and says "Two"

Is there something I am missing?

@totally-not-ai

totally-not-ai Bot commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

@Artur- What you see is the normal online path, and that one already worked before this PR. The click gets sessionExpired, the page reloads, index.html comes from the network and points to the new bundle, so it shows "Two". I got the same result in Chromium and WebKit, with and without skipWaiting().

The blank screen only shows up when the reload is answered by the old worker's offline fallback: the NavigationRoute in sw.ts serves the precached app shell when navigator.onLine is false or when the navigation fetch throws. That shell is the previous build's, so it asks for the old indexhtml-<hash>.js. In dev mode, /VAADIN/ URLs went through the NetworkFirst route, and that route passed the restarted server's 404 straight through without checking the precache. That gives the 404 on indexhtml-*.js, the "preloaded but not used" warning and the blank page from the issue. Without skipWaiting() it stays blank on every reload, because the new worker never gets control while the window is open.

I could only reproduce it by forcing that branch: I made the navigation fetch fail while the server served /VAADIN/ files normally. I haven't found what makes iOS take that branch in the reporter's setup, so I can't give you device steps that hit it reliably. Things that could make the difference:

  • The reload happens while the server is still restarting. The reporter used JRebel and a Spring Boot hot restart, and their log shows the push connection closing cleanly (Websocket closed, reason: Normal closure) right before the reload. If the reload navigation fails because the server is down, the old shell is served. If the bundle request then reaches the server once it's back up, it gets a 404. Try clicking right after you start the restart, before the server is up again, and use @Push like the reporter did.
  • iOS reports navigator.onLine as false for a moment when a standalone app comes back from the background. Try leaving the app in the background for a while before you go back to it and click.
  • The reporter used 25.1.6. The reconnect handling in the client has changed a lot since then (the TypeScript port of the engine), so 25.3.2 may reload at a different moment.

To check the fix itself without the timing, in a dev-bundle app with this branch: install the worker, rebuild with a frontend change, restart the server, then in the still-open page set the network to offline only for navigations (or fake navigator.onLine in the worker) and reload. On main you get a blank page with a 404 on the old indexhtml-*.js. With this branch the old bundle is served from the precache and the app loads. It's also worth asking @joelpop whether the steps from the issue still give a blank screen for them on 25.3.

@Artur-

Artur- commented Nov 10, 2026

Copy link
Copy Markdown
Member Author

By setting a breakpoint in the TaskRunDevBundleBuild constructor and reloading when the server hits the breakpoint, I can see a problem in desktop Chrome - seems like the same.

Failed to load resource: net::ERR_FAILED
rolldown-runtime-BdGkqNEt.js:1  Failed to load resource: net::ERR_FAILED
chunk-DiqZc92J-mGcS9MzV.js:1  Failed to load resource: net::ERR_FAILED
sw.js:2030 Uncaught (in promise) no-response: no-response :: [{"url":"http://localhost:2222/VAADIN/build/indexhtml-CIHwYKf2.js"}]
    at NetworkFirst._handle (sw.js:2030:25)
    at async NetworkFirst._getResponse (sw.js:829:16)
preload-helper-BzTzvvl5.js:1  Failed to load resource: net::ERR_FAILED
sw.js:2030 Uncaught (in promise) no-response: no-response :: [{"url":"http://localhost:2222/VAADIN/build/rolldown-runtime-BdGkqNEt.js"}]
    at NetworkFirst._handle (sw.js:2030:25)
    at async NetworkFirst._getResponse (sw.js:829:16)

This goes away with this PR - not sure if it is actually the same thing

@totally-not-ai

Copy link
Copy Markdown
Contributor

@Artur- Yes, that's the same bug. The server's answer is just different.

  • While the server is paused at the breakpoint, the reload navigation fails, so the old worker's NavigationRoute falls back to its precached app shell (the catch branch after networkOnly.handle). That's the previous build's shell, so it asks for the old indexhtml-<hash>.js, rolldown-runtime-*.js and so on.
  • On main, in dev mode those /VAADIN/ requests go through the NetworkFirst route, not the precache. The network fails, the runtime cache doesn't have them either, and workbox throws no-response. That's the ERR_FAILED and the blank page you see.
  • In the reported case the server is already back up, so the same request gets a 404 instead, and NetworkFirst passes that through. The page goes blank the same way.
  • With this PR, precached files skip the NetworkFirst route and come from the precache, so both cases load the old build's shell. That's why the error goes away.

So your breakpoint setup is a reliable desktop way to reproduce it, without needing iOS timing. I've added it to the test summary in the PR description.

@Artur-
Artur- added this pull request to the merge queue Nov 10, 2026
Merged via the queue into main with commit 9998590 Nov 10, 2026
62 checks passed
@Artur-
Artur- deleted the pwa-after-rebuild branch November 10, 2026 14:54
vaadin-bot added a commit to vaadin/docs that referenced this pull request Nov 10, 2026
Explain that a new service worker waits until every open window and
tab is closed before it activates, so a single reload doesn't pick up
a new build, on the PWA configuration page.

Documents vaadin/flow#24807 (`413503abaa8506b9a163b42c0d78bec777f4724e`).
@github-actions

Copy link
Copy Markdown
Contributor

Pull request created: #6619

Generated by Documentation Bot · agent · 73 AIC · ⊞ 11.7K

@github-actions

Copy link
Copy Markdown
Contributor

Documentation Bot: Draft documentation pull request for this change: vaadin/docs#6619

Files updated:

  • articles/flow/configuration/pwa.adoc

It was written from the state of this pull request as you see it now. Please review it and mark it ready for review.

Generated by Documentation Bot for #24807 · agent · 73 AIC · ⊞ 11.7K · ◷

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

PWA standalone: blank screen after frontend bundle rebuild due to mid-load service worker takeover causing 404

5 participants