fix: remove parent-reaches-into-child patterns, fix a real load-order bug

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.
main
Buddy 4 weeks ago
parent 255d10499b
commit 3845e53329

@ -21,6 +21,7 @@ Tests run under Vitest + jsdom (`test/**/*.test.ts`), with a single setup file (
- Some Leaflet DOM state (the `<img>`/`<video>` element behind an overlay, a marker's `dragging` handler) is only created in `onAdd()`, i.e. once the layer is actually added to a real map -- tests touching that state append through a `<leaflet-map>` rather than standalone.
- `test/core/with-props.test.ts` covers the `WithProps` mixin itself against a fake Leaflet-like class (no real Leaflet needed for most of it) -- the one exception is child registration (popup/tooltip/layer binding), which needs real `Popup`/`Tooltip`/`Layer` instances because `#onChildRegister` discriminates by `instanceof`, not duck typing.
- `test/load-order.test.ts` is structurally different from every other test file on purpose: it never statically imports a component module, so nothing is `customElements.define`d until it dynamically `import()`s `src/index.ts` partway through -- after building the DOM tree with plain, undefined elements first. This is the only test that reproduces a real page's actual load order (markup parsed, _then_ the deferred module script defines everything) rather than the "components already defined before any element is created" order every other test uses. That distinction is exactly what caught the `src/index.ts` export-order bug described below -- if you add a component with a similar "parent listens for a child's announcement" relationship, extend this test rather than trusting the others to catch an ordering regression, since they structurally can't.
`test/**` is excluded from the main `tsconfig.json` (so test code never ends up in `dist/`) and typechecked separately via `tsconfig.test.json`.
@ -55,12 +56,12 @@ All non-map components extend `WithProps(PROPS, options?)`. To add a new compone
2. Declare `class LeafletFoo extends WithProps(PROPS)` implementing `createLeafletObject(options): L.Layer`, and `declare readonly leafletObject?: TheLeafletClass;` (the mixin's own inference of the object type from the PROPS table alone isn't reliable enough to skip this).
3. Provide a `set` function on individual `PropDef`s only where the default setter-based dispatch won't work (common for coordinate pairs — see `latLngProps` in shared-props.ts).
4. Call `customElements.define('leaflet-foo', LeafletFoo)` at the bottom.
5. Export from `src/index.ts`, and add the tag to the `HTMLElementTagNameMap` augmentation at the bottom of that file (needs its own `import type` — a re-export doesn't bind the name locally).
5. Export from `src/index.ts`, and add the tag to the `HTMLElementTagNameMap` augmentation at the bottom of that file (needs its own `import type` — a re-export doesn't bind the name locally). **Export order in `src/index.ts` is load-bearing**, not cosmetic: if the new component listens for a bubbling announcement from some other tag (a `leaflet-register`-style child, or something like `leaflet-line-sync`), it must be exported _before_ that other tag. `customElements.define()` upgrades every matching element already in the document immediately, so on a real static-HTML page, whichever tag gets defined first wins the race — a child tag defined before its listening parent will fire its one-shot connect-time announcement into a parent that doesn't exist yet, and that announcement is gone for good. See the comment at the top of `index.ts` for the full ordering and worked example.
6. If the component fires meaningful Leaflet events (beyond nothing), compose or reuse an event map in `src/core/event-types.ts` and add `declare addEventListener: LeafletAddEventListener<TheEvents>;` / `declare removeEventListener: LeafletRemoveEventListener<TheEvents>;` (see "Typing a component's events" below).
### Special cases
- **`leaflet-polygon`** uses `<leaflet-line>` children for vertices. The polygon collects lat/lng from child `leaflet-line` elements rather than having them as direct attributes.
- **`leaflet-polygon`** / **`leaflet-polyline`** take their vertices from `<leaflet-line>` children rather than an attribute. This is purely event-driven, not a lookup: `<leaflet-line>` fires `leaflet-line-sync` (on connect and on every lat/lng change) and `leaflet-line-remove` (on disconnect) on itself, each carrying its own position (`src/core/register.ts`); the polygon/polyline never reads a child's property or queries the DOM for them. `src/core/vertex-tracker.ts`'s `VertexTracker` (shared by both) turns that event stream into an ordered coordinate list, inserting a newly-registered vertex at its actual document position via `compareDocumentPosition` rather than assuming registration order matches DOM order. One non-obvious wrinkle `<leaflet-line>` has to work around: `disconnectedCallback` fires _after_ a node is already detached from its parent, so a bubbling dispatch from the node itself has nowhere to go on removal — it caches `parentNode` in `connectedCallback` and dispatches `leaflet-line-remove` from that cached reference instead.
- **`leaflet-popup`** / **`leaflet-tooltip`**: content comes from `innerHTML`, not attributes. `leaflet-popup` watches for DOM mutations to keep Leaflet in sync.
- **`leaflet-layer-group`** / **`leaflet-feature-group`**: passthrough containers built with `WithProps({})` (an empty props table) — they have no options of their own, but still get the standard lifecycle, child registration, and `leaflet:` event forwarding for free. Children register themselves into them via the standard bubble mechanism.

@ -1,15 +1,8 @@
import { Control, type ControlPosition, type Layer } from 'leaflet';
import { Control, type ControlPosition } from 'leaflet';
import { bool, choice } from '../core/props.ts';
import { WithProps } from '../core/with-props.ts';
import type { LeafletRegisterEvent } from '../core/register.ts';
interface ChildLayer {
layer: Layer;
name: string;
base: boolean;
active: boolean;
}
export class LeafletControlLayers extends WithProps(
{
position: choice<ControlPosition>('topright'),
@ -22,26 +15,12 @@ export class LeafletControlLayers extends WithProps(
) {
declare readonly leafletObject?: Control.Layers;
// Children are read straight off the DOM rather than through registration:
// any that already exist are listed as base layers or overlays by their
// `name`, and `type="base"` / `active` decide how.
createLeafletObject(options: Control.LayersOptions): Control.Layers {
const baseLayers: Record<string, Layer> = {};
const overlays: Record<string, Layer> = {};
for (const child of this.#childLayers()) {
(child.base ? baseLayers : overlays)[child.name] = child.layer;
}
return new Control.Layers(baseLayers, overlays, options);
return new Control.Layers({}, {}, options);
}
connectedCallback(): void {
super.connectedCallback();
for (const child of this.#childLayers()) {
if (child.active) continue;
this.dispatchEvent(
new CustomEvent('leaflet-remove-layer', { bubbles: true, detail: { layer: child.layer } }),
);
}
this.addEventListener('leaflet-register', this.#onChildRegister);
}
@ -50,23 +29,11 @@ export class LeafletControlLayers extends WithProps(
super.disconnectedCallback();
}
#childLayers(): ChildLayer[] {
const children: ChildLayer[] = [];
for (const child of this.querySelectorAll(':scope > *')) {
const layer = (child as { leafletObject?: Layer }).leafletObject;
const name = child.getAttribute('name');
if (!name || !layer) continue;
children.push({
layer,
name,
base: child.getAttribute('type') === 'base',
active: child.hasAttribute('active'),
});
}
return children;
}
// Children that connect after us announce themselves instead.
// Every child -- present at construction or added later -- announces
// itself this way: a parent's connectedCallback always finishes before a
// child's does (even for an already-built subtree attached in one go), so
// there is no "read the children that are already here" case to handle
// separately.
#onChildRegister = (e: LeafletRegisterEvent) => {
e.stopPropagation();
const el = e.detail.element;

@ -1,14 +1,30 @@
import { emitLineRemove, emitLineSync } from '../core/register.ts';
export class LeafletLine extends HTMLElement {
static get observedAttributes() {
return ['lat', 'lng'];
}
// Cached because disconnectedCallback fires after this is already detached
// from it -- see emitLineRemove.
#parent: ParentNode | null = null;
get latlng(): [number, number] {
return [+(this.getAttribute('lat') ?? 0), +(this.getAttribute('lng') ?? 0)];
}
attributeChangedCallback() {
this.dispatchEvent(new CustomEvent('line-updated', { bubbles: true }));
connectedCallback(): void {
this.#parent = this.parentNode;
emitLineSync(this, this.latlng);
}
attributeChangedCallback(): void {
emitLineSync(this, this.latlng);
}
disconnectedCallback(): void {
emitLineRemove(this.#parent ?? this, this);
this.#parent = null;
}
}

@ -5,44 +5,45 @@ import {
type LeafletAddEventListener,
type LeafletRemoveEventListener,
} from '../core/with-props.ts';
import type { LeafletLine } from './leaflet-line.ts';
import type { PathEvents } from '../core/event-types.ts';
import type { LeafletLineRemoveEvent, LeafletLineSyncEvent } from '../core/register.ts';
import { VertexTracker } from '../core/vertex-tracker.ts';
export class LeafletPolygon extends WithProps({ ...pathProps }) {
declare readonly leafletObject?: Polygon;
declare addEventListener: LeafletAddEventListener<PathEvents>;
declare removeEventListener: LeafletRemoveEventListener<PathEvents>;
#observer?: MutationObserver;
#vertices = new VertexTracker();
createLeafletObject(options: PolylineOptions): Polygon {
return new Polygon(this.#coords(), options);
return new Polygon(this.#vertices.coords(), options);
}
// Vertices come from <leaflet-line> children rather than an attribute, so we
// re-read them whenever one is added, removed or moved.
// Vertices come from <leaflet-line> children rather than an attribute --
// each one announces its own position via leaflet-line-sync/-remove, we
// never read a child's state directly.
connectedCallback(): void {
super.connectedCallback();
this.addEventListener('line-updated', this.#syncCoords);
this.#observer = new MutationObserver(this.#syncCoords);
this.#observer.observe(this, { childList: true });
this.addEventListener('leaflet-line-sync', this.#onLineSync);
this.addEventListener('leaflet-line-remove', this.#onLineRemove);
}
disconnectedCallback(): void {
this.#observer?.disconnect();
this.#observer = undefined;
this.removeEventListener('line-updated', this.#syncCoords);
this.removeEventListener('leaflet-line-sync', this.#onLineSync);
this.removeEventListener('leaflet-line-remove', this.#onLineRemove);
super.disconnectedCallback();
}
#syncCoords = () => {
this.leafletObject?.setLatLngs(this.#coords());
#onLineSync = (e: LeafletLineSyncEvent) => {
this.#vertices.sync(e.detail.element, e.detail.latlng);
this.leafletObject?.setLatLngs(this.#vertices.coords());
};
#coords(): [number, number][] {
const lines = Array.from(this.querySelectorAll<LeafletLine>('leaflet-line'));
return lines.map((line) => line.latlng);
}
#onLineRemove = (e: LeafletLineRemoveEvent) => {
this.#vertices.remove(e.detail.element);
this.leafletObject?.setLatLngs(this.#vertices.coords());
};
}
customElements.define('leaflet-polygon', LeafletPolygon);

@ -6,8 +6,9 @@ import {
type LeafletAddEventListener,
type LeafletRemoveEventListener,
} from '../core/with-props.ts';
import type { LeafletLine } from './leaflet-line.ts';
import type { PathEvents } from '../core/event-types.ts';
import type { LeafletLineRemoveEvent, LeafletLineSyncEvent } from '../core/register.ts';
import { VertexTracker } from '../core/vertex-tracker.ts';
export class LeafletPolyline extends WithProps({
...pathProps,
@ -20,36 +21,36 @@ export class LeafletPolyline extends WithProps({
declare addEventListener: LeafletAddEventListener<PathEvents>;
declare removeEventListener: LeafletRemoveEventListener<PathEvents>;
#observer?: MutationObserver;
#vertices = new VertexTracker();
createLeafletObject(options: PolylineOptions): Polyline {
return new Polyline(this.#coords(), options);
return new Polyline(this.#vertices.coords(), options);
}
// Vertices come from <leaflet-line> children rather than an attribute, so we
// re-read them whenever one is added, removed or moved.
// Vertices come from <leaflet-line> children rather than an attribute --
// each one announces its own position via leaflet-line-sync/-remove, we
// never read a child's state directly.
connectedCallback(): void {
super.connectedCallback();
this.addEventListener('line-updated', this.#syncCoords);
this.#observer = new MutationObserver(this.#syncCoords);
this.#observer.observe(this, { childList: true });
this.addEventListener('leaflet-line-sync', this.#onLineSync);
this.addEventListener('leaflet-line-remove', this.#onLineRemove);
}
disconnectedCallback(): void {
this.#observer?.disconnect();
this.#observer = undefined;
this.removeEventListener('line-updated', this.#syncCoords);
this.removeEventListener('leaflet-line-sync', this.#onLineSync);
this.removeEventListener('leaflet-line-remove', this.#onLineRemove);
super.disconnectedCallback();
}
#syncCoords = () => {
this.leafletObject?.setLatLngs(this.#coords());
#onLineSync = (e: LeafletLineSyncEvent) => {
this.#vertices.sync(e.detail.element, e.detail.latlng);
this.leafletObject?.setLatLngs(this.#vertices.coords());
};
#coords(): [number, number][] {
const lines = Array.from(this.querySelectorAll<LeafletLine>('leaflet-line'));
return lines.map((line) => line.latlng);
}
#onLineRemove = (e: LeafletLineRemoveEvent) => {
this.#vertices.remove(e.detail.element);
this.leafletObject?.setLatLngs(this.#vertices.coords());
};
}
customElements.define('leaflet-polyline', LeafletPolyline);

@ -12,9 +12,14 @@ export type LeafletLayerEvent = CustomEvent<{ layer: Layer }>;
export type LeafletIconChangedEvent = CustomEvent<{ icon: Icon | null }>;
// Fired by <leaflet-line> on itself when its lat/lng changes; polygon/polyline
// listen for it (bubbling) to know when to re-read their vertices.
export type LeafletLineUpdatedEvent = CustomEvent<null>;
// Fired by <leaflet-line> on itself -- on connect and on every lat/lng
// change -- carrying its own current position. polygon/polyline listen for
// this (bubbling) to track vertices without ever reading a child's state
// directly.
export type LeafletLineSyncEvent = CustomEvent<{ element: HTMLElement; latlng: [number, number] }>;
// Fired by <leaflet-line> on disconnect, so a listening parent can drop it.
export type LeafletLineRemoveEvent = CustomEvent<{ element: HTMLElement }>;
declare global {
interface HTMLElementEventMap {
@ -22,7 +27,8 @@ declare global {
'leaflet-add-layer': LeafletLayerEvent;
'leaflet-remove-layer': LeafletLayerEvent;
'icon-changed': LeafletIconChangedEvent;
'line-updated': LeafletLineUpdatedEvent;
'leaflet-line-sync': LeafletLineSyncEvent;
'leaflet-line-remove': LeafletLineRemoveEvent;
}
}
@ -35,6 +41,22 @@ export function emitIconChanged(el: HTMLElement, icon: Icon | DivIcon | null | u
);
}
export function emitLineSync(el: HTMLElement, latlng: [number, number]): void {
el.dispatchEvent(
new CustomEvent('leaflet-line-sync', { bubbles: true, detail: { element: el, latlng } }),
);
}
// Dispatched on `from`, not `el`: by the time disconnectedCallback runs, `el`
// has already been detached from its parent, so a bubbling dispatch from
// `el` itself would have nowhere to bubble to. Callers pass the parent they
// cached while still connected.
export function emitLineRemove(from: EventTarget, el: HTMLElement): void {
from.dispatchEvent(
new CustomEvent('leaflet-line-remove', { bubbles: true, detail: { element: el } }),
);
}
// Dispatches a custom `leaflet-register` event upward through the DOM
// tree, carrying a Leaflet object and its host element. Parent components
// (map, circles, groups, etc.) intercept this event and add the layer,

@ -0,0 +1,37 @@
// Tracks an ordered set of vertices contributed by <leaflet-line> children,
// built entirely from the leaflet-line-sync/leaflet-line-remove events they
// fire (see register.ts) -- never by querying the DOM or reading a child's
// property directly. Shared by leaflet-polygon and leaflet-polyline.
//
// Order matters (it's the vertex sequence), so membership changes are
// inserted at their actual document position via compareDocumentPosition --
// that's a structural question about the tree, not a read of child state,
// and there's no other sane source of truth for "which vertex comes first."
export type LatLngTuple = [number, number];
export class VertexTracker {
#vertices = new Map<HTMLElement, LatLngTuple>();
#order: HTMLElement[] = [];
sync(element: HTMLElement, latlng: LatLngTuple): void {
if (!this.#vertices.has(element)) this.#insertOrdered(element);
this.#vertices.set(element, latlng);
}
remove(element: HTMLElement): void {
this.#vertices.delete(element);
this.#order = this.#order.filter((el) => el !== element);
}
coords(): LatLngTuple[] {
return this.#order.map((el) => this.#vertices.get(el)!);
}
#insertOrdered(el: HTMLElement): void {
const idx = this.#order.findIndex(
(existing) => (existing.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_PRECEDING) !== 0,
);
if (idx === -1) this.#order.push(el);
else this.#order.splice(idx, 0, el);
}
}

@ -1,22 +1,43 @@
// Export order here is load-bearing, not cosmetic: customElements.define()
// upgrades every matching element already in the document immediately and
// synchronously (including running connectedCallback if already connected),
// so a "child" custom element that fires a one-shot announcement on connect
// must be defined *after* every "container" type that needs to hear it --
// otherwise every instance already in a static HTML page upgrades and
// announces itself before the listening parent exists, and that
// announcement is lost for good (connectedCallback never fires again for an
// element that stays connected). This is the same reason leaflet-map is
// exported first: nothing can register with it before it exists to listen.
//
// The hierarchy, root to leaf:
// LeafletMap
// -> LeafletControlLayers, LeafletLayerGroup, LeafletFeatureGroup
// (all listen for "leaflet-register" from arbitrary layer children)
// -> every concrete layer type (Marker, Circle, Polygon, TileLayer, ...)
// -> LeafletLine (child of Polygon/Polyline specifically)
// -> LeafletPopup, LeafletTooltip (child of any layer)
// -> LeafletIcon, LeafletDivIcon (child of Marker specifically)
// Standalone controls (Zoom/Attribution/Scale) have no such relationship and
// can go anywhere.
export * from './core/register.ts';
export * from './core/props.ts';
export { LeafletMap } from './components/leaflet-map.ts';
export { LeafletControlLayers } from './components/leaflet-control-layers.ts';
export { LeafletLayerGroup } from './components/leaflet-layer-group.ts';
export { LeafletFeatureGroup } from './components/leaflet-feature-group.ts';
export { LeafletPolygon } from './components/leaflet-polygon.ts';
export { LeafletPolyline } from './components/leaflet-polyline.ts';
export { LeafletMarker } from './components/leaflet-marker.ts';
export { LeafletCircle } from './components/leaflet-circle.ts';
export { LeafletCircleMarker } from './components/leaflet-circle-marker.ts';
export { LeafletLine } from './components/leaflet-line.ts';
export { LeafletPolygon } from './components/leaflet-polygon.ts';
export { LeafletPolyline } from './components/leaflet-polyline.ts';
export { LeafletRectangle } from './components/leaflet-rectangle.ts';
export { LeafletTileLayer } from './components/leaflet-tile-layer.ts';
export { LeafletTileLayerWMS } from './components/leaflet-tile-layer-wms.ts';
export { LeafletImageOverlay } from './components/leaflet-image-overlay.ts';
export { LeafletVideoOverlay } from './components/leaflet-video-overlay.ts';
export { LeafletSVGOverlay } from './components/leaflet-svg-overlay.ts';
export { LeafletLayerGroup } from './components/leaflet-layer-group.ts';
export { LeafletFeatureGroup } from './components/leaflet-feature-group.ts';
export { LeafletGeoJSON } from './components/leaflet-geojson.ts';
export { LeafletControlLayers } from './components/leaflet-control-layers.ts';
export { LeafletLine } from './components/leaflet-line.ts';
export { LeafletControlZoom } from './components/leaflet-control-zoom.ts';
export { LeafletControlAttribution } from './components/leaflet-control-attribution.ts';
export { LeafletControlScale } from './components/leaflet-control-scale.ts';
@ -26,7 +47,8 @@ export { LeafletIcon } from './components/leaflet-icon.ts';
export { LeafletDivIcon } from './components/leaflet-div-icon.ts';
// `export { X } from '...'` re-exports X but doesn't bind it locally, so the
// tag map below needs its own type-only imports of the same classes.
// tag map below needs its own type-only imports of the same classes. Order
// doesn't matter here -- these are erased, no customElements.define behind them.
import type { LeafletMap } from './components/leaflet-map.ts';
import type { LeafletMarker } from './components/leaflet-marker.ts';
import type { LeafletCircle } from './components/leaflet-circle.ts';

@ -109,6 +109,33 @@ describe('leaflet-polygon', () => {
]);
el.remove();
});
it('tracks vertices added or removed after connecting, in DOM order', () => {
const el = document.createElement('leaflet-polygon');
const line1 = document.createElement('leaflet-line');
line1.setAttribute('lat', '1');
line1.setAttribute('lng', '2');
el.append(line1);
document.body.append(el);
expect(el.leafletObject?.getLatLngs()).toEqual([[{ lat: 1, lng: 2 }]]);
// Inserted before line1 -- should come first despite registering second.
const line0 = document.createElement('leaflet-line');
line0.setAttribute('lat', '5');
line0.setAttribute('lng', '6');
el.insertBefore(line0, line1);
expect(el.leafletObject?.getLatLngs()).toEqual([
[
{ lat: 5, lng: 6 },
{ lat: 1, lng: 2 },
],
]);
line0.remove();
expect(el.leafletObject?.getLatLngs()).toEqual([[{ lat: 1, lng: 2 }]]);
el.remove();
});
});
describe('leaflet-polyline', () => {

@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest';
// A real page has all its <leaflet-*> markup already parsed by the browser's
// HTML parser before the deferred module script (which calls
// customElements.define for every tag) ever runs. customElements.define()
// upgrades every matching element already in the document immediately and
// synchronously -- including firing connectedCallback -- so a "child" tag
// whose one-shot connect-time announcement is missed because its listening
// "parent" tag wasn't defined yet is broken in exactly this scenario, even
// though it looks fine in every other test in this suite (they all import
// the component modules -- and so call customElements.define -- before
// creating any element, which sidesteps the whole problem).
//
// This file deliberately does NOT statically import any component module,
// so nothing here is defined yet when the tree below is built.
describe('real page load order (markup before customElements.define)', () => {
it('wires control-layers, layer-group and polygon/line correctly', async () => {
const map = document.createElement('leaflet-map');
map.setAttribute('lat', '51.5');
map.setAttribute('lng', '-0.09');
map.setAttribute('zoom', '13');
const controlLayers = document.createElement('leaflet-control-layers');
const baseTile = document.createElement('leaflet-tile-layer');
baseTile.setAttribute('type', 'base');
baseTile.setAttribute('name', 'Base');
baseTile.setAttribute('active', '');
baseTile.setAttribute('url', 'https://tile.example/{z}/{x}/{y}.png');
controlLayers.append(baseTile);
const group = document.createElement('leaflet-layer-group');
const groupedMarker = document.createElement('leaflet-marker');
groupedMarker.setAttribute('lat', '1');
groupedMarker.setAttribute('lng', '2');
group.append(groupedMarker);
const polygon = document.createElement('leaflet-polygon');
const vertices = [
['51.509', '-0.08'],
['51.503', '-0.06'],
['51.51', '-0.047'],
].map(([lat, lng]) => {
const line = document.createElement('leaflet-line');
line.setAttribute('lat', lat);
line.setAttribute('lng', lng);
return line;
});
polygon.append(...vertices);
map.append(controlLayers, group, polygon);
document.body.append(map);
// Nothing is upgraded yet -- these are plain, undefined elements.
expect(map.leafletObject).toBeUndefined();
// Mirrors index.ts's real export order, which is what actually runs when
// a page does `import 'leaflet-components'`.
const lc = await import('../src/index.ts');
expect(map).toBeInstanceOf(lc.LeafletMap);
expect(map.leafletObject).toBeDefined();
expect(polygon.leafletObject?.getLatLngs()).toEqual([
[
{ lat: 51.509, lng: -0.08 },
{ lat: 51.503, lng: -0.06 },
{ lat: 51.51, lng: -0.047 },
],
]);
const controlLayersInternal = controlLayers.leafletObject as unknown as {
_layers: { name: string }[];
};
// oxlint-disable-next-line no-underscore-dangle -- Leaflet's own field name
expect(controlLayersInternal._layers.map((l) => l.name)).toEqual(['Base']);
expect(group.leafletObject?.hasLayer(groupedMarker.leafletObject!)).toBe(true);
map.remove();
});
});
Loading…
Cancel
Save