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:
@@ -452,7 +452,17 @@ input.visually-hidden[type="checkbox"] {
|
||||
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
|
||||
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,
|
||||
.sidebar__scroll,
|
||||
.inspector__body,
|
||||
@@ -460,6 +470,7 @@ input.visually-hidden[type="checkbox"] {
|
||||
.thread-scroll,
|
||||
.admin-scroll,
|
||||
.main > .tabs > .tabs__body {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -1099,27 +1099,59 @@
|
||||
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
|
||||
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 offer(worker) {
|
||||
if (!worker || !navigator.serviceWorker.controller) return;
|
||||
worker.addEventListener("statechange", function () {
|
||||
if (worker.state !== "installed") return;
|
||||
window.lembas.notify(
|
||||
"A new version is ready. Reload to use it.",
|
||||
{ kind: "info", action: { label: "Reload", run: function () {
|
||||
worker.postMessage({ type: "SKIP_WAITING" });
|
||||
} } }
|
||||
);
|
||||
if (isNewerThanThisPage(worker)) offerReload(worker);
|
||||
});
|
||||
}
|
||||
if (registration.waiting && navigator.serviceWorker.controller) {
|
||||
window.lembas.notify(
|
||||
"A new version is ready. Reload to use it.",
|
||||
{ kind: "info", action: { label: "Reload", run: function () {
|
||||
registration.waiting.postMessage({ type: "SKIP_WAITING" });
|
||||
} } }
|
||||
);
|
||||
if (registration.waiting && navigator.serviceWorker.controller &&
|
||||
isNewerThanThisPage(registration.waiting)) {
|
||||
offerReload(registration.waiting);
|
||||
}
|
||||
registration.addEventListener("updatefound", function () {
|
||||
offer(registration.installing);
|
||||
@@ -1130,17 +1162,24 @@
|
||||
the right answer to it -- the page is now being served by a worker whose
|
||||
cache it did not start from.
|
||||
|
||||
Two guards, and the second is the one that is easy to miss. A flag, because
|
||||
`controllerchange` can fire more than once. And `hadController`, because on
|
||||
a *first* visit there is no worker at all: the one that installs then calls
|
||||
`clients.claim()`, which fires this event for the first time -- so without
|
||||
it, the very first page anybody loads reloads itself in front of them for
|
||||
no reason they could possibly work out. */
|
||||
Three guards, and the second is the one that is easy to miss. A flag,
|
||||
because `controllerchange` can fire more than once. And `hadController`,
|
||||
because on a *first* visit there is no worker at all: the one that
|
||||
installs then calls `clients.claim()`, which fires this event for the
|
||||
first time -- so without it, the very first page anybody loads reloads
|
||||
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;
|
||||
if ("serviceWorker" in navigator) {
|
||||
var hadController = !!navigator.serviceWorker.controller;
|
||||
navigator.serviceWorker.addEventListener("controllerchange", function () {
|
||||
if (reloading || !hadController) return;
|
||||
var controller = navigator.serviceWorker.controller;
|
||||
if (controller && !isNewerThanThisPage(controller)) return;
|
||||
reloading = true;
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
@@ -1133,7 +1133,19 @@ document.addEventListener("lembas:notify", function (event) {
|
||||
shrinks the document and scrollTop is clamped to the new maximum, which
|
||||
for a short panel is somewhere below everything. */
|
||||
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;
|
||||
});
|
||||
})();
|
||||
|
||||
|
||||
@@ -152,6 +152,9 @@
|
||||
what `app.js` turns into a sentence on the settings page.
|
||||
#}
|
||||
<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" };
|
||||
if (!window.isSecureContext) {
|
||||
/* Reported separately from an outright failure: the fix is different. */
|
||||
|
||||
Reference in New Issue
Block a user