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 n'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

  1. useSyncClient pour les opérations CRUD offline-capable
  2. register(deviceUuid) avant toute synchronisation, sans exception
  3. useOnlineStatus pour détecter la connectivité
  4. useCachedQuery pour le cache intelligent avec stratégies
  5. useAuthenticatedImage pour les images protégées
  6. ConflictResolver pour la résolution de conflits UI
  7. Configurer les stores IndexedDB pour le cache, en gardant à l'esprit que useSyncClient gère sa propre base à part

← Chapitre précédent | Retour au module