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
useSyncClientn'est pas le seul dispositif de synchronisation de SmartMaker, et il ne convient pas à tous les besoins. Avant de le choisir, lisez Synchronisation offline, qui compare les trois dispositifs disponibles.
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,
upsert,
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>
);
}
Enregistrer le client, une étape obligatoire
Avant toute synchronisation, le client doit être enregistré auprès du serveur
pour obtenir son client_uuid. sync() ne le fait pas tout seul : sans cet
appel, ni le push ni le pull n'aboutissent.
function SyncBootstrap({ deviceUuid }) {
const { isInitialized, isRegistered, register } = useSyncClient({
apiUrl: '/api/smartauth',
getAccessToken: () => localStorage.getItem('access_token'),
scope: ['thirdparty']
});
useEffect(() => {
if (isInitialized && !isRegistered && deviceUuid) {
register(deviceUuid);
}
}, [isInitialized, isRegistered, deviceUuid, register]);
return null;
}
Le deviceUuid vient du JWT SmartAuth, après identification de l'appareil.
Paramètres
| Paramètre | Type | Défaut | 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 |
| autoSync | boolean | true | Synchronise au retour en ligne, après 2 s de stabilité |
| syncInterval | number | null | Synchronisation périodique, en millisecondes |
| onConflict | function | null | Appelée avec les conflits détectés |
| onSyncStart | function | null | Début de synchronisation |
| onSyncComplete | function | null | Fin de synchronisation |
| onSyncError | function | null | Échec de synchronisation |
| dbName | string | smartauth_sync | Nom de la base IndexedDB locale |
Valeurs retournées
| Propriété | Type | Description |
|---|---|---|
| isOnline | boolean | Statut de connexion du navigateur |
| isServerReachable | boolean/null | Serveur joignable |
| checkNow | function | Forcer une vérification de connectivité |
| isInitialized | boolean | Moteur et stockage local prêts |
| isRegistered | boolean | Client enregistré auprès du serveur |
| isSyncing | boolean | Synchronisation en cours |
| lastSyncTime | number | Horodatage de la dernière synchronisation |
| pendingCount | number | Nombre de modifications en attente |
| conflictsCount | number | Nombre de conflits non résolus |
| syncError | Error | Dernière erreur rencontrée |
| register | function | Enregistrer le client (register(deviceUuid)) |
| sync | function | Push puis pull |
| push | function | Push seul |
| pull | function | Pull seul |
| 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) |
| getEntity | function | Lire une entité locale |
| queryEntities | function | Lire et filtrer les entités locales |
| getConflicts | function | Récupérer les conflits |
| resolveConflict | function | Résoudre un conflit |
| getStatus | function | État complet du moteur |
| reset | function | Effacer toutes les données de synchronisation locales |
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, data) => {
await resolveConflict(conflictId, resolution, data);
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 | (conflictId, resolution, data) |
| onCancel | function | Ferme le résolveur |
| renderField | function | Rendu personnalisé d'un champ (optionnel) |
| labels | object | Libellés de l'interface (optionnel, français par défaut) |
Structure d'un conflit
{
conflict_id: 'conflict_123',
table: 'thirdparty',
object_id: 456,
client_data: { name: 'Version locale', /* ... */ },
server_data: { name: 'Version serveur', /* ... */ },
client_tms: '2026-02-14 10:00:00',
server_tms: '2026-02-14 09:45:00',
field_conflicts: ['name'],
created_at: '2026-02-14T10:01:00.000Z'
}
Résolutions possibles
- 'client' : garder la version locale
- 'server' : garder la version serveur
- fusion : passer
'client'en résolution et l'objet fusionné en troisième argument
await resolveConflict(conflict.conflict_id, 'client', {
...conflict.server_data,
label: conflict.client_data.label
});
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,
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,
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, telle que retournée par useDb |
| 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,
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 {
useApi,
useSyncClient,
useOnlineStatus,
useCachedQuery,
useDb,
CACHE_STRATEGIES,
Page,
Block,
List,
ListItem,
Button
} from '@cap-rel/smartcommon';
function ThirdpartyList({ deviceUuid }) {
const api = useApi();
// Ce store ne sert qu'au cache de useCachedQuery. useSyncClient, lui,
// gère sa propre base IndexedDB (smartauth_sync).
const db = useDb({
name: 'myApp',
version: 1,
stores: {
queryCache: 'key'
}
});
const { isOnline } = useOnlineStatus({
healthCheckUrl: '/api/smartauth/sync/status'
});
const {
sync,
isSyncing,
pendingCount,
isInitialized,
isRegistered,
register
} = useSyncClient({
apiUrl: '/api/smartauth',
getAccessToken: () => localStorage.getItem('access_token'),
scope: ['thirdparty']
});
const {
data: thirdparties,
isLoading,
isFromCache
} = useCachedQuery({
db,
store: 'queryCache',
key: 'thirdparties',
fetchFn: () => api.private.get('thirdparties').json(),
strategy: CACHE_STRATEGIES.STALE_WHILE_REVALIDATE
});
// Enregistrement du client, sans quoi aucune synchronisation n'aboutit
useEffect(() => {
if (isInitialized && !isRegistered && deviceUuid) {
register(deviceUuid);
}
}, [isInitialized, isRegistered, deviceUuid, register]);
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>
);
}
Aucun effet ne déclenche ici la synchronisation au retour en ligne : autoSync
est actif par défaut et s'en charge, après un délai de stabilité de 2 secondes
et seulement s'il reste des changements en attente.
Points clés à retenir
- useSyncClient pour les opérations CRUD offline-capable
- register(deviceUuid) avant toute synchronisation, sans exception
- 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, en gardant à l'esprit que
useSyncClientgère sa propre base à part