mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-22 06:41:46 +00:00
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:
@@ -20,6 +20,12 @@ export function getSocketId(): string | null {
|
|||||||
return mySocketId
|
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 {
|
export function setRefetchCallback(fn: RefetchCallback | null): void {
|
||||||
refetchCallback = fn
|
refetchCallback = fn
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ export interface TripStoreState
|
|||||||
handleRemoteEvent: (event: WebSocketEvent) => void
|
handleRemoteEvent: (event: WebSocketEvent) => void
|
||||||
resetTrip: () => void
|
resetTrip: () => void
|
||||||
loadTrip: (tripId: number | string) => Promise<void>
|
loadTrip: (tripId: number | string) => Promise<void>
|
||||||
|
hydrateActiveTrip: (tripId: number | string) => Promise<void>
|
||||||
refreshDays: (tripId: number | string) => Promise<void>
|
refreshDays: (tripId: number | string) => Promise<void>
|
||||||
updateTrip: (tripId: number | string, data: Partial<Trip>) => Promise<Trip>
|
updateTrip: (tripId: number | string, data: Partial<Trip>) => Promise<Trip>
|
||||||
addTag: (data: Partial<Tag> & { name: string }) => Promise<Tag>
|
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) => {
|
refreshDays: async (tripId: number | string) => {
|
||||||
try {
|
try {
|
||||||
const daysData = await dayRepo.list(tripId)
|
const daysData = await dayRepo.list(tripId)
|
||||||
|
|||||||
@@ -14,17 +14,34 @@
|
|||||||
*/
|
*/
|
||||||
import { mutationQueue } from './mutationQueue'
|
import { mutationQueue } from './mutationQueue'
|
||||||
import { tripSyncManager } from './tripSyncManager'
|
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
|
const PERIODIC_MS = 30_000
|
||||||
|
|
||||||
let _intervalId: ReturnType<typeof setInterval> | null = null
|
let _intervalId: ReturnType<typeof setInterval> | null = null
|
||||||
let _registered = false
|
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() {
|
function onOnline() {
|
||||||
mutationQueue.flush().catch(console.error)
|
mutationQueue.flush()
|
||||||
tripSyncManager.syncAll().catch(console.error)
|
.catch(console.error)
|
||||||
|
.finally(() => {
|
||||||
|
tripSyncManager.syncAll().catch(console.error)
|
||||||
|
rehydrateActiveTrips()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tab became visible — flush only; don't trigger a potentially expensive syncAll. */
|
/** 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
|
// WS reconnect: flush mutations only — no syncAll to avoid triggering rate
|
||||||
// limiters when the socket drops and reconnects while the device is online.
|
// limiters when the socket drops and reconnects while the device is online.
|
||||||
setPreReconnectHook(() => mutationQueue.flush())
|
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)
|
window.addEventListener('online', onOnline)
|
||||||
document.addEventListener('visibilitychange', onVisibility)
|
document.addEventListener('visibilitychange', onVisibility)
|
||||||
@@ -59,6 +81,7 @@ export function unregisterSyncTriggers(): void {
|
|||||||
_registered = false
|
_registered = false
|
||||||
|
|
||||||
setPreReconnectHook(null)
|
setPreReconnectHook(null)
|
||||||
|
setRefetchCallback(null)
|
||||||
window.removeEventListener('online', onOnline)
|
window.removeEventListener('online', onOnline)
|
||||||
document.removeEventListener('visibilitychange', onVisibility)
|
document.removeEventListener('visibilitychange', onVisibility)
|
||||||
if (_intervalId !== null) {
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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', () => {
|
describe('refreshDays', () => {
|
||||||
it('FE-TRIP-007: refreshDays re-fetches days and rebuilds assignments/dayNotes maps', async () => {
|
it('FE-TRIP-007: refreshDays re-fetches days and rebuilds assignments/dayNotes maps', async () => {
|
||||||
const assignment = buildAssignment({ day_id: 20, order_index: 0 });
|
const assignment = buildAssignment({ day_id: 20, order_index: 0 });
|
||||||
|
|||||||
Reference in New Issue
Block a user