Three offline and toolbar fixes, and no service worker in development

The drawing rail's option bar covered the Transform button. ol-ext positions
the flyout with `left: 100%`, which is meant to clear the whole rail; the rule
that makes that work sets the rail's controls to `position: static`, and it
only tied with ol-ext's own `.ol-control.ol-bar .ol-control` on specificity, so
stylesheet order decided it and ol-ext's injected sheet came last. The flyout
was therefore measured against the Select button and landed on the second
column. The selector now carries enough classes to win outright. This is the
same failure as v16, in a second place.

The local database did not open when the device was offline. The fetch handler
skipped every request whose destination was 'worker', to stay clear of the Vite
dev server's module plumbing — but in a build SQLocal's worker is a hashed file
under /assets/, so it was never cached either. With the network down the
request failed, initSchema() never resolved, and everything waiting on the
database stopped without an error. The shell, the map and the tiles all worked,
so the application looked like it was surviving offline while nothing could be
saved or read. The skip now covers only the dev server's own paths; a built
worker is cached like any other asset. Verified both ways against a production
build with the preview server stopped: before, the schema hangs and the table
list is empty; after, the schema completes and all twelve tables are listed.

The service worker is no longer registered on the development server. It cannot
do its job there anyway — the modules it would need come from node_modules,
which it deliberately keeps clear of — while it does cache main.js and src/*.js
cache-first, so it can serve a stale module and fight hot reloading. In Safari
it also costs the local database outright: a worker script that reaches the
browser through a controlling service worker loses its cross-origin-isolation
headers, and SQLocal's worker is refused. Because switching registration off
does nothing for a worker already installed, initPWA now unregisters whatever
it finds and clears the app caches when registration is off. That clearing runs
on every load rather than only on the one that unregistered: an unregistered
worker goes on controlling its page until the next navigation and refills the
caches behind us. The tile caches are left alone — they hold what an officer
downloaded for field work.

CACHE_VERSION goes to v27 for the first two.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ekke 2026-10-01 11:05:11 +02:00
parent 4132a12b38
commit 4809ef324a
13 changed files with 165 additions and 43 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
dist/assets/index-C6kuA4o8.js.map vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,4 +1,4 @@
import{d as f,z as d}from"./index-Ddi_Zl4K.js";import"./vite-helpers-BlTxHScW.js";import"./bootstrap-D1-uvFxm.js";import"./openlayers-D5-VSKzq.js";import"./ol-ext-DV70ljNY.js";function p(e){return String(e).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&apos;").replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g,"")}function h(e){let l="",o=e;for(;o>=0;)l=String.fromCharCode(o%26+65)+l,o=Math.floor(o/26)-1;return l}function x(e){return e&&!Array.isArray(e)&&Array.isArray(e.cells)?{cells:e.cells,bold:!!e.bold}:{cells:Array.isArray(e)?e:[e],bold:!1}}function u(e){const l=[];return e.forEach((o,n)=>{const{cells:t,bold:a}=x(o),r=a?' s="1"':"",i=t.map((s,c)=>{const m=`${h(c)}${n+1}`;return s==null||s===""?`<c r="${m}"${r}/>`:typeof s=="number"&&Number.isFinite(s)?`<c r="${m}"${r}><v>${s}</v></c>`:`<c r="${m}"${r} t="inlineStr"><is><t xml:space="preserve">${p(s)}</t></is></c>`}).join("");l.push(`<row r="${n+1}">${i}</row>`)}),`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
import{d as f,z as d}from"./index-C6kuA4o8.js";import"./vite-helpers-BlTxHScW.js";import"./bootstrap-D1-uvFxm.js";import"./openlayers-D5-VSKzq.js";import"./ol-ext-DV70ljNY.js";function p(e){return String(e).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&apos;").replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g,"")}function h(e){let l="",o=e;for(;o>=0;)l=String.fromCharCode(o%26+65)+l,o=Math.floor(o/26)-1;return l}function x(e){return e&&!Array.isArray(e)&&Array.isArray(e.cells)?{cells:e.cells,bold:!!e.bold}:{cells:Array.isArray(e)?e:[e],bold:!1}}function u(e){const l=[];return e.forEach((o,n)=>{const{cells:t,bold:a}=x(o),r=a?' s="1"':"",i=t.map((s,c)=>{const m=`${h(c)}${n+1}`;return s==null||s===""?`<c r="${m}"${r}/>`:typeof s=="number"&&Number.isFinite(s)?`<c r="${m}"${r}><v>${s}</v></c>`:`<c r="${m}"${r} t="inlineStr"><is><t xml:space="preserve">${p(s)}</t></is></c>`}).join("");l.push(`<row r="${n+1}">${i}</row>`)}),`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>${l.join("")}</sheetData></worksheet>`}const y=`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<fonts count="2"><font><sz val="11"/><name val="Calibri"/></font><font><b/><sz val="11"/><name val="Calibri"/></font></fonts>
@ -27,4 +27,4 @@ ${n.map(t=>`<Relationship Id="rId${t.id}" Type="http://schemas.openxmlformats.or
`)}
<Relationship Id="rIdStyles" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
</Relationships>`,o["xl/styles.xml"]=y,d(o,"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")}function S(e,l){f(g(e),l)}export{g as buildXlsxBlob,S as exportXlsx};
//# sourceMappingURL=xlsx-CR1gFYlV.js.map
//# sourceMappingURL=xlsx-CYAxSxVe.js.map

File diff suppressed because one or more lines are too long

11
dist/index.html vendored
View File

@ -1815,8 +1815,15 @@
block to the rail itself, so `left: 100%` clears the whole panel. The
flyout then always appears at the same place (rail's right edge, centred)
instead of jumping to whichever button was pressed.
The selector needs three classes plus `.ol-control` to beat ol-ext's own
`.ol-control.ol-bar .ol-control { position: relative }`. With one class
fewer the two rules tie on specificity, source order decides, and
ol-ext's stylesheet is injected after this block — so the controls stayed
`relative`, the flyout was positioned against the Select button instead
of the rail, and it covered the Transform button in the second column.
---------------------------------------------------------------- */
.ol-editbar.ol-editbar-rail > .ol-control {
.ol-control.ol-bar.ol-editbar.ol-editbar-rail > .ol-control {
position: static;
}
.ol-editbar-rail .ol-option-bar {
@ -1997,7 +2004,7 @@
}
</style>
<script type="module" crossorigin src="/assets/index-Ddi_Zl4K.js"></script>
<script type="module" crossorigin src="/assets/index-C6kuA4o8.js"></script>
<link rel="modulepreload" crossorigin href="/assets/vite-helpers-BlTxHScW.js">
<link rel="modulepreload" crossorigin href="/assets/bootstrap-D1-uvFxm.js">
<link rel="modulepreload" crossorigin href="/assets/openlayers-D5-VSKzq.js">

36
dist/sw.js vendored
View File

@ -157,7 +157,25 @@
// chunk, and JSZip inside the shpjs one, so the entry needed both on
// every visit — about 428 kB that is now fetched only when a COG
// layer or a shapefile import actually asks for it. New hashed bundle.
const CACHE_VERSION = 'v26';
// v27: The drawing rail's option bar covered the Transform button. ol-ext
// positions the flyout with `left: 100%`, which is meant to clear the
// whole rail — the rule that makes that work sets the rail's controls to
// `position: static`, and it only tied with ol-ext's own rule on
// specificity, so stylesheet order decided the winner and ol-ext's
// injected sheet came last. The flyout was therefore positioned against
// the Select button and landed on the second column. The selector now
// carries one class more. This is the same failure as v16, in a second
// place.
// Also: the local database now opens when the device is offline. The
// fetch handler skipped every request whose destination was 'worker', to
// stay clear of the Vite dev server's module plumbing. In a build,
// SQLocal's worker is a hashed file under /assets/, so it was never
// cached either — with the network down the request failed, initSchema()
// never resolved, and everything waiting on the database stopped without
// an error. The map, the tiles and the shell all worked, so the app
// looked like it was working offline while nothing could be saved or
// read. The skip now covers only the dev server's own paths.
const CACHE_VERSION = 'v27';
const SHELL_CACHE = `shell-${CACHE_VERSION}`;
const MODULES_CACHE = `modules-${CACHE_VERSION}`;
const API_CACHE = `api-${CACHE_VERSION}`;
@ -358,11 +376,17 @@ self.addEventListener('fetch', (event) => {
// Skip chrome-extension and other non-http(s) requests
if (!url.protocol.startsWith('http')) return;
// Skip worker files and Vite dev-server node_modules requests —
// intercepting these breaks module workers (e.g. SQLocal/SQLite).
if (url.pathname.includes('node_modules') ||
url.search.includes('worker_file') ||
request.destination === 'worker') return;
// Skip the Vite dev server's own module plumbing — it serves worker scripts
// straight out of node_modules with a ?worker_file query, and intercepting
// those breaks module workers (e.g. SQLocal/SQLite).
if (url.pathname.includes('node_modules') || url.search.includes('worker_file')) return;
// A worker script in a BUILT app is a hashed file under /assets/, as
// immutable as any other asset — and it HAS to be cached, or SQLocal cannot
// start its worker with the network down and the local database never opens.
// Skipping every 'worker' destination was what made that happen: see v27.
// Anything else with destination 'worker' is still left alone.
if (request.destination === 'worker' && !isAppAsset(url)) return;
// ----- TILE REQUESTS — passive cache-then-network (per-host bucket) -----
const tileCache = getTileCacheName(url);

View File

@ -1815,8 +1815,15 @@
block to the rail itself, so `left: 100%` clears the whole panel. The
flyout then always appears at the same place (rail's right edge, centred)
instead of jumping to whichever button was pressed.
The selector needs three classes plus `.ol-control` to beat ol-ext's own
`.ol-control.ol-bar .ol-control { position: relative }`. With one class
fewer the two rules tie on specificity, source order decides, and
ol-ext's stylesheet is injected after this block — so the controls stayed
`relative`, the flyout was positioned against the Select button instead
of the rail, and it covered the Transform button in the second column.
---------------------------------------------------------------- */
.ol-editbar.ol-editbar-rail > .ol-control {
.ol-control.ol-bar.ol-editbar.ol-editbar-rail > .ol-control {
position: static;
}
.ol-editbar-rail .ol-option-bar {

11
main.js
View File

@ -218,10 +218,19 @@ async function initApp() {
if (showNoDistrictBlockerIfNeeded()) return;
// 1. Initialize PWA features (Service Worker, install prompt, offline detection)
//
// The service worker is registered in built applications only. On the Vite
// dev server it cannot do its job anyway — the modules it would need are
// served out of node_modules, which it deliberately keeps clear of — while
// it does cache main.js and src/*.js cache-first, so it can serve a stale
// module and fight hot reloading. Offline behaviour is tested against a
// build (npm run build && npm run preview), never against the dev server.
// When registration is off, initPWA also removes any worker an earlier
// session installed on this origin.
await initPWA({
installButton: '#install-btn',
offlineIndicator: '#offline-indicator',
autoRegisterSW: true
autoRegisterSW: import.meta.env.PROD,
});
// 2. Initialize the map

View File

@ -157,7 +157,25 @@
// chunk, and JSZip inside the shpjs one, so the entry needed both on
// every visit — about 428 kB that is now fetched only when a COG
// layer or a shapefile import actually asks for it. New hashed bundle.
const CACHE_VERSION = 'v26';
// v27: The drawing rail's option bar covered the Transform button. ol-ext
// positions the flyout with `left: 100%`, which is meant to clear the
// whole rail — the rule that makes that work sets the rail's controls to
// `position: static`, and it only tied with ol-ext's own rule on
// specificity, so stylesheet order decided the winner and ol-ext's
// injected sheet came last. The flyout was therefore positioned against
// the Select button and landed on the second column. The selector now
// carries one class more. This is the same failure as v16, in a second
// place.
// Also: the local database now opens when the device is offline. The
// fetch handler skipped every request whose destination was 'worker', to
// stay clear of the Vite dev server's module plumbing. In a build,
// SQLocal's worker is a hashed file under /assets/, so it was never
// cached either — with the network down the request failed, initSchema()
// never resolved, and everything waiting on the database stopped without
// an error. The map, the tiles and the shell all worked, so the app
// looked like it was working offline while nothing could be saved or
// read. The skip now covers only the dev server's own paths.
const CACHE_VERSION = 'v27';
const SHELL_CACHE = `shell-${CACHE_VERSION}`;
const MODULES_CACHE = `modules-${CACHE_VERSION}`;
const API_CACHE = `api-${CACHE_VERSION}`;
@ -358,11 +376,17 @@ self.addEventListener('fetch', (event) => {
// Skip chrome-extension and other non-http(s) requests
if (!url.protocol.startsWith('http')) return;
// Skip worker files and Vite dev-server node_modules requests —
// intercepting these breaks module workers (e.g. SQLocal/SQLite).
if (url.pathname.includes('node_modules') ||
url.search.includes('worker_file') ||
request.destination === 'worker') return;
// Skip the Vite dev server's own module plumbing — it serves worker scripts
// straight out of node_modules with a ?worker_file query, and intercepting
// those breaks module workers (e.g. SQLocal/SQLite).
if (url.pathname.includes('node_modules') || url.search.includes('worker_file')) return;
// A worker script in a BUILT app is a hashed file under /assets/, as
// immutable as any other asset — and it HAS to be cached, or SQLocal cannot
// start its worker with the network down and the local database never opens.
// Skipping every 'worker' destination was what made that happen: see v27.
// Anything else with destination 'worker' is still left alone.
if (request.destination === 'worker' && !isAppAsset(url)) return;
// ----- TILE REQUESTS — passive cache-then-network (per-host bucket) -----
const tileCache = getTileCacheName(url);

View File

@ -52,6 +52,53 @@ export async function registerServiceWorker() {
}
}
/**
* Remove any service worker registered against this origin, and the caches it
* filled. Used on the development server, where the service worker is not
* registered at all (see initPWA) — without this, a worker installed by an
* earlier session would go on controlling the page for as long as it exists.
*
* It is not only a tidiness problem. On the dev server the worker caches
* `/main.js` and `/src/*.js` cache-first, so it can hand back a stale module
* and fight hot reloading; and in Safari a worker script that reaches the
* browser through a controlling service worker loses its cross-origin-isolation
* headers, so SQLocal's worker is refused and the local database never opens.
*
* @returns {Promise<boolean>} true if anything was removed
*/
export async function unregisterServiceWorkers() {
if (!('serviceWorker' in navigator)) return false;
let removed = false;
try {
for (const registration of await navigator.serviceWorker.getRegistrations()) {
if (await registration.unregister()) removed = true;
}
// Clear the app caches on every load, not only on the load that did the
// unregistering. An unregistered worker goes on controlling the page it is
// already attached to until the next navigation, so it refills those caches
// behind us during that one load; deleting them unconditionally is what
// makes this converge. Nothing in a development build reads them anyway.
//
// The tile caches are left alone: they hold what an officer downloaded for
// field work, they are expensive to refill, and they are equally valid
// with no service worker in sight.
if ('caches' in window) {
const names = await caches.keys();
await Promise.all(
names.filter((name) => !name.startsWith('tiles-')).map((name) => caches.delete(name)),
);
}
if (removed) {
console.log('[PWA] Service worker unregistered (development build) — reload once more to drop it completely');
}
} catch (error) {
console.warn('[PWA] Could not unregister the service worker:', error);
}
return removed;
}
// ============================================================================
// Install Prompt
// ============================================================================
@ -467,6 +514,10 @@ export async function initPWA(options = {}) {
if (autoRegisterSW) {
await registerServiceWorker();
} else {
// Nothing should be controlling the page when registration is off; clear
// out anything an earlier session left behind.
await unregisterServiceWorkers();
}
initInstallPrompt(installButton);