1. SSR-Safe Map Initialization
Leaflet's L.map(...) touches window and measures real DOM
elements the instant it's called — on the server, during Week 21's SSR render pass,
neither exists. Initialize a map anywhere Angular might execute on the server and the
build crashes outright, not just renders incorrectly.
import { Component, ElementRef, inject, afterNextRender, input } from '@angular/core';
import type * as Leaflet from 'leaflet';
@Component({
selector: 'app-leaflet-map',
template: `<div class="map-container" #mapEl></div>`,
})
export class LeafletMap {
private host = inject(ElementRef<HTMLElement>);
private map?: Leaflet.Map;
center = input<[number, number]>([51.505, -0.09]);
constructor() {
afterNextRender(async () => {
const L = await import('leaflet'); // dynamic import keeps Leaflet out
// of the server bundle entirely
this.map = L.map(this.host.nativeElement.querySelector('.map-container')!)
.setView(this.center(), 13);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors',
}).addTo(this.map);
});
}
}
Two SSR-safety layers, not one: afterNextRender (Week 21/28) guarantees the
code only runs in the browser, and the dynamic import('leaflet') ensures the
library itself is never even evaluated during the server build — some mapping libraries
throw immediately on module load, before any code you write runs, so gating only the
initialization call isn't always sufficient on its own.
2. Wrapping Leaflet in a Component
Beyond SSR safety, the map needs an explicit size — Leaflet, unlike a normal block element, doesn't compute its own layout from content, so a zero-height container renders a blank gray box with no error.
.map-container {
width: 100%;
height: 100%;
min-height: 320px; // Leaflet needs a real, non-zero size to measure at init
}
If the container's size can change after initial load (a resizable panel, a layout
reflow from content loading in elsewhere), call this.map.invalidateSize()
— the same ResizeObserver pattern from Week 28's responsive-chart directive
applies here directly, just triggering Leaflet's own resize method instead of a redraw.
3. Markers Driven by a Signal
Leaflet markers are imperative objects with no concept of Angular's declarative
@for — reconciling a signal of location data against them means diffing by
hand: add markers that are new, remove markers that are gone, and leave the rest alone.
locations = input.required<Location[]>();
private markers = new Map<string, Leaflet.Marker>();
constructor() {
afterNextRender(async () => {
const L = await import('leaflet');
// ...map init from Section 1...
effect(() => {
const current = new Set(this.locations().map((l) => l.id));
// remove markers no longer present
for (const [id, marker] of this.markers) {
if (!current.has(id)) {
marker.remove();
this.markers.delete(id);
}
}
// add markers that are new
for (const loc of this.locations()) {
if (!this.markers.has(loc.id)) {
const marker = L.marker([loc.lat, loc.lng])
.bindPopup(loc.name)
.on('click', () => this.selected.set(loc.id))
.addTo(this.map!);
this.markers.set(loc.id, marker);
}
}
});
});
}
This is the same enter/exit diffing D3 does internally (Week 28) — done by hand here
because Leaflet doesn't expose a data-join API the way D3 does. The effect()
re-runs whenever locations() changes, and the Map keyed by id
is what makes the diff efficient instead of tearing down and rebuilding every marker on
every change.
4. GeoJSON Layers & Clustering
For boundaries, routes, or large marker counts, Leaflet's built-in
L.geoJSON() and the separate leaflet.markercluster plugin
handle cases the manual diffing in Section 3 isn't meant for.
L.geoJSON(regionBoundary, {
style: { color: '#6366f1', weight: 2, fillOpacity: 0.08 },
}).addTo(this.map);
For hundreds or thousands of markers, plain Leaflet markers degrade badly — reach for
markercluster at that scale rather than optimizing the hand-rolled diffing
from Section 3 further; clustering is a genuinely different rendering strategy (grouping
nearby markers into a single cluster icon that expands on zoom), not just a performance
tweak to the same approach.
5. Two-Way Viewport Sync
A map screen usually needs state to flow both directions: selecting an item in a list pans the map to it, and moving the map should update what's considered "in view" for the rest of the app.
selected = model<string | null>(null); // Week 4's two-way signal API
// signal -> map: selecting an item pans to it
effect(() => {
const id = this.selected();
const loc = this.locations().find((l) => l.id === id);
if (loc && this.map) this.map.panTo([loc.lat, loc.lng]);
});
// map -> signal: moving the map updates a "bounds" output for the rest of the app
constructor() {
afterNextRender(async () => {
// ...init...
this.map!.on('moveend', () => {
this.zone.run(() => this.bounds.set(this.map!.getBounds()));
});
});
}
Using model() for selected means a parent component can bind
it with [(selected)] exactly like any other two-way signal input from Week
4 — the map component doesn't need to know whether a list, a search box, or something
else is driving the selection.
6. Cleanup on Destroy
A Leaflet map holds real browser resources — tile image requests, event listeners on
window and the container element — none of which Angular knows to release
on its own.
ngOnDestroy() {
this.map?.remove(); // detaches all Leaflet event listeners and tile requests
}
map.remove() is Leaflet's own teardown method — it's not optional the way
some cleanup calls are convenience-only; skipping it on a map component that mounts and
unmounts repeatedly (a modal, a routed detail view) is a reliable way to leak memory and
stray network requests, the same category of bug Week 15's subscription-leak coverage
warned about.
7. Hands-on Exercise
Build a "nearby locations" map + list, kept in sync
A split-screen layout: a list of locations on one side, a Leaflet map on the other.
Requirements:
- Build
LeafletMapfrom Sections 1-3, taking alocationssignal input of at least 8 points and rendering a marker per location with the diffing approach. - Build a sibling list component showing the same locations; clicking a list item pans the map to it and opens its popup (Section 5, signal → map direction).
- Clicking a marker on the map selects the corresponding item in the list (Section 5, map → signal direction, via the marker's
clickhandler updating the sameselectedmodel). - Add a GeoJSON layer (Section 4) for one simple boundary or route relevant to your data, styled distinctly from the markers.
- Confirm the map cleans up correctly: mount the map component behind a route or a toggle, navigate away, and verify (via dev tools' network tab) that tile requests stop after
ngOnDestroyruns.
If the map renders as a gray box with visible seams instead of tiles, it's almost always the Section 2 sizing issue — check that .map-container has a real measured height at the moment L.map() runs, not just a CSS rule that depends on a parent that hasn't laid out yet.
8. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
Why does initializing Leaflet during SSR crash the build, rather than just rendering wrong?
Why does initializing Leaflet during SSR crash the build, rather than just rendering wrong?
Leaflet reaches for window and measures real DOM elements the moment L.map() is called — neither exists in Angular's server-side rendering environment (Week 21), so the call throws immediately rather than degrading gracefully.
Q2
Why use a dynamic import('leaflet') in addition to afterNextRender?
Why use a dynamic import('leaflet') in addition to afterNextRender?
Some mapping libraries touch browser globals during module evaluation itself, not only when a function is called — a static top-level import would still break the server build even inside an afterNextRender callback. A dynamic import keeps the module out of the server bundle entirely, so it's never evaluated server-side at all.
Q3
Why does the marker-sync effect() diff by id instead of just clearing and re-adding every marker on every change?
Why does the marker-sync effect() diff by id instead of just clearing and re-adding every marker on every change?
Rebuilding every marker on every signal change is wasteful and visibly flickers open popups or in-progress marker animations even for locations that didn't change. Diffing by id only adds truly new markers and removes truly gone ones, leaving unchanged markers — and their open state — untouched.
Q4
What does map.remove() actually clean up, and why does skipping it matter for a map that mounts and unmounts repeatedly?
What does map.remove() actually clean up, and why does skipping it matter for a map that mounts and unmounts repeatedly?
It detaches Leaflet's internal event listeners (on both the container and window) and stops in-flight tile requests. Without it, a map component behind a modal or a routed view that mounts repeatedly accumulates listeners and requests on every mount — a genuine memory and network leak, not just untidy cleanup.