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
$stateSvelte 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.
| Copie | Profondeur | Où je l’utilise |
|---|---|---|
[...liste], { ...objet } | surface | service de stockage, structure plate |
$state.snapshot(valeur) | profonde | composant, 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
- Un
$stateSvelte 5 sur un tableau ou un objet est un Proxy, pas une donnée. IndexedDB, qui clone toute valeur enregistrée, le refuse avecDataCloneError. - La couche de stockage doit recopier ce qu’elle reçoit. Une copie défensive dans le service protège tous les appelants, y compris ceux qui n’existent pas encore.
- Un
catchvide transforme un bug d’une ligne en mystère. Logger l’erreur a suffi à faire apparaître la cause. - Un échec partiel ne doit jamais s’annoncer comme un échec total. Afficher « erreur » sur un signalement déjà en base pousse à recliquer et fabrique le doublon.
- Tester le parseur ne teste pas le chemin d’écriture. Le test utile appelle le service avec un
new Proxy(...), comme le ferait un composant réactif.