A reload offer only for a page that is older, and a tab switch that moves one scroller

The update toast fired whenever a service worker was waiting, so after every
release it appeared on pages that were already the release -- including one
fetched with Ctrl+Shift+R. It now compares the waiting worker's release with
the page's own, and so does the reload that follows another tab accepting it.

On Admin -> Prompts, visually hidden labels were positioned against the page
and made the document 6771px tall behind an overflow-hidden root; the tab
handler's scrollIntoView then scrolled that root and lifted the shell 56px.
Every scroll region is now a containing block, and the handler moves only the
container that scrolls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 06:17:18 +00:00
co-authored by Claude Opus 5.5
parent 793f9c8cad
commit f58aef02a7
8 changed files with 170 additions and 23 deletions
+28
View File
@@ -16,6 +16,34 @@ for 1.0.0 have something to be assembled from.
## Unreleased ## Unreleased
## 1.8.4
Two things that kept showing up after they should have gone away.
- **"A new version is ready" no longer appears on a page that is already the
new version.** After an update the toast showed up on every page, even one just
fetched with Ctrl+Shift+R, and reloading never made it go away. It fired
whenever a new service worker was waiting. But a page loaded after the update
already *is* the update: pages always come from the server, and every
stylesheet and script they name carries the release in its address. The
worker that waits is nearly always the one from the previous release, still
holding the tab, because a reload opens the new page before the old one goes
away. The toast now compares the waiting worker's release with the page's
own, so it only appears in a tab that was opened before the update. For the
same reason, pressing Reload in one tab no longer reloads the other tabs that
are already up to date. That matters when one of them has a reply streaming
into it.
- **Switching tabs on Administration → Prompts no longer lifts the page.**
Choosing any tab but the first pushed the whole window up by the height of
the title bar and left a blank strip along the bottom, under the sidebar too.
The prompt cards' hidden labels were positioned against the page instead of
the panel. That made the page 6,771px tall behind a window that cannot scroll
by hand, and the tab switch then scrolled it anyway. Every scrolling area now
contains what is inside it, and a tab switch moves only the panel that
scrolls. Administration → General on a small phone had the same leak and is
fixed with it.
## 1.8.3 ## 1.8.3
The model picker on a phone, which could not be read once a chat was open. The model picker on a phone, which could not be read once a chat was open.
+1 -1
View File
@@ -1,3 +1,3 @@
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints.""" """LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
__version__ = "1.8.3" __version__ = "1.8.4"
+12 -1
View File
@@ -452,7 +452,17 @@ input.visually-hidden[type="checkbox"] {
child will not shrink below its content without it, so a scroller missing it child will not shrink below its content without it, so a scroller missing it
grows its parent instead of scrolling inside it. `.thread-scroll` relied on a grows its parent instead of scrolling inside it. `.thread-scroll` relied on a
scroll container's automatic minimum size to get away with omitting it, which scroll container's automatic minimum size to get away with omitting it, which
is true and is not something the next person should have to know. */ is true and is not something the next person should have to know.
`position: relative` makes each scroller the containing block for what is in
it, and without it a `.visually-hidden` label is not in it at all. That class
is `position: absolute`, so with no positioned ancestor it is placed against
the *page*, at its static position -- six thousand pixels down the Tools
panel on /admin/prompts -- and the document grew to 6771px behind a root
that is `overflow: hidden`. Nobody can scroll that by hand, but
`scrollIntoView()` and `focus()` scroll every ancestor that can scroll,
and the root can. Switching a tab there lifted the whole shell 56px: the
topbar gone off the top and a strip of bare background under everything. */
.scroll-region, .scroll-region,
.sidebar__scroll, .sidebar__scroll,
.inspector__body, .inspector__body,
@@ -460,6 +470,7 @@ input.visually-hidden[type="checkbox"] {
.thread-scroll, .thread-scroll,
.admin-scroll, .admin-scroll,
.main > .tabs > .tabs__body { .main > .tabs > .tabs__body {
position: relative;
flex: 1; flex: 1;
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto;
+59 -20
View File
@@ -1099,27 +1099,59 @@
The worker no longer takes over open pages on its own -- see sw.js -- so The worker no longer takes over open pages on its own -- see sw.js -- so
something has to say that one is waiting, and the reader decides. A toast something has to say that one is waiting, and the reader decides. A toast
rather than a reload: an application with a reply streaming into it must rather than a reload: an application with a reply streaming into it must
not be navigated out from under somebody. */ not be navigated out from under somebody.
🚨 "A worker is waiting" is not the same as "this page is out of date",
and the toast used to treat them as one. After a release it offered a
reload on every page, including one just fetched with Ctrl+Shift+R, and
reloading could not make it stop. A page is always fetched from the network
and every asset it names carries `?v=<release>`, so a page loaded after
the update IS the update, whichever worker happens to control it. And the
worker that controls it is nearly always the previous one: a reload
creates the new page before the old one goes away, so the old worker
never runs out of pages and the new one never stops waiting.
So the question is asked of the page. The worker's release is in its own
script URL (`/sw.js?v=`), and the page's is `window.lembasRelease` from
base.html. When the two match there is nothing newer to reload into, and
the worker is left to take over once the old tabs are closed. */
var PAGE_RELEASE = window.lembasRelease || "";
function releaseOf(worker) {
try {
return new URL(worker.scriptURL).searchParams.get("v") || "";
} catch (error) {
return "";
}
}
/* Unknown on either side counts as newer: better an extra offer than a
release nobody is told about. */
function isNewerThanThisPage(worker) {
var release = releaseOf(worker);
return !PAGE_RELEASE || !release || release !== PAGE_RELEASE;
}
function offerReload(worker) {
window.lembas.notify(
"A new version is ready. Reload to use it.",
{ kind: "info", action: { label: "Reload", run: function () {
worker.postMessage({ type: "SKIP_WAITING" });
} } }
);
}
function watchForUpdate(registration) { function watchForUpdate(registration) {
function offer(worker) { function offer(worker) {
if (!worker || !navigator.serviceWorker.controller) return; if (!worker || !navigator.serviceWorker.controller) return;
worker.addEventListener("statechange", function () { worker.addEventListener("statechange", function () {
if (worker.state !== "installed") return; if (worker.state !== "installed") return;
window.lembas.notify( if (isNewerThanThisPage(worker)) offerReload(worker);
"A new version is ready. Reload to use it.",
{ kind: "info", action: { label: "Reload", run: function () {
worker.postMessage({ type: "SKIP_WAITING" });
} } }
);
}); });
} }
if (registration.waiting && navigator.serviceWorker.controller) { if (registration.waiting && navigator.serviceWorker.controller &&
window.lembas.notify( isNewerThanThisPage(registration.waiting)) {
"A new version is ready. Reload to use it.", offerReload(registration.waiting);
{ kind: "info", action: { label: "Reload", run: function () {
registration.waiting.postMessage({ type: "SKIP_WAITING" });
} } }
);
} }
registration.addEventListener("updatefound", function () { registration.addEventListener("updatefound", function () {
offer(registration.installing); offer(registration.installing);
@@ -1130,17 +1162,24 @@
the right answer to it -- the page is now being served by a worker whose the right answer to it -- the page is now being served by a worker whose
cache it did not start from. cache it did not start from.
Two guards, and the second is the one that is easy to miss. A flag, because Three guards, and the second is the one that is easy to miss. A flag,
`controllerchange` can fire more than once. And `hadController`, because on because `controllerchange` can fire more than once. And `hadController`,
a *first* visit there is no worker at all: the one that installs then calls because on a *first* visit there is no worker at all: the one that
`clients.claim()`, which fires this event for the first time -- so without installs then calls `clients.claim()`, which fires this event for the
it, the very first page anybody loads reloads itself in front of them for first time -- so without it, the very first page anybody loads reloads
no reason they could possibly work out. */ itself in front of them for no reason they could possibly work out.
The third is the same question as the toast's. Somebody pressing Reload in
one tab activates the worker for all of them, and a tab that was already
rendered by that release has nothing to gain from a reload -- and may have
a reply streaming into it. */
var reloading = false; var reloading = false;
if ("serviceWorker" in navigator) { if ("serviceWorker" in navigator) {
var hadController = !!navigator.serviceWorker.controller; var hadController = !!navigator.serviceWorker.controller;
navigator.serviceWorker.addEventListener("controllerchange", function () { navigator.serviceWorker.addEventListener("controllerchange", function () {
if (reloading || !hadController) return; if (reloading || !hadController) return;
var controller = navigator.serviceWorker.controller;
if (controller && !isNewerThanThisPage(controller)) return;
reloading = true; reloading = true;
window.location.reload(); window.location.reload();
}); });
+13 -1
View File
@@ -1133,7 +1133,19 @@ document.addEventListener("lembas:notify", function (event) {
shrinks the document and scrollTop is clamped to the new maximum, which shrinks the document and scrollTop is clamped to the new maximum, which
for a short panel is somewhere below everything. */ for a short panel is somewhere below everything. */
var outer = scroller(bar); var outer = scroller(bar);
if (outer && outer !== body) bar.scrollIntoView({ block: "start" }); if (!outer || outer === body) return;
/* Moved by hand, and only `outer`. `scrollIntoView` scrolls *every*
ancestor that can scroll, the document included -- and the document
could, by the height of whatever leaked out of the scroller, so a tab
switch lifted the whole shell and left a strip of background under it.
The containing block in app.css stops the leak; this stops a leak
anyone adds later from being turned into a visible one.
Measured from `.tabs`, not the bar: the bar is sticky, so once the page
is scrolled past the lede it reports the scroller's own top and the
sum below would come out as nothing to do. */
var tabs = bar.parentElement;
outer.scrollTop += tabs.getBoundingClientRect().top - outer.getBoundingClientRect().top;
}); });
})(); })();
+3
View File
@@ -152,6 +152,9 @@
what `app.js` turns into a sentence on the settings page. what `app.js` turns into a sentence on the settings page.
#} #}
<script> <script>
/* The release this page was rendered by, for `app.js` to hold a waiting
worker up against -- see "A release that arrived while you were reading". */
window.lembasRelease = {{ version | tojson }};
window.lembasWorker = { state: "unsupported" }; window.lembasWorker = { state: "unsupported" };
if (!window.isSecureContext) { if (!window.isSecureContext) {
/* Reported separately from an outright failure: the fix is different. */ /* Reported separately from an outright failure: the fix is different. */
+30
View File
@@ -311,3 +311,33 @@ def test_the_worker_precaches_what_a_page_will_ask_for():
source = (STATIC_DIR / "js" / "sw.js").read_text() source = (STATIC_DIR / "js" / "sw.js").read_text()
assert 'path + "?v=" + VERSION' in source assert 'path + "?v=" + VERSION' in source
assert "versioned(path)" in source assert "versioned(path)" in source
def test_the_update_offer_asks_whether_this_page_is_older(client: TestClient, registered):
"""After a release the toast offered a reload on every page, including one
just fetched with Ctrl+Shift+R, and reloading never made it go away. It
fired whenever a worker was waiting. But a page loaded after the update
already IS the update: it comes from the network, and every asset it names
carries `?v=`. The worker that waits is nearly always the previous one,
still holding the tab, because a reload never lets it run out of pages.
So the offer, and the automatic reload when another tab accepts it, compare
the worker's release with the page's own. Driven under Node against stubs
before committing: 1.8.3 offered a reload to a current page and to a page
that another tab's Reload had just made current. This does neither, and
still offers it to a page from an older release.
"""
import re
import lembas
page = client.get("/chat").text
assert f"window.lembasRelease = \"{lembas.__version__}\";" in page
source = (STATIC_DIR / "js" / "app.js").read_text()
code = re.sub(r"/\*.*?\*/", "", source, flags=re.S)
start = code.index("function watchForUpdate")
block = code[start : code.index("navigator.serviceWorker.ready.then(watchForUpdate)")]
# Both ways a waiting worker is found, and the controllerchange reload.
assert block.count("isNewerThanThisPage(") == 3
assert 'searchParams.get("v")' in code
+24
View File
@@ -177,6 +177,30 @@ def test_the_tab_reset_finds_the_container_that_actually_scrolls():
assert "scrollHeight > " in SOURCE assert "scrollHeight > " in SOURCE
def test_a_tab_switch_moves_only_the_container_that_scrolls():
"""Switching a tab on /admin/prompts lifted the whole shell 56px, with the
topbar gone off the top and a strip of bare background under everything.
Two halves, and either one alone is enough to bring it back. The prompt
cards' `.visually-hidden` labels are `position: absolute`. With no
positioned ancestor they were placed against the page and stretched the
document to 6771px behind an `overflow: hidden` root. And the handler used
`scrollIntoView`, which scrolls every ancestor that can scroll, the root
included. Measured in headless Chromium at 1640x930 before and after.
"""
app = (ROOT / "web/static/css/app.css").read_text(encoding="utf-8")
start = app.index(".scroll-region,")
rule = app[start : app.index("}", start)]
assert ".admin-scroll" in rule
assert "position: relative" in rule
code = re.sub(r"/\*.*?\*/", "", SOURCE, flags=re.S)
start = code.index('closest(".tabs__bar")')
handler = code[start : code.index("})();", start)]
assert "scrollIntoView" not in handler
assert "outer.scrollTop" in handler
def test_the_two_ends_of_the_shell_stay_level(): def test_the_two_ends_of_the_shell_stay_level():
"""The sidebar footer and the composer sit either side of the same vertical """The sidebar footer and the composer sit either side of the same vertical
edge and are both content-sized, so without a common floor they end at edge and are both content-sized, so without a common floor they end at