feat(02-05): bound the Leaflet map's lifecycle across Mesh tab deactivation
Demo images / Build & push demo images (push) Failing after 3m45s
Demo images / Build & push demo images (push) Failing after 3m45s
MeshMap.vue's onMounted setup (window resize listener + ResizeObserver)
is refactored into an idempotent armMapVisibility()/disarmMapVisibility()
pair, dual-registered on both onMounted and onActivated (onActivated is a
documented no-op outside a KeepAlive boundary) and torn down on
onDeactivated, matching the arm/disarm idiom Mesh.vue itself already uses.
The Leaflet instance is never destroyed or recreated by this — initMap()'s
own `if (!mapContainer.value || map) return` guard already makes
construction idempotent, so exactly one map is built per session. On
reactivation the map's size is invalidated via nextTick so a map laid out
while off screen re-tiles at its real size instead of showing an unsized
or partially tiled canvas.
FLAGGED: RESEARCH.md's premise that Mesh.vue owns a live D3 force
simulation does not hold for this codebase — a grep for
d3/forceSimulation/simulation across neode-ui/src found nothing in
Mesh.vue's or MeshMap.vue's tree; the only D3 force simulation belongs to
NetworkMap.vue (Federation.vue's graph, out of this plan's scope). The
plan's D3-specific truths are therefore vacuously satisfied — see
02-05-SUMMARY.md for detail. Only the real Leaflet-map lifecycle work
landed here.
meshMapLifecycle.test.ts is a new, separate file (not appended to
meshTabCache.test.ts) because its vi.mock('@/stores/mesh')/vi.mock('leaflet')
hoist file-wide and would otherwise clobber meshTabCache.test.ts's need for
the real mesh/transport stores — mirrors the MarketplaceRefresh.test.ts
precedent from 02-02 for the same class of vi.mock-hoisting conflict.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
31389bcc3c
commit
abdfa07a77
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
|
import { ref, onMounted, onUnmounted, onActivated, onDeactivated, nextTick, watch, computed } from 'vue'
|
||||||
import { useMeshStore } from '@/stores/mesh'
|
import { useMeshStore } from '@/stores/mesh'
|
||||||
import type { NodePosition } from '@/stores/mesh'
|
import type { NodePosition } from '@/stores/mesh'
|
||||||
import L from 'leaflet'
|
import L from 'leaflet'
|
||||||
@@ -361,34 +361,76 @@ watch(
|
|||||||
|
|
||||||
let resizeObserver: ResizeObserver | null = null
|
let resizeObserver: ResizeObserver | null = null
|
||||||
|
|
||||||
onMounted(() => {
|
// Only-while-visible: the window resize listener and ResizeObserver follow
|
||||||
|
// the Mesh tab's activate/deactivate lifecycle now that this component
|
||||||
|
// survives a tab switch under KeepAlive (T-02-03/D-03 — Mesh.vue joined
|
||||||
|
// KEEP_ALIVE_PATHS in 02-04). The Leaflet instance itself is never destroyed
|
||||||
|
// or recreated by any of this: initMap()'s own `if (!mapContainer.value ||
|
||||||
|
// map) return` guard already makes construction idempotent, so exactly one
|
||||||
|
// map is built per session no matter how many times armMapVisibility() runs.
|
||||||
|
// This only quiesces/repairs the listener + observer and re-tiles the map
|
||||||
|
// around it, so a map laid out while off screen doesn't paint at a stale or
|
||||||
|
// partial size on return.
|
||||||
|
//
|
||||||
|
// Called from BOTH onMounted and onActivated: onActivated is a documented
|
||||||
|
// no-op outside a <KeepAlive> boundary (see Mesh.vue's own armMeshLive for
|
||||||
|
// the same idiom), so a bare mount (a unit test, or any future non-KeepAlive
|
||||||
|
// usage) must not silently skip this setup.
|
||||||
|
function armMapVisibility() {
|
||||||
|
window.removeEventListener('resize', handleResize)
|
||||||
window.addEventListener('resize', handleResize)
|
window.addEventListener('resize', handleResize)
|
||||||
|
|
||||||
if (mapContainer.value) {
|
if (mapContainer.value) {
|
||||||
resizeObserver = new ResizeObserver((entries) => {
|
if (!resizeObserver) {
|
||||||
const entry = entries[0]
|
resizeObserver = new ResizeObserver((entries) => {
|
||||||
if (!entry) return
|
const entry = entries[0]
|
||||||
const { height } = entry.contentRect
|
if (!entry) return
|
||||||
if (!map && height > 10) {
|
const { height } = entry.contentRect
|
||||||
initMap()
|
if (!map && height > 10) {
|
||||||
} else if (map) {
|
initMap()
|
||||||
try { map.invalidateSize() } catch { /* destroyed */ }
|
} else if (map) {
|
||||||
}
|
try { map.invalidateSize() } catch { /* destroyed */ }
|
||||||
})
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
resizeObserver.observe(mapContainer.value)
|
resizeObserver.observe(mapContainer.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback init
|
// Fallback init for the very first mount (idempotent no-op once `map` is
|
||||||
|
// already set — mirrors initMap's own not-yet-laid-out retry loop).
|
||||||
setTimeout(initMap, 300)
|
setTimeout(initMap, 300)
|
||||||
|
|
||||||
|
// A map laid out while the tab was off screen may have settled at a
|
||||||
|
// zero/partial size; re-tile it at its real size now that it's back on
|
||||||
|
// screen instead of showing an unsized or partially tiled canvas.
|
||||||
|
if (map) {
|
||||||
|
void nextTick(() => {
|
||||||
|
try { map?.invalidateSize() } catch { /* destroyed */ }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function disarmMapVisibility() {
|
||||||
|
window.removeEventListener('resize', handleResize)
|
||||||
|
if (resizeObserver) resizeObserver.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vue fires onActivated immediately after onMounted on a KeepAlive-wrapped
|
||||||
|
// component's first mount, which would otherwise double the fallback
|
||||||
|
// setTimeout(initMap, 300) call on every fresh page load (harmless since
|
||||||
|
// initMap is idempotent, but redundant). This flag lets onMounted's call
|
||||||
|
// count as the first activation's arm, so onActivated only re-arms on a
|
||||||
|
// genuine later reactivation, matching Mesh.vue's own meshFreshMount idiom.
|
||||||
|
let mapMountFresh = true
|
||||||
|
onActivated(() => {
|
||||||
|
if (mapMountFresh) { mapMountFresh = false; return }
|
||||||
|
armMapVisibility()
|
||||||
})
|
})
|
||||||
|
onMounted(() => armMapVisibility())
|
||||||
|
onDeactivated(() => disarmMapVisibility())
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('resize', handleResize)
|
disarmMapVisibility()
|
||||||
stopSharing()
|
stopSharing()
|
||||||
if (resizeObserver) {
|
|
||||||
resizeObserver.disconnect()
|
|
||||||
resizeObserver = null
|
|
||||||
}
|
|
||||||
if (map) {
|
if (map) {
|
||||||
map.remove()
|
map.remove()
|
||||||
map = null
|
map = null
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
// 02-05 Task 2: bounds MeshMap.vue's Leaflet instance across Mesh.vue's
|
||||||
|
// activate/deactivate lifecycle (established in 02-04 — Mesh.vue joined
|
||||||
|
// KEEP_ALIVE_PATHS). Kept in its own file rather than
|
||||||
|
// src/views/__tests__/meshTabCache.test.ts because `vi.mock('@/stores/mesh')`
|
||||||
|
// and `vi.mock('leaflet')` are hoisted file-wide and would otherwise clobber
|
||||||
|
// that file's need for the REAL mesh/transport stores (mirrors the
|
||||||
|
// MarketplaceRefresh.test.ts precedent set in 02-02 for the same class of
|
||||||
|
// vi.mock-hoisting conflict — Rule 1/3 auto-fix, documented in
|
||||||
|
// 02-05-SUMMARY.md).
|
||||||
|
//
|
||||||
|
// FLAGGED (see 02-05-SUMMARY.md): RESEARCH.md's premise that Mesh.vue owns a
|
||||||
|
// live D3 force simulation does not hold for this codebase — a full grep for
|
||||||
|
// `d3`/`forceSimulation`/`simulation` across neode-ui/src turns up nothing in
|
||||||
|
// Mesh.vue's component tree (or MeshMap.vue's); the only D3 force simulation
|
||||||
|
// in the codebase belongs to NetworkMap.vue (Federation.vue's graph, out of
|
||||||
|
// this plan's scope). This file therefore only covers the Leaflet map's
|
||||||
|
// activate/deactivate lifecycle — the D3-specific truths from the plan are
|
||||||
|
// vacuously satisfied (there is nothing to leak).
|
||||||
|
|
||||||
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import { KeepAlive, defineComponent, h, ref } from 'vue'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import MeshMap from '../MeshMap.vue'
|
||||||
|
|
||||||
|
const mapInstances: Array<{ invalidateSize: ReturnType<typeof vi.fn>; remove: ReturnType<typeof vi.fn> }> = []
|
||||||
|
let resizeObserverInstances: Array<{ observe: ReturnType<typeof vi.fn>; disconnect: ReturnType<typeof vi.fn> }> = []
|
||||||
|
|
||||||
|
vi.mock('@/stores/mesh', () => ({
|
||||||
|
useMeshStore: () => ({
|
||||||
|
nodePositions: new Map(),
|
||||||
|
federatedPositions: new Map(),
|
||||||
|
peers: [],
|
||||||
|
status: null,
|
||||||
|
deadmanStatus: null,
|
||||||
|
updateSelfPosition: vi.fn(),
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('leaflet', () => ({
|
||||||
|
default: {
|
||||||
|
map: vi.fn(() => {
|
||||||
|
const instance = { invalidateSize: vi.fn(), fitBounds: vi.fn(), remove: vi.fn(), setView: vi.fn() }
|
||||||
|
mapInstances.push(instance)
|
||||||
|
return instance
|
||||||
|
}),
|
||||||
|
tileLayer: vi.fn(() => ({ addTo: vi.fn() })),
|
||||||
|
layerGroup: vi.fn(() => ({ addTo: vi.fn(), clearLayers: vi.fn(), addLayer: vi.fn() })),
|
||||||
|
divIcon: vi.fn((opts: unknown) => opts),
|
||||||
|
marker: vi.fn(() => ({ bindPopup: vi.fn() })),
|
||||||
|
polyline: vi.fn(() => ({})),
|
||||||
|
latLngBounds: vi.fn(() => ({})),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const Other = defineComponent({ name: 'Other', render: () => h('div', 'other') })
|
||||||
|
|
||||||
|
function mountMapHost() {
|
||||||
|
const Host = defineComponent({
|
||||||
|
setup() {
|
||||||
|
const show = ref(true)
|
||||||
|
return { show }
|
||||||
|
},
|
||||||
|
render() {
|
||||||
|
return h(KeepAlive, null, () =>
|
||||||
|
this.show ? h(MeshMap, { key: 'map' }) : h(Other, { key: 'other' }),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return mount(Host)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleTab(wrapper: ReturnType<typeof mountMapHost>, show: boolean) {
|
||||||
|
;(wrapper.vm as unknown as { show: boolean }).show = show
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Mesh graphics lifecycle (Task 2): Leaflet map (MeshMap.vue)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
mapInstances.length = 0
|
||||||
|
resizeObserverInstances = []
|
||||||
|
vi.stubGlobal('ResizeObserver', vi.fn(() => {
|
||||||
|
const inst = { observe: vi.fn(), disconnect: vi.fn(), unobserve: vi.fn() }
|
||||||
|
resizeObserverInstances.push(inst)
|
||||||
|
return inst
|
||||||
|
}))
|
||||||
|
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({
|
||||||
|
height: 200, width: 200, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0, toJSON: () => undefined,
|
||||||
|
} as DOMRect)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('entering and leaving the tab three times constructs exactly one map instance', async () => {
|
||||||
|
const wrapper = mountMapHost()
|
||||||
|
await flushPromises()
|
||||||
|
vi.advanceTimersByTime(300) // the onMounted-arm's fallback initMap()
|
||||||
|
await flushPromises()
|
||||||
|
expect(mapInstances.length).toBe(1)
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
await toggleTab(wrapper, false)
|
||||||
|
await toggleTab(wrapper, true)
|
||||||
|
await flushPromises()
|
||||||
|
}
|
||||||
|
expect(mapInstances.length).toBe(1)
|
||||||
|
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reactivating calls the Leaflet map size-invalidation so a map laid out off screen re-tiles at its real size', async () => {
|
||||||
|
const wrapper = mountMapHost()
|
||||||
|
await flushPromises()
|
||||||
|
vi.advanceTimersByTime(300)
|
||||||
|
await flushPromises()
|
||||||
|
const instance = mapInstances[0]!
|
||||||
|
instance.invalidateSize.mockClear()
|
||||||
|
|
||||||
|
await toggleTab(wrapper, false)
|
||||||
|
await toggleTab(wrapper, true)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(instance.invalidateSize).toHaveBeenCalled()
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('a window resize listener is removed on deactivate and re-added exactly once on activate', async () => {
|
||||||
|
const addSpy = vi.spyOn(window, 'addEventListener')
|
||||||
|
const removeSpy = vi.spyOn(window, 'removeEventListener')
|
||||||
|
const wrapper = mountMapHost()
|
||||||
|
await flushPromises()
|
||||||
|
// armMapVisibility's own idempotent idiom (remove-then-add) means mount
|
||||||
|
// itself issues one defensive remove alongside the one add — baseline
|
||||||
|
// both counts here rather than assuming remove starts at zero.
|
||||||
|
const addCountAtMount = addSpy.mock.calls.filter((c) => c[0] === 'resize').length
|
||||||
|
const removeCountAtMount = removeSpy.mock.calls.filter((c) => c[0] === 'resize').length
|
||||||
|
expect(addCountAtMount).toBe(1)
|
||||||
|
|
||||||
|
await toggleTab(wrapper, false)
|
||||||
|
expect(removeSpy.mock.calls.filter((c) => c[0] === 'resize').length).toBe(removeCountAtMount + 1)
|
||||||
|
|
||||||
|
await toggleTab(wrapper, true)
|
||||||
|
expect(addSpy.mock.calls.filter((c) => c[0] === 'resize').length).toBe(addCountAtMount + 1) // once more, not twice
|
||||||
|
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deactivating disconnects the ResizeObserver; reactivating re-observes the container', async () => {
|
||||||
|
const wrapper = mountMapHost()
|
||||||
|
await flushPromises()
|
||||||
|
const inst = resizeObserverInstances[0]!
|
||||||
|
expect(inst.observe).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
await toggleTab(wrapper, false)
|
||||||
|
expect(inst.disconnect).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
await toggleTab(wrapper, true)
|
||||||
|
expect(inst.observe).toHaveBeenCalledTimes(2)
|
||||||
|
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user