add network listener management and enhance sync on reconnect functionality

This commit is contained in:
Mo Tarbin
2026-05-16 19:33:07 -04:00
parent 5869b39035
commit 45e667abf7
8 changed files with 92 additions and 36 deletions

View File

@@ -57,6 +57,11 @@ class NetworkManager {
registerNetworkListener(callback) { registerNetworkListener(callback) {
this.connectionStatusListeners.push(callback) this.connectionStatusListeners.push(callback)
} }
unregisterNetworkListener(callback) {
this.connectionStatusListeners = this.connectionStatusListeners.filter(
cb => cb !== callback,
)
}
registerBackendSyncListener(callback) { registerBackendSyncListener(callback) {
// if callback is not in the list already, add it // if callback is not in the list already, add it
if (!this.queueSyncListeners.includes(callback)) { if (!this.queueSyncListeners.includes(callback)) {

View File

@@ -1,3 +1,5 @@
import { App as capacitorApp } from '@capacitor/app'
import { Capacitor } from '@capacitor/core'
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import { commandQueue } from '../utils/CommandQueue' import { commandQueue } from '../utils/CommandQueue'
@@ -14,6 +16,18 @@ export function useSyncOnReconnect() {
const initialized = useRef(false) const initialized = useRef(false)
useEffect(() => { useEffect(() => {
let pendingPollInterval
let cacheRefreshInterval
let resumeListener
let networkListener
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
runSync()
}
}
const handleOnline = () => runSync()
const init = async () => { const init = async () => {
if (initialized.current) return if (initialized.current) return
initialized.current = true initialized.current = true
@@ -23,24 +37,36 @@ export function useSyncOnReconnect() {
} }
// 1. Device network change (works on native + real network drops) // 1. Device network change (works on native + real network drops)
networkManager.registerNetworkListener(async isOnline => { networkListener = async isOnline => {
if (isOnline) { if (isOnline) {
await runSync() await runSync()
} }
}) }
networkManager.registerNetworkListener(networkListener)
// 2. Tab becomes visible (user switches back to the tab after reconnecting backend) // 2. Tab becomes visible (user switches back to the tab after reconnecting backend)
document.addEventListener('visibilitychange', () => { document.addEventListener('visibilitychange', handleVisibilityChange)
if (document.visibilityState === 'visible') {
runSync()
}
})
// 3. Browser online event (fires when device network is restored) // 3. Browser online event (fires when device network is restored)
window.addEventListener('online', () => runSync()) window.addEventListener('online', handleOnline)
// 3.5 Native app resume (fires when returning to the foreground)
if (Capacitor.isNativePlatform()) {
resumeListener = await capacitorApp.addListener(
'appStateChange',
({ isActive }) => {
if (isActive) {
console.log(
'App resumed, checking connectivity and syncing if online...',
)
runSync()
}
},
)
}
// 4. Retry pending commands every 30s (catches backend restart) // 4. Retry pending commands every 30s (catches backend restart)
setInterval(async () => { pendingPollInterval = setInterval(async () => {
const pending = await commandQueue.getPending() const pending = await commandQueue.getPending()
if (pending.length > 0) { if (pending.length > 0) {
runSync() runSync()
@@ -48,7 +74,7 @@ export function useSyncOnReconnect() {
}, PENDING_POLL_MS) }, PENDING_POLL_MS)
// 5. Keep IDB cache fresh every 5 min while online (so offline reads are current) // 5. Keep IDB cache fresh every 5 min while online (so offline reads are current)
setInterval(() => { cacheRefreshInterval = setInterval(() => {
runSync() runSync()
}, CACHE_REFRESH_MS) }, CACHE_REFRESH_MS)
} }
@@ -62,5 +88,22 @@ export function useSyncOnReconnect() {
} }
init() init()
return () => {
if (pendingPollInterval) {
clearInterval(pendingPollInterval)
}
if (cacheRefreshInterval) {
clearInterval(cacheRefreshInterval)
}
if (networkListener) {
networkManager.unregisterNetworkListener(networkListener)
}
document.removeEventListener('visibilitychange', handleVisibilityChange)
window.removeEventListener('online', handleOnline)
resumeListener?.remove()
}
}, [queryClient]) }, [queryClient])
} }

View File

@@ -150,7 +150,7 @@ export const useCreateChore = () => {
return { ...oldData, res: [...oldData.res, offlineChore] } return { ...oldData, res: [...oldData.res, offlineChore] }
}) })
return { res: offlineChore } return offlineChore
} }
return useMutation({ return useMutation({
@@ -170,11 +170,11 @@ export const useCreateChore = () => {
} }
// Successfully created the chore on the server, return the created chore // Successfully created the chore on the server, return the created chore
// update the local chores cache with the new chore: // update the local chores cache with the new chore:
queryClient.setQueryData(['chores'], oldData => { queryClient.setQueryData(['chores', false], oldData => {
if (!oldData) return { res: [createdChore.res] } if (!oldData) return { res: [createdChore.res] }
return { res: [...oldData.res, createdChore.res] } return { res: [...oldData.res, createdChore.res] }
}) })
return { res: createdChore } return createdChore.res
} catch (error) { } catch (error) {
if (isNetworkError(error)) { if (isNetworkError(error)) {
return queueOfflineCreate(newTask) return queueOfflineCreate(newTask)

View File

@@ -36,7 +36,7 @@ export const useCircleMembers = () => {
const result = await GetAllCircleMembers() const result = await GetAllCircleMembers()
// Cache for offline use // Cache for offline use
if (result?.res) { if (result?.res) {
offlineDB.saveKV('circle_members', result.res) offlineDB.saveKV('circle_members', result.res).catch(() => {})
} }
return result return result
} catch { } catch {

View File

@@ -966,7 +966,6 @@ class OfflineDB {
async getHistoryByChore(choreId) { async getHistoryByChore(choreId) {
if (!isOfflineFeatureEnabled()) return [] if (!isOfflineFeatureEnabled()) return []
await this._ensureInit() await this._ensureInit()
console.log('MO: Fetching history for chore', choreId)
return this.backend.getHistoryByChore(choreId) return this.backend.getHistoryByChore(choreId)
} }

View File

@@ -367,7 +367,11 @@ const ChoreEdit = () => {
SaveFunction(chore) SaveFunction(chore)
.then(result => { .then(result => {
if (result?._pendingUpdate || result?._pendingCreate) { if (
result?._pendingUpdate ||
result?._pendingCreate ||
result?.res?._pendingCreate
) {
showSuccess({ showSuccess({
title: 'Saved Offline', title: 'Saved Offline',
message: 'Your changes will sync when you are back online.', message: 'Your changes will sync when you are back online.',

View File

@@ -907,8 +907,8 @@ const ChoreView = () => {
}} }}
> >
{(() => { {(() => {
const content = decodeHtmlEntities(chore.description || '') const raw = chore.description || ''
const shouldRenderHtml = hasHtmlTags(content) const shouldRenderHtml = hasHtmlTags(raw)
return shouldRenderHtml ? ( return shouldRenderHtml ? (
<Box <Box
@@ -916,7 +916,7 @@ const ChoreView = () => {
whiteSpace: 'pre-wrap', whiteSpace: 'pre-wrap',
wordBreak: 'break-word', wordBreak: 'break-word',
}} }}
dangerouslySetInnerHTML={{ __html: content }} dangerouslySetInnerHTML={{ __html: raw }}
/> />
) : ( ) : (
<Typography <Typography
@@ -926,7 +926,7 @@ const ChoreView = () => {
wordBreak: 'break-word', wordBreak: 'break-word',
}} }}
> >
{content} {decodeHtmlEntities(raw)}
</Typography> </Typography>
) )
})()} })()}
@@ -975,8 +975,8 @@ const ChoreView = () => {
}} }}
> >
{(() => { {(() => {
const content = decodeHtmlEntities(chore.notes || '') const raw = chore.notes || ''
const shouldRenderHtml = hasHtmlTags(content) const shouldRenderHtml = hasHtmlTags(raw)
return shouldRenderHtml ? ( return shouldRenderHtml ? (
<Box <Box
@@ -984,7 +984,7 @@ const ChoreView = () => {
whiteSpace: 'pre-wrap', whiteSpace: 'pre-wrap',
wordBreak: 'break-word', wordBreak: 'break-word',
}} }}
dangerouslySetInnerHTML={{ __html: content }} dangerouslySetInnerHTML={{ __html: raw }}
/> />
) : ( ) : (
<Typography <Typography
@@ -994,7 +994,7 @@ const ChoreView = () => {
wordBreak: 'break-word', wordBreak: 'break-word',
}} }}
> >
{content} {decodeHtmlEntities(raw)}
</Typography> </Typography>
) )
})()} })()}

View File

@@ -1,5 +1,10 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { GetProjects, CreateProject, UpdateProject, DeleteProject } from '../../utils/Fetcher' import {
CreateProject,
DeleteProject,
GetProjects,
UpdateProject,
} from '../../utils/Fetcher'
import { offlineDB } from '../../utils/OfflineDB' import { offlineDB } from '../../utils/OfflineDB'
// Query hook for fetching all projects // Query hook for fetching all projects
@@ -33,7 +38,7 @@ export const useCreateProject = () => {
const queryClient = useQueryClient() const queryClient = useQueryClient()
return useMutation({ return useMutation({
mutationFn: async (projectData) => { mutationFn: async projectData => {
try { try {
const response = await CreateProject(projectData) const response = await CreateProject(projectData)
if (response.ok) { if (response.ok) {
@@ -53,7 +58,7 @@ export const useCreateProject = () => {
return localProject return localProject
} }
}, },
onSuccess: (newProject) => { onSuccess: newProject => {
// Update the projects cache // Update the projects cache
queryClient.setQueryData(['projects'], (oldProjects = []) => { queryClient.setQueryData(['projects'], (oldProjects = []) => {
const updatedProjects = [...oldProjects, newProject] const updatedProjects = [...oldProjects, newProject]
@@ -63,7 +68,7 @@ export const useCreateProject = () => {
// Invalidate and refetch // Invalidate and refetch
queryClient.invalidateQueries(['projects']) queryClient.invalidateQueries(['projects'])
}, },
onError: (error) => { onError: error => {
console.error('Create project mutation failed:', error) console.error('Create project mutation failed:', error)
}, },
}) })
@@ -92,18 +97,18 @@ export const useUpdateProject = () => {
} }
} }
}, },
onSuccess: (updatedProject) => { onSuccess: updatedProject => {
// Update the projects cache // Update the projects cache
queryClient.setQueryData(['projects'], (oldProjects = []) => { queryClient.setQueryData(['projects'], (oldProjects = []) => {
return oldProjects.map(project => return oldProjects.map(project =>
project.id === updatedProject.id ? updatedProject : project project.id === updatedProject.id ? updatedProject : project,
) )
}) })
// Invalidate and refetch // Invalidate and refetch
queryClient.invalidateQueries(['projects']) queryClient.invalidateQueries(['projects'])
}, },
onError: (error) => { onError: error => {
console.error('Update project mutation failed:', error) console.error('Update project mutation failed:', error)
}, },
}) })
@@ -114,7 +119,7 @@ export const useDeleteProject = () => {
const queryClient = useQueryClient() const queryClient = useQueryClient()
return useMutation({ return useMutation({
mutationFn: async (projectId) => { mutationFn: async projectId => {
try { try {
// Prevent deletion of default project // Prevent deletion of default project
if (projectId === 'default') { if (projectId === 'default') {
@@ -141,14 +146,14 @@ export const useDeleteProject = () => {
// Invalidate and refetch // Invalidate and refetch
queryClient.invalidateQueries(['projects']) queryClient.invalidateQueries(['projects'])
}, },
onError: (error) => { onError: error => {
console.error('Delete project mutation failed:', error) console.error('Delete project mutation failed:', error)
}, },
}) })
} }
// Hook to get a specific project by ID // Hook to get a specific project by ID
export const useProject = (projectId) => { export const useProject = projectId => {
return useQuery({ return useQuery({
queryKey: ['projects', projectId], queryKey: ['projects', projectId],
queryFn: async () => { queryFn: async () => {
@@ -179,4 +184,4 @@ export const useProject = (projectId) => {
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
cacheTime: 10 * 60 * 1000, cacheTime: 10 * 60 * 1000,
}) })
} }