Avec Astro, un îlot imbriqué dans un autre îlot peut faire planter Svelte 5 sur node.remove is not a function. C’est ce qui bloquait l’application d’une PWA de signalements sur « Chargement… », en dev uniquement. La page déclarait <AuthGuard client:load><ReportHub client:load /></AuthGuard> : la garde d’authentification remplaçait son contenu au montage et détruisait le DOM de l’îlot enfant pendant que celui-ci s’initialisait. Le correctif tient en une ligne de balisage, un seul îlot racine, plus un test qui échoue si un îlot en contient un autre. Il a fallu trois heures pour le trouver. Versions déclarées : astro ^6.1.3, @astrojs/svelte ^8.0.4, svelte ^5.55.1.
Le symptôme : « Chargement… » et une erreur interne de Svelte
Sur le serveur de dev, /app restait sur « Chargement… ». La console montrait une première erreur interne de Svelte :
node.remove is not a function
puis une seconde, sur next_sibling_getter.call appelé sur undefined. Aucune ne pointe vers le code applicatif. Astro les présente sous le libellé « Error hydrating », qu’il applique à toute erreur d’îlot, même une erreur de rendu purement client.
Le build de production, lui, fonctionnait. C’est ce qui a longtemps fait passer le problème pour un artefact du mode dev. J’avais déjà rencontré le cas inverse : sous Gatsby, le mode develop masquait un SSR cassé que seul le build révélait. Une différence entre dev et build est un indice, pas un bruit.
Quatre fausses pistes, chacune vérifiée
Un îlot client:load est aussi rendu côté serveur. Dès avril, j’avais dû charger Dexie et le client Supabase par import() dynamique pour que ce rendu serveur ne plante pas faute d’IndexedDB. La piste SSR était donc la première candidate.
| Hypothèse | Vérification | Résultat |
|---|---|---|
| Désynchro entre HTML serveur et hydratation | passer l’îlot en client:only | rien ne change |
| Runtime Svelte chargé deux fois | le renderer et le composant importent-ils la même URL ? | même URL |
| Ré-optimisation des dépendances par Vite en cours de page | ajouter optimizeDeps.include | rien ne change |
Node.prototype altéré par une bibliothèque | inspecter le descripteur de remove | intact |
La cause : des îlots Astro imbriqués
Un îlot Astro est un composant de framework hydraté côté client, déclaré par une directive client:* ; le reste de la page reste du HTML statique. Chaque îlot s’hydrate indépendamment des autres.
La garde d’authentification, elle-même un îlot, faisait ceci :
{#if !ready}
<div class="loading">Chargement...</div>
{:else if authenticated || skipAuth}
{@render children()}
{:else}
<AuthPage onlogin={handleLogin} onskip={handleSkip} />
{/if}
Ses children sont le contenu du slot, c’est-à-dire l’îlot ReportHub. Au montage, la garde bascule de « Chargement… » vers {@render children()} : elle détruit et recrée le sous-arbre DOM qui contient l’îlot enfant, pendant que celui-ci s’initialise. Svelte part alors démonter des nœuds qui ne lui appartiennent plus, d’où node.remove is not a function.
Un îlot Astro ne doit pas vivre dans le slot d’un autre îlot dont le rendu change : on compose les deux côté framework, dans un seul îlot.
Le correctif : un seul îlot racine
Un composant Svelte compose la garde et le hub. Le rendu conditionnel redevient un {#if} interne, sans frontière d’îlot :
<!-- AppRoot.svelte -->
<script lang="ts">
import AuthGuard from "./AuthGuard.svelte";
import ReportHub from "./ReportHub.svelte";
</script>
<AuthGuard>
<ReportHub />
</AuthGuard>
<Layout title="Mes signalements">
- <AuthGuard client:load>
- <ReportHub client:load />
- </AuthGuard>
+ <AppRoot client:load />
</Layout>
Vérifié au navigateur en dev et sur le build.
Un test qui échoue si un îlot en contient un autre
Le piège tient dans une ligne de balisage, donc il reviendra. Un test Vitest relit toutes les pages .astro :
const ISLAND_DIRECTIVE = /client:(load|idle|visible|media|only)/g;
function nestedIslands(source: string): string[] {
const found: string[] = [];
const openTag = /<([A-Z][A-Za-z0-9_]*)\b([^>]*?)(\/?)>/g;
let match: RegExpExecArray | null;
while ((match = openTag.exec(source)) !== null) {
const [, name, attrs, selfClosing] = match;
if (selfClosing === "/" || !ISLAND_DIRECTIVE.test(attrs)) {
ISLAND_DIRECTIVE.lastIndex = 0;
continue;
}
ISLAND_DIRECTIVE.lastIndex = 0;
const closing = source.indexOf(`</${name}>`, match.index);
if (closing === -1) continue;
const body = source.slice(match.index + match[0].length, closing);
if (ISLAND_DIRECTIVE.test(body)) found.push(name);
ISLAND_DIRECTIVE.lastIndex = 0;
}
return found;
}
Le test principal applique nestedIslands à chaque page et attend une liste vide. Quatre cas unitaires figent la détection : le motif fautif, deux îlots frères (acceptés), un îlot qui contient du balisage statique (accepté), et l’imbrication avec client:only. J’ai vérifié que le test échoue en réintroduisant le motif.
Lessons learned
- Un îlot Astro ne doit pas vivre dans le slot d’un autre îlot qui change son rendu. Le parent détruit le DOM de l’enfant pendant son initialisation.
- « Error hydrating » ne veut pas dire erreur d’hydratation. Astro met ce libellé sur toute erreur d’îlot, y compris un rendu purement client.
- Une erreur présente en dev seulement n’est pas un artefact. Le build passait, mais le défaut de structure était bien là.
- Une fausse piste se ferme par une vérification, pas par une intuition. Les quatre hypothèses ont été testées et notées avant d’être écartées.
- Un piège de balisage se verrouille par un test sur les sources. Une regex sur les pages
.astroempêche de réintroduire l’imbrication.