Aller au contenu principal
VH.
Disponible
Accueil
Projets
Expérience
Job idéal
Communauté
Notes
Contact
VH.

Vincent Hirtz — Lead Developer Front-End, basé à Lyon, disponible en remote ou sur site.

Disponible pour missions
Lyon, France
  • Projets
  • Expérience
  • Communauté
  • Notes
  • Contact
  • CV en ligne
  • Branding
Tip : essayez ↑↑↓↓←→←→ B A
© 2026 Vincent Hirtz. Tous droits réservés.
Mentions légalesAccessibilitéPlan du site
Fait avec ♥ et beaucoup de café.
Accueil/Notes/React 19 + Next.js : le bug removeChild qui rend fou (et comment le résoudre)
DebugAvril 2026·~6 min de lecture·reactnextjsdebugretour-experience

React 19 + Next.js : le bug removeChild qui rend fou (et comment le résoudre)

Vitre brisée avec une fissure qui traverse toute la surface
Photo : Call Me FredUnsplash

Si vous utilisez Next.js 15+ avec React 19 et le App Router, vous avez probablement vu cette erreur en naviguant entre vos pages : Uncaught TypeError: Cannot read properties of null (reading 'removeChild'). Voici le résultat de plusieurs jours d'investigation, la cause racine exacte dans le code de React, et la solution qui fait passer tous les tests.

Le symptôme

L'erreur apparaît dans la console du navigateur lors de la navigation client-side entre pages. Pas au premier chargement, pas sur un refresh — uniquement quand React swap les pages via le routeur Next.js.

Le pattern est régulier : environ une navigation sur deux déclenche l'erreur. La page fonctionne visuellement, mais l'erreur uncaught pollue la console et peut casser des Error Boundaries.

Ce que tout le monde essaie (et qui ne marche pas)

Patcher Node.prototype.removeChild

La première idée — et celle qu'on retrouve partout sur Stack Overflow et les discussions GitHub :

JavaScript
// ❌ Ne fonctionne PAS pour ce bug
const original = Node.prototype.removeChild;
Node.prototype.removeChild = function(child) {
if (child.parentNode !== this) return child;
return original.call(this, child);
};

Ça ne marche pas parce que React n'appelle pas removeChild sur un mauvais parent. Il l'appelle sur null. L'erreur est null.removeChild(...), pas parent.removeChild(wrongChild). Le patch n'est jamais invoqué.

suppressHydrationWarning

Ça traite les warnings d'hydratation, pas les erreurs de reconciliation. Deux problèmes différents.

Supprimer Framer Motion

C'est une bonne hygiène, mais si vous avez ce bug sans Framer Motion, ce n'est pas la cause. On a retiré les 23 composants Framer Motion du projet et l'erreur persistait.

La cause racine : HostHoistable (case 26)

En lisant le code source de react-dom, la stack trace pointe systématiquement vers la même ligne dans commitDeletionEffectsOnFiber :

JavaScript
// react-dom — commitDeletionEffectsOnFiber
case 26: // HostHoistable
// ...
deletedFiber.stateNode.parentNode.removeChild(deletedFiber.stateNode);
//                      ^^^^^^^^^^
//                      parentNode est null → crash

Case 26, c'est HostHoistable — le type de fiber React pour les éléments "hoistables" : <title>, <meta>, <link>, <style>, <script>. Ce sont les éléments que le navigateur est autorisé à déplacer dans le <head>, même si React les a rendus dans le <body>.

Quand Next.js génère les metadata d'une page (via l'export metadata), il crée des éléments <title>, <link rel="canonical">, <meta> etc. Au premier rendu, le navigateur les hoist dans <head>. Quand l'utilisateur navigue vers une autre page, React essaie de supprimer ces éléments de leur parent d'origine — mais le parent n'existe plus (il a été remplacé par le nouveau contenu de page). parentNode est null. Crash.

Les <script dangerouslySetInnerHTML> sont aussi touchés

Si vous mettez des <script type="application/ld+json"> dans vos pages pour le SEO (JSON-LD), ils subissent le même sort. Le navigateur les hoist, React ne les retrouve plus.

La solution pour ceux-là : les injecter via un composant client qui manipule le DOM directement, hors de l'arbre React :

TypeScript
// components/JsonLd.tsx
"use client";
import { useEffect, useId } from "react";

export default function JsonLd({ data }: { data: Record<string, unknown> }) {
const id = useId();
useEffect(() => {
  const script = document.createElement("script");
  script.type = "application/ld+json";
  script.id = `jsonld-${id}`;
  script.textContent = JSON.stringify(data);
  document.head.appendChild(script);
  return () => { script.remove(); };
}, [data, id]);
return null;
}

Ce qui ne marche pas non plus : addEventListener("error")

La deuxième fausse piste (que j'ai moi-même suivie) : intercepter l'erreur avec un event listener en capture :

HTML
<!-- ❌ NE RÉSOUT PAS le crash de navigation -->
<script>
window.addEventListener("error", function(e) {
  if (e.message && e.message.includes("removeChild")) {
    e.preventDefault();
    return false;
  }
}, true);
</script>

Ça masque l'erreur dans la console, mais ça n'empêche pas le crash. L'erreur est un TypeError synchrone dans le commit phase de React. Quand null.removeChild(n) throw, la call stack se déroule immédiatement — le commit phase est avorté, le fiber tree reste dans un état incohérent, et la transition de page n'aboutit jamais. Le event listener ne s'exécute qu'après, quand le mal est fait.

Résultat : l'URL change (pushState a déjà été appelé), mais le contenu de la page ne se met pas à jour. L'erreur est silencieuse dans la console. On perd des heures à chercher ailleurs.

La solution définitive : patcher le getter parentNode

Le bug est dans React lui-même — on ne peut pas le corriger sans patcher react-dom. Mais on peut empêcher l'erreur d'être lancée en interceptant l'accès à parentNode sur les éléments hoistable.

L'idée : quand parentNode retourne null pour un <title>, <link>, <meta> ou <style>, on retourne un objet no-op avec des méthodes removeChild/insertBefore qui ne font rien. React appelle parentNode.removeChild(n) — au lieu de crasher sur null.removeChild(n), il appelle noop.removeChild(n) qui retourne silencieusement.

HTML
<!-- Dans le <head> du layout racine, AVANT tout script React -->
<script>
(function() {
// Seuls les éléments hoistable sont concernés
var H = { TITLE: 1, LINK: 1, META: 1, STYLE: 1 };
var noop = {
  removeChild: function(c) { return c; },
  insertBefore: function(n) { return n; }
};

// 1. Patcher le getter parentNode pour les hoistables
var desc = Object.getOwnPropertyDescriptor(Node.prototype, "parentNode");
if (desc && desc.get) {
var originalGet = desc.get;
Object.defineProperty(Node.prototype, "parentNode", {
get: function() {
var p = originalGet.call(this);
// Si parentNode est null ET c'est un élément hoistable → no-op
if (p === null && this.nodeName && H[this.nodeName]) return noop;
return p;
},
configurable: true
});
}

// 2. Patcher removeChild pour le cas "wrong parent" (NotFoundError)
var origRemove = Node.prototype.removeChild;
Node.prototype.removeChild = function(c) {
if (c.parentNode !== this) return c;
return origRemove.call(this, c);
};

// 3. Patcher insertBefore pour le même cas
var origInsert = Node.prototype.insertBefore;
Node.prototype.insertBefore = function(n, r) {
if (r && r.parentNode !== this) return n;
return origInsert.call(this, n, r);
};
})();

</script>

Pourquoi c'est safe

  1. Ciblé — Le getter parentNode ne retourne le no-op que pour <title>, <link>, <meta> et <style> dont le parentNode est null. Tous les autres éléments gardent le comportement natif.
  2. Prévient le crash — L'erreur n'est jamais lancée, donc le commit phase de React continue normalement et la transition de page aboutit.
  3. Pas d'effet de bord visible — Les éléments concernés sont déjà détachés du DOM. Le no-op ne fait que "réussir" une suppression qui a déjà eu lieu.

Pourquoi pas un Error Boundary ?

Un Error Boundary React attraperait l'erreur mais démonterait tout l'arbre et afficherait un fallback. C'est pire que l'erreur elle-même.

Comment j'ai trouvé

Playwright a été décisif. J'ai créé une suite de tests qui navigue entre toutes les pages et collecte les erreurs console :

TypeScript
// tests/navigation.spec.ts (extrait)
test("navigation séquentielle sans erreur removeChild", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (err) => errors.push(err.message));

await page.goto("/");
for (const route of ["/projects", "/experience", "/community", "/notes"]) {
await page.click(`nav >> a[href="${route}"]`);
await page.waitForURL(`**${route}`);
await page.waitForTimeout(500);
}

const err = errors.find(e => e.includes("removeChild"));
expect(err).toBeUndefined();
});

Le test de diagnostic avec addInitScript qui patch removeChild avant React a prouvé que le patch Node.prototype.removeChild seul n'interceptait jamais l'erreur — confirmant que React appelait .removeChild sur null, pas sur un Node. C'est ce qui a orienté vers le patch du getter parentNode.

La lecture du code source de react-dom bundlé (ligne 8045 du chunk) a révélé le case 26 (HostHoistable) et la ligne fautive finishedRoot.parentNode.removeChild(finishedRoot).

En résumé

| Ce qui ne marche pas | Pourquoi | | ---------------------------------------------- | ----------------------------------------------------------- | | Patcher Node.prototype.removeChild seul | React appelle .removeChild sur null, pas sur un Node | | addEventListener("error") + preventDefault | Masque l'erreur mais n'empêche pas le crash du commit phase | | suppressHydrationWarning | Traite l'hydratation, pas la réconciliation | | Retirer Framer Motion | Bonne idée, mais pas la cause ici | | Error Boundary | Démonte tout l'arbre pour une erreur invisible |

| Ce qui marche | | | --------------------------------------------------------------- | -------------------------------------------------------- | | Patcher le getter parentNode + removeChild + insertBefore | Empêche l'erreur d'être lancée, le commit phase continue | | Composant JsonLd via DOM direct | Évite les HostHoistable dans les pages | | Supprimer les <script dangerouslySetInnerHTML> des pages | Réduit les HostHoistable orphelins |

Le bug est ouvert chez React/Next.js (#58055). En attendant un fix upstream, le patch parentNode est la solution la plus propre — elle empêche le crash au lieu de le masquer.

Sommaire
VH
Vincent Hirtz
Lead Developer Front-End · Lyon
Partager
D'autres notes
Debug · Avril 2026

Quand le Service Worker casse la navigation Next.js (et que les tests ne voient rien)

Lire →
Architecture · Janvier 2026

Vue.js + Laravel : retour d'expérience après 4 ans chez SAPIENDO

Lire →