[Bug][E2E flake] /geography/:countryCode click-through — Leaflet "Cannot read properties of undefined (reading 'appendChild')" pageerror on CI only
#363
Epic #307 (E2E walk findings). Failed e2e-anonymous on master run 793 (sha 98d3778, the art-studio viewer merge — not caused by that PR: the identical commit passes locally, including --repeat-each=6).
Symptom:anonymous-click-throughs.spec.ts:41 (geography grid → country detail) fails the console-error assertion with [pageerror] Cannot read properties of undefined (reading 'appendChild').
Suspected root cause: Leaflet/markercluster post-destroy race in country-map.component.ts (used by geography-explorer). The component's own setTimeout(100/150ms) callbacks are destroyed-guarded, but Leaflet-internal timers/animation callbacks (tile load, zoom anim, cluster spiderfy) are not — on fast grid→detail navigation the component re-creates while a queued Leaflet callback fires against a removed map whose panes are gone → container.appendChild on undefined. Slower CI hardware widens the window; M5-local never hits it (6/6 pass).
Repro: CI-timing only so far; locally green on the same sha. Error context artifact: test-results/anonymous-click-throughs-a-*/error-context.md on run 793.
Fix directions: guard/teardown — cancel Leaflet animations before map.remove() (map.stop(), zoomAnimation:false on CI-sized viewports, or defer remove() until whenReady); markercluster removeLayers before map removal; alternatively harden the spec to retry-on-known-flake only if the component fix proves elusive (component fix preferred — a real pageerror reaches students' consoles too).
Impact beyond CI: the same failed run skipped build_and_publish_frontend, so a red e2e-anonymous blocks frontend deploys — flakes here directly stall the deploy train.
Epic #307 (E2E walk findings). Failed `e2e-anonymous` on master run [793](https://git.spikersoft.com/spikerj/spikersoft-angular/actions/runs/793/jobs/1) (sha `98d3778`, the art-studio viewer merge — **not caused by that PR**: the identical commit passes locally, including `--repeat-each=6`).
**Symptom:** `anonymous-click-throughs.spec.ts:41` (geography grid → country detail) fails the console-error assertion with `[pageerror] Cannot read properties of undefined (reading 'appendChild')`.
**Suspected root cause:** Leaflet/markercluster post-destroy race in `country-map.component.ts` (used by `geography-explorer`). The component's own `setTimeout(100/150ms)` callbacks are `destroyed`-guarded, but **Leaflet-internal** timers/animation callbacks (tile load, zoom anim, cluster spiderfy) are not — on fast grid→detail navigation the component re-creates while a queued Leaflet callback fires against a removed map whose panes are gone → `container.appendChild` on undefined. Slower CI hardware widens the window; M5-local never hits it (6/6 pass).
**Repro:** CI-timing only so far; locally green on the same sha. Error context artifact: `test-results/anonymous-click-throughs-a-*/error-context.md` on run 793.
**Fix directions:** guard/teardown — cancel Leaflet animations before `map.remove()` (`map.stop()`, `zoomAnimation:false` on CI-sized viewports, or defer `remove()` until `whenReady`); markercluster `removeLayers` before map removal; alternatively harden the spec to retry-on-known-flake only if the component fix proves elusive (component fix preferred — a real pageerror reaches students' consoles too).
**Impact beyond CI:** the same failed run skipped `build_and_publish_frontend`, so a red e2e-anonymous blocks frontend deploys — flakes here directly stall the deploy train.
spikerj
added the bug label 2026-07-04 20:08:48 +00:00
Resolved in spikersoft-angular PR #116 (merged to master). Leaflet teardown hardened in country-map.component.ts — animations stopped, listeners detached, cluster markers cleared before map.remove(), guarded so internal races can't escape as pageerrors. 1840/1840 unit tests; geography click-through green locally. The merge also re-triggers the master pipeline, which redelivers the blocked frontend deploy. Closing.
Resolved in spikersoft-angular PR #116 (merged to `master`). Leaflet teardown hardened in `country-map.component.ts` — animations stopped, listeners detached, cluster markers cleared before `map.remove()`, guarded so internal races can't escape as pageerrors. 1840/1840 unit tests; geography click-through green locally. The merge also re-triggers the master pipeline, which redelivers the blocked frontend deploy. Closing.
Reopened — the flake recurred on master ee927b8 (e2e-anonymous passed as task 8852, then failed on a later run of the same commit — the classic intermittent signature).
Root cause pinned more precisely than the original hypothesis. It's not (only) Leaflet-internal timers — it's three of our own async callbacks in country-map.component.ts that touch the map after remove(), and unlike the setTimeout callbacks (which aredestroyed-guarded) these had no guard:
loadGeoData() HTTP next → renderGeoLayer() adds an L.geoJSON layer / applyCountryFitBounds() invalidates size
reloadCountryGeoData() HTTP next
the ResizeObserver callback → invalidateSize()
On a fast grid→detail nav the map is remove()d before the geo-JSON GET resolves; adding a layer to / invalidating the removed map appends to panes that no longer exist → the uncaught appendChild pageerror. The original fix wrapped only the synchronous ngOnDestroy, so these async throws escaped it.
Fix in spikersoft-angular PR #149 (open, for review): if (this.destroyed) return; at the top of all three, matching the existing guard pattern. Added a unit test asserting a GeoJSON response that arrives after fixture.destroy() neither throws nor applies to the torn-down map. nx lint + nx test spikersoft (1870) green. Full confirmation is the next e2e-anonymous run after merge.
**Reopened — the flake recurred on master `ee927b8`** (`e2e-anonymous` passed as task 8852, then failed on a later run of the *same commit* — the classic intermittent signature).
**Root cause pinned more precisely than the original hypothesis.** It's not (only) Leaflet-*internal* timers — it's **three of our own async callbacks in `country-map.component.ts` that touch the map after `remove()`**, and unlike the `setTimeout` callbacks (which *are* `destroyed`-guarded) these had no guard:
- `loadGeoData()` HTTP `next` → `renderGeoLayer()` adds an `L.geoJSON` layer / `applyCountryFitBounds()` invalidates size
- `reloadCountryGeoData()` HTTP `next`
- the `ResizeObserver` callback → `invalidateSize()`
On a fast grid→detail nav the map is `remove()`d before the geo-JSON GET resolves; adding a layer to / invalidating the removed map appends to panes that no longer exist → the uncaught `appendChild` pageerror. The original fix wrapped only the synchronous `ngOnDestroy`, so these async throws escaped it.
**Fix in spikersoft-angular PR #149** (open, for review): `if (this.destroyed) return;` at the top of all three, matching the existing guard pattern. Added a unit test asserting a GeoJSON response that arrives after `fixture.destroy()` neither throws nor applies to the torn-down map. `nx lint` + `nx test spikersoft` (1870) green. Full confirmation is the next `e2e-anonymous` run after merge.
Resolved in spikersoft-angular PR #149 (merged to master, 74dff52). Extended the this.destroyed guard to the three async callbacks in country-map.component.ts that touched the Leaflet map after remove() — loadGeoData()/reloadCountryGeoData() HTTP next handlers and the ResizeObserver callback — closing the exact escape paths that let the "appendChild of undefined" pageerror surface on fast grid→detail navigation (the original fix guarded only the synchronous ngOnDestroy). Added a unit test asserting a GeoJSON response arriving after fixture.destroy() neither throws nor applies to the torn-down map. Closing; the next e2e-anonymous run on master is the live confirmation.
Resolved in spikersoft-angular PR #149 (merged to `master`, `74dff52`). Extended the `this.destroyed` guard to the three async callbacks in `country-map.component.ts` that touched the Leaflet map after `remove()` — `loadGeoData()`/`reloadCountryGeoData()` HTTP `next` handlers and the `ResizeObserver` callback — closing the exact escape paths that let the "appendChild of undefined" pageerror surface on fast grid→detail navigation (the original fix guarded only the synchronous `ngOnDestroy`). Added a unit test asserting a GeoJSON response arriving after `fixture.destroy()` neither throws nor applies to the torn-down map. Closing; the next `e2e-anonymous` run on master is the live confirmation.
Reopened again — recurred on master 74dff52 (e2e-anonymous task 8884), which already contains both prior rounds of hardening:
6f20b71 — ngOnDestroystop()/off()/clearLayers()/remove() in try-catch
6dbca58 (PR #149) — this.destroyed guards on the async geo-JSON/resize callbacks
Neither reaches the remaining escape: Leaflet's requestAnimationFrame-driven_animateZoom / tile _fadeIn, which append to map panes from an rAF callback that can fire after teardown. map.stop() only races to cancel an already-queued frame.
This is exactly the zoomAnimation:false direction called out in the original report. PR #150 (open) disables zoomAnimation + fadeAnimation + markerZoomAnimation at map construction so no frame is ever queued — no visual regression since the country map already fits bounds with { animate: false }. Unit test added; nx lint + nx test spikersoft (1871) green.
Leaving open until #150 merges and the next e2e-anonymous on master confirms the walk is clean.
(Caveat: CI action logs aren't reachable from the agent env, so 8884's failing spec isn't 100% confirmed as geography vs. another intermittent item — but the signature + both prior guards already being in the failing tree point squarely at the animation path.)
**Reopened again — recurred on master `74dff52`** (`e2e-anonymous` task 8884), which already contains **both** prior rounds of hardening:
- `6f20b71` — `ngOnDestroy` `stop()`/`off()`/`clearLayers()`/`remove()` in try-catch
- `6dbca58` (PR #149) — `this.destroyed` guards on the async geo-JSON/resize callbacks
Neither reaches the remaining escape: Leaflet's **`requestAnimationFrame`-driven** `_animateZoom` / tile `_fadeIn`, which append to map panes from an rAF callback that can fire *after* teardown. `map.stop()` only *races* to cancel an already-queued frame.
**This is exactly the `zoomAnimation:false` direction called out in the original report.** PR #150 (open) disables `zoomAnimation` + `fadeAnimation` + `markerZoomAnimation` at map construction so no frame is ever queued — no visual regression since the country map already fits bounds with `{ animate: false }`. Unit test added; `nx lint` + `nx test spikersoft` (1871) green.
Leaving open until #150 merges and the next `e2e-anonymous` on master confirms the walk is clean.
_(Caveat: CI action logs aren't reachable from the agent env, so 8884's failing spec isn't 100% confirmed as geography vs. another intermittent item — but the signature + both prior guards already being in the failing tree point squarely at the animation path.)_
Resolved in spikersoft-angular PR #150 (merged to master, b1024b1). Disabled Leaflet's zoomAnimation/fadeAnimation/markerZoomAnimation at map construction in country-map.component.ts, so the requestAnimationFrame-driven _animateZoom / tile _fadeIn callbacks that appended to map panes after teardown are never queued — the remaining escape path after the earlier ngOnDestroy teardown (6f20b71) and async-callback guards (PR #149, 6dbca58). This is the zoomAnimation:false direction the original report called out. No visual regression (the map already fits bounds with { animate: false }); unit test asserts the flags.
Closing. The next e2e-anonymous on master is the live confirmation the /geography/:countryCode walk stays green.
Resolved in spikersoft-angular PR #150 (merged to `master`, `b1024b1`). Disabled Leaflet's `zoomAnimation`/`fadeAnimation`/`markerZoomAnimation` at map construction in `country-map.component.ts`, so the `requestAnimationFrame`-driven `_animateZoom` / tile `_fadeIn` callbacks that appended to map panes after teardown are never queued — the remaining escape path after the earlier `ngOnDestroy` teardown (`6f20b71`) and async-callback guards (PR #149, `6dbca58`). This is the `zoomAnimation:false` direction the original report called out. No visual regression (the map already fits bounds with `{ animate: false }`); unit test asserts the flags.
Closing. The next `e2e-anonymous` on master is the live confirmation the `/geography/:countryCode` walk stays green.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Epic #307 (E2E walk findings). Failed
e2e-anonymouson master run 793 (sha98d3778, the art-studio viewer merge — not caused by that PR: the identical commit passes locally, including--repeat-each=6).Symptom:
anonymous-click-throughs.spec.ts:41(geography grid → country detail) fails the console-error assertion with[pageerror] Cannot read properties of undefined (reading 'appendChild').Suspected root cause: Leaflet/markercluster post-destroy race in
country-map.component.ts(used bygeography-explorer). The component's ownsetTimeout(100/150ms)callbacks aredestroyed-guarded, but Leaflet-internal timers/animation callbacks (tile load, zoom anim, cluster spiderfy) are not — on fast grid→detail navigation the component re-creates while a queued Leaflet callback fires against a removed map whose panes are gone →container.appendChildon undefined. Slower CI hardware widens the window; M5-local never hits it (6/6 pass).Repro: CI-timing only so far; locally green on the same sha. Error context artifact:
test-results/anonymous-click-throughs-a-*/error-context.mdon run 793.Fix directions: guard/teardown — cancel Leaflet animations before
map.remove()(map.stop(),zoomAnimation:falseon CI-sized viewports, or deferremove()untilwhenReady); markerclusterremoveLayersbefore map removal; alternatively harden the spec to retry-on-known-flake only if the component fix proves elusive (component fix preferred — a real pageerror reaches students' consoles too).Impact beyond CI: the same failed run skipped
build_and_publish_frontend, so a red e2e-anonymous blocks frontend deploys — flakes here directly stall the deploy train.Resolved in spikersoft-angular PR #116 (merged to
master). Leaflet teardown hardened incountry-map.component.ts— animations stopped, listeners detached, cluster markers cleared beforemap.remove(), guarded so internal races can't escape as pageerrors. 1840/1840 unit tests; geography click-through green locally. The merge also re-triggers the master pipeline, which redelivers the blocked frontend deploy. Closing.Reopened — the flake recurred on master
ee927b8(e2e-anonymouspassed as task 8852, then failed on a later run of the same commit — the classic intermittent signature).Root cause pinned more precisely than the original hypothesis. It's not (only) Leaflet-internal timers — it's three of our own async callbacks in
country-map.component.tsthat touch the map afterremove(), and unlike thesetTimeoutcallbacks (which aredestroyed-guarded) these had no guard:loadGeoData()HTTPnext→renderGeoLayer()adds anL.geoJSONlayer /applyCountryFitBounds()invalidates sizereloadCountryGeoData()HTTPnextResizeObservercallback →invalidateSize()On a fast grid→detail nav the map is
remove()d before the geo-JSON GET resolves; adding a layer to / invalidating the removed map appends to panes that no longer exist → the uncaughtappendChildpageerror. The original fix wrapped only the synchronousngOnDestroy, so these async throws escaped it.Fix in spikersoft-angular PR #149 (open, for review):
if (this.destroyed) return;at the top of all three, matching the existing guard pattern. Added a unit test asserting a GeoJSON response that arrives afterfixture.destroy()neither throws nor applies to the torn-down map.nx lint+nx test spikersoft(1870) green. Full confirmation is the nexte2e-anonymousrun after merge.Resolved in spikersoft-angular PR #149 (merged to
master,74dff52). Extended thethis.destroyedguard to the three async callbacks incountry-map.component.tsthat touched the Leaflet map afterremove()—loadGeoData()/reloadCountryGeoData()HTTPnexthandlers and theResizeObservercallback — closing the exact escape paths that let the "appendChild of undefined" pageerror surface on fast grid→detail navigation (the original fix guarded only the synchronousngOnDestroy). Added a unit test asserting a GeoJSON response arriving afterfixture.destroy()neither throws nor applies to the torn-down map. Closing; the nexte2e-anonymousrun on master is the live confirmation.Reopened again — recurred on master
74dff52(e2e-anonymoustask 8884), which already contains both prior rounds of hardening:6f20b71—ngOnDestroystop()/off()/clearLayers()/remove()in try-catch6dbca58(PR #149) —this.destroyedguards on the async geo-JSON/resize callbacksNeither reaches the remaining escape: Leaflet's
requestAnimationFrame-driven_animateZoom/ tile_fadeIn, which append to map panes from an rAF callback that can fire after teardown.map.stop()only races to cancel an already-queued frame.This is exactly the
zoomAnimation:falsedirection called out in the original report. PR #150 (open) disableszoomAnimation+fadeAnimation+markerZoomAnimationat map construction so no frame is ever queued — no visual regression since the country map already fits bounds with{ animate: false }. Unit test added;nx lint+nx test spikersoft(1871) green.Leaving open until #150 merges and the next
e2e-anonymouson master confirms the walk is clean.(Caveat: CI action logs aren't reachable from the agent env, so 8884's failing spec isn't 100% confirmed as geography vs. another intermittent item — but the signature + both prior guards already being in the failing tree point squarely at the animation path.)
Resolved in spikersoft-angular PR #150 (merged to
master,b1024b1). Disabled Leaflet'szoomAnimation/fadeAnimation/markerZoomAnimationat map construction incountry-map.component.ts, so therequestAnimationFrame-driven_animateZoom/ tile_fadeIncallbacks that appended to map panes after teardown are never queued — the remaining escape path after the earlierngOnDestroyteardown (6f20b71) and async-callback guards (PR #149,6dbca58). This is thezoomAnimation:falsedirection the original report called out. No visual regression (the map already fits bounds with{ animate: false }); unit test asserts the flags.Closing. The next
e2e-anonymouson master is the live confirmation the/geography/:countryCodewalk stays green.