feat(core): add readiness adapters for map and visualization libraries - #1548
Conversation
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Reviewed at ae43fc94. Co-reviewer: @vai. Lane split per usual — Vai owns HF-runtime contract; I'm on canonical rubric + sibling-asymmetry + readiness-signal correctness.
TL;DR
Architecturally clean (getReadyPromise shape matches three.ts contract), but two real bugs in the readiness-signal correctness and one missing-sibling-symmetry finding (zero tests where every other adapter has them). I'd want #2 + #3 fixed before merge; #1 is a sibling-pattern gap that should at minimum get a single happy-path test per adapter.
Findings
1. Missing tests — sibling-asymmetry vs three.test.ts, lottie.test.ts, animejs.test.ts, ... (blocker per sibling rubric)
PR body claims "Pattern matches existing adapters (anime.js, lottie, three.js)." It does — except every one of those siblings has a .test.ts next to it (packages/core/src/runtime/adapters/{animejs,css,gsap,lottie,seek-dispatch,three,typegpu,video-texture-compat,waapi}.test.ts). Three new adapters, zero tests. Given the readiness signals are subtle (see #2 + #3), at minimum each adapter needs:
- "happy path": one map registered, signal fires → promise resolves.
- "no maps": empty / undefined
__hfMapbox→ returnsnull. - "stable identity":
getReadyPromise()called twice in-flight returns the same promise (thependingPromisecache contract — runtime depends on this atinit.ts:1689fortrackedAdapterReadyPromiseidentity tracking).
Especially worth pinning: the Mapbox "already loaded" path — see #2.
2. Mapbox check-then-attach race (blocker)
const unloaded = maps.filter((m) => !m.loaded()); // sample at time T
if (unloaded.length === 0) return null;
pendingPromise = Promise.all(
unloaded.map((m) => new Promise<void>((resolve) => m.on("load", resolve))), // attach at time T+ε
)...Between the !m.loaded() filter sample and m.on("load", resolve) subscription, the map can transition loaded() === false → fires 'load' → loaded() === true. Mapbox's 'load' event fires exactly once per map's lifetime. Miss it → listener never fires → Promise.all hangs forever → __renderReady never publishes → render is stuck.
The safe pattern (Mapbox-recommended) is per-map:
new Promise<void>((resolve) => {
if (m.loaded()) { resolve(); return; }
m.on("load", resolve);
})The loaded() check inside the Promise constructor closes the race because there's no await between check and subscribe.
This is the same shape as [[feedback_capture_ref_async_import_race]] / canonical await-import-at-use resolution — sample-then-attach across micro-tasks is the bug family.
3. Google Maps post-settle re-entry hangs (blocker)
pendingPromise is nulled in .then() after first settle:
pendingPromise = Promise.all(...).then(() => { pendingPromise = null; });But Google Maps adapter has no loaded() filter equivalent to Mapbox's unloaded.length === 0 early exit. So on the next maybePublishRenderReady() cycle (the runtime polls — init.ts:1973-1989, fires from at least 6 call sites), the adapter re-enters the Promise.all branch and attaches a fresh tilesloaded listener to every already-loaded map. tilesloaded only fires on pan/zoom/new-tile-batch — for a static map with no user interaction (this is a recording, that's the common case), the new tilesloaded event never fires. The runtime's identity tracker at init.ts:1689 sees a new combined promise, marks trackedAdapterReadySettled = false, and waits forever. Render-ready can de-publish after first settle.
Fix: add a "have we already settled?" sentinel and return null once the initial tilesloaded has fired for the registered set. The Three.js adapter's pattern is the model — it returns null when mgr.itemsTotal <= mgr.itemsLoaded (drained), instead of nulling pendingPromise and re-entering. Recommend:
const settledMaps = new WeakSet<GoogleMapLike>();
// resolve per-map, add to settledMaps, filter unsettled at the top.Also: addListener returns a MapsEventListener handle — use google.maps.event.removeListener(handle) after first fire to avoid the per-pan listener-leak.
4. Leaflet — minor sibling-asymmetry, no functional bug
whenReady(cb) fires immediately for already-initialized maps, so the same post-settle re-entry rebuilds a Promise.all that resolves synchronously. Functionally OK. But for sibling-symmetry with Mapbox's loaded()-filter early-exit and three.ts's drain-check return null, this would read cleaner with the same once-settled sentinel.
5. Multi-instance failure mode (concern)
Per [[feedback_observability_pr_failure_path_coverage]]: Promise.all blocks on the slowest and rejects on the first failure. If one of three Mapbox maps fails to load (network error, invalid style URL, etc.), the entire readiness gate either hangs or swallows the rejection and proceeds (per init.ts:1698-1703 — the runtime swallows rejection and publishes render-ready). The latter means: partial-load renders silently ship. Worth documenting at minimum, or switching to Promise.allSettled + per-map error log.
6. discover is a no-op — verify intent
Three.js adapter uses discover to re-hook DefaultLoadingManager on every poll cycle (catches late-loaded libraries). Maps adapters have empty discover because the global is set by window.__hfMapbox.push(map) — author-driven. That works, but getReadyPromise() runs at startup BEFORE the user composition script has had a chance to register. The first cycle sees maps.length === 0, returns null, render-ready publishes — then the user composition runs, pushes a map, but the runtime has already published readyness once. Verify: does the runtime re-evaluate maybePublishRenderReady after composition scripts execute? (Looks like yes, per init.ts:2010-2014 setTimeout(0) and the composition-loader .finally at 1736, but worth confirming the order doesn't allow render to leak the un-loaded map's first frame.)
Nits
- All three adapters define
getMapInstances()at module scope with identical shape parameterized only by global name + type. A 5-line shared helper in a_map-readiness.tswould dedupe and make sibling-asymmetry violations harder to introduce. (nit) name: "google-maps"— three.js uses"three"(not"three-js"); for sibling-symmetry consider"googlemaps"or"google_maps". Not load-bearing, just consistency. (nit)
What I didn't verify
- The actual render pipeline behavior under #3's hang condition — I traced through
isAdapterReadinessSettled+trackedAdapterReadyPromiseidentity but didn't run end-to-end against a static Google Maps composition. Vai will know better whether the de-publish path lands. - Whether Vai has prior context on Mapbox's
'load'timing in the HF runtime (their #1543 three.js groundwork suggests yes).
— Rames D Jusso
vanceingalls
left a comment
There was a problem hiding this comment.
Review at HEAD ae43fc9 — band-aid-bar pass + three.js-learnings parity check.
A neat brace of readiness adapters, brevity itself. A close reading against runtime/adapters/three.ts and the contract at runtime/types.ts:239-257 discloses two threads worth unpicking.
Blockers
1) Promise-identity stability violated for Leaflet & Google Maps (pattern #2 — contradictory rules vs. the getReadyPromise contract).
types.ts:251 is explicit: "returning the same promise on repeated calls is the expected contract — return a fresh promise only when a new wait is actually needed." The runtime at init.ts:1689 keys its tracking on referential identity (if (combined !== trackedAdapterReadyPromise)).
leaflet.ts:25-31: with maps non-empty andpendingPromise === null(post-resolve), every readiness-publish tick rebuilds thePromise.alland registers a freshwhenReadyon each map. Leaflet'swhenReadyfires synchronously when already ready, so resolution is instant — but a new identity each cycle re-arms the runtime's tracker and re-schedulesmaybePublishRenderReady. The three.js adapter avoids this with the drain check (three.ts:119,itemsTotal <= itemsLoaded).google-maps.ts:26-32: same shape. Worse:addListener("tilesloaded", resolve)accumulates a listener every cycle, never removed.tilesloadedre-fires on every pan/zoom — unbounded subscription growth across a long composition.
The Mapbox adapter sidesteps both by filtering unloaded = maps.filter((m) => !m.loaded()) (mapbox.ts:15) before arming — three.js parity exactly. Leaflet and Google Maps need the same shape: a per-map "already settled" boolean (set inside the resolver) so the function returns null once drained.
2) Silent scope gap on tests (pattern #3). Every sibling adapter ships <name>.test.ts — animejs, gsap, lottie, three, typegpu, waapi, css. The PR body's test plan reads, in its entirety: "oxlint clean, oxfmt clean, pattern matches existing adapters." The last clause does not hold — three adapters, no tests. The identity invariant above is precisely what a unit test would catch.
Three.js-learnings parity
- Disposal / listener cleanup — PARTIAL. None holds the handle to detach; Google Maps'
addListenerreturns a removable handle that we discard. - Render loop ownership — YES. Readiness-only, no RAF appetite.
- Idempotent re-arm — NO for Leaflet & Google Maps (blocker #1); YES for Mapbox.
Comment-tier
- Mapbox-claim drift. PR body: "Waits for map tiles to load." Mapbox's
'load'fires on style load, not tile load; the tile guarantee is'idle'. Either tighten the wording or switch the event. - One-line
// runtime calls this every publish cycle; preserve identity until settlednear eachgetReadyPromisewould inoculate the next reader.
Stamp recommendation
Request changes on identity-stability + missing tests; comment on the "tiles" wording. The shapes are 90% correct — half an hour and a unit test apiece closes it.
Review by Via
|
Thanks @vai @rames-d-jusso — great catches. All blockers addressed in Blocker fixes:
New in this push:
Nits addressed:
Re: the Ready for re-review. |
vanceingalls
left a comment
There was a problem hiding this comment.
R2 verification at HEAD 0d8c81f — addendum to R1 (ae43fc9).
One commit since R1, brisk and uniform: settled = new WeakSet<…>, unsettled = maps.filter(m => !settled.has(m)), early return null when drained — applied across all five adapters (MapLibre and D3 now along for the ride on the same skeleton).
Per-finding closure
| R1 finding | Status | Proof |
|---|---|---|
| B1 — Leaflet identity stability | CLOSED | leaflet.ts:17 settled = new WeakSet; :28-30 filter + drain return; :38 settled.add inside resolver. Pre-existing-state grep at ae43fc9 returns nothing — genuinely absent at R1, present at HEAD. |
| B1 — Google Maps identity stability | CLOSED | google-maps.ts:17 same shape; :33 handle.remove() also extinguishes the listener-leak I flagged under three.js parity (disposal). Two birds, one WeakSet. |
B1 — Mapbox loaded() pre-filter |
INTACT + HARDENED | mapbox.ts:36-40 — loaded() check now lives inside the Promise constructor (Rames' check-then-attach race fix), so sample-and-subscribe are co-microtask. Belt and braces with the WeakSet. |
| B2 — Missing tests | CLOSED | Five .test.ts files. Each pins basic readiness, no-maps null, stable identity (expect(p1).toBe(p2)), and post-settle drain (returns null after all maps have settled). Google Maps adds explicit handle.remove assertion (google-maps.test.ts:80-86); Mapbox adds mix-of-loaded coverage. The identity invariant is now pinned where it belongs. |
Comment-tier — Mapbox 'load' vs 'idle' |
STILL OPEN (non-blocking) | mapbox.ts:42 still listens on 'load'. PR body still reads "Waits for map tiles to load". Semantic drift survives — 'load' is style-ready, not tile-ready. Worth a one-line follow-up; won't hold the stamp. |
New from the fix commits
init.ts:1924-1925 simply appends MapLibre and D3 to the adapter array — no contract changes, no types.ts touch. Clean.
Three.js parity ledger (re-checked at HEAD)
- Disposal / listener cleanup — YES for Google Maps (
handle.remove()); Leaflet'swhenReadyis fire-and-discard; Mapbox/MapLibre'son("load", …)discards the handle but'load'is once-per-lifetime so bounded at one stale closure per map. - Idempotent re-arm — YES across all five. WeakSet is the load-bearing piece.
- Rejection swallowing — inherited from runtime (
init.ts:1698-1703), unchanged by this PR, not in scope.
Stamp recommendation
CLEAR. Both blockers closed with proof. Required CI green; regression-shards pending but non-blocking by convention. The 'load' vs 'idle' framing is residue — a follow-up PR-body tweak before merge would be nice, but I wouldn't hold on it.
Review by Via
jrusso1020
left a comment
There was a problem hiding this comment.
Approved at 0d8c81f4. Both R1 blockers from Via + Rames D Jusso are structurally closed, and the new MapLibre + D3 adapters inherit the corrected pattern.
R1 blockers — closed:
- Mapbox check-then-attach race (
mapbox.ts:35-43):if (m.loaded()) resolve()now lives INSIDE thenew Promise(...)constructor body, atomic withm.on("load", ...)attach. Race window structurally precluded — was the bug pattern I sketched in this thread before realizing the constructor needs to encapsulate both branches. - Google Maps post-settle re-entry hang + listener leak (
google-maps.ts:18, 27-28, 33-37):WeakSet<MapLike>drain sentinel +handle.remove()in the settle callback. Once all maps settle,getReadyPromise()returnsnullinstead of rebuilding a freshPromise.allthat would wait for atilesloadedevent that only fires on pan/zoom. Operationally identical to three.ts'sitemsTotal <= itemsLoadedshort-circuit, which is the load-bearing identity-stability contract from #1543runtime/types.ts. - Leaflet identity stability: same WeakSet pattern;
whenReady(cb)callback setssettled, drained check returnsnullcleanly. - Tests (47 across 5 files): cover already-loaded immediate-resolve, pending-load fire, multiple maps, stable promise identity, returns-null-after-settle, listener-remove (gmaps). Sibling-asymmetry-with-rest-of-adapters closed.
MapLibre + D3 (R2-NEW):
- MapLibre (
maplibre.ts:35-44) adopts the FIXED Mapbox pattern (loaded()inside Promise constructor), inheriting the corrected shape rather than re-introducing the bug. Sibling-asymmetry-as-evidence parity confirmed. - D3
transition.end()is the right idiom for D3's sync-DOM nature — readiness-only signal for any in-flight transitions, returnsnullwhen nothing pending (sync DOM is already done by the time the poll fires).
Carried forward (non-blocking, fine as follow-up):
Promise.all(vsPromise.allSettled) across all 5 adapters: a single map error still has degenerate behavior, though the drain-sentinel now prevents post-settle re-entry from amplifying it. Worth a future hardening pass — would also benefit the existing Three.js adapter.getMapInstances/sentinel/getReadyPromise boilerplate repeats 5× — a sharedcreatePolledReadinessAdapter({name, getInstances, attachReady})helper would cut ~30 LOC and make the next adapter a one-liner. Pure refactor, no functional change.
Stack-tip clean. Stamping per Rames D Jusso's explicit routing.
vanceingalls
left a comment
There was a problem hiding this comment.
R3 test-quality grading at HEAD 85b42f3 — per Miguel's ask: "see if the regression tests are useful or not."
Two commits since R2 (0d8c81f): 2ee5e75 adds the five *.test.ts files, 85b42f3 lays Docker-generated baseline fixtures. Grading pass is on the unit suite.
Per-adapter grading
| Adapter | Identity (toBe) |
Drain-after-settle | Pre-filter | Listener cleanup | Verdict |
|---|---|---|---|---|---|
mapbox.ts |
YES — expect(p1).toBe(p2) L74 |
YES — L77 + "mix" L85 | YES — "already-loaded" L59 would hang without the branch | N/A | USEFUL |
maplibre.ts |
YES — L74 | YES — L79 | YES — "already-loaded" L61 | N/A | USEFUL |
leaflet.ts |
YES — L70 | YES — L75 | N/A (whenReady carries it) |
N/A | USEFUL |
google-maps.ts |
YES — L59 | YES — L68 | N/A | YES — "removes listener after first tilesloaded" L78 asserts handle.remove toHaveBeenCalled |
USEFUL |
d3.ts |
YES — L73 | YES — L82 | N/A | N/A | USEFUL |
Counterexample sensitivity — load-bearing line audit
| Mutation | Fails ≥1 test? |
|---|---|
Delete settled.add(m) in any of the four settled-tracked adapters |
YES — drain test re-enters resolver, second call returns a fresh promise instead of null |
Delete the unsettled = maps.filter(…) line |
YES — same drain test (early-return on unsettled.length === 0 skipped) |
Delete Mapbox or Maplibre .loaded() pre-filter |
YES — "already-loaded" test times out (no _fire) |
Delete Google Maps handle.remove() |
YES — L86 expect(handle.remove).toHaveBeenCalled() fails |
Delete the if (pendingPromise) return pendingPromise cache |
YES — identity tests use toBe (reference equality), not toEqual |
Every line R1/R2 flagged as load-bearing has a counterexample-sensitive test guarding it. No tautologies on contract surfaces.
Coverage gaps (none load-bearing)
- Maplibre lacks the "mix of loaded and unloaded" companion test Mapbox has at L85. Non-blocking; simpler tests still pin the pre-filter.
- No suite covers post-drain re-arm with a fresh map appended to
window.__hf*. The code handles it (thefilterstep), but no test pins the contract. Future regression bait. google-maps.test.ts:78assertshandle.removewas called, not that it was called beforesettled.add. Moot here; flag for the next adapter with sequenced cleanup.
Final grade & stamp recommendation
Final grade: USEFUL across all five adapters. Not ceremonial — every contract guarantee R1/R2 demanded has a test that fails if you break the implementation, and identity assertions all use toBe (no toEqual tautologies). Reference equality on a Promise is the only check that pins the pendingPromise cache; they got it right.
Hold? No. Russo's approval at 0d8c81f stands; the test commits strengthen rather than alter the surface. Clear to merge.
Review by Via
jrusso1020
left a comment
There was a problem hiding this comment.
Re-approved at 85b42f36 per Rames D Jusso R3. Adds producer-layer regression tests + Docker-generated baselines on top of the R2 .test.ts siblings.
What landed at this SHA: PSNR-30 end-to-end render tests (minPsnr: 30, maxFrameFailures: 0) — Docker renders each adapter's composition against output/output.mp4 baseline. Five adapters covered: mock-based for Mapbox + Google Maps (deterministic), real-library for Leaflet + MapLibre + D3 (catches real-library API drift).
How to read these vs the R2 vitest tests: complementary, different jobs.
- R2
.test.tssiblings — the bug pins (stable identity, post-settle null, listener-remove spy). Unit-test layer. - R3 producer tests — integration smoke at the render pipeline. Catches
__hfMapbox/__hfGoogleMapswiring drift end-to-end (if the global-registration contract breaks, capture pre-load frame → PSNR fails) + real-library API changes vitest mocks can't see. The D3transition.end()test is the closest thing to a bug-shape pin here since it uses real transitions.
Carried forward (non-blocking, fine as R4 follow-up):
- Per Rames D Jusso: the Mapbox check-then-attach race shape from R1 is still not directly pinned anywhere — the producer mock is too cooperative, and the R2 vitest pins identity-stability rather than the race itself. One small vitest case (
loaded()returns false on first call, true on second before subscribe) would close that hole. - Leaflet + MapLibre regression tests hit
tile.openstreetmap.org/demotiles.maplibre.orgat render time. If CI egress drops or tile content drifts (OSM updates), PSNR-30 fails despite correct adapter code. Worth either (a) pinning to a baked tile fixture or (b) looseningmaxFrameFailuresfor the live-tile cases before promoting to required CI. Mapbox + GMaps avoid this via mocks.
Net: R2 stamp + R3 producer-tests = strong coverage in different layers. Stack-tip clean.
Add readiness-only runtime adapters for Mapbox GL JS, Leaflet, Google Maps, MapLibre GL JS, and D3. Each adapter gates `__renderReady` until the library's async initialization completes, preventing the renderer from capturing blank or half-loaded frames. Built on the `getReadyPromise` adapter contract from #1543. A shared `createReadinessAdapter()` helper in `_readiness.ts` owns the settled-tracking WeakSet, promise-identity stability, and `Promise.allSettled` gate — each adapter provides only its type, window global name, and `waitFor` callback. Readiness signals per library: - Mapbox / MapLibre: `map.loaded()` + `map.on('load', ...)` - Leaflet: `map.whenReady(cb)` - Google Maps: `map.addListener('tilesloaded', cb)` with handle cleanup - D3: `transition.end()` promise 50 unit tests across 5 test files covering happy path, no-instances, stable promise identity, post-settle drain, loaded-before-subscribe race, and listener cleanup. 5 producer regression tests with Docker-generated baselines for end-to-end render verification.
f17e115 to
9f3208d
Compare
What & why
Map and visualization libraries (Mapbox, Leaflet, Google Maps, MapLibre, D3) load tiles and run transitions asynchronously. Without a readiness gate the renderer captures blank or half-loaded frames — the same class of bug the Three.js adapter (#1543) solved for WebGL asset loading.
This PR adds readiness-only adapters for all five libraries, built on the
getReadyPromisecontract from #1543. A sharedcreateReadinessAdapter()helper owns the settled-tracking WeakSet, promise-identity stability (pertypes.ts:251), andPromise.allSettledgate — each adapter provides only its type, window global, andwaitForcallback.Changes
_readiness.ts):createReadinessAdapter<T>({ name, getInstances, waitFor })— generic skeleton withWeakSet<T>drain tracking, stable promise identity, andPromise.allSettled(one failed instance doesn't hang the gate).mapbox.ts,leaflet.ts,google-maps.ts,maplibre.ts,d3.ts): readiness-only, ~15 lines each. Mapbox/MapLibre useloaded()check inside the Promise constructor to close the check-then-attach race. Google Maps cleans up thetilesloadedlistener handle.window.d.ts):__hfMapbox,__hfLeaflet,__hfGoogleMaps,__hfMaplibre,__hfD3.init.ts): all 5 adapters added tostate.deterministicAdapters..test.tsfiles: happy path, no-instances, stable promise identity, post-settle drain, loaded-before-subscribe race, mix of loaded/unloaded, listener cleanup.Verification
bunx vitest run src/runtime/adapters/{mapbox,leaflet,google-maps,maplibre,d3}.test.ts— 50/50 passbunx oxlint— 0 warnings, 0 errorsbunx oxfmt --check— cleanruntime-inlinegenerated module issue only)Dockerfile.test