Start at once on a device marked as your own (#395)

* Start at once on a device marked as your own

On a distant link, opening the app waited on four round trips before the
inbox showed: the app page, the session, the folder list, then the folder.

A trusted device now starts from what it kept:

- the service worker answers an app route from its kept page and fetches a
  fresh one behind it; the app checks the server's version at start, and a
  reload for a new build puts the new page in place first, so it is not
  answered with the old one. Assets of the page just replaced are kept one
  build longer for a tab still running it.
- the session's public details, so requests for mail go out before the
  server has confirmed the session; the answer replaces it, and a session
  that has ended lands on the sign-in form as before.
- the folder list and the first page of up to four recently read folders,
  list properties only, so the folders and the inbox paint before any reply
  and the folder query does not wait on the folder list. The "folder no
  longer exists" check still waits for the server's list.

All of it goes through the storage gate: nothing is written or read on a
device not marked as the reader's own, and signing out clears it.

* Show nothing kept before the session is confirmed

Starting from a kept session put the kept inbox on screen before the
server had said the session was still good; a session that had ended
showed mail and then the sign-in form. The spinner stays until the
server answers, as before.

The kept session is gone -- it existed only to start early. The kept
folder list and rows are still applied, from setAccount, which runs once
the session is confirmed: the inbox paints the moment that answer
arrives, and the folder query goes out then without waiting on the
folder list. An unreachable server lands on the sign-in form as before.
This commit is contained in:
jcoffey
2026-09-16 13:47:59 -07:00
committed by GitHub
parent 4c67460450
commit 82dc877fe1
8 changed files with 378 additions and 16 deletions
+36 -7
View File
@@ -67,12 +67,17 @@ function assetsNamedIn(html) {
return out;
}
/** Drop failed responses, and assets the cached app page does not name. */
async function tidy() {
/**
* Drop failed responses, and assets the cached app page does not name. `also`
* is a page whose assets are kept as well: the one just replaced, which a tab
* opened from the kept copy may still be running.
*/
async function tidy(also = "") {
const cache = await caches.open(VERSION);
const shell = await cache.match(SHELL_KEY);
// Without a page to go by, which assets are current is unknown; keep them.
const keep = shell ? assetsNamedIn(await shell.text()) : null;
if (keep) for (const path of assetsNamedIn(also)) keep.add(path);
for (const req of await cache.keys()) {
const path = new URL(req.url).pathname;
if (path.startsWith(ASSETS)) {
@@ -91,9 +96,10 @@ async function refreshShell(res) {
const html = await res.text();
const cache = await caches.open(VERSION);
const prev = await cache.match(SHELL_KEY);
if (!prev || (await prev.text()) !== html) {
const prevHtml = prev ? await prev.text() : "";
if (prevHtml !== html) {
await cache.put(SHELL_KEY, new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } }));
await tidy();
await tidy(prevHtml);
}
await precache(html);
}
@@ -228,15 +234,38 @@ self.addEventListener("fetch", (event) => {
return;
}
// Navigations & everything else: network-first, fall back to cached shell.
/*
* Navigations: the kept app page at once, and the network's behind it.
*
* Every route in the app is the same page, and waiting on the server for it
* cost a full round trip before anything could start -- the longest single
* wait on a distant link. So a route is answered from the kept copy when
* there is one, and the fresh page is fetched alongside to replace it for
* next time. A page that is a build behind is caught the way it always was:
* the version check reloads it (lib/sw/staleBuild.ts), and the assets it
* names are kept for one more build so it can run until then.
*
* Only app routes. An address ending in a file name -- an image or the
* manifest opened in a tab of its own -- is not the app page, and goes to
* the network as before. So does the first visit, which has no copy yet.
*/
if (req.mode === "navigate") {
event.respondWith(fetch(req).then((res) => {
const network = fetch(req).then((res) => {
// Every route is the same app page; a fresh one replaces the offline copy.
if (res.ok && (res.headers.get("content-type") || "").startsWith("text/html")) {
event.waitUntil(refreshShell(res.clone()).catch(() => {}));
}
return res;
}).catch(() => caches.match(SHELL_KEY)));
});
const appRoute = !/\.[a-z0-9]+$/i.test(url.pathname);
event.respondWith((async () => {
const kept = appRoute ? await caches.match(SHELL_KEY) : undefined;
if (kept) {
event.waitUntil(network.catch(() => {}));
return kept;
}
return network.catch(() => caches.match(SHELL_KEY));
})());
return;
}
event.respondWith(fetch(req).catch(() => caches.match(req)));