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.
This commit is contained in:
Buddy 2026-08-22 14:34:58 -07:00
parent 255d10499b
commit 3845e53329
10 changed files with 263 additions and 89 deletions

View File

@ -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.

View File

@ -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;

View File

@ -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;
}
}

View File

@ -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);

View File

@ -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);

View File

@ -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,

View File

@ -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);
}
}

View File

@ -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';

View File

@ -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', () => {

80
test/load-order.test.ts Normal file
View File

@ -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();
});
});