perf(map): defer mobile SVG-map dynamic overlays off the first-paint path (#4429)#4431
Merged
Conversation
…path On mobile the dashboard renders the D3/SVG MapComponent (not deck.gl). Its first render built the base map AND all dynamic overlays (cables/pipelines/conflicts/ AIS/cluster markers/overlays) synchronously — measured as ~1277ms of boot scripting, the #1 mobile-TBT contributor (prod mobile Lighthouse, 2026-06-25). Gate the dynamic-overlay pass behind a one-time flag + scheduleAfterFirstPaint: the base map (countries) still paints synchronously for LCP, while the heavy overlay build moves off the first-paint critical path. After first paint, render() runs the full pipeline so interactions (zoom/pan/toggle/theme) update immediately. Runtime-only change (no chunk-graph impact; 0 circular; Map chunk unchanged at ~88KB). Structural test locks the gate (base before gate, overlays after). Perf verification: prod mobile Lighthouse re-read vs the 1277ms baseline (#4429).
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Contributor
Greptile P2 on #4431: the deferred scheduleAfterFirstPaint callback (and the pre-existing resize/visibility rAF callbacks) could fire after destroy() and run render() on a torn-down instance — the clientWidth===0 guard only covers a detached container, not display:none/off-screen. Add a destroyed flag set in destroy() and checked at the top of render(); hardens all render entry paths.
This was referenced Jun 26, 2026
Closed
koala73
added a commit
that referenced
this pull request
Jun 26, 2026
…s tasks (#4442) (#4448) * perf(map): chunk the first-paint dynamic-overlay render into yielded <50ms tasks #4431 deferred the mobile SVG-map overlay render off first paint but it still ran as one long task — validated on prod (Map-*.js boot scripting 1277→~880ms, but TBT/Perf unchanged) because deferral reorders work without cutting long tasks. Chunk it: extract the dynamic-layer sequence into renderDynamicLayers(width,height, chunk). The first-paint pass (renderInitialDynamicPass, via scheduleAfterFirstPaint) runs it with chunk=true — yielding to the main thread between layers so each is a short task (TBT = Σ(task−50ms) over tasks>50ms). Steady-state render() calls it with chunk=false → no await is reached → runs synchronously, so interactions are unchanged. A dynamicRenderToken lets a chunked pass bail when a newer render supersedes it (re-entrancy); the destroyed guard covers teardown mid-yield. Bounded by design: only the deferred first pass yields; renderOverlays remains one step — if prod shows it alone >50ms, chunk inside it as a follow-up (#4442). 0 circular; Map chunk unchanged (~89KB, still lazy). * fix(map): skip final dynamic-layer yield
6 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #4429.
Summary
On mobile the dashboard renders the D3/SVG
MapComponent(src/components/Map.ts), not deck.gl/maplibre (desktop). Its first render built the base map and all dynamic overlays synchronously — measured as ~1277 ms of boot scripting, the #1 mobile-TBT contributor (prod mobile Lighthouse ×3, 2026-06-25: Perf 67, TBT ~1691 ms, LCP fine at 1.3 s;scriptEvaluation3705 ms ≫styleLayout1320 ms).This gates the dynamic-overlay pass behind a one-time flag + the existing
scheduleAfterFirstPaint:render()runs the full pipeline → zoom/pan/layer-toggle/theme interactions update overlays immediately (no deferred-overlay lag).The change maps onto
render()'s existing base-vs-dynamic seam — 22 lines, runtime-scheduling only (no chunk-graph change).Why this (and not #4410)
Build-verified + measured this session: #4410's data-table defers (feeds/bases/proto) are ~49 ms each — noise vs the 1277 ms map cost. #4425 already banked the big eager-JS win (zod/satellite/confetti, −29.5 KB brotli). #4410/#3944 closed. The byte-attribution lesson: rank eager-JS work by measured boot-scripting ms, not chunk byte size (the feeds catalog is 19 KB brotli but only 49 ms).
Risk
MapComponentonly; desktopDeckGLMap/GlobeMapunchanged.scheduleAfterFirstPainthas a 3 s timeout fallback; next interaction render recovers).Test plan
npm run typecheck+typecheck:apinpm run lint(biome) +lint:md+version:check+ CJS syntax + edge esbuild bundlenode --test tests/edge-functions.test.mjsnpm run test:data— 11650 pass / 0 failVITE_VARIANT=fullbuild — 0 circular chunks; Map chunk unchanged (~88 KB, no bootstrap bloat); still lazy (off eager modulepreload)tests/map-deferred-overlays.test.mts— structural guard (base before gate, overlays after; scheduler imported)Map-*.jsboot scripting vs the 1277 ms baseline (the meaningful perf verification; local mobile Lighthouse is CPU-contention noise)