Repository navigation
fix(map): ignore stale async work and keep Path Inspector controls usable - #139
Conversation
E2E test, red on master (0/12): the Map Controls panel covers the Path Inspector at 641-1440 px; the pane toggle stops toggling after node reloads (duplicate listeners); a config response after leaving or a quick return creates or re-inits a map; the 100 ms invalidateSize timer and a resolve-hops response after leaving throw; an older node load overwrites a newer one. Relates to #123 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019TcZHooUiiknVWbECVWzk8
…ear (#123) - Lifecycle token: init() and destroy() bump mapGeneration; the config fetch, geo-filter and area-outline loads, route drawing (resolve-hops), deep-link loaders, reference-node lookups and the affinity overlay return early when their mount is gone, so old work can no longer create, clear, recentre or populate a later map. - Request generation for loadNodes(): only the newest request of the current mount assigns nodes/observers, renders and sets data-loaded; a superseded call resolves with the newest one so chained deep-link handling still sees loaded nodes. - Mount-scoped timers (mapTimeout) for invalidateSize and the target popup; destroy() clears them plus the zoom/resize timer and removes both AreaFilter listeners, which were added again on every mount. - initMapSidePane() wires each mount's pane once; loadNodes() called it on every reload, so one click toggled the pane several times. - CSS: at >= 641px the Map Controls panel and its toggle are offset by the Path Inspector width (32px collapsed, 320px expanded). Relates to #123 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019TcZHooUiiknVWbECVWzk8
Independent review of
|
| Criterion | Result |
|---|---|
| Lifecycle token per mount, request generation per same-instance node load | Met [F]. mapGeneration/mapToken() at map.js:32-60, bumped in init() (:208) and destroy() (:2562). nodesLoadGeneration with current()/stale() at map.js:1722-1730. |
| Async results/delayed callbacks affect only their generation; only newest same-instance load renders | Met [F]. The E2E "out-of-order node loads" is green on the head and red on master (nodes: a). M2 (drop the request generation) and M15 (assign nodes before the observers await) are both caught. My S5 (initial load superseded by a filter change while /api/observers is delayed) is green: data-loaded appears only once nodes are assigned. |
| Destroy cancels timers/listeners where practical; remaining work inert | Met [F]. destroy() clears mapTimers and _zoomResizeTimer and calls AreaFilter.offChange for both handlers (map.js:2561-2569). S2: 2 live map.js listeners after 4 mounts on the head, 8 on master. The fetches are not aborted, only made inert, as the PR states [T/F]. |
| Old work cannot clear, recentre or populate a later map | Met [F]. The E2E "quick return" (one .leaflet-container, the same __mc_map) is green on the head and red on master (Map container is already initialized). My S4 adds a random 0-1500 ms delay to every /api/* request and does 5 rapid map↔packets cycles, then a final mount. On the head: 1 container, __mc_map is the visible map, 202 nodes, 0 errors. On master: Map container not found and null.invalidateSize. |
| Path Inspector controls visible, non-overlapping, clickable at tablet/desktop widths | Met [F]. The E2E covers 1440x900, 1280x600, 1024x768, 800x900 and 641x800, collapsed and expanded, with real clicks: green on the head, 5/5 red on master. My bounding boxes with the pane expanded, head: controls [718..948] inside map [0..960], pane from 960 at 1280x600; [129..309] inside [0..321] at 641x800; no overlap with the Leaflet zoom control [10..58]. Master: controls [1038..1268] over the pane. I checked the screenshots visually. CSS mutants M16/M17/M18 are all caught. |
| Preserve route rendering, filters, saved viewport, selected-node behaviour | Met on existing tests [F]. The existing map E2Es and map unit tests give identical results on master and the head (below). The map-view save code is untouched. Selected-node behaviour beyond the existing tests: [A]. |
Test-first and mutants
- Commit A
c68accbdadds only the test and the workflow line. The test file is byte-identical between A and the head (git diff c68accbd df235cb7touches onlypublic/). A'spublic/map.js/style.cssequal master's (master's later drift is inanalytics.js/app.js). [F] - New E2E on the master server: 0 passed, 12 failed. On the head server: 12 passed, 0 failed. Same result as the PR table. [F]
All mutants were applied to a separate archive tree (a separate local tree and server). After each mutant I restored the files and checked them with shasum against git show df235cb7:<path>: map.js b6d79c2a…, style.css 774c509b…, both match. [F]
| # | Mutant | New E2E | My probe (S1-S5) | Result |
|---|---|---|---|---|
| M1 | drop alive() after /api/config/map in init |
9/3 | – | caught |
| M2 | drop request generation from current() |
11/1 | – | caught |
| M3 | drop data-wired once-guard on side pane |
11/1 | – | caught |
| M4 | mapTimeout without alive() and destroy() not clearing timers |
12/0 | 5/0 | survived: equivalent in practice [A] (only a harmless invalidateSize on the next map) |
| M5 | mapTimeout without alive() only |
12/0 | 5/0 | survived: equivalent (destroy still clears) |
| M6 | drop post-resolve-hops guard in drawPacketRouteMulti |
12/0 | S1 fails | test gap (Finding 1) |
| M7 | drop post-resolve-hops guard in drawPacketRoute |
10/2 | – | caught |
| M8 | destroy() keeps AreaFilter listeners |
12/0 | S2 fails (8 listeners) | test gap (Finding 1) |
| M9 | drop alive() in loadNodes().then in init |
12/0 | 5/0 | survived: near-equivalent [A]. The deep-link loaders read the current hash and have their own guards |
| M10 | drop stale-selection guard in selectReferenceNode |
12/0 | S3 fails (c) |
test gap (Finding 1) |
| M11 | area outline: drop refreshGen check |
12/0 | 5/0 | survived: untested, low impact (polygons are cached after the first fetch) [A] |
| M12 | superseded loadNodes resolves immediately (stale() → undefined) |
12/0 | 5/0 | survived: untested; only matters for a deep link combined with an early reload [A] |
| M13 | data-loaded set by any request |
12/0 | 5/0 | survived: untested; affects only E2E synchronisation |
| M14 | drop both alive() guards in loadRouteFromDeepLink |
12/0 | 5/0 | survived: near-equivalent [A]. drawPacketRoute has its own !map guard |
| M15 | assign nodes before the observers await (old order) |
11/1 | – | caught |
| M16 | CSS: drop expanded offsets | 7/5 | – | caught |
| M17 | CSS: media min-width 641 → 1025 |
9/3 | – | caught |
| M18 | CSS: drop toggle offsets | 7/5 | – | caught |
Suites run locally
Frontend-only PR, so I ran no Go suites; cmd/server and cmd/ingestor are untouched. [F]
| Suite | master ad011021 |
head df235cb7 |
|---|---|---|
test-issue-123-map-lifecycle-e2e.js |
0/12 | 12/0 |
test-issue-1236-map-mobile-e2e.js |
3/3 | 3/3 |
test-issue-1329-map-controls-accordion-e2e.js |
5/5 | 5/5 |
test-issue-1374-route-map-a11y-e2e.js |
20/0 | 20/0 |
test-map-modal-fluid-e2e.js |
10/0 | 10/0 |
test-map-nodes-pagination-e2e.js |
3/0 | 3/0 |
test-path-inspector-coverage-e2e.js |
10/0 | 10/0 |
test-issue-1146-path-link-contrast-e2e.js |
11/0 | 11/0 |
test-issue-1705-subpath-contrast-e2e.js, test-a11y-axe-routes-coverage.js |
rc 0 | rc 0 |
test-e2e-playwright.js (my copy with fail-fast disabled) |
130/135, 4 skip, 1 fail | 131/135, 3 skip, 1 fail |
test-analytics-fluid-charts.js (the CI failure) |
8/0 ×5 | 8/0 ×5 |
-
The single
test-e2e-playwright.jsfailure is identical on both: "Version info lives on Perf dashboard", which is known environment noise. The skip difference is "Short URL: 8-char prefix" (fixture prefix collision after freshen). -
Unit tests, same results on master and the head:
test-top-routes-overlay28/0test-map-scope-filter17/0test-packet-path-map42/0test-area-nodes-map14/0test-issue-1356-map-a11y40/0test-issue-11365/0test-issue-15744/0test-area-filter16/0test-xss-escape-sinks34/0test-packet-filter92/0test-aging19/0test-issue-129323/0test-issue-13609/0test-issue-140761/0test-issue-1418×6, all 0 failedtest-issue-1438-marker-css-vars14/0test-issue-148824/0test-issue-163321/0
-
Pre-existing failures, identical on both:
test-map-clustering4/1test-frontend-helpers705/2test-issue-1438-customizer-mcrole7/1test-issue-1648-m3,-m6-final-sweepand-m6-lint-selffail (emoji scans of other files)
The failure sets are identical, so the PR introduces no regressions. [F]
Browser
- I ran the new E2E against master, the head and the merge tree (above). [F]
- My own Playwright scenarios (reviewer probe, kept locally), with route interception to delay, hold and reorder responses and to navigate away mid-flight. The head passes 5/5, master fails 4/5 (S5 passes on master). [F]
- S1: multi-path
drawPacketRouteMulti, leave whileresolve-hopsis held. - S2: mock one area, mount the map 4 times, count live
map.jsAreaFilter listeners, then pick the area. - S3:
selectReferenceNoderace (A held, B immediate, then release A). - S4: random 0-1500 ms delay on every
/api/*request plus 5 rapid leave/return cycles. - S5: initial node load superseded by a filter change while the first
/api/observersis delayed.
- S1: multi-path
- Screenshots with the pane expanded at 641x800, 1024x768 and 1280x600, on master and the head (kept locally). On master the controls cover the inspector input and toggle; on the head they sit inside the Leaflet area. At 641 px the 180 px-wide controls clip the "3-byte" pill. That is the same width and the same clipping as master, so it is not a regression. [F]
Performance and security
- Not a hot path. The added work is one integer comparison per await continuation or timer. Superseded node loads still complete their network requests, as before; only their results are dropped. [F]
- Removing the leaked AreaFilter listeners (S2) and the pane
clicklisteners that piled up on every reload reduces work. As a side effect, a?prefix=deep link is no longer re-submitted on every WS node reload (initMapSidePanenow wires once per mount,map.js:2447-2448). [F] mapTimersis bounded: it removes each id as its timer fires and is cleared indestroy().nodesLoadPromiseis reset indestroy(). [F]- No new
innerHTMLsink with data. The onlyinnerHTMLin the diff context is the existing static caret SVG. No new Go code, nomap[string]interface{}, andcmd/serveris untouched. [F] - No new UI state, so no deep-link requirement. [F]
- Unrelated, pre-existing, out of scope:
packets.js:1679also registers anAreaFilter.onChangelistener on every mount without removing it (S2 shows 3 after 3 packets mounts).area-filter.jsinterpolatesa.label/a.keyintoinnerHTMLunescaped (operator config, not mesh data).- The PR's own note is correct:
path-inspector.js:191callswindow.drawPacketRoute, butmap.jsexports onlydrawPacketRouteMulti(map.js:1413). [F]
Not verified
- I did not check real touch devices or tablets, or widths outside 641/800/1024/1280/1440. [K]
- I did not reproduce the CI flake of
test-analytics-fluid-charts.jsAC3 (5/5 green locally on both trees). I did not look for a root cause. [K] - I tested deep-link combinations (
?packet=,?node=,?prefix=) during a quick return only by reading the code (see M9/M12/M14), not in a browser. [A] - I did not run
scripts/check-xss-sinks.shor eslint myself. I relied on reading the diff; the PR reports both as clean [T]. - The PR's "Not verified" section is honest as far as it goes. It does not mention that the listener cleanup, the
selectReferenceNodeguard and the multi-path guard have no automated test (Finding 1).
|
CI-note: Playwright-kørslen fejlede i Testen dækker analytics-layout. Denne PR's CSS er afgrænset til Generated by Claude Code |
…123) Criterion 3 (destroy makes remaining work inert) had two gaps: - Leaving the map with an open route threw "Cannot read properties of undefined (reading '_leaflet_pos')": the route view's teardown ran invalidateSize 50 ms later on the map that destroy() had removed. route-view.js now marks a map as removed on Leaflet's 'unload' event and runs every timer that touches the map (teardown/close/collapse invalidateSize, mobile sheet refit, spider fan, resize refit) through mapTimeout(), which skips a removed map. - Every mount added a window 'mc-tile-provider-changed' listener and a data-theme MutationObserver that were never removed, and each kept calling setUrl on its removed map's tile layer. destroy() now removes both. Tests (test-issue-123-map-lifecycle-e2e.js): leaving an open route at desktop 1440x900 and tablet 1024x768 without page errors; after six mounts one listener and one observer remain, two theme switches plus a provider change cause as many setUrl calls as after one mount, and leaving removes both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011FcyXW5RdFzLZhuL1ntAsY
|
Review feedback addressed (commit
Numbers:
Generated by Claude Code |
Relates to #123
Plan and design
The user asked for autonomous work, so the plan is written here instead of waiting for sign-off (AGENTS.md rule 5).
c68accbd): a new Playwright test,test-issue-123-map-lifecycle-e2e.js, registered in the CI Playwright step. It fails on master with 0/12.df235cb7):public/map.jsandpublic/style.css.da47a0b0): completes criterion 3. The route view's delayed timers are inert after destroy, and destroy removes the theme observer and the tile-provider listener. Touchespublic/route-view.js,public/map.jsand the E2E.Lifecycle token per mount
mapGenerationis bumped by bothinit()anddestroy().mapToken()returns analive()check that stays true only while the mount that created it is current.alive()after its awaits:init()after/api/config/map. This await happens beforeL.map(), so leaving during it used to throwMap container not found, and a quick return threwMap container is already initialized.loadNodes().then(...).drawPacketRouteanddrawPacketRouteMultiafter/api/resolve-hops.loadRouteFromDeepLinkandloadEstimatedNodesFromDeepLink.selectReferenceNode, which now fills a local set and publishes it only if the mount and the selection are both still current.if (map)is not enough. After a quick return it sees the next mount's map, which is the core point of the issue.Request generation for
loadNodes()nodes/observers, renders, and setsdata-loaded.init()still runs with loaded nodes.Timers and listeners
mapTimeout()replaces the loosesetTimeouts: the 100 msinvalidateSize, the target-node popup, and the two paneinvalidateSizecalls. Its timers are mount-scoped and tracked in a set that removes itself when a timer fires.destroy()clears those timers and the zoom/resize timer.destroy()removes the twoAreaFilter.onChangelisteners, which were previously added again on every mount and never removed.destroy()also disconnects thedata-themeMutationObserver and removes the windowmc-tile-provider-changedlistener (da47a0b0). Before that, every mount added both for good, and each kept callingsetUrlon its removed map's tile layer: 6 mounts left 6 listeners.public/route-view.js,da47a0b0): leaving the map with an open route threwTypeError: Cannot read properties of undefined (reading '_leaflet_pos'), because the teardown raninvalidateSize50 ms later on the map thatdestroy()had removed.unloadevent, whichmap.remove()fires.mapTimeout(mapRef, fn, ms)that skips a removed map. That covers the teardown/close/collapseinvalidateSize, the mobile-sheet refit, the spider fan and the resize refit.initMapSidePane()was called by everyloadNodes(), including WS advert reloads, and added a new click listener each time. After an even number of reloads one click toggled the pane twice, so it looked dead. The pane is now wired once per mount (data-wired).Layout
.map-controlsand.map-controls-toggleare offset by the Path Inspector's width: 32 px collapsed, 320 px expanded.How this differs from upstream
Kpa-clawbot/CoreScope#2048Upstream is read as a reference only; nothing was cherry-picked.
map === initializedMap). That does not cover the await ininit()beforeL.map()exists, which is the navigate-during-config and quick-return case, and it does not cover out-of-orderloadNodes()calls on one instance. Both are acceptance criteria here, so this PR uses a mount generation plus a request generation.Acceptance criteria
mapGeneration/mapToken(),nodesLoadGenerationdestroy()clearsmapTimersand the zoom/resize timer, removes the AreaFilter listeners, the theme observer and the tile-provider listener; route-view timers are inert on a removed map. E2E "leaving the map with an open route" (desktop 1440x900, tablet 1024x768) and "six mounts leave one tile-provider listener and one theme observer". Thefetches themselves are not aborted; their continuations become no-ops..leaflet-container, the same map object), plus the resolve-hops testTests
New test
test-issue-123-map-lifecycle-e2e.js(Playwright, run locally against a server ontest-fixtures):d264716cdf235cb7df235cb7+ the 3 review-round steps (before the fix)da47a0b0da47a0b0merged withorigin/master(local)Before the review-round fix, these steps failed:
pageerror: Cannot read properties of undefined (reading '_leaflet_pos').6 map tile-provider listeners after 6 mounts, want 1.Review-round mutants, each caught:
mapTimeoutin route-view ignores the removal: 12/3.destroy()keeps the theme observer: 14/1 ("6 map theme observers").destroy()keeps the tile-provider listener: 14/1 ("6 map tile-provider listeners").The six-mount step also checks the effect: two theme switches plus one provider event cause as many
setUrlcalls after 6 mounts as after 1, and leaving the map leaves 0 listeners and 0 observers.Errors on master, as reported by the test:
Map container not found.Map container is already initialized.Cannot read properties of null (reading 'invalidateSize')Cannot read properties of null (reading 'clearLayers')Existing suites on
da47a0b0(local):test-issue-1374-route-map-a11y-e2e.jstest-issue-1236-map-mobile-e2e.jstest-issue-1329-map-controls-accordion-e2e.jstest-map-modal-fluid-e2e.jstest-path-inspector-coverage-e2e.jstest-map-nodes-pagination-e2e.jstest-issue-1418-*(6 files, including spider-fan and polish-review)test-top-routes-overlay.jstest-map-scope-filter.jstest-packet-path-map.jstest-xss-escape-sinks.jstest-packet-filter.jstest-aging.jstest-issue-1633-hide-1byte-hops.jstest-issue-1420-tile-providers.jstest-issue-1614-tile-url-function.jstest-frontend-helpers.jsEarlier runs on
df235cb7:test-e2e-playwright.jswith fail-fast disabled: branch 130 passed, 4 skipped, 1 failed; master 131 passed, 3 skipped, 1 failed.Packets type filter includes Group Data (#1791).Short URL: 8-char prefix ..., skipped on a prefix collision in the freshened fixture copy.test-map-clustering.js: 4 passed / 1 failed, identical on master.Other checks:
scripts/check-xss-sinks.sh --diff origin/master: clean onda47a0b0.Browser check
Perf
WeakSetlookup per route-view timer.mapTimersholds at most the few pending mount timers and empties itself as they fire.setUrlonce per earlier mount.Known limitation (pre-existing, not changed here)
public/route-view.csssets#leaflet-maptoleft: 320px; width: calc(100% - 320px), so the map covers the Path Inspector column and its toggle#mapPaneToggle.Not verified
#/map. That is refuted in issue cleanup(path-inspector): remove unreachable showOnMap branch that calls undefined window.drawPacketRoute #143. The branch inpublic/path-inspector.jsthat calls the undefinedwindow.drawPacketRoutecannot be reached in normal navigation: the standalone inspector always stores a pending route and navigates to#/map, and the embedded pane calls the localdrawPacketRoute. It is dead code, and cleanup(path-inspector): remove unreachable showOnMap branch that calls undefined window.drawPacketRoute #143 tracks its removal.Overlap with other open PRs
.github/workflows/deploy.yml(Playwright step, one added line): the same step was also edited by PR fix(live): wire every persisted view toggle before Live init awaits #135 (fix(live): wire persisted view toggles before initialization awaits #125, merged). Neighbouring lines only; the branch merges cleanly withorigin/master.public/map.js,public/route-view.jsorpublic/style.css.🤖 Generated with Claude Code
https://claude.ai/code/session_011FcyXW5RdFzLZhuL1ntAsY