Two parent-looks-for-child patterns found and removed, per the
child-emits-events-parent-never-reaches-in principle:
- leaflet-control-layers: #childLayers() queried children directly via
querySelectorAll + reading their leafletObject/attributes, in two call
sites. Verified empirically that a parent's connectedCallback always
completes before a freshly-connected child's does (even for an
already-built subtree attached in one shot), which means both call
sites always ran against unpopulated children -- dead code. Deleted;
#onChildRegister (already event-driven) was doing all the real work.
- leaflet-polygon/leaflet-polyline: #coords() queried <leaflet-line>
children via querySelectorAll + read their .latlng property directly,
using a MutationObserver + a payload-less event only as a "something
changed, rescan everyone" signal. Rewritten to be purely event-driven:
<leaflet-line> now fires leaflet-line-sync (connect + every lat/lng
change, carrying its own position) and leaflet-line-remove (disconnect)
on itself; a new shared VertexTracker (src/core/vertex-tracker.ts)
turns that event stream into an ordered coordinate list, using
compareDocumentPosition only to place a newly-registered vertex at its
real document position rather than assuming registration order matches
DOM order. No querySelectorAll, no MutationObserver, no reading a
child's property.
Testing the polygon rewrite in an actual browser (not jsdom) surfaced two
real bugs, both fixed:
1. disconnectedCallback fires *after* a node is already detached from its
parent, so leaflet-line's removal event had nowhere to bubble to.
Fixed by caching parentNode while still connected and dispatching from
that cached reference instead of from the (by-then-detached) node.
2. A load-order hazard affecting every "parent listens for a specific
child tag's announcement" relationship in this codebase:
customElements.define() upgrades every matching element already
parsed into the page immediately and synchronously, so whichever tag
gets defined first wins a race -- a child tag defined before its
listening parent fires its one-shot connect-time announcement into a
parent that doesn't exist yet, and it's lost for good. This broke
polygon/polyline (introduced by this rewrite, since leaflet-line was
exported before leaflet-polygon) and, independently, was already
silently broken for leaflet-control-layers (a base/overlay layer
leaked directly onto the map, bypassing the control entirely) and
latently for leaflet-layer-group/leaflet-feature-group. src/index.ts's
export order now encodes and documents the real dependency hierarchy
(map -> containers that listen for child announcements -> concrete
layer types -> their own children).
Added test/load-order.test.ts, which is structurally different from
every other test file: it never statically imports a component
module, building the DOM with plain undefined elements first and only
dynamically import()ing src/index.ts afterward -- reproducing a real
page's actual load order, which every other test's "define everything
first" pattern cannot catch. Confirmed it fails against the old
ordering and passes against the fix.
Two augmentations, so TypeScript actually knows about the custom elements:
- HTMLElementTagNameMap (src/index.ts): createElement/querySelector now
infer the exact component class for all 24 tags instead of HTMLElement.
- Per-component addEventListener/removeEventListener overrides, typing
`leaflet:<name>` events against the real Leaflet event payload
(PopupEvent, DragEndEvent, LeafletMouseEvent, etc.) while still accepting
ordinary DOM events normally, and rejecting event names that component
doesn't fire.
src/core/event-types.ts holds reusable event-name -> payload-type
fragments (mirroring shared-props.ts's fragment reuse), composed per
family: MapEvents, MarkerEvents, PathEvents, TileLayerEvents,
DivOverlayLayerEvents, GroupEvents. LeafletAddEventListener<T>/
LeafletRemoveEventListener<T> in with-props.ts are type-only
intersection-of-overloads helpers applied via `declare addEventListener:
...`, the same pattern every component already uses for `declare readonly
leafletObject?: X` -- zero runtime cost. Deliberately no generic `string`
fallback overload: a fallback would silently accept unrecognized
`leaflet:*` names too, defeating the point.
Fixed a real bug found while building this: #forwardEvents was
dispatching the raw pre-merge `data` Leaflet passes to fire(), missing
type/target/sourceTarget that Leaflet's own fire() merges in before
notifying real .on() listeners. Typing `detail` against Leaflet's actual
event interfaces would have been dishonest otherwise, so the merge now
matches Leaflet's own Evented#fire.
Also added 'line-updated' to the existing internal-event HTMLElementEventMap
augmentation in register.ts (needed once addEventListener got overridden
on polygon/polyline, which use it internally) and cleaned up ~35 now-
redundant `as HTMLElement & {...}` casts across the test suite that the
tag name map makes unnecessary.
No test coverage existed before this. Adds vitest + jsdom (2 new
devDependencies) and a suite that runs entirely without a browser: real
Leaflet objects work fine under jsdom for everything this library needs to
verify (option/attribute wiring, event forwarding), given a ResizeObserver
stub in test/setup.ts (jsdom's only real gap here).
- test/core/with-props.test.ts: the WithProps mixin itself, against a fake
Leaflet-like class -- attribute<->setter dispatch, get/attribute/default
fallback order, event-driven attribute sync-back, positional/recreate/
attach modes, and the generic fire()-patch event forwarding. Child
registration (popup/tooltip/layer binding) uses real Popup/Tooltip/Marker
instances since #onChildRegister discriminates by instanceof.
- test/core/props.test.ts: the codec functions in isolation.
- test/components/*.test.ts: grouped smoke tests across all components.
- test/integration.test.ts: full tree wiring (map + tile-layer +
feature-group + marker + popup).
Caught and fixed one real bug along the way: urlProp's "live url getter"
from the last refactor was dead code -- neither ImageOverlay nor
VideoOverlay actually expose getUrl(). Removed it and the now-pointless
getUrl?() from the Sourced interface in shared-props.ts.
tsconfig.test.json keeps test/ out of the tsc build (dist/ stays
test-free) while still typechecking it; oxlint.config.ts now covers test/
too, with max-classes-per-file relaxed there since testing a class
factory means many small one-off element classes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Finishes the WithProps(PROPS, options) mixin (src/core/with-props.ts): every
component gets reactive attributes, live-value property getters that read
through to the Leaflet object where possible, and every Leaflet event
re-emitted on the element as `leaflet:<type>` via a generic fire() patch (no
per-component or per-event registration needed).
Prop coverage:
- Centralized pathProps in shared-props.ts (stroke, lineCap, lineJoin,
dashArray, dashOffset, fillRule, interactive, className,
bubblingMouseEvents, pane), deduped leaflet-geojson against it.
- Added live getters backed by Leaflet's own accessors: radius (circle,
circle-marker), bounds (rectangle, image/video/svg-overlay), url
(image/video-overlay).
- Filled gaps: smoothFactor/noClip on polyline; a shared tileLayerProps
fragment now used by both tile-layer and tile-layer-wms (WMS previously
exposed none of its base tile options); crs on WMS; zIndex/className/
keepAspectRatio/errorOverlayUrl on video-overlay; fixed tile-layer's
zIndex default and div-icon's className default to match Leaflet.
Tooling:
- Removed dead src/core/events.ts (broken, unused, superseded by the
generic event forwarding above) and src/core/attributes.ts (emptied by
an earlier rename, nothing imported it).
- Swapped ESLint + @typescript-eslint for oxlint: no released
@typescript-eslint version supports the pinned typescript@7, even for
parsing alone. oxlint has its own parser and lints clean.
- Dropped the Rollup CJS/UMD bundle step; dist/ is ESM-only from tsc now.
Updated package.json's main/module/exports/unpkg accordingly.
- Updated CLAUDE.md to match: build/lint commands, ESM-only output, the
event-forwarding mechanism, and layer-group/feature-group now going
through WithProps({}) instead of raw HTMLElement.
Removed the utils.ts file. The functions in there either should have
been a part of the register.ts or props.ts files. The only functions
left related to attributes so utils.ts was renamed to attributes.ts
Upgrade ESLint config to use flat config with @typescript-eslint/strict-type-checked and stylistic-type-checked presets.
- Use defineConfig helper, scope to .ts files only
- Replace || with ??, convert type to interface, remove redundant type assertions and non-null assertions
- Annotate intentional violations (unbound-method, non-null-assertion) with eslint-disable
- Use querySelectorAll<T> for typed selection, Map over globalThis.Map
- Add @eslint/js dev dependency
WithProps is tightly coupled to PropDef types, so it belongs in
props.ts alongside defineProps and the type definitions.
- Move WithProps, definePropAccessors, and Ctor to props.ts
- Import PropTypesFromTable in utils.ts (used by buildOptions)
- Update all 18 component imports to source WithProps from props.ts
Change ChildEntry from { type: 'layer'|'popup'|'tooltip' } to just
the union string, eliminating the wrapper object. Update Map.set calls
in createChildRegisterHandler and destructuring in leaflet-layer-group,
leaflet-feature-group, and leaflet-geojson disconnectedCallback.
The discovery handshake (marker dispatching leaflet-request-icon on
children, icons listening and responding with icon-changed) existed
to handle the edge case where icon definitions register before marker
definitions. Since bundled imports register all components in deterministic
order and deep imports are an advanced use case left to the consumer,
remove the event entirely. icon-changed bubbling alone covers all
practical timing scenarios.
- Remove LeafletRequestIconEvent type and HTMLElementEventMap entry
- Remove #onRequest handler and addEventListener/removeEventListener
calls from both leaflet-icon and leaflet-div-icon
- Remove child-iteration dispatch loop from leaflet-marker
- Strip leaflet-icon.ts to image-only (new Icon(), remove div/html/bgPos)
- Create leaflet-div-icon.ts with DivIcon, innerHTML content, MutationObserver
- Convert marker↔icon from querySelector to event-only protocol:
marker iterates children dispatching leaflet-request-icon on each,
icons listen on themselves and respond with icon-changed
- Extract emitIconChanged() helper into register.ts to eliminate
repeated CustomEvent construction across both icon components
- Type icon-changed and leaflet-request-icon in HTMLElementEventMap
- Update marker's #onIconChanged to use typed event instead of cast
- Export LeafletDivIcon from index.ts
- Add DivIcon demo markers to custom-icon.html and index.html
Replace the #map getter (which used closest("leaflet-map") + leafletObject)
with two new custom events: leaflet-add-layer and leaflet-remove-layer.
The map listens for them and delegates to map.addLayer/removeLayer.
This removes the coupling between leaflet-control-layers and the map
element, aligning with the existing leaflet-register event protocol.
Replace Number(x) with +x, String(x) with template literal,
parseInt(x, 10) with +, and parseFloat(x || "0") with +(x ?? 0).
Fix operator precedence with parentheses around ?? expressions.
Merge path-style.ts and with-props.ts into utils.ts to reduce the
number of core files from 5 to 3 (utils.ts, props.ts, register.ts).
Unify duplicate ../core/utils.ts imports in all component files.
Remove stale re-exports from src/index.ts.
Make NumProp and BoolOffProp generic (NumProp<T>, BoolOffProp<T>)
instead of using any, with T inferred from callback parameters at
call sites via the num<T>() and off<T>() factory helpers. Remove
unused num import from leaflet-tile-layer-wms. Fix prettier
formatting issues.
Extend num() factory to accept optional extra fields (mapGet, mapSet,
viewState, event). Add off() factory and BoolOffPropInput type. Update
defineProps to derive disable- prefix for bool-off attrs. Remove
~40 lines of local type definitions from leaflet-map.ts.
Add num(), str(), on() factory functions and defineProps() wrapper
to core/props.ts. Each helper returns a PropDef fragment without
'attr'; defineProps fills it in via camelToKebab(key), with an
optional override for edge cases like playsInline->playsinline.
Input types are derived from their canonical counterparts via
OptionalAttr<T> = Omit<T, 'attr'> & { attr?: string }, keeping
definitions in sync automatically.
Net effect: ~3800 chars removed across 18 component files, no
behavior change, full type inference preserved.
Change all import specifiers from ".js" to ".ts" in TypeScript source
files. Add allowImportingTsExtensions and rewriteRelativeImportExtensions
to tsconfig so tsc strips them to ".js" in the dist output — no build
pipeline changes needed. Update CLAUDE.md to reflect the new extension policy.
The types/ directory now only had a single file. The PropDef and
PropTypesFromTable types are foundational to the module infrastructure
(used by utils.ts, with-props.ts, and every component), so core/ is a
better home. Deleted the empty types/ directory.
- camelCase removed entirely (never imported anywhere)
- PATH_STYLE_ATTRS, ChildEntry, createChildRegisterHandler made private
(used only internally within their modules)
- src/types/css.d.ts deleted (leftover from an earlier
approach — no .css imports exist in the source)
Use a const type parameter on the exclude array so literal tuples narrow
correctly. The return type now reflects the actual properties and their
kinds (number/string/boolean) instead of Record<string, unknown>.
Four call sites needed local casts where PROPS declares str but Leaflet
expects a narrower type (crossOrigin, offset) — these are design-level
mismatches between HTML attribute types and Leaflet's option types.
Adds buildAttrMap(props) and setLayerAttr(obj, props, attrMap,
name, val) to utils.ts. The former replaces the PROP_BY_ATTR Map
construction (2 fewer lines per component), the latter replaces
the 6-line dynamic setter dispatch pattern used as the fallback
else block in 6 components.
For WMS, setLayerAttr returns false when no setter exists, so the
caller can fall back to setParams for non-method props.
Replaces the identical private #parsedBounds method in rectangle,
image-overlay, video-overlay, and svg-overlay with a call to
parseBoundsAttr(el) from utils.ts. Removes 4 x 5 = 20 lines
of method definitions.
Adds registerChildren / unregisterChildren / getChildren to
register.ts using module-scoped WeakMaps, removing the duplicated
#children / #childHandler fields + lifecycle wiring from 15
components.
registerChildren(el, layer) creates the children Map + handler,
stores both in WeakMaps, wires the event listener, and calls
registerWithParent — all in one call. unregisterChildren(el)
tears it down. getChildren(el) gives access to the map for
components that need custom child iteration in disconnectedCallback
(geojson, layer-group, feature-group).
Net: -101 lines, 15 components lose 2-4 fields + 4-8 lifecycle
lines each. Components with extra lifecycle work (marker -> dragend,
polygon/polyline -> observer + syncCoords) keep their custom
additions inline, just the register boilerplate is replaced.
Removes the identical private #num method from 5 components
(leaflet-circle, leaflet-circle-marker, leaflet-marker,
leaflet-popup, leaflet-tooltip). Replaces with numAttr(this, PROPS, name)
call to the new helper in utils.ts.
18 components now use withProps(HTMLElement, PROPS) instead of
repeating TypedBase cast, static get observedAttributes(), and
static { definePropAccessors(...) } in each file.
Adds src/core/with-props.ts — a mixin factory that derives
PropTypesFromTable, defines property accessors, and sets up
observedAttributes from a PROPS table in a single expression.
Augment HTMLElementEventMap with 'leaflet-register' so that
addEventListener('leaflet-register', handler) natively accepts
(e: LeafletRegisterEvent) => void, removing the need for
as EventListener casts in all 14 consumer components.
All 17 TypedBase components now share a single definePropAccessors(proto, PROPS)
call in their static block instead of duplicating the getter/setter loop.
Also includes pre-existing cleanup:
- Refine LeafletRegisterEvent type (interface -> generic CustomEvent)
- Drop unused attrToPropName helper
- Fix createChildRegisterHandler return type in layer-group/feature-group
buildOptions was passing default values for absent attributes,
including empty-string defaults like pane:'', which caused Leaflet to
look up this._panes[''] and return undefined, crashing in appendChild.
- Skip empty-string defaults in buildOptions so Leaflet uses its own
- Broaden createChildRegisterHandler param from LayerGroup to Layer so
non-container layers (marker, circle, etc.) can intercept popup/tooltip
children and call bindPopup/bindTooltip instead of bubbling to the map
- Add child-registration handler to every layer component (all 12)
- Fix disconnectedCallback in container components to unbind popups and
tooltips and remove child layers properly
Remove LeafletElement and LeafletControl base classes. Every component
now self-describes its attributes via a PROPS table, derives observed-
Attributes from it, and generates prototype getters/setters in static {}.
Registration lifecycle is extracted into standalone helpers
(registerWithParent, createChildRegisterHandler). Common update
patterns (path style) are shared via imported helpers instead of
duplicated across 5+ components.
Components manage their own #obj private field instead of a shared
protected leafletObject. Leaflet-map-style declarative patterns are
now consistent across all 20 components.
Wraps Leaflet.js as native Web Components. Each element maps 1:1 to a
Leaflet object with reactive attribute binding and proper lifecycle cleanup.
Key design decisions:
- LeafletElement base class handles attribute→option mapping, the
leaflet-register bubble event for parent/child wiring, and
disconnectedCallback cleanup tracking parent bindings.
- LeafletControl base class for map controls (zoom, attribution, scale).
- leaflet-map uses a PROPS table to drive observedAttributes, getters/setters,
attributeChangedCallback, and map event listeners from a single source of
truth. Property types are derived via a mapped type over the table; the
mixin-base pattern (TypedBase) makes them visible to TypeScript without an
interface merge.
- Boolean map options that default to true use disable-* attributes.
- Leaflet and its CSS are bundled into dist/index.js via esbuild.
- JSR config included for @buddy/leaflet-components.