Aller au contenu
Sylvain Joffraud l4w
Retour

Svelte 5 `$state` et IndexedDB : `DataCloneError`

Avec Svelte 5, passer un $state à IndexedDB lève DataCloneError : un tableau déclaré en $state([]) est un Proxy, et l’algorithme de clonage qu’IndexedDB applique à toute valeur enregistrée refuse les Proxy. Sur une PWA de signalements (Astro, îlots Svelte 5, offline-first avec Dexie au-dessus d’IndexedDB), le bouton « Créer le dossier » échouait sans explication, parce qu’un catch vide avalait l’erreur. Corrigé en mai 2026 par une copie défensive, le piège est revenu en août par les métadonnées EXIF des photos, avec un effet de bord pire : des doublons. Versions déclarées : svelte ^5.55.1, dexie ^4.4.2, astro ^6.1.3.

Le symptôme : un bouton qui échoue sans rien dire

Un dossier regroupe les signalements d’un même problème. Le formulaire de création affichait « Erreur lors de la création du dossier. » et rien d’autre. Le code, simplifié :

<script lang="ts">
  let selectedCategories = $state<string[]>([]);

  async function submit() {
    try {
      const id = await createCase({
        name: name.trim(),
        startDate: new Date(startDate),
        categories: selectedCategories,
      });
      oncreated(id);
    } catch {
      error = "Erreur lors de la création du dossier.";
      submitting = false;
    }
  }
</script>

Une fois le catch équipé d’un console.error, la console a livré le vrai message :

DataCloneError: Failed to execute 'add' on 'IDBObjectStore': [object Array] could not be cloned.

Pourquoi un $state Svelte 5 provoque DataCloneError ?

$state est la rune de Svelte 5 qui déclare un état réactif. Pour un tableau ou un objet, Svelte ne renvoie pas la valeur elle-même mais un Proxy profond : un objet JavaScript qui intercepte lectures et écritures pour déclencher les mises à jour de l’interface.

IndexedDB, lui, ne stocke pas une référence : il copie la valeur avec l’algorithme de clonage structuré, le même que structuredClone(). Cet algorithme ne sait pas cloner un Proxy. Le message est trompeur : il parle de [object Array], parce que le Proxy se présente comme un tableau, et ne mentionne jamais Svelte.

Un $state Svelte 5 n’est pas une donnée, c’est un Proxy : tout ce qui part vers IndexedDB doit d’abord redevenir un objet ordinaire.

Le correctif : copier dans le service, pas seulement dans le composant

Le premier correctif détache le Proxy dans le service, pour couvrir tous les appelants, pas seulement ce formulaire :

export async function createCase(data: NewCase): Promise<number> {
  const table = await cases();
  const id = await table.add({
    ...data,
    // Détache un éventuel Proxy Svelte 5 : IndexedDB clone la valeur
    // et refuse les Proxy. Le spread produit un Array natif.
    categories: [...data.categories],
    createdAt: new Date(),
  });
  setActiveCaseId(id);
  return id;
}

Le composant fait aussi la copie (défense en profondeur), et son catch logue désormais l’erreur et affiche son message. Un test de régression parmi les huit nouveaux du service enveloppe la liste dans un new Proxy([...], { get: Reflect.get }) et vérifie qu’un Array natif est persisté.

La récidive : un objet EXIF réactif, et des doublons

En août 2026, en recette, toute photo porteuse d’EXIF échouait à l’enregistrement. Le formulaire de signalement passait à addPhoto l’objet EXIF tiré de son état réactif exifs : encore un Proxy, dans un autre service que celui corrigé en mai.

L’effet de bord était plus grave que le bug. Le signalement était déjà créé quand la photo échouait, mais tout tenait dans un seul try, et l’écran annonçait « Erreur lors de l’enregistrement. ». L’utilisateur recliquait, et le doublon partait au gestionnaire de la résidence.

Deux correctifs, donc. D’abord $state.snapshot(), qui renvoie une copie ordinaire et non réactive d’un état (doc Svelte), au point d’appel. Ensuite, l’échec n’est déclaré que si la création du signalement elle-même a échoué :

let id: number;
try {
  id = await addReport({ category, intensity, description });
} catch {
  error = "Erreur lors de l'enregistrement.";
  submitting = false;
  return;
}

// À partir d'ici, le signalement est en base : plus d'échec « total ».
for (const [index, file] of selectedFiles.entries()) {
  try {
    await addPhoto(id, file, $state.snapshot(exifs[index]) ?? {});
  } catch {
    lostPhotos += 1;
  }
}

Une photo perdue s’affiche en avertissement, hors du tiroir de saisie. Côté stockage, addPhoto recopie à plat l’objet reçu ({ ...metadata }) : ses trois champs sont primitifs, une copie de surface suffit.

CopieProfondeurOù je l’utilise
[...liste], { ...objet }surfaceservice de stockage, structure plate
$state.snapshot(valeur)profondecomposant, au point d’appel

Pourquoi les tests n’ont rien vu ?

Le parseur EXIF était testé, pas le chemin d’écriture depuis un état réactif. Les tests passaient des objets littéraux, jamais ce qu’un composant transmet réellement. Le test ajouté reproduit l’appel réel, et échoue en DataCloneError sans le correctif :

it("accepte un objet venu d'un état réactif", async () => {
  const reactive = new Proxy({ dateTimeOriginal: "2026-08-11T22:15:03" }, {});
  const id = await addPhoto(6, jpegWithDate(), reactive);

  const [photo] = await getPhotosForReport(6);
  expect(photo.id).toBe(id);
  expect(photo.exif).toEqual({ dateTimeOriginal: "2026-08-11T22:15:03" });
});

Lessons learned

Questions fréquentes

Pourquoi IndexedDB refuse-t-il un tableau venant d’un $state Svelte 5 ?
En Svelte 5, $state([]) ne renvoie pas un tableau ordinaire mais un Proxy réactif. IndexedDB copie chaque valeur enregistrée avec l’algorithme de clonage structuré, qui ne sait pas cloner un Proxy. L’appel add() lève alors une DataCloneError dont le message parle de [object Array] et pas de Proxy, ce qui égare le diagnostic.
Faut-il utiliser $state.snapshot() ou une copie par spread avant d’écrire dans IndexedDB ?
$state.snapshot() renvoie une copie ordinaire et profonde d’un état réactif : c’est l’outil prévu par Svelte 5 au point d’appel, dans le composant. Une copie par spread ([...liste], { ...objet }) suffit pour une structure plate et a sa place dans la couche de stockage, qui ne doit pas dépendre de ce que l’appelant lui passe. Les deux se combinent.
Dexie est-il responsable de la DataCloneError avec Svelte 5 ?
Non. Dexie transmet l’objet à IndexedDB, et c’est IndexedDB qui le clone avant de l’écrire. Avec Dexie 4.4 et Svelte 5.55, un tableau ou un objet issu d’un $state passé tel quel à table.add() lève la même DataCloneError qu’avec l’API IndexedDB brute. La correction se fait avant l’appel : transmettre un objet ordinaire.
Comment tester qu’un service IndexedDB accepte un état réactif Svelte ?
Sous Vitest avec fake-indexeddb, il suffit d’envelopper la donnée dans new Proxy(valeur, {}) : l’identité du Proxy suffit à faire échouer le clonage, sans dépendre de Svelte. Le test appelle le service avec ce Proxy, relit la ligne et vérifie qu’elle contient un tableau ou un objet natif. Sans copie défensive, ce test échoue en DataCloneError.

Partager cet article :

Article précédent
Pattern Strategy : un moteur de facturation sans `persist()`
Article suivant
Runners GitHub : `_temp` en tmpfs, `IOWeight` inutile