Skip to content

perf(map): defer mobile SVG-map dynamic overlays off the first-paint path (#4429)#4431

Merged
koala73 merged 3 commits into
mainfrom
perf/defer-mobile-svg-map-overlays
Jun 25, 2026
Merged

perf(map): defer mobile SVG-map dynamic overlays off the first-paint path (#4429)#4431
koala73 merged 3 commits into
mainfrom
perf/defer-mobile-svg-map-overlays

Conversation

@koala73

@koala73 koala73 commented Jun 25, 2026

Copy link
Copy Markdown
Owner

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; scriptEvaluation 3705 ms ≫ styleLayout 1320 ms).

This gates the dynamic-overlay pass behind a one-time flag + the existing scheduleAfterFirstPaint:

  • Base map (countries/grid/graticule) still paints synchronously → no LCP regression.
  • Dynamic layer (cables/pipelines/conflicts/AIS + always-on cluster markers + overlays) is deferred off the first-paint critical path.
  • After first paint, 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

  • Mobile/fallback SVG MapComponent only; desktop DeckGLMap/GlobeMap unchanged.
  • Low blast radius: worst case (if base/topojson dominated the 1277 ms) it's a smaller win, not a regression. Flags guard against stuck overlays (scheduleAfterFirstPaint has a 3 s timeout fallback; next interaction render recovers).
  • This is the mobile-primary renderer → verify on a real mobile viewport post-merge.

Test plan

  • npm run typecheck + typecheck:api
  • npm run lint (biome) + lint:md + version:check + CJS syntax + edge esbuild bundle
  • node --test tests/edge-functions.test.mjs
  • npm run test:data — 11650 pass / 0 fail
  • VITE_VARIANT=full build — 0 circular chunks; Map chunk unchanged (~88 KB, no bootstrap bloat); still lazy (off eager modulepreload)
  • New tests/map-deferred-overlays.test.mts — structural guard (base before gate, overlays after; scheduler imported)
  • Post-merge: prod mobile Lighthouse re-read — Map-*.js boot scripting vs the 1277 ms baseline (the meaningful perf verification; local mobile Lighthouse is CPU-contention noise)

…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).
@vercel

vercel Bot commented Jun 25, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
worldmonitor Ready Ready Preview, Comment Jun 25, 2026 5:27pm

Request Review

@greptile-apps

greptile-apps Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR defers the mobile SVG MapComponent's dynamic-overlay pass (cables/pipelines/conflicts/AIS/cluster markers) off the first-paint critical path, targeting a measured ~1277 ms reduction in mobile boot scripting. The base map (countries/grid/graticule) still renders synchronously for LCP; a one-time flag + the existing scheduleAfterFirstPaint scheduler defers the heavy dynamic build until after first paint, after which all render() calls run the full pipeline immediately.

  • Two private flags (initialDynamicRendered, initialDynamicScheduled) and a 16-line gate are inserted between the base-render block and the existing dynamic-layer block in render() — minimal surface area, no chunk-graph change.
  • A new structural test (map-deferred-overlays.test.mts) uses source-text assertions to guard ordering and scheduling invariants without instantiating the full DOM-heavy component.

Confidence Score: 4/5

Safe to merge for the mobile SVG renderer; the only gap is the deferred callback holding a live reference to the component after destroy(), which is mitigated in practice by the clientWidth === 0 early-return but is not an explicit lifecycle guard.

The change is narrow (22 lines, runtime-scheduling only) and maps onto the pre-existing base-vs-dynamic seam in render(). The deferred callback captures this with no cancellation path through destroy() — the implicit protection via clientWidth === 0 covers the common detached-container case, but leaves a gap for hidden/off-screen containers. Desktop renderers are untouched.

src/components/Map.ts — the destroy() method vs the dangling scheduleAfterFirstPaint callback; no concerns in the test file.

Important Files Changed

Filename Overview
src/components/Map.ts Adds two one-time flags (initialDynamicRendered, initialDynamicScheduled) and a gate in render() to defer the heavy dynamic-overlay pass via scheduleAfterFirstPaint; base map remains synchronous for LCP. The callback closure captures this with no cancel mechanism in destroy().
tests/map-deferred-overlays.test.mts New structural guard test that uses readFileSync + regex/indexOf assertions to verify the deferral gate's source ordering without instantiating the full DOM-heavy MapComponent. Patterns are robust to internal whitespace variance.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Browser
    participant MapComponent
    participant scheduleAfterFirstPaint
    participant rIC as requestIdleCallback

    Browser->>MapComponent: render() [first call]
    MapComponent->>MapComponent: renderCountries() — synchronous (LCP)
    MapComponent->>MapComponent: "baseRendered = true"
    Note over MapComponent: initialDynamicRendered=false → gate triggered
    MapComponent->>scheduleAfterFirstPaint: register callback
    scheduleAfterFirstPaint->>Browser: addEventListener('load', afterPaint)
    MapComponent-->>Browser: return early (no dynamic overlays)

    Browser->>scheduleAfterFirstPaint: load event fires
    scheduleAfterFirstPaint->>Browser: requestAnimationFrame × 2
    Browser->>rIC: "requestIdleCallback(runOnce, {timeout:3000})"
    rIC->>MapComponent: "initialDynamicRendered = true"
    rIC->>MapComponent: render() [deferred]
    MapComponent->>MapComponent: renderCables / renderPipelines / renderConflicts / renderAis
    MapComponent->>MapComponent: renderClusterLayer / renderOverlays
    MapComponent-->>Browser: full map visible

    Browser->>MapComponent: render() [any subsequent — zoom/pan/toggle]
    Note over MapComponent: initialDynamicRendered=true → gate skipped
    MapComponent->>MapComponent: full pipeline immediately
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Browser
    participant MapComponent
    participant scheduleAfterFirstPaint
    participant rIC as requestIdleCallback

    Browser->>MapComponent: render() [first call]
    MapComponent->>MapComponent: renderCountries() — synchronous (LCP)
    MapComponent->>MapComponent: "baseRendered = true"
    Note over MapComponent: initialDynamicRendered=false → gate triggered
    MapComponent->>scheduleAfterFirstPaint: register callback
    scheduleAfterFirstPaint->>Browser: addEventListener('load', afterPaint)
    MapComponent-->>Browser: return early (no dynamic overlays)

    Browser->>scheduleAfterFirstPaint: load event fires
    scheduleAfterFirstPaint->>Browser: requestAnimationFrame × 2
    Browser->>rIC: "requestIdleCallback(runOnce, {timeout:3000})"
    rIC->>MapComponent: "initialDynamicRendered = true"
    rIC->>MapComponent: render() [deferred]
    MapComponent->>MapComponent: renderCables / renderPipelines / renderConflicts / renderAis
    MapComponent->>MapComponent: renderClusterLayer / renderOverlays
    MapComponent-->>Browser: full map visible

    Browser->>MapComponent: render() [any subsequent — zoom/pan/toggle]
    Note over MapComponent: initialDynamicRendered=true → gate skipped
    MapComponent->>MapComponent: full pipeline immediately
Loading

Reviews (1): Last reviewed commit: "perf(map): defer mobile SVG-map dynamic ..." | Re-trigger Greptile

Comment thread src/components/Map.ts
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.
@koala73
koala73 merged commit 7f094f6 into main Jun 25, 2026
23 checks passed
@koala73
koala73 deleted the perf/defer-mobile-svg-map-overlays branch June 25, 2026 17:28
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf: defer mobile SVG-map dynamic overlays off the boot path (Map-*.js = 1277ms, #1 mobile TBT)

1 participant