Chapitre 4 : Synchronisation Offline
SmartCommon fournit un module complet pour la synchronisation offline-first des applications PWA Dolibarr.
Vue d'ensemble
Le module sync permet de :
- Travailler hors connexion avec les données locales
- Synchroniser automatiquement quand la connexion revient
- Gérer les conflits de données entre client et serveur
useSyncClient
Hook principal pour la synchronisation offline-first.
Import
import { useSyncClient } from '@cap-rel/smartcommon';
Configuration
function MyApp() {
const {
isOnline,
isSyncing,
pendingCount,
sync,
create,
update,
remove,
getConflicts,
resolveConflict
} = useSyncClient({
apiUrl: '/api/smartauth',
getAccessToken: () => localStorage.getItem('access_token'),
scope: ['thirdparty', 'contact', 'product']
});
return (
<div>
<p>Status : {isOnline ? 'En ligne' : 'Hors ligne'}</p>
<p>Modifications en attente : {pendingCount}</p>
</div>
);
}
Paramètres
| Paramètre |
Type |
Description |
| apiUrl |
string |
URL de base de l'API de synchronisation |
| getAccessToken |
function |
Fonction retournant le token JWT |
| scope |
string[] |
Liste des entités à synchroniser |
Valeurs retournées
| Propriété |
Type |
Description |
| isOnline |
boolean |
Statut de connexion |
| isSyncing |
boolean |
Synchronisation en cours |
| pendingCount |
number |
Nombre de modifications en attente |
| sync |
function |
Déclencher la synchronisation |
| create |
function |
Créer une entité (offline-capable) |
| update |
function |
Modifier une entité |
| remove |
function |
Supprimer une entité |
| upsert |
function |
Créer ou mettre à jour localement (cache) |
| getConflicts |
function |
Récupérer les conflits |
| resolveConflict |
function |
Résoudre un conflit |
Créer une entité
function CreateThirdpartyForm() {
const { create, pendingCount } = useSyncClient({
apiUrl: '/api/smartauth',
getAccessToken: () => localStorage.getItem('access_token'),
scope: ['thirdparty']
});
const handleCreate = async (data) => {
// Crée localement avec un ID temporaire
// Sera synchronisé quand la connexion revient
const tempId = await create('thirdparty', {
name: data.name,
email: data.email,
phone: data.phone
});
console.log('Créé avec ID temporaire:', tempId);
};
return (
<form onSubmit={handleSubmit}>
{/* ... */}
<p>En attente de sync : {pendingCount}</p>
</form>
);
}
Modifier et supprimer
function ThirdpartyActions({ thirdparty }) {
const { update, remove } = useSyncClient({
apiUrl: '/api/smartauth',
getAccessToken: () => localStorage.getItem('access_token'),
scope: ['thirdparty']
});
const handleUpdate = async () => {
await update('thirdparty', thirdparty.id, {
name: 'Nouveau nom'
});
};
const handleDelete = async () => {
await remove('thirdparty', thirdparty.id);
};
return (
<div>
<button onClick={handleUpdate}>Modifier</button>
<button onClick={handleDelete}>Supprimer</button>
</div>
);
}
Upsert (cache local)
La méthode upsert permet de stocker des données localement sans déclencher de synchronisation vers le serveur. Elle crée l'entité si elle n'existe pas, ou la met à jour si elle existe déjà.
function ThirdpartyDetail({ id }) {
const { upsert, getEntity } = useSyncClient({
apiUrl: '/api/smartauth',
getAccessToken: () => localStorage.getItem('access_token'),
scope: ['thirdparty']
});
const cacheServerData = async () => {
// Récupérer les données du serveur
const data = await api.private.get(`thirdparties/${id}`).json();
// Stocker localement sans déclencher de sync
await upsert('thirdparty', id, data);
};
// Avec queueChange = true, la modification sera synchronisée
const upsertAndSync = async (data) => {
await upsert('thirdparty', id, data, true);
};
// ...
}
Paramètres
| Paramètre |
Type |
Défaut |
Description |
| table |
string |
- |
Nom de la table |
| id |
number/string |
- |
ID de l'entité |
| data |
object |
- |
Données de l'entité |
| queueChange |
boolean |
false |
Si true, ajoute la modification à la queue de sync |
Synchronisation manuelle
function SyncButton() {
const { sync, isSyncing, pendingCount, isOnline } = useSyncClient({
apiUrl: '/api/smartauth',
getAccessToken: () => localStorage.getItem('access_token'),
scope: ['thirdparty', 'contact']
});
const handleSync = async () => {
const result = await sync();
console.log('Synchronisé:', result);
};
return (
<button
onClick={handleSync}
disabled={isSyncing || !isOnline || pendingCount === 0}
>
{isSyncing ? 'Synchronisation...' : `Synchroniser (${pendingCount})`}
</button>
);
}
ConflictResolver
Composant UI pour résoudre les conflits de synchronisation.
Import
import { ConflictResolver } from '@cap-rel/smartcommon';
Utilisation
function SyncManager() {
const {
getConflicts,
resolveConflict
} = useSyncClient({
apiUrl: '/api/smartauth',
getAccessToken: () => localStorage.getItem('access_token'),
scope: ['thirdparty']
});
const [conflicts, setConflicts] = useState([]);
useEffect(() => {
loadConflicts();
}, []);
const loadConflicts = async () => {
const list = await getConflicts();
setConflicts(list);
};
const handleResolve = async (conflictId, resolution) => {
await resolveConflict(conflictId, resolution);
await loadConflicts();
};
if (conflicts.length === 0) {
return <p>Aucun conflit</p>;
}
return (
<ConflictResolver
conflicts={conflicts}
onResolve={handleResolve}
/>
);
}
Props
| Prop |
Type |
Description |
| conflicts |
array |
Liste des conflits à afficher |
| onResolve |
function |
Callback appelé lors de la résolution |
Structure d'un conflit
{
id: 'conflict_123',
entity: 'thirdparty',
entityId: 456,
localData: { name: 'Version locale', ... },
serverData: { name: 'Version serveur', ... },
localTimestamp: 1707900000000,
serverTimestamp: 1707899000000
}
Résolutions possibles
- 'local' : Garder la version locale
- 'server' : Garder la version serveur
- 'merge' : Fusionner (si supporté)
useOnlineStatus
Hook pour détecter le statut online/offline avec health check serveur optionnel.
Import
import { useOnlineStatus } from '@cap-rel/smartcommon';
Utilisation simple
function NetworkStatus() {
const { isOnline, isOffline } = useOnlineStatus();
return (
<div className={isOffline ? 'bg-red-500' : 'bg-green-500'}>
{isOnline ? 'En ligne' : 'Hors ligne'}
</div>
);
}
Avec health check serveur
function ServerStatus() {
const {
isOnline,
isServerReachable,
lastCheck,
checkNow
} = useOnlineStatus({
healthCheckUrl: '/api/health',
healthCheckInterval: 60000, // Vérifier toutes les 60s
stabilityDelay: 2000, // Attendre 2s avant de déclarer "en ligne"
timeout: 5000 // Timeout de 5s
});
return (
<div>
<p>Navigateur : {isOnline ? 'En ligne' : 'Hors ligne'}</p>
<p>Serveur : {isServerReachable ? 'Accessible' : 'Inaccessible'}</p>
<p>Dernière vérification : {new Date(lastCheck).toLocaleTimeString()}</p>
<button onClick={checkNow}>Vérifier maintenant</button>
</div>
);
}
Paramètres
| Paramètre |
Type |
Défaut |
Description |
| healthCheckUrl |
string |
null |
URL pour vérifier le serveur (null = désactivé) |
| healthCheckInterval |
number |
30000 |
Intervalle entre les vérifications (ms) |
| stabilityDelay |
number |
2000 |
Délai avant de déclarer "en ligne" (ms) |
| timeout |
number |
5000 |
Timeout du health check (ms) |
Valeurs retournées
| Propriété |
Type |
Description |
| isOnline |
boolean |
Navigateur en ligne |
| isOffline |
boolean |
Navigateur hors ligne |
| isServerReachable |
boolean/null |
Serveur accessible (null si non testé) |
| lastOnline |
number |
Timestamp du dernier état "en ligne" |
| lastCheck |
number |
Timestamp de la dernière vérification |
| checkNow |
function |
Forcer une vérification immédiate |
useCachedQuery
Hook pour le cache de requêtes avec stratégies multiples.
Import
import { useCachedQuery, CACHE_STRATEGIES } from '@cap-rel/smartcommon';
Stratégies disponibles
| Stratégie |
Description |
| NETWORK_FIRST |
Réseau d'abord, cache en fallback |
| CACHE_FIRST |
Cache d'abord si valide, sinon réseau |
| STALE_WHILE_REVALIDATE |
Afficher le cache, rafraîchir en arrière-plan |
Exemple : Cache-first pour dictionnaires
function CountrySelect() {
const { data: countries, isLoading, isFromCache } = useCachedQuery({
db: db.instance,
store: 'queryCache',
key: 'countries',
fetchFn: () => api.get('dictionaries/countries').json(),
strategy: CACHE_STRATEGIES.CACHE_FIRST,
ttl: 86400000 // 24h
});
if (isLoading) return <Spinner />;
return (
<select>
{countries.map(c => (
<option key={c.code} value={c.code}>{c.label}</option>
))}
</select>
);
}
Exemple : Stale-while-revalidate pour config
function AppConfig() {
const {
data: config,
isStale,
refetch,
invalidate
} = useCachedQuery({
db: db.instance,
store: 'queryCache',
key: 'app-config',
fetchFn: () => api.get('config').json(),
strategy: CACHE_STRATEGIES.STALE_WHILE_REVALIDATE,
staleTime: 300000 // 5 min
});
return (
<div>
{isStale && <p>Mise à jour en cours...</p>}
<button onClick={invalidate}>Forcer le rafraîchissement</button>
</div>
);
}
Paramètres
| Paramètre |
Type |
Défaut |
Description |
| db |
object |
- |
Instance Dexie (db.instance) |
| store |
string |
- |
Nom du store IndexedDB |
| key |
string |
- |
Clé de cache |
| fetchFn |
function |
- |
Fonction de récupération des données |
| strategy |
string |
NETWORK_FIRST |
Stratégie de cache |
| ttl |
number |
3600000 |
Durée de vie du cache (1h) |
| staleTime |
number |
60000 |
Temps avant données "stale" (1min) |
| enabled |
boolean |
true |
Activer/désactiver le fetch |
Valeurs retournées
| Propriété |
Type |
Description |
| data |
any |
Données récupérées/cachées |
| isLoading |
boolean |
Chargement en cours |
| isFromCache |
boolean |
Données provenant du cache |
| isStale |
boolean |
Données périmées |
| error |
Error |
Erreur éventuelle |
| lastFetch |
number |
Timestamp du dernier fetch |
| refetch |
function |
Relancer le fetch |
| invalidate |
function |
Vider le cache et refetch |
useAuthenticatedImage
Hook pour charger des images authentifiées avec cache IndexedDB.
Import
import { useAuthenticatedImage } from '@cap-rel/smartcommon';
Utilisation
function UserAvatar({ userId }) {
const { src, isLoading, isFromCache, error } = useAuthenticatedImage({
db: db.instance,
store: 'imageCache',
url: `/api/users/${userId}/photo`,
token: accessToken,
placeholder: '/images/default-avatar.png',
ttl: 86400000, // 24h
staleTime: 3600000 // 1h
});
if (isLoading) return <Spinner />;
return <img src={src} alt="Avatar" />;
}
Paramètres
| Paramètre |
Type |
Défaut |
Description |
| db |
object |
- |
Instance Dexie |
| store |
string |
'imageCache' |
Nom du store |
| url |
string |
- |
URL de l'image |
| token |
string |
- |
Token JWT |
| ttl |
number |
86400000 |
Durée de vie (24h) |
| staleTime |
number |
3600000 |
Temps avant stale (1h) |
| placeholder |
string |
null |
Image par défaut |
Valeurs retournées
| Propriété |
Type |
Description |
| src |
string |
URL de l'image (blob ou placeholder) |
| isLoading |
boolean |
Chargement en cours |
| isFromCache |
boolean |
Image provenant du cache |
| error |
Error |
Erreur éventuelle |
Configuration IndexedDB
Pour utiliser useCachedQuery et useAuthenticatedImage, configurer les stores Dexie :
const db = useDb({
name: 'myApp',
version: 2,
stores: {
// Store pour les requêtes cachées
queryCache: 'key',
// Store pour les images
imageCache: 'key',
// Autres stores...
items: 'id++, name'
}
});
Exemple complet : Application offline-first
import { useEffect } from 'react';
import {
useSyncClient,
useOnlineStatus,
useCachedQuery,
useDb,
Page,
Block,
List,
ListItem,
Button,
ConflictResolver
} from '@cap-rel/smartcommon';
function ThirdpartyList() {
const db = useDb({
name: 'myApp',
version: 1,
stores: {
queryCache: 'key',
pendingChanges: 'id++, entity, action'
}
});
const { isOnline } = useOnlineStatus({
healthCheckUrl: '/api/health'
});
const {
sync,
isSyncing,
pendingCount,
getConflicts
} = useSyncClient({
apiUrl: '/api/smartauth',
getAccessToken: () => localStorage.getItem('access_token'),
scope: ['thirdparty']
});
const {
data: thirdparties,
isLoading,
isFromCache,
refetch
} = useCachedQuery({
db: db.instance,
store: 'queryCache',
key: 'thirdparties',
fetchFn: () => api.get('thirdparties').json(),
strategy: 'swr'
});
// Synchroniser automatiquement quand on revient en ligne
useEffect(() => {
if (isOnline && pendingCount > 0) {
sync();
}
}, [isOnline]);
return (
<Page title="Tiers">
<Block>
<div className="flex justify-between items-center">
<span>
{isOnline ? 'En ligne' : 'Hors ligne'}
{isFromCache && ' (cache)'}
</span>
{pendingCount > 0 && (
<Button
onClick={sync}
disabled={!isOnline || isSyncing}
>
Sync ({pendingCount})
</Button>
)}
</div>
</Block>
<Block>
<List>
{thirdparties?.map(t => (
<ListItem key={t.id}>
{t.name}
</ListItem>
))}
</List>
</Block>
</Page>
);
}
Points clés à retenir
- useSyncClient pour les opérations CRUD offline-capable
- useOnlineStatus pour détecter la connectivité
- useCachedQuery pour le cache intelligent avec stratégies
- useAuthenticatedImage pour les images protégées
- ConflictResolver pour la résolution de conflits UI
- Configurer les stores IndexedDB pour le cache
← Chapitre précédent | Retour au module