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

Le bug parfait : la navigation fonctionne en dev, les 18 tests Playwright passent au vert, mais en production il faut cliquer deux fois sur les liens du menu. Retour sur un diagnostic en 3 couches — Service Worker, Lenis, et un error handler trop zélé — et pourquoi vos tests E2E ne peuvent pas tout attraper.
Le symptôme
En production, sur vincenthirtz.fr, la navigation client-side plante de manière intermittente. Le premier clic sur un lien du menu ne fait rien. Le deuxième fonctionne. Pas d'erreur visible dans la console, pas de crash, pas de page blanche. Juste un clic perdu dans le vide.
Le plus frustrant : 18 tests Playwright couvrent la navigation de long en large — séquentielle, rapide, back/forward, mobile, stress test. Tous passent.
La piste Service Worker : le coupable principal
Le site utilise un Service Worker pour le support offline (PWA). Voici la règle qui posait problème :
Pourquoi ça casse la navigation
Quand l'utilisateur clique un lien dans Next.js App Router, le framework ne fait pas un chargement de page classique. Il envoie une requête fetch vers l'URL de destination avec des headers spéciaux :
RSC: 1— indique que le serveur doit renvoyer un payload React Server Component, pas du HTMLNext-Router-State-Tree— l'état actuel de l'arbre de routesNext-Router-Prefetch— pour le prefetching
Le serveur répond avec un flux binaire RSC que le router interprète pour mettre à jour le DOM de manière granulaire. C'est ce qui rend la navigation quasi-instantanée.
Mais le Service Worker ne connaît pas ces headers. Quand il voit un GET / ou GET /notes, il pense que c'est un chargement de page normal et renvoie le HTML complet depuis le cache. Le router Next.js reçoit du HTML au lieu d'un payload RSC. Il ne sait pas quoi en faire. La navigation échoue silencieusement.
Au deuxième clic, le router détecte l'échec et fait un fallback vers un chargement complet de page, ce qui fonctionne.
Le fix
4 vérifications, une seule suffit. La ceinture et les bretelles, parce que les versions de Next.js n'envoient pas toujours les mêmes headers.
La couche Lenis : scroll désynchronisé
Le site utilisait Lenis pour le smooth scroll. L'instance était créée une seule fois au mount du layout :
Lenis maintient un état de scroll interne (animatedScroll, targetScroll). Quand Next.js navigue vers une autre page, le contenu change mais Lenis pense être toujours en train de scroller l'ancien contenu. Le scroll ne revient pas en haut, l'utilisateur arrive au milieu du néant, et ça ressemble à une navigation cassée.
Le fix : supprimer Lenis
Plutôt que de patcher Lenis pour chaque changement de route, on l'a supprimé. Le smooth scroll natif du navigateur (scroll-behavior: smooth en CSS) fait le même travail sans couche d'abstraction, sans rAF loop, sans désynchronisation avec le router.
Pour garantir le scroll-to-top sur chaque navigation — y compris quand les erreurs removeChild de React 19 cassent les hooks — on utilise un patch natif sur history.pushState :
Ce patch fonctionne même quand usePathname() ne se met plus à jour (ce qui arrive quand React 19 corrompt son arbre interne via les erreurs removeChild).
force: true est important — sans ça, si Lenis est dans un état "stoppé" (par exemple à cause d'un overflow: hidden sur le body pendant l'ouverture du menu mobile), le scrollTo est ignoré.
La couche error handler : trop de zèle
L'article précédent expliquait comment on gère le bug removeChild de React 19. Voici le handler qu'on avait en place :
stopImmediatePropagation() empêche tous les autres error handlers de voir l'erreur. Y compris ceux de Next.js qui pourraient déclencher un retry ou un fallback de navigation. En bloquant la propagation, on empêchait le router de se remettre d'une erreur de réconciliation.
Le fix
preventDefault() suffit pour empêcher l'erreur d'apparaître dans la console. Les autres handlers reçoivent toujours l'événement et peuvent réagir. Et on ajoute insertBefore au passage — même famille de bugs React 19.
Pourquoi les tests ne voyaient rien
C'est la leçon la plus importante de ce debug.
Le Service Worker n'existe qu'en production
En dev, pas de SW. Les tests Playwright lancent npm run dev. Le bug n'existe littéralement pas dans l'environnement de test.
waitForURL est trop indulgent
Si le premier clic échoue et que le router retente automatiquement, waitForURL finit par passer. Le test ne sait pas que ça a pris 2 clics — il voit juste que l'URL a fini par changer.
Les nouveaux tests
Checklist pour les Service Workers avec Next.js App Router
Si vous avez un Service Worker sur un site Next.js App Router, vérifiez ces points :
- Requêtes RSC — Le SW doit détecter les headers
RSC,Next-Router-State-Tree,Next-Router-Prefetchet les laisser passer au serveur - Scope
/_next/— Ne cacher que/_next/static/(assets hashés). Ne pas cacher/_next/data/(Pages Router) ou les RSC payloads - Versioning — Incrémenter la version du cache à chaque déploiement pour éviter les chunks JS obsolètes
- Tests en production — Au minimum un smoke test sur un build de production avec le SW actif
En résumé
| Couche | Problème | Impact |
| --------------------------- | -------------------------------------------------------- | --------------------------------------------- |
| Service Worker | Intercepte les requêtes RSC, renvoie du HTML caché | Navigation échoue au 1er clic |
| Lenis (smooth scroll) | Ne reset pas le scroll sur navigation | Page semble bloquée au mauvais endroit |
| Error handler removeChild | stopImmediatePropagation bloque le recovery de Next.js | Le router ne peut pas se remettre des erreurs |
Trois bugs indépendants qui se cumulent pour produire un symptôme unique : "il faut cliquer deux fois". En dev, aucun des trois ne se manifeste. En production, les trois se déclenchent en même temps.
La morale : testez en production build. npm run dev et npm run build && npm start ne sont pas le même logiciel.