Skip to content

Re-architect ui-mapbox: declarative, dynamic-DOM-native family + StoreLocator orchestrator - #567

Merged
titouanmathis merged 10 commits into
mainfrom
spike/mapbox-declarative
Aug 2, 2026
Merged

Re-architect ui-mapbox: declarative, dynamic-DOM-native family + StoreLocator orchestrator#567
titouanmathis merged 10 commits into
mainfrom
spike/mapbox-declarative

Conversation

@titouanmathis

Copy link
Copy Markdown
Contributor

Summary

Re-architects the (still-unreleased) @studiometa/ui-mapbox family to be declarative and dynamic-DOM-native, and supersedes #566. MapboxMap no longer declares its children: every child registers independently, resolves its parent map on mount, and self-gates on map readiness. As a result the whole family self-injects when its elements enter the DOM (via Fetch, appendChild, template swaps) and self-cleans when they leave — no orchestration from the map, no config.components.

It also dissolves StoreLocator/StoreLocatorItem into composable primitives: a MapboxCluster source-driver + MapboxClusterItem where the rendered items are simultaneously the sidebar list and the map's GeoJSON cluster source, plus a thin reintroduced StoreLocator orchestrator that layers the search UX on top.

Because the package was never published to npm (npm view @studiometa/ui-mapbox → 404), there is no backward-compatibility surface — this ships cleanly as part of the initial feature.

What changed

Declarative, dynamic-DOM-native family

  • MapboxMap drops config.components and owns only the map. Children (MapboxMarker, MapboxPopup, controls, MapboxSource, MapboxLayer, MapboxImage(s), MapboxCluster) resolve the map via $closest and inject through AbstractMapboxMapChild.whenMapReady(cb) (runs now if loaded, else once on map-load).
  • New registerMapboxComponents() helper registers the whole family (since the map no longer resolves children). Per-component registerComponent + lazy importWhen* still work, unchanged.

Hardening (the load-bearing part — see the adversarial review that drove it)

The move from an orchestrated lifecycle to an unordered one dropped three guarantees; all are restored package-locally so the package is self-sufficient:

  • Queue-wedge containmentAbstractMapboxMapChild runs the ready-callback and a base-owned __onDestroyed() teardown inside try/catch, routing errors to $warn + an error emit and never rethrowing, so a throwing mapbox call can't wedge js-toolkit's shared task queue.
  • Dead-map teardown safety — children subscribe once to the map's remove event at ready-time and null their cached map ref, so teardown is order-independent and never calls a method on a removed map.
  • Id-ownership for sources/layers — a per-map ownership registry lets MapboxSource/MapboxLayer adopt-or-add on mount (a Fetch swap with the same source id refreshes data instead of throwing a duplicate-id error) and remove only what they still own.
  • Retryable + standing resolutionMapboxMap/MapboxCluster dispatch document-level connected events; a child that mounts before its map exists (e.g. an eagerly-registered marker under a lazily-registered map) resolves once the map connects, and re-injects if the map is destroyed and remounted.

MapboxCluster + MapboxClusterItem + StoreLocator orchestrator

  • MapboxCluster is a pure source driver: item registry → GeoJSON cluster:true source + layers + cluster-click expansion zoom; emits item-click/update. Usable standalone for any declarative clustered map + list.
  • MapboxClusterItem is a rendered item exposing id/lngLat/properties/popupContent, reflecting data-in-bounds/data-active when driven.
  • StoreLocator is a thin orchestrator owning the search UX only: selection (select/deselect/active/flyTo/popup), geocoder result → fit, viewport moveend → in-bounds + distance sort + filter, fit-on-update. StoreLocatorItem is dissolved.

Validation

  • 137 mapbox unit tests pass (18 files); full suite green. Lint clean (0 errors).
  • Real-browser pressure-test, two phases, all scenarios PASS (headless Chromium via agent-browser; evidence is programmatic since headless doesn't paint the WebGL canvas):
    • Core dynamic-DOM: marker/source/layer add-remove; dup-id source swap (one-tick innerHTML replace → adopted + data refreshed, no throw); whole-map-section removal (zero TypeErrors); queue-not-wedged after a failure; child-before-map resolves on connect.
    • Orchestrator: selection/deselect, cluster item-click bus, viewport filter (source not rebuilt on pan), Fetch-swap keeping list + map in lockstep with stale-selection drop, geocoder→fit, and bare-cluster-does-not-select.

Before merge: worth one human eyeball on a real-GPU browser to confirm the visual layer, since the automated evidence is programmatic (headless WebGL is blank).

Companion upstream PRs (defense-in-depth, not required)

The package hardening is self-sufficient, but two js-toolkit PRs harden the shared machinery underneath and let the workarounds relax over time:

Supersedes #566

The lazy-children approach in #566 is subsumed: children are independently registered now, so lazy loading is just the existing importWhen* + per-component subpaths — no special child-resolution path needed. #566 will be closed in favour of this.

Note: the branch is named spike/mapbox-declarative for historical reasons (it began as a spike) but the change is production-ready.

🤖 Generated with Claude Code

titouanmathis and others added 4 commits August 1, 2026 16:19
Re-architect the @studiometa/ui-mapbox family so MapboxMap no longer
declares its children. Children register independently, resolve their
parent map via $closest on mount and self-gate on map readiness through
AbstractMapboxMapChild.whenMapReady, injecting on ready and cleaning up
in destroyed() against a cached map ref. This makes every child
Fetch/appendChild dynamic-DOM-native (self-inject on insertion,
self-clean on removal).

Dissolve StoreLocator/StoreLocatorItem into declarative MapboxCluster +
MapboxClusterItem: rendered items are simultaneously the list and the
GeoJSON source, one registry driving both. Add registerMapboxComponents()
convenience helper.

Spike baseline (pre-hardening): build + lint + 102 tests green. Known
open blockers to address before shipping: unguarded throws can deadlock
the global SmartQueue (B1), teardown against a removed map throws (B2),
Fetch-swap dup-id on Source/Layer (B3), and no-retry map resolution for
isolated dynamic insertion (M1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NeZwHwo3d9rYsCxJUQqTep
… teardown

Fix four blockers found by adversarial review of the declarative family:

- B1: wrap ready-callback and subclass teardown in AbstractMapboxMapChild so a
  throwing mapbox call routes to $warn + an `error` event instead of wedging
  js-toolkit's global queue. Subclasses implement `__onDestroyed` (guarded by
  the base) instead of overriding `destroyed`.
- B2: subscribe to the map's own `remove` event at ready-time; drop the cached
  map reference when it fires so no teardown ever calls a method on a removed
  map. Teardown is now order-independent.
- B3: add per-map ownership tokens for id-addressed sources and layers so a
  Fetch swap adopts/updates the existing id instead of throwing a duplicate,
  and the outgoing instance never deletes the contribution the incoming one now
  owns; externally declared ids stay untouched.
- M1/M2: make map resolution retryable and standing. MapboxMap and MapboxCluster
  dispatch a document-level connected event; children park on it when no
  parent exists yet, and re-inject on a new map after a destroy -> remount.

Add regression specs for each blocker and relax the layer/source specs only as
needed for ownership tracking.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NeZwHwo3d9rYsCxJUQqTep
…rce driver

Split the search-UX concerns back out of MapboxCluster: the cluster is now a
pure clustered-source driver (item registry, GeoJSON source + layers,
cluster-click zoom, item-click reporting, update event), and a new StoreLocator
orchestrator owns selection, popup, viewport filtering, distance sort, geocoder
wiring and fit-on-update on top of it. MapboxClusterItem stays the single item
class; StoreLocatorItem stays dissolved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NeZwHwo3d9rYsCxJUQqTep
The list of items lives inside the nested MapboxCluster, outside the
StoreLocator's ref scope, so a `data-ref="list"` on the sidebar never
bound to the orchestrator — `__reorderList` already fell back to each
item's shared parent. Remove the misleading ref from the config, props,
and docs; reorder is unchanged (still appends each in-view item into its
shared parent to match the distance sort).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NeZwHwo3d9rYsCxJUQqTep
@codecov

codecov Bot commented Aug 1, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.90541% with 42 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.86%. Comparing base (fd3b905) to head (bce8ba8).

Files with missing lines Patch % Lines
packages/ui-mapbox/StoreLocator.ts 86.95% 13 Missing and 5 partials ⚠️
packages/ui-mapbox/MapboxCluster.ts 87.25% 12 Missing and 1 partial ⚠️
packages/ui-mapbox/AbstractMapboxMapChild.ts 95.95% 4 Missing ⚠️
packages/ui-mapbox/MapboxClusterItem.ts 92.00% 4 Missing ⚠️
packages/ui-mapbox/MapboxLayer.ts 93.33% 3 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##               main     #567      +/-   ##
============================================
+ Coverage     86.19%   86.86%   +0.66%     
  Complexity      145      145              
============================================
  Files           139      139              
  Lines          4354     4696     +342     
  Branches        817      877      +60     
============================================
+ Hits           3753     4079     +326     
- Misses          528      540      +12     
- Partials         73       77       +4     
Flag Coverage Δ
unittests 86.86% <92.90%> (+0.66%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
packages/ui-mapbox/AbstractMapboxControl.ts 86.66% <100.00%> (+0.95%) ⬆️
packages/ui-mapbox/MapboxGeocoder.ts 100.00% <100.00%> (ø)
packages/ui-mapbox/MapboxImage.ts 100.00% <100.00%> (ø)
packages/ui-mapbox/MapboxImages.ts 100.00% <100.00%> (ø)
packages/ui-mapbox/MapboxMap.ts 100.00% <100.00%> (ø)
packages/ui-mapbox/MapboxMarker.ts 100.00% <100.00%> (+6.66%) ⬆️
packages/ui-mapbox/MapboxPopup.ts 100.00% <100.00%> (ø)
packages/ui-mapbox/MapboxSource.ts 95.34% <100.00%> (+2.24%) ⬆️
packages/ui-mapbox/utils.ts 100.00% <100.00%> (+39.13%) ⬆️
packages/ui-mapbox/MapboxLayer.ts 92.30% <93.33%> (-3.85%) ⬇️
... and 4 more
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown

Export Size

Unchanged

@studiometa/ui

Name Size Diff
AbstractCarouselChild 747 B -
AbstractCarouselComponent 156 B -
AbstractFrameTrigger 1.74 kB -
AbstractPrefetch 366 B -
AbstractScrollAnimation 3.87 kB -
AbstractSliderChild 702 B -
Accordion 1.78 kB -
AccordionItem 1.66 kB -
Action 1.11 kB -
AnchorNav 3.88 kB -
AnchorNavLink 3.77 kB -
AnchorNavTarget 125 B -
AnchorScrollTo 2.56 kB -
animationScrollWithEase 763 B -
Carousel 4.09 kB -
CarouselBtn 900 B -
CarouselDrag 709 B -
CarouselItem 954 B -
CarouselWrapper 595 B -
CircularMarquee 506 B -
ClickOutside 160 B -
Cursor 650 B -
DataBind 3.64 kB -
DataComputed 3.72 kB -
DataEffect 3.7 kB -
DataModel 3.75 kB -
DataScope 1.35 kB -
default 763 B -
Dialog 2.43 kB -
Draggable 1.61 kB -
Fetch 2.4 kB -
FetchShopifyPartial 2.85 kB -
FetchShopifySection 2.62 kB -
Figure 1.81 kB -
FigureShopify 2.08 kB -
FigureTwicpics 2.34 kB -
FigureVideo 1.98 kB -
FigureVideoTwicpics 2.56 kB -
Frame 3.47 kB -
FrameAnchor 1.84 kB -
FrameForm 1.92 kB -
FrameLoader 1.45 kB -
FrameTarget 1.75 kB -
FrameTriggerLoader 1.46 kB -
Hoverable 904 B -
Indexable 1.02 kB -
InView 157 B -
InViewOnce 206 B -
LargeText 678 B -
LazyInclude 322 B -
Menu 2.33 kB -
MenuBtn 140 B -
MenuList 1.9 kB -
Modal 2.11 kB -
ModalWithTransition 2.22 kB -
Panel 2.51 kB -
PrefetchWhenOver 408 B -
PrefetchWhenVisible 417 B -
ScrollAnimation 4 kB -
ScrollAnimationChild 4.12 kB -
ScrollAnimationChildWithEase 4.72 kB -
ScrollAnimationParent 4.18 kB -
ScrollAnimationTarget 4.06 kB -
ScrollAnimationTimeline 4.12 kB -
ScrollAnimationWithEase 4.6 kB -
ScrollReveal 1.63 kB -
Sentinel 129 B -
Slider 3.65 kB -
SliderBtn 928 B -
SliderCount 753 B -
SliderDots 1.97 kB -
SliderDrag 269 B -
SliderItem 958 B -
SliderProgress 1.06 kB -
Sticky 771 B -
Tabs 1.38 kB -
Target 86 B -
Timer 496 B -
TimerProgress 627 B -
Track 1.64 kB -
TrackContext 340 B -
TrackShopify 1.72 kB -
Transition 1.41 kB -
viewTransition 295 B -
ViewTransition 738 B -
withDeprecation 199 B -
withIndex 968 B -
withScrollAnimationDebug 2.03 kB -
withTransition 1.39 kB -

titouanmathis and others added 6 commits August 1, 2026 22:19
Address the confirmed findings from the PR #567 external review.

Foundation:
- whenMapReady now accepts void | Promise<void> and awaits the returned
  promise inside the same containment, so a rejected async callback routes
  to $warn + the error event instead of an unhandled rejection. Subclasses
  guard on __readyMap === map after every await so a callback whose map was
  removed/replaced mid-flight no-ops (F-async).

Hardening:
- Bind the concrete map's remove handler at bind-time, before load, so a map
  removed before it finishes loading re-resolves the child onto a replacement
  instead of stranding it (H2).
- Detach MapboxMap forwarding listeners on destroy, and add an __onMapRemove
  hook so a layer/cluster flushes its map-scoped listeners before the map ref
  is cleared (H3).
- MapboxLayer adopts AND refreshes a same-id layer (remove + re-add) instead
  of keeping the outgoing definition (H4).
- Move image ownership to the per-map registry: a same-name swap adopts the
  sprite so the outgoing instance no longer removes it, and image adds are
  tracked incrementally so a mid-batch failure still cleans up (H5).
- Wire a nested marker/popup from both sides so a dynamic append to an
  already-loaded map cannot leave the popup attached to neither (H8).
- Remove a previous element-targeted geocoder control before recreating it on
  map replacement (H9).
- Restore a popup source element's visibility on teardown when it hid it.

Orchestrator:
- StoreLocator subscribes to MAPBOX_MAP_CONNECTED / MAPBOX_CLUSTER_CONNECTED
  for retryable + standing resolution and re-wires onto a replacement cluster;
  it also subscribes to the map's remove event so a removed map is dropped and
  never called into (D1, D3).
- Dropping a stale selection during a swap runs the full deselect cleanup so
  the removed store's popup is removed and deselect is emitted (D2).
- Guard MapboxCluster.getClusterExpansionZoom against a removed map (D4).
- Expose cluster items / the update payload as a defensive copy; document the
  setData invariant, duplicate-id behavior and popup-close independence.

Deferred with code comments and follow-up notes: stale ownership entries after
external resource removal / setStyle (H6), and layer recovery after a source is
removed while the layer stays mounted (H7).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NeZwHwo3d9rYsCxJUQqTep
Fix PR #567 review H6/H7: a map.setStyle() wipes every source, layer and
sprite while the still-mounted declarative children survive, so the family
resources silently vanished; and the per-map ownership registry went stale
when a resource was removed outside component teardown (setStyle, external
removeSource/removeLayer), letting a later same-id resource be misclassified
as family-owned and wrongly adopted or deleted.

H7 — AbstractMapboxMapChild now keeps a standing style.load subscription
(the Mapbox-recommended hook for re-adding custom sources/layers when the
base style changes, fired once per style load) and re-runs its injection so
MapboxSource/Layer/Image/Cluster re-add themselves onto the fresh style. The
re-run reuses the first-injection containment and is guarded on the resource
already being present, so it never double-injects (at the initial style load
readyMap is not set yet, so only genuine later setStyle calls re-inject).
MapboxLayer keeps a standing sourcedata recovery watch (gated on its source
id) so a still-mounted layer re-commits when its source is removed then
re-added — e.g. a sibling MapboxSource teardown dropping it. MapboxCluster's
injection was made idempotent (guarded addSource/addLayer, off-before-on
listeners) so re-injection never duplicates a listener.

H6 — ownership entries now carry a liveness probe validated on read in
getMapboxOwner: sources and layers key it to the very object they added
(getSource/getLayer identity), so a resource wiped by setStyle or
removed/replaced externally reads as unowned and is pruned, rather than a
concurrency-fragile "clear the whole map on style reload" pass that would
race sibling re-injections. Sprites can only probe hasImage (no object
identity); that external-readd edge is documented as a remaining follow-up.

Extend the mock with setStyle (clears sources/layers/images + fires
style.load) and a sourcedata emit on addSource, and add style-reload.spec.ts
covering re-injection, layer recovery, and ownership self-healing. 162
MapboxMap tests pass (was 153).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NeZwHwo3d9rYsCxJUQqTep
Update the MapboxMap family and StoreLocator documentation, the
vue-mapbox-gl migration guide and the package README to match the
self-registering, orchestrator-based architecture:

- Document `registerMapboxComponents()` and per-component registration;
  drop the stale "registering MapboxMap alone mounts the children" and
  `config.components` claims.
- Fix the story `app.js` files to register every component they use.
- Add `MapboxCluster`/`MapboxClusterItem` and `registerMapboxComponents`
  API entries; remove spurious marker/popup option getters; add the
  `MapboxGeocoder` `result` event and `MapboxSource` `source` getter.
- Document `setStyle` layer persistence and the Boolean-option
  presence-not-value gotcha (geocoder `add-to-map`, StoreLocator sort).
- Rewrite the migration guide's cluster mapping/data section and mark
  StoreLocator as available (StoreLocatorItem removed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NeZwHwo3d9rYsCxJUQqTep
Drop the registerMapboxComponents convenience helper from
@studiometa/ui-mapbox. Now that every family component is self-registering
and resolves its parent map through the connected-event retry, registration
order is irrelevant, so document per-component registration with js-toolkit's
lazy importWhen* helpers as the recommended default and keep an eager
registerComponent example for completeness.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NeZwHwo3d9rYsCxJUQqTep
Convert the MapboxMap and StoreLocator js-api tables to the heading
format used across the other component docs, state current facts
without past/migration framing, link the usage guide instead of
restating generic js-toolkit behaviour, and de-duplicate the repeated
registration and lazy-loading notes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NeZwHwo3d9rYsCxJUQqTep
…component registration

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NeZwHwo3d9rYsCxJUQqTep
@titouanmathis
titouanmathis merged commit 7a02d05 into main Aug 2, 2026
20 checks passed
@titouanmathis
titouanmathis deleted the spike/mapbox-declarative branch August 2, 2026 09:06
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.

1 participant