From 485b2f720148ce907ccb116b586bf73e08fac092 Mon Sep 17 00:00:00 2001 From: Jason E Plumb Date: Fri, 5 Jun 2026 12:36:55 -0700 Subject: [PATCH 1/2] fix(pwa): auto-reload on iOS WebKit render freeze (Paint Timing self-heal) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On-device evidence resolved the real nature of the blank-on-cold-start: on a blank load, neither the consent overlay nor the (separate) inline diagnostic overlay paints — two unrelated DOM elements both invisible. The whole page fails to paint (white) on ~5/9 cold loads in Edge/iOS (iOS 26 / WKWebView). DOM is complete, bundle runs (bundleRan=true), SW uninvolved (swController=no). User confirms only a full refresh clears it — scroll/tap/rotate do not — and the browser chrome is fine. That's a hard iOS-WebKit render-process freeze: content is never painted and won't be until a fresh navigation. So none of the content- level theories (service worker, consent timing) could have fixed it. Since a reload reliably clears it, self-heal: ~3s after load, check the Paint Timing API for a 'first-contentful-paint' entry; if absent, the page never painted → reload once (sessionStorage-guarded against loops). Runs in the inline script, independent of the bundle, so it works through the freeze. Complements the boot-watchdog (which covers a bundle that never runs). If Paint Timing is unavailable, treat as painted so we never risk a reload loop. Also revert the requestAnimationFrame consent-overlay mount (#219): it targeted the wrong layer and 5/9-vs-3/7 suggested it made things slightly worse. Closes #220 Refs #218 Co-Authored-By: Claude Opus 4.8 --- index.html | 25 +++++++++++++++++++++++++ src/consent.ts | 22 +++++----------------- 2 files changed, 30 insertions(+), 17 deletions(-) diff --git a/index.html b/index.html index c7c6430..be71a67 100644 --- a/index.html +++ b/index.html @@ -76,6 +76,31 @@ }, 3000); } + // Render-freeze self-heal. iOS WebKit (iOS 26 / WKWebView — e.g. Edge & Chrome + // on iOS) intermittently loads the page into a frozen render process: the DOM is + // complete and the bundle runs, but the whole content area stays white and ONLY + // a reload (not scroll/touch/rotate) clears it. The Paint Timing API records a + // 'first-contentful-paint' entry only when WebKit actually paints content; if + // it's still absent ~3s in, the page froze — reload once (guarded) to recover. + // This complements the boot-watchdog above (which covers a bundle that never + // runs); here the bundle runs fine but the render is dead until a fresh load. + var RKEY = 'webmap-render-reload'; + setTimeout(function () { + var painted; + try { + painted = performance.getEntriesByType('paint').some(function (p) { + return p.name === 'first-contentful-paint'; + }); + } catch (e) { painted = true; } // no Paint Timing API → never risk a reload loop + if (painted) { try { sessionStorage.removeItem(RKEY); } catch (e) {} return; } + var rAlready; + try { rAlready = sessionStorage.getItem(RKEY) === '1'; } catch (e) { rAlready = false; } + if (!rAlready) { + try { sessionStorage.setItem(RKEY, '1'); } catch (e) {} + location.reload(); + } + }, 3000); + // Blank-state probe: if the map still hasn't rendered after 5s and no error // was captured, dump enough state to see WHY it's blank. setTimeout(function () { diff --git a/src/consent.ts b/src/consent.ts index 8a36c21..d273c8c 100644 --- a/src/consent.ts +++ b/src/consent.ts @@ -65,6 +65,7 @@ export function showConsentModal(): Promise { `; overlay.appendChild(panel); + document.body.appendChild(overlay); function cleanup(accepted: boolean): void { overlay.remove(); @@ -72,10 +73,8 @@ export function showConsentModal(): Promise { resolve(accepted); } - // Wire listeners on the still-detached panel so they're ready before mount - // (querySelector works on a detached element; getElementById would not). - panel.querySelector('#consent-accept')!.addEventListener('click', () => cleanup(true)); - panel.querySelector('#consent-decline')!.addEventListener('click', () => cleanup(false)); + document.getElementById('consent-accept')!.addEventListener('click', () => cleanup(true)); + document.getElementById('consent-decline')!.addEventListener('click', () => cleanup(false)); overlay.addEventListener('click', (e) => { if (e.target === overlay) cleanup(false); }); @@ -88,18 +87,7 @@ export function showConsentModal(): Promise { }; document.addEventListener('keydown', onKey); - // Mount on the next frame, NOT synchronously during the page's first paint. - // iOS WebKit (including Edge/Chrome on iOS, which run on WKWebView) intermittently - // fails to composite a fixed-position layer injected during that first paint, - // leaving a white screen with the modal present in the DOM but unpainted — the - // blank-on-cold-start that "prevents usage" until a manual refresh (confirmed via - // on-screen diagnostic: bundleRan=true, hasConsent=false, nothing visible). - // Mounting after first paint and flushing layout makes the overlay paint reliably. - requestAnimationFrame(() => { - document.body.appendChild(overlay); - void overlay.getBoundingClientRect(); // flush layout → ensure a paint - // Focus the accept button for keyboard accessibility (post-mount). - (panel.querySelector('#consent-accept') as HTMLElement | null)?.focus(); - }); + // Focus the accept button for keyboard accessibility + document.getElementById('consent-accept')!.focus(); }); } From 9858594854a24d773d1be343d8ff824a95d7bb5e Mon Sep 17 00:00:00 2001 From: Jason E Plumb Date: Fri, 5 Jun 2026 12:40:56 -0700 Subject: [PATCH 2/2] fix(pwa): guard render-freeze reload on sessionStorage write success Prevents an infinite reload loop when sessionStorage is fully unavailable (private mode / storage disabled): only reload if the guard key actually persisted, mirroring the boot-watchdog. Addresses review feedback on #221. Co-Authored-By: Claude Opus 4.8 --- index.html | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/index.html b/index.html index be71a67..9100248 100644 --- a/index.html +++ b/index.html @@ -96,8 +96,12 @@ var rAlready; try { rAlready = sessionStorage.getItem(RKEY) === '1'; } catch (e) { rAlready = false; } if (!rAlready) { - try { sessionStorage.setItem(RKEY, '1'); } catch (e) {} - location.reload(); + // Only reload if the guard actually persisted — if sessionStorage is + // unavailable (private mode, storage disabled), reloading would loop + // forever. Mirrors the boot-watchdog above. (Key: 'webmap-render-reload'.) + var rPersisted = false; + try { sessionStorage.setItem(RKEY, '1'); rPersisted = true; } catch (e) { rPersisted = false; } + if (rPersisted) { location.reload(); } } }, 3000);