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({
        scope: ['thirdparty', 'contact', 'product']
    });

    return (
        <div>
            <p>Statut : {isOnline ? 'En ligne' : 'Hors ligne'}</p>
            <p>Modifications en attente : {pendingCount}</p>
        </div>
    );
}

Le hook passe par useApi : monté sous <Provider>, il récupère tout seul l'URL de l'API, le token et l'identifiant d'appareil. Aucun composant n'a à lui transmettre de token.

scope liste des types d'objets SmartAuth (thirdparty, contact, product, category, order, invoice...), parents d'abord : les créations partent dans cet ordre, les suppressions dans l'ordre inverse.

Enregistrement du client

L'enregistrement auprès du serveur est automatique : au premier sync(), push() ou pull(), le hook génère un client_uuid, le garde dans sa base et l'envoie à sync/register. Il faut seulement que l'appareil ait été identifié auparavant (<DeviceIdentificationComponent>) : le serveur lit l'appareil dans le token.

Deux identifiants interviennent, à ne pas confondre :

  • device_uuid : fourni par SmartAuth à l'identification de l'appareil, porté par le JWT ;
  • client_uuid : généré côté client, envoyé dans la query (GET) ou le body (POST) de chaque appel sync/*.

register() reste disponible pour s'enregistrer avant la première synchronisation, sans argument.

Paramètres

Paramètre Type Défaut Description
scope string[] - Types d'objets SmartAuth à synchroniser, parents d'abord
appVersion string null Version envoyée à sync/register
healthCheckUrl string/null route health Sonde de joignabilité, null pour l'état réseau seul
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 (route health)
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 string Heure serveur de la dernière synchronisation complète
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 sans attendre la première synchronisation
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 local du moteur
getServerStatus function État du client côté serveur (GET sync/status)
reset function Effacer toutes les données de synchronisation locales

Créer une entité

function CreateThirdpartyForm() {
    const { create, pendingCount } = useSyncClient({
        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({
        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({
        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({
        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({
        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: 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
  • 'merged' : fusionner, avec l'objet fusionné en troisième argument

Un conflit sur une modification est enregistré par SmartAuth (identifiant numérique). Un conflit sur une suppression reste local (identifiant local:<type>:<id>) et ne se fusionne pas : 'client' remet la suppression en file, 'server' y renonce et récupère à nouveau l'objet.

await resolveConflict(conflict.conflict_id, 'merged', {
    ...conflict.server_data,
    label: conflict.client_data.label
});

useOnlineStatus

Hook pour détecter le statut online/offline, avec une sonde serveur : par défaut une requête HEAD sans token sur la route publique health de SmartAuth, sous le prefixUrl de l'API. Les instances montées partagent une seule requête par URL.

Import

import { useOnlineStatus } from '@cap-rel/smartcommon';

Utilisation simple

function NetworkStatus() {
    // État réseau seul : pas de requête au serveur
    const { isOnline, isOffline } = useOnlineStatus({ healthCheckUrl: null });

    return (
        <div className={isOffline ? 'bg-red-500' : 'bg-green-500'}>
            {isOnline ? 'En ligne' : 'Hors ligne'}
        </div>
    );
}

Avec une sonde personnalisée

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 <prefixUrl>/health URL de la sonde, null pour la désactiver
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 : réponse inférieure à 500 (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

useCachedQuery et useAuthenticatedImage ont besoin de stores Dexie. Ils se déclarent dans la base unique de l'application, src/db/index.js, et non dans un composant (voir Stockage de données) :

// src/db/index.js
import { Db } from '@cap-rel/smartcommon';

export const db = new Db({
    name: 'myApp',
    version: 1,
    stores: {
        // Store pour les requêtes cachées
        queryCache: 'key',

        // Store pour les images
        imageCache: 'key',

        // Autres stores...
        items: '++id, name'
    }
}).db;

Exemple complet : Application offline-first

import {
    useApi,
    useSyncClient,
    useOnlineStatus,
    useCachedQuery,
    CACHE_STRATEGIES,
    Page,
    Block,
    List,
    ListItem,
    Button
} from '@cap-rel/smartcommon';
// La base de l'application, déclarée une fois dans src/db/index.js (store
// queryCache). useSyncClient, lui, gère sa propre base IndexedDB (smartauth_sync).
import { db } from 'src/db';

function ThirdpartyList() {
    const api = useApi();

    // Sonde par défaut : route publique health de SmartAuth
    const { isOnline } = useOnlineStatus();

    const {
        sync,
        isSyncing,
        pendingCount
    } = useSyncClient({
        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
    });

    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. L'enregistrement du client se fait lui aussi tout seul, à la première synchronisation.

Points clés à retenir

  1. useReferenceSync pour consulter un référentiel hors connexion, useSyncClient seulement pour le modifier
  2. Aucun token ni URL à fournir : tout passe par useApi, et le client s'enregistre seul (appareil identifié au préalable)
  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