From f02253a44c2245e2a6d7d9e291d186eb9061b603 Mon Sep 17 00:00:00 2001 From: Maurice Date: Wed, 7 Oct 2026 16:23:47 +0200 Subject: [PATCH] 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. --- .../Planner/DayDetailPanel.test.tsx | 24 +++++++++++++++++- .../Planner/LocationSelect.test.tsx | 25 ++++++++++++++++++- .../src/components/Planner/LocationSelect.tsx | 15 ++++++++--- client/src/components/Planner/useDayDetail.ts | 14 ++++++++--- 4 files changed, 69 insertions(+), 9 deletions(-) diff --git a/client/src/components/Planner/DayDetailPanel.test.tsx b/client/src/components/Planner/DayDetailPanel.test.tsx index d0734c600..17b4b852e 100644 --- a/client/src/components/Planner/DayDetailPanel.test.tsx +++ b/client/src/components/Planner/DayDetailPanel.test.tsx @@ -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(); + rerender(); + + 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', () => diff --git a/client/src/components/Planner/LocationSelect.test.tsx b/client/src/components/Planner/LocationSelect.test.tsx index f625ebdca..5589815cd 100644 --- a/client/src/components/Planner/LocationSelect.test.tsx +++ b/client/src/components/Planner/LocationSelect.test.tsx @@ -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(); + 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(); + }); }); diff --git a/client/src/components/Planner/LocationSelect.tsx b/client/src/components/Planner/LocationSelect.tsx index 3d8dde4e5..2e5150fe5 100644 --- a/client/src/components/Planner/LocationSelect.tsx +++ b/client/src/components/Planner/LocationSelect.tsx @@ -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) diff --git a/client/src/components/Planner/useDayDetail.ts b/client/src/components/Planner/useDayDetail.ts index 942ac8d55..65f4a2606 100644 --- a/client/src/components/Planner/useDayDetail.ts +++ b/client/src/components/Planner/useDayDetail.ts @@ -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])