From 4fea813adcb6b6a23dbf2ab2b111b3cc0b4d1970 Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sun, 3 May 2026 06:09:47 +0000 Subject: [PATCH] fix(sw-cache): version style.css link so old SW cannot return stale CSS (#1507) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Container restart / in-place upgrade left the previous service worker still controlling open tabs. Its fetch handler intercepted 'static/style.css', matched the unversioned URL exactly against its old shell cache, and returned the OLD CSS — while the JS files (which already carry ?v=__WEBUI_VERSION__) hit the cache as misses and loaded fresh from network. New JS + old CSS broke the layout until a force refresh bypassed the SW. Fix is a 1-line attribute change plus aligning the SW pre-cache list: * static/index.html: add ?v=__WEBUI_VERSION__ to the style.css link, matching the pattern already in use for every JS file in the page. * static/sw.js: add the same ?v=__CACHE_VERSION__ suffix to every versioned entry in SHELL_ASSETS so that pre-cache URLs match what the page actually requests. Unversioned entries (root, manifest, favicons) stay unversioned. Tests: * New regression test_index_versions_stylesheet (lock the href) and test_sw_shell_assets_match_versioned_asset_urls in test_pwa_manifest_sw.py. * test_workspace_panel_preload_marker_restored_in_head in test_sprint37.py loosened to match the css link prefix (preserves the ordering invariant). Verified live on port 8789: served HTML carries 'static/style.css?v=v0.50.275-dirty' and SW SHELL_ASSETS receive the matching VQ at request time. Closes #1507. --- static/index.html | 2 +- static/sw.js | 35 +++++++++++++-------- tests/test_pwa_manifest_sw.py | 57 +++++++++++++++++++++++++++++++++++ tests/test_sprint37.py | 5 ++- 4 files changed, 84 insertions(+), 15 deletions(-) diff --git a/static/index.html b/static/index.html index 08ebb423..b972611f 100644 --- a/static/index.html +++ b/static/index.html @@ -18,7 +18,7 @@ - + diff --git a/static/sw.js b/static/sw.js index 58fa2c46..9e43db66 100644 --- a/static/sw.js +++ b/static/sw.js @@ -9,21 +9,30 @@ // Bumps automatically whenever the git commit changes — no manual edits needed. const CACHE_NAME = 'hermes-shell-__CACHE_VERSION__'; -// Static assets that form the app shell +// Static assets that form the app shell. +// +// Versioned assets (CSS + JS) include `?v=__CACHE_VERSION__` to match the +// query string the page sends — see index.html. Without the version query +// here, every cache lookup against `?v=...` URLs would miss and fall through +// to network, defeating the pre-cache. +// +// Unversioned assets (`./`, manifest.json, favicons) are referenced from +// index.html without a cache-bust query, so they stay unversioned here too. +const VQ = '?v=__CACHE_VERSION__'; const SHELL_ASSETS = [ './', - './static/style.css', - './static/boot.js', - './static/ui.js', - './static/messages.js', - './static/sessions.js', - './static/panels.js', - './static/commands.js', - './static/icons.js', - './static/i18n.js', - './static/workspace.js', - './static/terminal.js', - './static/onboarding.js', + './static/style.css' + VQ, + './static/boot.js' + VQ, + './static/ui.js' + VQ, + './static/messages.js' + VQ, + './static/sessions.js' + VQ, + './static/panels.js' + VQ, + './static/commands.js' + VQ, + './static/icons.js' + VQ, + './static/i18n.js' + VQ, + './static/workspace.js' + VQ, + './static/terminal.js' + VQ, + './static/onboarding.js' + VQ, './static/favicon.svg', './static/favicon-32.png', './manifest.json', diff --git a/tests/test_pwa_manifest_sw.py b/tests/test_pwa_manifest_sw.py index f2d0e5db..40220dec 100644 --- a/tests/test_pwa_manifest_sw.py +++ b/tests/test_pwa_manifest_sw.py @@ -160,6 +160,63 @@ class TestIndexHtmlIntegration: assert "sw.js?v=__WEBUI_VERSION__" in src assert "static/ui.js?v=__WEBUI_VERSION__" in src + def test_index_versions_stylesheet(self): + """Regression for #1507: the `` for style.css MUST + carry the same `?v=__WEBUI_VERSION__` cache-bust query as the JS files. + + Without the version, a stale service worker controlling a tab across a + version upgrade would intercept `static/style.css`, find an exact URL + match in its old shell cache, and return OLD CSS — while the new JS + URLs (which DO carry `?v=`) miss the cache and load fresh. The mismatch + breaks the layout until a force-refresh bypasses the SW. + """ + src = INDEX.read_text(encoding="utf-8") + assert "static/style.css?v=__WEBUI_VERSION__" in src, ( + "static/style.css must carry ?v=__WEBUI_VERSION__ so an old service " + "worker controlling the tab across a version upgrade does not return " + "stale CSS — see #1507" + ) + # And the unversioned form must NOT appear (defensive — catches accidental + # reverts that leave both lines). + assert 'href="static/style.css"' not in src, ( + "unversioned static/style.css link found — must include " + "?v=__WEBUI_VERSION__ for cache busting" + ) + + def test_sw_shell_assets_match_versioned_asset_urls(self): + """The service worker's SHELL_ASSETS pre-cache list must use the same + `?v=__CACHE_VERSION__` suffix on JS+CSS that index.html sends, so that + the pre-cached entries actually serve when the page requests them. + + Without this, every `cache.match()` for a versioned asset URL (e.g. + `static/style.css?v=vN`) would miss against the unversioned pre-cached + entry (`static/style.css`), defeating the pre-cache. + """ + src = SW.read_text(encoding="utf-8") + # Versioned shell assets must include the cache version query. + for asset in ( + "style.css", + "boot.js", + "ui.js", + "messages.js", + "sessions.js", + "panels.js", + "commands.js", + "icons.js", + "i18n.js", + "workspace.js", + "terminal.js", + "onboarding.js", + ): + # Either inline `?v=__CACHE_VERSION__` or via the VQ constant + # produces a URL string the cache lookup can match. + has_inline = f"{asset}?v=__CACHE_VERSION__" in src + has_concat = f"{asset}' + VQ" in src or f"{asset}\" + VQ" in src + assert has_inline or has_concat, ( + f"sw.js SHELL_ASSETS entry for {asset} must carry " + "?v=__CACHE_VERSION__ to match the URL the page requests" + ) + def test_index_route_url_encodes_asset_version(self): src = ROUTES.read_text(encoding="utf-8") idx = src.find('parsed.path in ("/", "/index.html")') diff --git a/tests/test_sprint37.py b/tests/test_sprint37.py index cf8c3de8..b16a253d 100644 --- a/tests/test_sprint37.py +++ b/tests/test_sprint37.py @@ -94,7 +94,10 @@ def test_workspace_panel_restore_before_sync(): def test_workspace_panel_preload_marker_restored_in_head(): """index.html must preload the workspace panel state before the main stylesheet paints.""" marker = "document.documentElement.dataset.workspacePanel" - css_link = '' + # The stylesheet href carries `?v=__WEBUI_VERSION__` for SW cache busting (#1507). + # We just need the link's start position; the version query doesn't change the + # ordering invariant this test enforces. + css_link = '