Aller au contenu
Sylvain Joffraud l4w
Retour

Astro : îlot dans un îlot, `node.remove is not a function`

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èseVérificationRésultat
Désynchro entre HTML serveur et hydratationpasser l’îlot en client:onlyrien ne change
Runtime Svelte chargé deux foisle renderer et le composant importent-ils la même URL ?même URL
Ré-optimisation des dépendances par Vite en cours de pageajouter optimizeDeps.includerien ne change
Node.prototype altéré par une bibliothèqueinspecter le descripteur de removeintact

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

Questions fréquentes

Pourquoi Astro affiche-t-il « Error hydrating » pour une erreur qui n’a rien à voir avec l’hydratation ?
Astro étiquette « Error hydrating » toute erreur levée dans un îlot, y compris une erreur de rendu purement client. Le libellé oriente vers une désynchronisation entre le HTML serveur et le client, alors que la cause peut être ailleurs : ici, un îlot parent qui détruisait le DOM de l’îlot enfant. Passer l’îlot en client:only permet de vérifier vite si le rendu serveur est en cause.
Peut-on mettre un composant client:load dans le slot d’un autre îlot Astro ?
Astro l’accepte syntaxiquement, mais c’est fragile dès que l’îlot parent change son rendu au montage, par exemple une garde d’authentification qui affiche « Chargement… » puis ses enfants. Le parent détruit et recrée le sous-arbre qui contient l’îlot enfant pendant que celui-ci s’initialise. La solution sûre : composer les deux composants côté framework, dans un seul îlot.
Une erreur Astro qui n’apparaît qu’en dev et pas dans le build est-elle un faux problème ?
Non. Dans ce cas, le build de production fonctionnait alors que la page déclarait deux îlots imbriqués, un défaut de structure bien réel que seul astro dev révélait. Un comportement présent dans un mode et absent de l’autre est une information : il faut tester les deux avant d’accuser le code applicatif, et ne pas classer l’écart comme un artefact.
Comment détecter automatiquement des îlots Astro imbriqués ?
Un test Vitest peut relire toutes les pages .astro, repérer chaque balise de composant non auto-fermante qui porte une directive client:load, client:idle, client:visible, client:media ou client:only, et échouer si une autre directive apparaît avant sa balise fermante. Quelques lignes de regex suffisent pour empêcher la réintroduction du motif.

Partager cet article :

Article précédent
Migrations SQL : `sort` en locale UTF-8 change l’ordre
Article suivant
mlx-lm `mask_prompt` : seul le dernier message est appris