fix(map,mcp): review follow-ups for the issue-fix batch

- mapbox-gl (unlike maplibre) still emits the map contextmenu after a
  right-button rotate/pitch drag on Windows - guard it with the pressed
  position so ending a rotate can't open the Add-Place form (#1398)
- a long-press whose fire was deduped (or that never yields a click) no
  longer leaves suppressNextClick armed to swallow a later real tap
- MCP_SSE_KEEPALIVE=0 keeps the open-stream-counts-as-activity
  guarantee: the touch interval survives, only the pings stop (#1414)
- swagger-ui-dist ships @scarf/scarf install-time analytics - disabled
  via scarfSettings in the root package.json, TREK sends no telemetry
- Budget wiki currency list: 47 incl. KGS (#1400)
This commit is contained in:
Maurice
2026-07-04 23:00:36 +02:00
committed by Maurice
parent 90a2833011
commit 094622f06d
6 changed files with 83 additions and 9 deletions
@@ -344,6 +344,52 @@ describe('MapViewGL', () => {
expect(onContext.mock.calls[0][0].latlng).toEqual({ lat: 48.8566, lng: 2.3522 })
})
it('FE-COMP-MAPVIEWGL-012: a right-button rotate/pitch drag does not open Add-Place on release (#1398)', async () => {
const onContext = vi.fn()
render(<MapViewGL places={[]} fitKey={1} onMapContextMenu={onContext} />)
await act(async () => {})
const handler = glMap.on.mock.calls.find(c => c[0] === 'contextmenu')?.[1] as (e: unknown) => void
act(() => {
// mapbox-gl (unlike maplibre) still emits contextmenu after a right-drag
// on Windows — the movement guard must drop it.
glCanvasContainer.dispatchEvent(new MouseEvent('mousedown', { button: 2, clientX: 10, clientY: 10, bubbles: true }))
handler({ lngLat: { lat: 1, lng: 2 }, originalEvent: new MouseEvent('contextmenu', { clientX: 140, clientY: 90 }) })
})
expect(onContext).not.toHaveBeenCalled()
// ...while a stationary right-click still fires.
act(() => {
glCanvasContainer.dispatchEvent(new MouseEvent('mousedown', { button: 2, clientX: 10, clientY: 10, bubbles: true }))
handler({ lngLat: { lat: 1, lng: 2 }, originalEvent: new MouseEvent('contextmenu', { clientX: 11, clientY: 10 }) })
})
expect(onContext).toHaveBeenCalledTimes(1)
})
it('FE-COMP-MAPVIEWGL-013: a stale long-press suppression never swallows a later real tap (#1398)', async () => {
vi.useFakeTimers()
try {
const onContext = vi.fn()
const onMapClick = vi.fn()
render(<MapViewGL places={[]} fitKey={1} onMapContextMenu={onContext} onMapClick={onMapClick} />)
await act(async () => {})
// Long-press fires (arms the suppression), but no click follows.
act(() => {
glCanvasContainer.dispatchEvent(touchEvent('touchstart', [{ clientX: 30, clientY: 40 }]))
vi.advanceTimersByTime(650)
})
expect(onContext).toHaveBeenCalledTimes(1)
// The NEXT gesture starts fresh: its tap must reach the map click handler.
const clickHandler = glMap.on.mock.calls.find(c => c[0] === 'click')?.[1] as (e: unknown) => void
act(() => {
glCanvasContainer.dispatchEvent(touchEvent('touchstart', [{ clientX: 80, clientY: 90 }]))
glCanvasContainer.dispatchEvent(touchEvent('touchend', []))
clickHandler({ lngLat: { lat: 3, lng: 4 }, originalEvent: { target: glCanvasContainer } })
})
expect(onMapClick).toHaveBeenCalledWith({ latlng: { lat: 3, lng: 4 } })
} finally {
vi.useRealTimers()
}
})
it('FE-COMP-MAPVIEWGL-010: middle-click still opens Add-Place (#1398 regression guard)', async () => {
const onContext = vi.fn()
render(<MapViewGL places={[]} fitKey={1} onMapContextMenu={onContext} />)
+23 -4
View File
@@ -501,14 +501,25 @@ export function MapViewGL({
// does, which is why the OSM map already worked on mobile).
const canvas = map.getCanvasContainer()
let lastContextFire = 0
const fireContext = (lngLat: { lat: number; lng: number }, originalEvent: MouseEvent | TouchEvent) => {
const fireContext = (lngLat: { lat: number; lng: number }, originalEvent: MouseEvent | TouchEvent): boolean => {
// Android fires a native contextmenu for a long-press on top of our own
// timer — dedupe so the form doesn't open twice.
if (Date.now() - lastContextFire < 700) return
if (Date.now() - lastContextFire < 700) return false
lastContextFire = Date.now()
onClickRefs.current.context?.({ latlng: { lat: lngLat.lat, lng: lngLat.lng }, originalEvent })
return true
}
// MapLibre swallows the map contextmenu at the end of a right-button
// rotate/pitch drag, but mapbox-gl does NOT — and on Windows the DOM
// contextmenu arrives after mouseup, so every rotate would end by opening
// the Add-Place form. Track the right-button press position and drop a
// contextmenu whose pointer travelled like a drag rather than a click.
let rightDownAt: { x: number; y: number } | null = null
const onAuxDown = (ev: MouseEvent) => {
if (ev.button === 2) {
rightDownAt = { x: ev.clientX, y: ev.clientY }
return
}
if (ev.button !== 1) return
ev.preventDefault()
const rect = canvas.getBoundingClientRect()
@@ -522,6 +533,9 @@ export function MapViewGL({
canvas.addEventListener('mousedown', onAuxDown)
canvas.addEventListener('auxclick', onAuxClick)
map.on('contextmenu', (e: { lngLat: { lat: number; lng: number }; originalEvent: MouseEvent }) => {
const down = rightDownAt
rightDownAt = null
if (down && Math.hypot(e.originalEvent.clientX - down.x, e.originalEvent.clientY - down.y) > 5) return
fireContext(e.lngLat, e.originalEvent)
})
// Touch long-press: 600 ms hold (Leaflet's tapHold feel) with a 10 px
@@ -534,6 +548,10 @@ export function MapViewGL({
lpStart = null
}
const onTouchStart = (ev: TouchEvent) => {
// A fresh gesture clears a stale suppression flag: not every long-press
// is followed by a click (finger drag after the hold, Android's native
// contextmenu path), and the flag must never swallow a later real tap.
suppressNextClick = false
if (ev.touches.length !== 1) { cancelLongPress(); return }
if ((ev.target as HTMLElement).closest('.mapboxgl-marker, .maplibregl-marker')) return
const t = ev.touches[0]
@@ -544,8 +562,9 @@ export function MapViewGL({
const rect = canvas.getBoundingClientRect()
const lngLat = map.unproject([lpStart.x - rect.left, lpStart.y - rect.top])
lpStart = null
suppressNextClick = true
fireContext({ lat: lngLat.lat, lng: lngLat.lng }, ev)
// Only suppress the tap when OUR fire opened the form — if the native
// contextmenu beat us to it (dedupe), no click needs swallowing.
if (fireContext({ lat: lngLat.lat, lng: lngLat.lng }, ev)) suppressNextClick = true
}, 600)
}
const onTouchMove = (ev: TouchEvent) => {
+4
View File
@@ -28,6 +28,10 @@
"concurrently": "^10.0.3",
"unrun": "^0.3.1"
},
"comment:scarfSettings": "swagger-ui-dist ships @scarf/scarf install-time analytics; TREK sends no telemetry, so it stays off.",
"scarfSettings": {
"enabled": false
},
"comment:overrides": "Force a single React 19 across the workspace so the test renderer (@testing-library/react) and the app share one react-dom.",
"overrides": {
"react": "19.2.6",
+8 -3
View File
@@ -110,7 +110,12 @@ const KEEPALIVE_MS = resolveKeepaliveMs(process.env.MCP_SSE_KEEPALIVE);
* stream is still connected.
*/
function armSseKeepalive(res: Response, touch?: () => void): void {
if (KEEPALIVE_MS <= 0) return;
// With pings disabled (MCP_SSE_KEEPALIVE=0) an open stream must STILL count
// as session activity, or the sweep would evict a live idle client — keep
// the interval for touch() and only skip the writes.
const writePings = KEEPALIVE_MS > 0;
if (!writePings && !touch) return;
const intervalMs = writePings ? KEEPALIVE_MS : 25_000;
const timer = setInterval(() => {
if (!res.headersSent) return;
const ct = String(res.getHeader('content-type') ?? '');
@@ -118,9 +123,9 @@ function armSseKeepalive(res: Response, touch?: () => void): void {
clearInterval(timer);
return;
}
res.write(': keepalive\n\n');
if (writePings) res.write(': keepalive\n\n');
touch?.();
}, KEEPALIVE_MS);
}, intervalMs);
timer.unref();
res.once('close', () => clearInterval(timer));
}
+1 -1
View File
@@ -16,7 +16,7 @@ Open the **Budget** tab inside the trip planner. The tab is only visible when th
## Currency
Use the currency picker in the Budget toolbar to select one currency for the entire trip. 46 currencies are supported (EUR, USD, GBP, JPY, CHF, CZK, PLN, SEK, NOK, DKK, TRY, THB, AUD, CAD, NZD, BRL, MXN, INR, IDR, MYR, PHP, SGD, KRW, CNY, HKD, TWD, ZAR, AED, SAR, ILS, EGP, MAD, HUF, RON, BGN, HRK, ISK, RUB, UAH, BDT, LKR, VND, CLP, COP, PEN, ARS). All amounts are displayed in this currency.
Use the currency picker in the Budget toolbar to select one currency for the entire trip. 47 currencies are supported (EUR, USD, GBP, JPY, CHF, CZK, PLN, SEK, NOK, DKK, TRY, THB, AUD, CAD, NZD, BRL, MXN, INR, IDR, MYR, PHP, SGD, KRW, CNY, HKD, TWD, ZAR, AED, SAR, ILS, EGP, MAD, HUF, RON, BGN, HRK, ISK, RUB, UAH, KGS, BDT, LKR, VND, CLP, COP, PEN, ARS). All amounts are displayed in this currency.
## Categories
+1 -1
View File
@@ -186,7 +186,7 @@ For setup instructions, see [MCP-Overview](MCP-Overview).
| `MCP_RATE_LIMIT` | Max MCP API requests per user per minute | `300` |
| `MCP_MAX_SESSION_PER_USER` | Max concurrent MCP sessions per user | `20` |
| `MCP_SESSION_TTL` | Session idle timeout in seconds (max 86400) | `3600` |
| `MCP_SSE_KEEPALIVE` | SSE keep-alive ping interval in seconds — keeps the stream alive through reverse proxies; `0` disables it | `25` |
| `MCP_SSE_KEEPALIVE` | SSE keep-alive ping interval in seconds — keeps the stream alive through reverse proxies. `0` disables the pings; an open stream still refreshes the session's idle timeout. | `25` |
---