mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 06:11:45 +00:00
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user