fix(planner): drop search and day-panel answers that arrive too late

The desktop day panel stays mounted across a day switch, so a forecast
or the stays still loading for the previous day overwrote the new day's
once they arrived. The location search in the transport form showed
results for text the user had already changed, or refilled the list
after a pick. Both now drop an answer for a request that no longer
matches.
This commit is contained in:
Maurice
2026-10-07 16:23:47 +02:00
parent 205759e3cf
commit f02253a44c
4 changed files with 69 additions and 9 deletions
@@ -1,4 +1,4 @@
// FE-PLANNER-DAYDETAIL-001 to FE-PLANNER-DAYDETAIL-098
// FE-PLANNER-DAYDETAIL-001 to FE-PLANNER-DAYDETAIL-101
import React from 'react';
import { fireEvent, render, screen, waitFor, within, act } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
@@ -156,6 +156,28 @@ describe('DayDetailPanel', () => {
expect(spinner.querySelector('.animate-spin')).not.toBeNull();
});
it('FE-PLANNER-DAYDETAIL-101: a late forecast for the previous day never replaces the one for the current day', async () => {
const { delay } = await import('msw');
server.use(
http.get('/api/weather/detailed', async ({ request }) => {
const date = new URL(request.url).searchParams.get('date');
if (date === '2025-06-15') {
await delay(400);
return HttpResponse.json({ main: 'Rain', temp: 11, temp_min: 9, temp_max: 13, description: 'rain' });
}
return HttpResponse.json({ main: 'Clear', temp: 27, temp_min: 20, temp_max: 30, description: 'sunny' });
}),
);
const next = buildDay({ id: 2, trip_id: 1, date: '2025-06-16', title: 'Day two' });
const { rerender } = render(<DayDetailPanel {...defaultProps} days={[day, next]} lat={48.8566} lng={2.3522} />);
rerender(<DayDetailPanel {...defaultProps} day={next} days={[day, next]} lat={48.8566} lng={2.3522} />);
expect(await screen.findByText(/27°C/)).toBeInTheDocument();
await act(async () => { await new Promise((r) => setTimeout(r, 600)); });
expect(screen.queryByText(/11°C/)).toBeNull();
expect(screen.getByText(/27°C/)).toBeInTheDocument();
});
it('FE-PLANNER-DAYDETAIL-010: weather data renders temperature in Celsius', async () => {
server.use(
http.get('/api/weather/detailed', () =>
@@ -1,4 +1,4 @@
// FE-PLANNER-LOCSEL-001 to FE-PLANNER-LOCSEL-024
// FE-PLANNER-LOCSEL-001 to FE-PLANNER-LOCSEL-025
import { useState } from 'react';
import { delay, http, HttpResponse } from 'msw';
import userEvent from '@testing-library/user-event';
@@ -341,4 +341,27 @@ describe('LocationSelect', () => {
await user.keyboard('{ArrowDown}{Enter}');
expect(onPick).toHaveBeenLastCalledWith(expect.objectContaining({ name: 'Bach Suites Saigon' }));
});
it('FE-PLANNER-LOCSEL-025: an answer for text the user has since changed never replaces the newer results', async () => {
const user = userEvent.setup();
server.use(http.post('/api/maps/search', async ({ request }) => {
const { query } = (await request.json()) as { query: string };
if (query === 'Gare') {
await delay(600);
return HttpResponse.json({ places: [GARE] });
}
return HttpResponse.json({ places: [LYON] });
}));
render(<Host />);
const input = screen.getByRole('textbox');
await user.type(input, 'Gare');
await settle(400);
await user.type(input, ' de Lyon');
expect(await screen.findByText('Gare de Lyon')).toBeInTheDocument();
await settle(800);
expect(screen.queryByText('Gare du Nord')).not.toBeInTheDocument();
expect(screen.getByText('Gare de Lyon')).toBeInTheDocument();
});
});
@@ -57,20 +57,27 @@ export default function LocationSelect({ value, onChange, placeholder, style, pl
setResults([])
return
}
// Clearing the timer does nothing to a request already out: an answer for
// text the user has since changed, or that lands after a pick, is dropped.
let stale = false
debounceRef.current = setTimeout(async () => {
setLoading(true)
try {
const data = await mapsApi.search(trimmed, placeLang, locationBias)
if (stale) return
setResults(data.places || [])
setHighlight(-1)
} catch {
setResults([])
if (!stale) setResults([])
} finally {
setLoading(false)
if (!stale) setLoading(false)
}
}, 320)
return () => { if (debounceRef.current) clearTimeout(debounceRef.current) }
}, [query, value, locale])
return () => {
stale = true
if (debounceRef.current) clearTimeout(debounceRef.current)
}
}, [query, value, locale, placeLang])
const pick = (r: any) => {
const lat = Number(r.lat)
+11 -3
View File
@@ -47,19 +47,26 @@ export function useDayDetail(day: Day | null, days: Day[], tripId: number, lat:
const isForDay = (a: Accommodation) => day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false
// Both effects drop an answer that arrives after the day changed: the panel
// stays mounted across a day switch, and the previous day's late response
// would otherwise overwrite the new day's forecast and hotel.
useEffect(() => {
if (!day?.date || !lat || !lng) { setWeather(null); return }
let cancelled = false
setLoading(true)
weatherApi.getDetailed(lat, lng, day.date, language)
.then(data => setWeather(data.error ? null : data))
.catch(() => setWeather(null))
.finally(() => setLoading(false))
.then(data => { if (!cancelled) setWeather(data.error ? null : data) })
.catch(() => { if (!cancelled) setWeather(null) })
.finally(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
}, [day?.date, lat, lng, language])
useEffect(() => {
if (!tripId) return
let cancelled = false
accommodationsApi.list(tripId)
.then(data => {
if (cancelled) return
const all: Accommodation[] = data.accommodations || []
setAccommodations(all)
const allForDay = all.filter(isForDay)
@@ -67,6 +74,7 @@ export function useDayDetail(day: Day | null, days: Day[], tripId: number, lat:
setAccommodation(allForDay[0] || null)
})
.catch(() => {})
return () => { cancelled = true }
}, [tripId, day?.id])
useEffect(() => { if (day) setHotelDayRange(defaultHotelDayRange(day)) }, [day?.id])