From 39b5af790e497e1a5fc48e7ab9de82bb6b2dee16 Mon Sep 17 00:00:00 2001 From: jubnl <66769052+jubnl@users.noreply.github.com> Date: Mon, 15 Jun 2026 09:32:28 +0200 Subject: [PATCH] fix(sync): re-hydrate active trip store on reconnect/online (H1) (#1181) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setRefetchCallback was dead code, so on reconnect the queue flushed and Dexie re-seeded but the open trip's Zustand store was never refreshed — a collaborator's edits made while we were offline didn't appear until navigating away and back. - new tripStore.hydrateActiveTrip(): silent refresh of the active trip's collaborative state (days/places/packing/todo/budget/reservations/files), no resetTrip and no isLoading toggle so there's no splash on reconnect - syncTriggers wires setRefetchCallback to it (WS layer awaits the flush hook first) and re-hydrates open trips after the online-event syncAll; cleared on unregister - websocket exposes getActiveTrips() for the online-event path - tests: refetch wiring + ordering, silent hydrate without reset/splash --- client/src/api/websocket.ts | 6 ++ client/src/store/tripStore.ts | 17 +++++ client/src/sync/syncTriggers.ts | 31 +++++++-- client/tests/unit/sync/syncTriggers.test.ts | 76 +++++++++++++++++++++ client/tests/unit/tripStore.test.ts | 34 +++++++++ 5 files changed, 160 insertions(+), 4 deletions(-) create mode 100644 client/tests/unit/sync/syncTriggers.test.ts diff --git a/client/src/api/websocket.ts b/client/src/api/websocket.ts index 5a07d357..00a1eddf 100644 --- a/client/src/api/websocket.ts +++ b/client/src/api/websocket.ts @@ -20,6 +20,12 @@ export function getSocketId(): string | null { return mySocketId } +/** Trip ids the app currently has open (joined). Used to re-hydrate the active + * trip's store after the network comes back via the `online` event. */ +export function getActiveTrips(): string[] { + return Array.from(activeTrips) +} + export function setRefetchCallback(fn: RefetchCallback | null): void { refetchCallback = fn } diff --git a/client/src/store/tripStore.ts b/client/src/store/tripStore.ts index 491cada2..b8826ae8 100644 --- a/client/src/store/tripStore.ts +++ b/client/src/store/tripStore.ts @@ -66,6 +66,7 @@ export interface TripStoreState handleRemoteEvent: (event: WebSocketEvent) => void resetTrip: () => void loadTrip: (tripId: number | string) => Promise + hydrateActiveTrip: (tripId: number | string) => Promise refreshDays: (tripId: number | string) => Promise updateTrip: (tripId: number | string, data: Partial) => Promise addTag: (data: Partial & { name: string }) => Promise @@ -164,6 +165,22 @@ export const useTripStore = create((set, get) => ({ } }, + // Silently re-fetch the active trip's collaborative state into the store after + // the network comes back (WS reconnect or `online` event) so edits missed while + // offline appear in place — no splash, no resetTrip. Each resource is + // best-effort; a failure on one must not wipe the others. + hydrateActiveTrip: async (tripId: number | string) => { + await Promise.all([ + get().refreshDays(tripId), + placeRepo.list(tripId).then(d => set({ places: d.places })).catch(() => {}), + packingRepo.list(tripId).then(d => set({ packingItems: d.items })).catch(() => {}), + todoRepo.list(tripId).then(d => set({ todoItems: d.items })).catch(() => {}), + get().loadBudgetItems(tripId), + get().loadReservations(tripId), + get().loadFiles(tripId), + ]) + }, + refreshDays: async (tripId: number | string) => { try { const daysData = await dayRepo.list(tripId) diff --git a/client/src/sync/syncTriggers.ts b/client/src/sync/syncTriggers.ts index 2c84afe1..7b2ea248 100644 --- a/client/src/sync/syncTriggers.ts +++ b/client/src/sync/syncTriggers.ts @@ -14,17 +14,34 @@ */ import { mutationQueue } from './mutationQueue' import { tripSyncManager } from './tripSyncManager' -import { setPreReconnectHook } from '../api/websocket' +import { setPreReconnectHook, setRefetchCallback, getActiveTrips } from '../api/websocket' +import { useTripStore } from '../store/tripStore' const PERIODIC_MS = 30_000 let _intervalId: ReturnType | null = null let _registered = false -/** Network came back — flush mutations AND re-seed Dexie for all cacheable trips. */ +/** Pull the latest server state for every open trip into the Zustand store. */ +function rehydrateActiveTrips() { + const store = useTripStore.getState() + for (const tripId of getActiveTrips()) { + store.hydrateActiveTrip(tripId).catch(console.error) + } +} + +/** + * Network came back — flush local writes first, then re-seed Dexie for all + * cacheable trips and re-hydrate the open trip's store so a collaborator's + * edits made while we were offline appear without navigating away. + */ function onOnline() { - mutationQueue.flush().catch(console.error) - tripSyncManager.syncAll().catch(console.error) + mutationQueue.flush() + .catch(console.error) + .finally(() => { + tripSyncManager.syncAll().catch(console.error) + rehydrateActiveTrips() + }) } /** Tab became visible — flush only; don't trigger a potentially expensive syncAll. */ @@ -48,6 +65,11 @@ export function registerSyncTriggers(): void { // WS reconnect: flush mutations only — no syncAll to avoid triggering rate // limiters when the socket drops and reconnects while the device is online. setPreReconnectHook(() => mutationQueue.flush()) + // After the reconnect flush, pull canonical state for the open trip back into + // the store (the WS layer awaits the flush hook before invoking this). + setRefetchCallback(tripId => { + useTripStore.getState().hydrateActiveTrip(tripId).catch(console.error) + }) window.addEventListener('online', onOnline) document.addEventListener('visibilitychange', onVisibility) @@ -59,6 +81,7 @@ export function unregisterSyncTriggers(): void { _registered = false setPreReconnectHook(null) + setRefetchCallback(null) window.removeEventListener('online', onOnline) document.removeEventListener('visibilitychange', onVisibility) if (_intervalId !== null) { diff --git a/client/tests/unit/sync/syncTriggers.test.ts b/client/tests/unit/sync/syncTriggers.test.ts new file mode 100644 index 00000000..ad3bc265 --- /dev/null +++ b/client/tests/unit/sync/syncTriggers.test.ts @@ -0,0 +1,76 @@ +/** + * syncTriggers — reconnect/online wiring (H1). + * + * Verifies the previously-dead refetch path is wired: on WS reconnect and on the + * `online` event the active trip's store is re-hydrated (after the queue flush). + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const flush = vi.fn(() => Promise.resolve()); +const syncAll = vi.fn(() => Promise.resolve()); +const hydrate = vi.fn(() => Promise.resolve()); + +let refetchCb: ((tripId: string) => void) | null = null; +let preReconnect: (() => Promise) | null = null; + +vi.mock('../../../src/sync/mutationQueue', () => ({ + mutationQueue: { flush: () => flush() }, +})); +vi.mock('../../../src/sync/tripSyncManager', () => ({ + tripSyncManager: { syncAll: () => syncAll() }, +})); +vi.mock('../../../src/api/websocket', () => ({ + setPreReconnectHook: (fn: (() => Promise) | null) => { preReconnect = fn; }, + setRefetchCallback: (fn: ((tripId: string) => void) | null) => { refetchCb = fn; }, + getActiveTrips: () => ['7'], +})); +vi.mock('../../../src/store/tripStore', () => ({ + useTripStore: { getState: () => ({ hydrateActiveTrip: hydrate }) }, +})); + +import { registerSyncTriggers, unregisterSyncTriggers } from '../../../src/sync/syncTriggers'; + +const flushMicrotasks = async () => { + for (let i = 0; i < 5; i++) await Promise.resolve(); +}; + +beforeEach(() => { + flush.mockClear(); syncAll.mockClear(); hydrate.mockClear(); + refetchCb = null; preReconnect = null; + Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true }); +}); + +afterEach(() => { + unregisterSyncTriggers(); +}); + +describe('syncTriggers', () => { + it('registers a refetch callback that hydrates the active trip', () => { + registerSyncTriggers(); + expect(refetchCb).toBeTypeOf('function'); + refetchCb!('7'); + expect(hydrate).toHaveBeenCalledWith('7'); + }); + + it('also registers the pre-reconnect flush hook', () => { + registerSyncTriggers(); + expect(preReconnect).toBeTypeOf('function'); + }); + + it('clears both reconnect hooks on unregister', () => { + registerSyncTriggers(); + unregisterSyncTriggers(); + expect(refetchCb).toBeNull(); + expect(preReconnect).toBeNull(); + }); + + it('online event flushes, then re-seeds Dexie and re-hydrates active trips', async () => { + registerSyncTriggers(); + window.dispatchEvent(new Event('online')); + await flushMicrotasks(); + + expect(flush).toHaveBeenCalled(); + expect(syncAll).toHaveBeenCalled(); + expect(hydrate).toHaveBeenCalledWith('7'); + }); +}); diff --git a/client/tests/unit/tripStore.test.ts b/client/tests/unit/tripStore.test.ts index 7132ebc0..8f8259a2 100644 --- a/client/tests/unit/tripStore.test.ts +++ b/client/tests/unit/tripStore.test.ts @@ -259,6 +259,40 @@ describe('tripStore', () => { }); }); + describe('hydrateActiveTrip', () => { + const loadHandlers = (places: unknown[] = [], budget: unknown[] = []) => [ + http.get('/api/trips/1', () => HttpResponse.json({ trip: buildTrip({ id: 1 }) })), + http.get('/api/trips/1/days', () => HttpResponse.json({ days: [] })), + http.get('/api/trips/1/places', () => HttpResponse.json({ places })), + http.get('/api/trips/1/packing', () => HttpResponse.json({ items: [] })), + http.get('/api/trips/1/todo', () => HttpResponse.json({ items: [] })), + http.get('/api/trips/1/budget', () => HttpResponse.json({ items: budget })), + http.get('/api/trips/1/reservations', () => HttpResponse.json({ reservations: [] })), + http.get('/api/trips/1/files', () => HttpResponse.json({ files: [] })), + http.get('/api/tags', () => HttpResponse.json({ tags: [] })), + http.get('/api/categories', () => HttpResponse.json({ categories: [] })), + ]; + + it('FE-TRIP-H1: silently refreshes resources without resetting or splashing', async () => { + server.use(...loadHandlers()); + await useTripStore.getState().loadTrip(1); + expect(useTripStore.getState().trip!.id).toBe(1); + + // New collaborative state arrives (as if edited by someone while we were offline). + const place = buildPlace({ trip_id: 1 }); + const budgetItem = buildBudgetItem({ trip_id: 1 }); + server.use(...loadHandlers([place], [budgetItem])); + + await useTripStore.getState().hydrateActiveTrip(1); + const state = useTripStore.getState(); + + expect(state.places).toEqual([place]); + expect(state.budgetItems).toEqual([budgetItem]); + expect(state.trip!.id).toBe(1); // trip not reset + expect(state.isLoading).toBe(false); // no splash toggled + }); + }); + describe('refreshDays', () => { it('FE-TRIP-007: refreshDays re-fetches days and rebuilds assignments/dayNotes maps', async () => { const assignment = buildAssignment({ day_id: 20, order_index: 0 });