From fb9746b8719e3eb6dd8073e699557a22781af58f Mon Sep 17 00:00:00 2001 From: Jason E Plumb Date: Fri, 5 Jun 2026 13:24:05 -0700 Subject: [PATCH] fix(pwa): strip first-paint compositing layers to prevent iOS WebKit freeze MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The blank-on-cold-start is a whole-page compositing freeze in iOS-26 WKWebView (Edge/Chrome on iOS); Safari works because system WebKit handles the compositing, while WKWebView is more sensitive to first-paint compositing-layer pressure. A compositing freeze can't be detected from JS (FCP is recorded but never composited to screen), so the Paint-Timing auto-reload couldn't fire — pivot from recover to prevent by reducing what's composited at first paint. Before any JS runs (the freeze is at the consent gate), two elements force compositing layers: - #map { isolation: isolate } — a full-screen composited layer, empty until the map inits. Removed: isolation only contained the hillshade mix-blend-mode:multiply, which blends against the opaque base tiles beneath it regardless (multiply against the white body behind a full-screen map is a no-op), so it's visually safe. - static #offline-banner (position:fixed + transform) — moved out of the static HTML and lazy-created in updateOfflineBanner() only when the app goes offline, so it's absent from first paint. If this stops the freeze, first-paint compositing pressure was the trigger and we can reintroduce containment more surgically if ever needed. Closes #222 Refs #218, #220 Co-Authored-By: Claude Opus 4.8 --- index.html | 7 ++++--- src/main.ts | 19 ++++++++++++++++--- src/style.css | 9 +++++++-- 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/index.html b/index.html index 9100248..4897030 100644 --- a/index.html +++ b/index.html @@ -145,9 +145,10 @@
-
- You are offline — cached tiles and GPS recording still work -
+ diff --git a/src/main.ts b/src/main.ts index 22d28d5..2e3c731 100644 --- a/src/main.ts +++ b/src/main.ts @@ -511,11 +511,24 @@ document.getElementById('map')?.focus(); document.body.style.zoom = '100%'; // ── Offline detection ───────────────────────────────────────────────────────── +// The banner is created lazily (not in static HTML) so it isn't a position:fixed + +// transform compositing layer at first paint — that, with #map, contributed to the +// iOS-26 WKWebView whole-page render freeze. It only ever exists once the app has +// been offline. function updateOfflineBanner(): void { - const banner = document.getElementById('offline-banner'); - if (!banner) return; + let banner = document.getElementById('offline-banner'); if (navigator.onLine) { - banner.classList.remove('visible'); + banner?.classList.remove('visible'); + return; + } + if (!banner) { + banner = document.createElement('div'); + banner.id = 'offline-banner'; + banner.className = 'offline-banner'; + banner.innerHTML = 'You are offline — cached tiles and GPS recording still work'; + document.body.appendChild(banner); + // Mount hidden, then reveal next frame so the slide-in transition plays. + requestAnimationFrame(() => banner!.classList.add('visible')); } else { banner.classList.add('visible'); } diff --git a/src/style.css b/src/style.css index 289b9f6..77a46f5 100644 --- a/src/style.css +++ b/src/style.css @@ -6,8 +6,13 @@ html { height: 100%; width: 100%; padding: 0; margin: 0; } body { height: 100%; width: 100%; padding: 0; margin: 0; } -/* isolation: isolate keeps mix-blend-mode contained to the map's own stacking context. */ -#map { position: absolute; top: 0; bottom: 0; right: 0; left: 0; isolation: isolate; } +/* No `isolation: isolate` here on purpose: an empty, full-screen *composited* #map + layer at first paint was a prime trigger for the iOS-26 WKWebView render freeze + (whole page never composites to screen until a reload). Containment isn't needed — + the hillshade overlay's mix-blend-mode:multiply blends against the opaque base + tiles directly beneath it regardless, and multiply against the white body behind a + full-screen map is a no-op. */ +#map { position: absolute; top: 0; bottom: 0; right: 0; left: 0; } /* Blend at the layer container so multiply composites against the base-layer sibling below. */ .hillshade-blend { mix-blend-mode: multiply; }