Overview
Refactored the debounced sync system into an extensible, registry-based architecture with global cache invalidation that works reliably across client-side navigation.
Problem Solved
Previously, when users performed actions (likes, notification dismissals) and navigated away before the debounced sync completed:
- The sync would complete in the background
- But React Query cache wouldn't be invalidated (component unmounted)
- Navigating back showed stale data
The new system ensures cache invalidation happens globally, regardless of component lifecycle.
Architecture
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Sync Registry (Global) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β handlers: Map<handlerId, SyncHandler> β
β pendingActions: Map<actionKey, SyncAction> β
β successCallbacks: Map<handlerId, Set<Callback>> β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β register(handler) - Add a sync handler β
β queue(handlerId, payload) - Queue action for debounced sync β
β onSyncSuccess(handlerId, callback) - Subscribe to success β
β flush() - Force immediate sync β
β hasPending(handlerId) - Check for pending actions β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β β
βΌ βΌ
βββββββββββββββββββββββ βββββββββββββββββββββββ
β Likes Handler β β Notifications Handlerβ
βββββββββββββββββββββββ€ βββββββββββββββββββββββ€
β batch: 'individual' β β batch: 'grouped' β
β debounce: 1000ms β β debounce: 1000ms β
β sync: POST /api/likesβ β sync: POST /api/notifβ
β beaconSync: sendBeaconβ β beaconSync: sendBeaconβ
βββββββββββββββββββββββ βββββββββββββββββββββββ
Features Implemented
1. Sync Registry
File: src/lib/sync/registry.ts
Global registry that manages:
- Handler registration
- Pending action queue with deduplication
- Debounced sync scheduling
- Priority-based action superseding
- Success callback invocation
- sendBeacon for page unload
export const syncRegistry: SyncRegistry = {
register(handler) { ... },
queue(handlerId, payload, priority) { ... },
onSyncSuccess(handlerId, callback) { ... },
hasPending(handlerId) { ... },
flush() { ... },
getPendingCount() { ... },
};
2. Type Definitions
File: src/lib/sync/types.ts
export type SyncHandler<TPayload, TBatched> = {
id: string;
getKey: (payload: TPayload) => string;
batch: 'individual' | 'grouped';
groupPayloads?: (payloads: TPayload[]) => TBatched;
sync: (data: TPayload | TBatched) => Promise<void>;
beaconSync?: (data: TPayload | TBatched) => void;
onSuccess?: () => void;
onError?: (error: unknown) => void;
priority?: number;
debounceMs?: number;
};
export type SyncCallback = () => void;
3. Likes Handler
File: src/lib/sync/handlers/likes.ts
- Individual sync (each like synced separately)
- Key:
${entityType}:${entityId} - Uses sendBeacon on page unload
export const likesHandler: SyncHandler<LikePayload> = {
id: 'likes',
getKey: (payload) => `${payload.entityType}:${payload.entityId}`,
batch: 'individual',
sync: async (payload) => { /* POST /api/likes */ },
beaconSync: (payload) => { navigator.sendBeacon('/api/likes', ...) },
};
4. Notifications Handler
File: src/lib/sync/handlers/notifications.ts
- Grouped sync (batch multiple actions)
- Priority system: dismiss (1) > mark_seen (0)
- Key: notification ID
export const notificationsHandler: SyncHandler<NotificationPayload, NotificationBatchPayload> = {
id: 'notifications',
getKey: (payload) => payload.id,
batch: 'grouped',
groupPayloads: (payloads) => ({ updates: payloads }),
sync: async (data) => { /* POST /api/notifications */ },
beaconSync: (data) => { navigator.sendBeacon('/api/notifications', ...) },
};
5. Global Cache Invalidation
File: src/lib/sync/index.ts
Registers handlers AND cache invalidation callbacks at app startup:
export function initializeSyncHandlers(): void {
registerLikesHandler();
registerNotificationsHandler();
// Global cache invalidation - persists across navigation
syncRegistry.onSyncSuccess(SYNC_HANDLER_IDS.LIKES, () => {
const queryClient = getQueryClient();
queryClient.invalidateQueries({ queryKey: ['photo-likes'] });
queryClient.invalidateQueries({ queryKey: ['album-likes'] });
queryClient.invalidateQueries({ queryKey: ['batch-photo-like-counts'] });
queryClient.invalidateQueries({ queryKey: ['batch-album-like-counts'] });
});
syncRegistry.onSyncSuccess(SYNC_HANDLER_IDS.NOTIFICATIONS, () => {
const queryClient = getQueryClient();
queryClient.invalidateQueries({ queryKey: ['notifications'] });
});
}
6. Singleton QueryClient
File: src/lib/queryClient.ts
let queryClient: QueryClient | null = null;
export function getQueryClient(): QueryClient {
if (!queryClient) {
queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
refetchOnWindowFocus: false,
retry: 1,
},
},
});
}
return queryClient;
}
7. Updated DetailLikesSection
File: src/components/shared/DetailLikesSection.tsx
- Initialize local state from React Query cache
- Prevents flash of stale data on navigation
- Optimistic UI with user avatar injection
const queryKey = entityType === 'photo' ? ['photo-likes', entityId] : ['album-likes', entityId];
const cachedData = queryClient.getQueryData<{ likes: unknown[]; count: number; userHasLiked: boolean }>(queryKey);
const [liked, setLiked] = useState(cachedData?.userHasLiked ?? false);
const [count, setCount] = useState(cachedData?.count ?? initialCount);
How It Works
Sync Flow
- User clicks like β
queueLike('photo', photoId, true) - Registry stores action with key
likes:photo:${photoId} - Debounce timer starts (1 second)
- User navigates away β component unmounts (but registry persists)
- Timer fires β
syncToServer()called - POST request sent β success
invokeSuccessCallbacks('likes')called- Global callback invalidates React Query cache
- User navigates back β stale query refetches β fresh data shown
Page Unload Flow
- User has pending sync
- User closes tab/navigates to external site
beforeunloadevent firesflushWithBeacon()callednavigator.sendBeacon()sends data reliably
Priority System
For notifications, dismiss supersedes mark_seen:
- User marks notification as seen (priority 0)
- User then dismisses same notification (priority 1)
- Only dismiss action is synced (higher priority wins)
All Modified Files (12 total)
New Files (6)
src/lib/sync/types.ts- Type definitionssrc/lib/sync/registry.ts- Core registry implementationsrc/lib/sync/handlers/likes.ts- Like sync handlersrc/lib/sync/handlers/notifications.ts- Notification sync handlersrc/lib/sync/index.ts- Public API and initializationsrc/lib/queryClient.ts- Singleton QueryClient
Modified Files (6)
src/app/providers/QueryProvider.tsx- Use singleton getQueryClient()src/hooks/useDebouncedSync.ts- Thin wrapper around sync registrysrc/hooks/useLikes.ts- Remove event listeners (handled by registry)src/hooks/useNotifications.ts- Initialize state from cachesrc/components/shared/DetailLikesSection.tsx- Cache init, optimistic UIsrc/app/account/activity/ActivityContent.tsx- Use useNotifications hook
Testing Scenarios
Like Persistence
- Navigate to photo detail page
- Click like (heart fills, count increments)
- Navigate away before 1 second
- Wait 1 second (watch network tab for POST)
- Navigate back β like should be persisted
Notification Dismissal
- Have unseen notifications
- Dismiss one from activity page
- Navigate away quickly
- Wait for sync
- Navigate back β notification should be gone
Page Unload
- Like a photo
- Immediately close the tab
- Re-open β like should be persisted (sendBeacon)