fix(sw-cache): version style.css link so old SW cannot return stale CSS (#1507)

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.
This commit is contained in:
nesquena-hermes committed 2026-05-03 06:09:47 +00:00
1 parent 52226bcdd7
commit 4fea813adc
4 files changed
+84 -15

No files matched your search

+1 -1
View File
@@ -18,7 +18,7 @@
<script>(function(){var themes={light:1,dark:1,system:1},skins={default:1,ares:1,mono:1,slate:1,poseidon:1,sisyphus:1,charizard:1,sienna:1},legacy={slate:['dark','slate'],solarized:['dark','poseidon'],monokai:['dark','sisyphus'],nord:['dark','slate'],oled:['dark','default']},t=(localStorage.getItem('hermes-theme')||'dark').toLowerCase(),s=(localStorage.getItem('hermes-skin')||'').toLowerCase(),m=legacy[t],theme=m?m[0]:(themes[t]?t:'dark'),skin=skins[s]?s:(m?m[1]:'default');localStorage.setItem('hermes-theme',theme);localStorage.setItem('hermes-skin',skin);if(theme==='system')theme=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';if(theme==='dark')document.documentElement.classList.add('dark');if(skin!=='default')document.documentElement.dataset.skin=skin;})()</script>
<script>(function(){var fs=localStorage.getItem('hermes-font-size');if(fs&&fs!=='default')document.documentElement.dataset.fontSize=fs;})()</script>
<script>(function(){try{document.documentElement.dataset.workspacePanel=localStorage.getItem('hermes-webui-workspace-panel')==='open'?'open':'closed';}catch(e){document.documentElement.dataset.workspacePanel='closed';}})()</script>
<link rel="stylesheet" href="static/style.css">
<link rel="stylesheet" href="static/style.css?v=__WEBUI_VERSION__">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css" integrity="sha384-LJcOxlx9IMbNXDqJ2axpfEQKkAYbFjJfhXexLfiRJhjDU81mzgkiQq8rkV0j6dVh" crossorigin="anonymous">
<!-- KaTeX math rendering CSS (loaded eagerly to prevent layout shift) -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.css" integrity="sha384-5TcZemv2l/9On385z///+d7MSYlvIEw9FuZTIdZ14vJLqWphw7e7ZPuOiCHJcFCP" crossorigin="anonymous">
+22 -13
View File
@@ -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',
+57
View File
@@ -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 `<link rel=stylesheet>` 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")')
+4 -1
View File
@@ -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 = '<link rel="stylesheet" href="static/style.css">'
# 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 = '<link rel="stylesheet" href="static/style.css'
marker_pos = HTML.find(marker)
css_pos = HTML.find(css_link)
assert marker_pos >= 0, "index.html must preload documentElement.dataset.workspacePanel from localStorage"