fix(search-autocomplete): address PR #542 review issues

- Fix race condition: AbortController cancels in-flight autocomplete
  requests on each keystroke; stale responses no longer overwrite fresh ones
- Remove acTrigger state hack; onFocus calls fetchSuggestions directly
- Cap autocomplete input at 200 chars server-side (400 on violation)
- Filter Nominatim suggestions with empty osm_id segments
- Revert getPlaceDetails OSM branch from unconditional parallel fetch to
  conditional serial: Nominatim called only when Overpass lacks coords/address
- Wire places.loadingDetails i18n key to Loader2 spinner via aria-label/role
- Add tests: MAPS-017, MAPS-040c, MAPS-093, FE-MAPS-004
This commit is contained in:
jubnl
2026-04-15 04:16:56 +02:00
parent 35321076cf
commit 607498cabe
7 changed files with 133 additions and 35 deletions
+2 -2
View File
@@ -347,8 +347,8 @@ export const journeyApi = {
export const mapsApi = {
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => r.data),
autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }) =>
apiClient.post('/maps/autocomplete', { input, lang, locationBias }).then(r => r.data),
autocomplete: (input: string, lang?: string, locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } }, signal?: AbortSignal) =>
apiClient.post('/maps/autocomplete', { input, lang, locationBias }, { signal }).then(r => r.data),
details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => r.data),
placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => r.data),
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => r.data),
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef, useMemo } from 'react'
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect'
import { mapsApi } from '../../api/client'
@@ -87,7 +87,7 @@ export default function PlaceFormModal({
const [acSuggestions, setAcSuggestions] = useState<{ placeId: string; mainText: string; secondaryText: string }[]>([])
const [acHighlight, setAcHighlight] = useState(-1)
const acDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [acTrigger, setAcTrigger] = useState(0)
const acAbortRef = useRef<AbortController | null>(null)
const toast = useToast()
const { t, language } = useTranslation()
const { hasMapsKey } = useAuthStore()
@@ -151,7 +151,29 @@ export default function PlaceFormModal({
return { low: { lat: minLat, lng: minLng }, high: { lat: maxLat, lng: maxLng } }
}, [places])
// Debounced autocomplete
// Autocomplete fetch — aborts any in-flight request before starting a new one
const fetchSuggestions = useCallback(async (query: string) => {
if (query.length < 2 || isGoogleMapsUrl(query)) {
setAcSuggestions([])
setAcHighlight(-1)
return
}
acAbortRef.current?.abort()
const controller = new AbortController()
acAbortRef.current = controller
try {
const result = await mapsApi.autocomplete(query, language, locationBias, controller.signal)
setAcSuggestions(result.suggestions || [])
setAcHighlight(-1)
} catch (err: unknown) {
if (err instanceof Error && err.name === 'AbortError') return
if (err instanceof Error && err.name === 'CanceledError') return // axios abort
console.error('Autocomplete failed:', err)
setAcSuggestions([])
}
}, [language, locationBias])
// Debounce effect — only watches mapsSearch
useEffect(() => {
if (acDebounceRef.current) clearTimeout(acDebounceRef.current)
@@ -162,21 +184,12 @@ export default function PlaceFormModal({
return
}
acDebounceRef.current = setTimeout(async () => {
try {
const result = await mapsApi.autocomplete(trimmed, language, locationBias)
setAcSuggestions(result.suggestions || [])
setAcHighlight(-1)
} catch (err) {
console.error('Autocomplete failed:', err)
setAcSuggestions([])
}
}, 300)
acDebounceRef.current = setTimeout(() => fetchSuggestions(trimmed), 300)
return () => {
if (acDebounceRef.current) clearTimeout(acDebounceRef.current)
}
}, [mapsSearch, language, locationBias, acTrigger])
}, [mapsSearch, fetchSuggestions])
const handleChange = (field, value) => {
setForm(prev => ({ ...prev, [field]: value }))
@@ -366,7 +379,7 @@ export default function PlaceFormModal({
onBlur={() => setTimeout(() => setAcSuggestions([]), 150)}
onFocus={() => {
if (mapsSearch.trim().length >= 2 && acSuggestions.length === 0 && mapsResults.length === 0) {
setAcTrigger(prev => prev + 1)
fetchSuggestions(mapsSearch.trim())
}
}}
placeholder={t('places.mapsSearchPlaceholder')}
@@ -436,8 +449,8 @@ export default function PlaceFormModal({
className="form-input"
/>
{isSearchingMaps && (
<div className="absolute right-2.5 top-0 bottom-0 flex items-center">
<Loader2 className="w-4 h-4 animate-spin text-slate-400" />
<div className="absolute right-2.5 top-0 bottom-0 flex items-center" role="status" aria-label={t('places.loadingDetails')}>
<Loader2 className="w-4 h-4 animate-spin text-slate-400" aria-hidden="true" />
</div>
)}
</div>