fix(sync): re-hydrate active trip store on reconnect/online (H1) (#1181)

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
This commit is contained in:
jubnl
2026-06-15 09:32:28 +02:00
committed by GitHub
parent 1eb2cb8eb2
commit 39b5af790e
5 changed files with 160 additions and 4 deletions
+6
View File
@@ -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
}
+17
View File
@@ -66,6 +66,7 @@ export interface TripStoreState
handleRemoteEvent: (event: WebSocketEvent) => void
resetTrip: () => void
loadTrip: (tripId: number | string) => Promise<void>
hydrateActiveTrip: (tripId: number | string) => Promise<void>
refreshDays: (tripId: number | string) => Promise<void>
updateTrip: (tripId: number | string, data: Partial<Trip>) => Promise<Trip>
addTag: (data: Partial<Tag> & { name: string }) => Promise<Tag>
@@ -164,6 +165,22 @@ export const useTripStore = create<TripStoreState>((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)
+27 -4
View File
@@ -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<typeof setInterval> | 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) {
@@ -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<void>) | 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<void>) | 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');
});
});
+34
View File
@@ -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 });