From 1b0f0435069ef5547cafb235e42bfc3c1a8a0664 Mon Sep 17 00:00:00 2001 From: Maurice Date: Tue, 27 Oct 2026 15:02:24 +0100 Subject: [PATCH] fix(roadtrip): stop pacing the joins between days that come back from the route cache With connected days, every join between two days kept its one-request-a-second slot even when the route came back from the device's cache, so a long road trip still waited about a second per join on every open. A join answered from the cache now gives its slot back; requests that go out stay spaced as before. Refs #2794 --- .../Roadtrip/useRoadtripRoutes.test.ts | 28 +++++++++++++++++++ .../components/Roadtrip/useRoadtripRoutes.ts | 21 +++++++++++--- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/client/src/components/Roadtrip/useRoadtripRoutes.test.ts b/client/src/components/Roadtrip/useRoadtripRoutes.test.ts index 99121f09c..6a744cd04 100644 --- a/client/src/components/Roadtrip/useRoadtripRoutes.test.ts +++ b/client/src/components/Roadtrip/useRoadtripRoutes.test.ts @@ -803,6 +803,34 @@ describe('useRoadtripRoutes', () => { } }) + it('FE-ROADTRIP-ROUTES-052: a join this device routed before is not paced like a request (#2794)', async () => { + // A join kept its request slot whatever answered it, so a month of connected days + // waited half a minute on every open with every road already on the device. The + // clock is held still, so every answer reads as one from the cache; a join that + // still waited its turn would wait 1.1 s of real time, past what waitFor gives it. + const clock = vi.spyOn(performance, 'now').mockImplementation(() => 10_000) + try { + const MUNICH: [number, number] = [48.1374, 11.5755] + const INNSBRUCK: [number, number] = [47.2692, 11.4041] + const BOZEN: [number, number] = [46.4983, 11.3548] + setting({ roadtrip_connect_days: true }) + const daysList = [day(1, 1), day(2, 2), day(3, 3)] + const assignments = { + ...map(1, [{ id: 1, at: HAMBURG, time: '09:00' }, { id: 2, at: LUENEBURG }]), + ...map(2, [{ id: 3, at: BERLIN, time: '09:00' }, { id: 4, at: MUNICH }]), + ...map(3, [{ id: 5, at: INNSBRUCK, time: '09:00' }, { id: 6, at: BOZEN }]), + } as AssignmentsMap + renderHook(() => useRoadtripRoutes(7, daysList, assignments)) + + await waitFor(() => { + expect(askedFor(LUENEBURG, BERLIN)).toHaveLength(1) + expect(askedFor(MUNICH, INNSBRUCK)).toHaveLength(1) + }) + } finally { + clock.mockRestore() + } + }) + it('FE-ROADTRIP-ROUTES-051: a join OSRM answered in the engine’s place is drawn, flagged, and asked for again on request', async () => { // Filed like any answer, it stood in for the weighed road until the page was loaded // again, and a picker on it read the stand-in line as the road already taken. diff --git a/client/src/components/Roadtrip/useRoadtripRoutes.ts b/client/src/components/Roadtrip/useRoadtripRoutes.ts index 87011f441..2a7b31da1 100644 --- a/client/src/components/Roadtrip/useRoadtripRoutes.ts +++ b/client/src/components/Roadtrip/useRoadtripRoutes.ts @@ -692,11 +692,19 @@ export function useRoadtripRoutes( * for both, so a choice being checked cannot fire into the gap a seam is keeping. */ const lastSpacedRequestAt = useRef(0) - /** Waits until the gap since the last such request is kept, then claims the next slot. */ - const spacedRequest = useCallback(async (signal: AbortSignal): Promise => { + /** + * Waits until the gap since the last such request is kept, then claims the next slot. + * Hands back what gives the slot up again, for a request the cache answered. + */ + const spacedRequest = useCallback(async (signal: AbortSignal): Promise<() => void> => { const since = performance.now() - lastSpacedRequestAt.current if (since < REQUEST_SPACING_MS) await sleep(REQUEST_SPACING_MS - since, signal) - if (!signal.aborted) lastSpacedRequestAt.current = performance.now() + const before = lastSpacedRequestAt.current + const claimed = performance.now() + if (!signal.aborted) lastSpacedRequestAt.current = claimed + return () => { + if (lastSpacedRequestAt.current === claimed) lastSpacedRequestAt.current = before + } }, []) useEffect(() => { @@ -727,7 +735,7 @@ export function useRoadtripRoutes( // shared routing hosts answered the tail with 429, and a refused seam // writes no state, so nothing changed to make the effect try again: the // map drew the trip in pieces and the totals came back short. - await spacedRequest(controller.signal) + const release = await spacedRequest(controller.signal) if (controller.signal.aborted) return try { // The via points on this seam, threaded in the same way the day runs thread @@ -735,6 +743,7 @@ export function useRoadtripRoutes( // shape: it is asked for on its own, so the points the traveller dropped on it // never reached the router and dragging one did visibly nothing. const shaping = viasOnLeg(seam.from, seam.to, viasByDay[seam.from.ownerDayId] ?? []) + const startedAt = performance.now() const r = await calculateRouteWithLegs( [ { lat: seam.from.lat, lng: seam.from.lng }, @@ -743,6 +752,10 @@ export function useRoadtripRoutes( ], { signal: controller.signal, profile: mode, tripId: tripId ?? null, dayId: seam.dayId, avoid }, ) + // A seam this device routed before came back from its cache and went nowhere, so + // its slot goes back before the store below re-runs this effect. Holding it made a + // trip of connected days wait a second per join on every open (#2794). + if (performance.now() - startedAt <= CACHE_HIT_MS) release() if (controller.signal.aborted) return // One leg per waypoint PAIR, so a shaped seam comes back in pieces and the rail // wants the whole drive: merged here, exactly as a day run folds its via legs