Skip to content

fix(pwa): strip first-paint compositing layers to prevent iOS WebKit freeze#223

Merged
jasoneplumb merged 1 commit into
mainlinefrom
fix/reduce-first-paint-compositing
Jun 5, 2026
Merged

fix(pwa): strip first-paint compositing layers to prevent iOS WebKit freeze#223
jasoneplumb merged 1 commit into
mainlinefrom
fix/reduce-first-paint-compositing

Conversation

@jasoneplumb

Copy link
Copy Markdown
Owner

Summary

Pivot from recover to prevent on the iOS-26 WKWebView whole-page render freeze. A compositing freeze can't be detected from JS (FCP is recorded but never composited to screen), so the Paint-Timing auto-reload (#221) never fired — and Safari works while only Edge/WKWebView freezes, which points at WKWebView's greater sensitivity to first-paint compositing-layer pressure. So: reduce what's composited at first paint.

Before any JS runs (the freeze is at the consent gate), two elements force compositing layers:

Changes

  • src/style.css — remove isolation: isolate from #map (a full-screen composited layer present from first paint). Visually safe: isolation only contained the hillshade overlay's mix-blend-mode: multiply, which 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).
  • index.html + src/main.ts — remove the static #offline-banner (position: fixed + transform) from the HTML; lazy-create it in updateOfflineBanner() only when the app actually goes offline, so it's absent from first paint.

Test plan

  • type-check (app + worker), lint, test (96), build — all green
  • Verified built output: no isolation in CSS, no static #offline-banner in HTML, lazy-create present in bundle
  • Real-device soak (Edge/iPhone) — clear data, cold-start ×N. If the white-screen rate drops to zero, first-paint compositing pressure was the trigger. If unchanged, the trigger is elsewhere / a deeper WKWebView issue and we reassess (the offline-banner lazy-load and isolation removal are both harmless to keep regardless).

Notes

The Paint-Timing auto-reload from #221 stays as a harmless fallback. Once this is confirmed, the temporary diagnostics can be stripped and the (exonerated) service worker simplified.

Closes #222
Refs #218, #220

🤖 Generated with Claude Code

…freeze

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 <noreply@anthropic.com>
@jasoneplumb jasoneplumb added bug Something isn't working review-requested Triggers Claude code review labels Jun 5, 2026
@claude

claude Bot commented Jun 5, 2026

Copy link
Copy Markdown

Code Review — PR #223: strip first-paint compositing layers

Overall: Well-reasoned, focused fix. The root cause analysis (WKWebView compositing sensitivity at first paint) is sound, and both changes are independently justified and harmless if the hypothesis turns out to be wrong.


What the PR does

  1. src/style.css — removes isolation: isolate from #map, eliminating a full-screen composited layer present before any JS runs.
  2. index.html + src/main.ts — moves #offline-banner from static HTML to lazy DOM creation, so its position:fixed + transform compositing layer doesn't exist at first paint.

Correctness

Offline banner lazy-creation logic is correct. The three-branch structure handles all cases:

  • Online, no banner yet: optional-chains safely, returns early — no work needed.
  • Offline, first time: creates the element, appends it hidden, then reveals it via requestAnimationFrame so the CSS slide-in transition has a "before" state to animate from. This is the right pattern; skipping the rAF delay would cause the transition to not fire on first creation.
  • Offline, banner already exists (re-offline after being online): directly adds visible — correct, since the element is already rendered and the transition fires normally on class toggle.

banner! non-null assertion in the rAF callback is correct — TypeScript sees the closure variable as HTMLElement | null (from the let declaration at the top of the function), but structurally it was just assigned a new element. The assertion is safe.

CSS isolation removal is correctly reasoned. The .hillshade-blend layer's mix-blend-mode: multiply blends against the opaque tile siblings beneath it in the map container's own layer stack — the containing block's stacking context doesn't affect that. And multiply against the white body that shows through a full-screen map is a visual no-op. No visual regression expected.


Minor observations

  • banner.innerHTML: The string is hardcoded with no user input involved, so no XSS concern. Could use textContent on the inner <span> if you want to avoid innerHTML entirely, but it makes no practical difference here.
  • Banner stays in DOM when back online: Once created, the banner element persists (hidden) when the app goes back online. This matches the old behavior (the static banner was always in the DOM), so there's no behavioral change after first offline — just a different point of creation.
  • Comments: All three comment blocks explain a non-obvious platform-specific workaround, which is exactly the right threshold per project conventions.

Test coverage

Per CLAUDE.md, vitest covers pure functions only (no DOM/Leaflet integration). The DOM mutation logic in updateOfflineBanner isn't covered by automated tests, which is consistent with the project's testing approach. The manual test plan in the PR description is the right vehicle for this change.


Summary

No issues found. The logic is sound, edge cases are handled, and the two changes are justified independently of the iOS fix (the lazy banner is a minor DOM hygiene improvement; removing isolation is semantically safe). The one open item is the real-device soak test, which is correctly called out as the true validation gate in the PR description.

🤖 Generated with Claude Code

@jasoneplumb jasoneplumb merged commit e1e03c1 into mainline Jun 5, 2026
4 checks passed
@jasoneplumb jasoneplumb deleted the fix/reduce-first-paint-compositing branch June 5, 2026 20:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working review-requested Triggers Claude code review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Reduce first-paint compositing to prevent iOS-26 WKWebView render freeze

1 participant