« Ajouter au panier, ajouter au panier » : sur ce bloc de blocs personnalisé, VoiceOver énonce deux fois le libellé du même bouton, mais uniquement une fois la page complètement chargée. Au premier instant de l’affichage, juste après le rendu serveur, l’annonce est correcte et unique. Quelques centaines de millisecondes plus tard, dès que le composant React qui gère les interactions du panier prend le relais côté client, l’annonce se dédouble sans raison apparente à l’inspection visuelle.
Ce type d’anomalie, propre aux blocs interactifs combinant rendu serveur et hydratation JavaScript, ne se manifeste jamais dans une revue de code classique ni dans un test visuel : elle exige un test au lecteur d’écran effectué après le chargement complet de la page, pas seulement au moment du premier affichage.
Reproduire l’anomalie pas à pas
Le bouton en question fait partie d’un bloc personnalisé développé pour une fiche produit WooCommerce, dont l’interactivité (gestion de quantité, ajout au panier sans rechargement) est déléguée à un composant React monté après le chargement initial de la page. Le rendu serveur produit un bouton statique fonctionnel en l’absence de JavaScript ; une fois React chargé, ce bouton statique est censé être « repris en main » par le composant, sans modification visible pour l’utilisateur.
Le diagnostic : deux arbres DOM, pas un

L’inspection de l’arbre d’accessibilité via les DevTools Chrome, avant et après hydratation, révèle la cause exacte : React, faute d’une clé stable sur l’élément concerné, ne réutilise pas le bouton généré par le serveur mais en recrée un second, laissant temporairement les deux dans l’arbre DOM avant que le premier ne soit retiré. Cette fenêtre de duplication, invisible visuellement car les deux boutons se superposent exactement au même endroit, suffit à VoiceOver pour capter et annoncer les deux éléments avant le nettoyage final.
// Rendu React fautif : aucune clé stable transmise
function BoutonPanier({ produitId }) {
return (
<button onClick={() => ajouterAuPanier(produitId)}>
Ajouter au panier
</button>
);
}
Le correctif : hydrater plutôt que recréer
Le correctif retenu consiste à s’assurer que React hydrate le bouton existant plutôt que d’en générer un nouveau, en utilisant hydrateRoot (l’API d’hydratation de React 18) directement sur le conteneur du bouton rendu côté serveur, avec un balisage strictement identique entre les deux rendus. Toute différence de structure entre le HTML serveur et le rendu React initial déclenche ce type de duplication temporaire.
import { hydrateRoot } from 'react-dom/client';
const conteneur = document.querySelector('.bloc-panier[data-produit]');
hydrateRoot(conteneur, <BoutonPanier produitId={conteneur.dataset.produit} />);
Vérifier que le correctif tient dans le temps
- Comparer le HTML généré côté serveur et le rendu initial du composant React caractère pour caractère.
- Tester systématiquement avec VoiceOver après le chargement complet, pas seulement au premier affichage.
- Ajouter un test automatisé Playwright qui capture l’arbre d’accessibilité 500 millisecondes après le chargement, pour détecter une duplication transitoire.
Prévenir la récidive sur d’autres blocs
Cette anomalie ne se limite pas au bouton du panier : tout composant interactif combinant rendu serveur WordPress et hydratation JavaScript côté client est exposé au même risque dès que le balisage généré par les deux rendus diverge, même légèrement. Une checklist de revue a été ajoutée pour les futurs blocs interactifs, incluant une comparaison automatisée des deux arbres DOM en intégration continue.
Ce qu’il faut retenir
Un test d’accessibilité mené uniquement au premier affichage d’une page ne révèle rien des anomalies introduites par l’hydratation JavaScript, qui n’apparaissent qu’une fois le composant client pleinement monté. Sur des blocs interactifs modernes, le test au lecteur d’écran doit systématiquement être répété après le chargement complet de la page, avec une attention particulière portée aux éléments dont l’interactivité est déléguée à un framework côté client.