Safari se fermait sur notre site : deux translate: none dans une animation au défilement

Par Maxime Bénard

13 min de lecture

Couverture d’article Mintfull : « À chaque clic depuis Google, ça fait cracher mon Safari », message d’un visiteur, et la cause : translate: none vers translate: none dans une animation CSS

Le jour de la mise en ligne de notre nouveau site, un visiteur nous a écrit que Safari se fermait à chaque fois qu’il cliquait sur un de nos liens depuis Google. Pas un onglet en erreur : toute l’application. La cause tenait dans une animation CSS pilotée par le défilement, celle de nos témoignages, dont deux étapes consécutives valaient translate: none. Safari 26.6.2 sur macOS 26.6.2 plantait en interpolant ce « rien » vers « rien », alors que Safari 26.2 et iPadOS 27 ne bronchaient pas. Le correctif tient en une valeur (translate: 0 0, rendu identique). La partie utile est ailleurs : comment on est passé de « ça plante chez quelqu’un » à la ligne fautive en lisant le rapport de plantage, et pourquoi aucun outil de compatibilité ne pouvait nous prévenir.

« À chaque clic depuis Google, ça fait cracher mon Safari »

Le message est arrivé sur LinkedIn à 16 h 35, quelques heures après la bascule du site, de la part de quelqu’un qui ne nous connaissait pas. Il cherchait à voir notre travail, avait cliqué sur un résultat Google, et Safari s’était fermé. Quatre fois, « pour être sûr de la cause ».

De notre côté, rien. Le site répondait, les pages étaient en 200, la préproduction avait été recettée pendant des semaines, et Maxime ouvrait le site tous les jours dans Safari sur son Mac. La première réaction a été celle de tout le monde : « chelou, je suis sur Mac et Safari aussi, sans souci ».

Un détail est pourtant revenu en mémoire. La semaine précédente, l’iPad de Maxime, encore sous iPadOS 26, avait planté en ouvrant le site. Une mise à jour vers iPadOS 27 avait tout réglé, et l’affaire avait été classée comme un caprice de l’iPad. C’était le même bug, vu une semaine plus tôt, et effacé par la mise à jour avant que quelqu’un ne le regarde.

Safari 26.2 ouvrait le site, Safari 26.6.2 se fermait

Les versions ont été la première information solide, et elles ont démoli notre première hypothèse.

Appareil Système Safari Résultat
Mac de Maxime macOS 26.2 26.2 s’ouvre
iPad de Maxime, semaine précédente iPadOS 26 26.x plante
même iPad, après mise à jour iPadOS 27 27 s’ouvre
Mac17,5 de test macOS 26.6.2 (25G83) 26.6.2 (21624.5.1.11.3) Safari se ferme

L’hypothèse de départ était un Safari trop ancien. Notre thème utilise des propriétés que seul Safari 26 exécute : les animations pilotées par le défilement (animation-timeline, view-timeline, timeline-scope), arrivées dans Safari 26.0. On les protège par @supports, donc Safari 18 les ignore et Safari 26 les exécute. Un bug de jeunesse en 26.0, corrigé depuis, collait bien.

Sauf que la machine qui plantait avait Safari 26.6.2, plus récent que le 26.2 qui marchait. Le bug n’était pas dans une version ancienne, il était dans une version intermédiaire, absent avant, absent après. On n’a pas pu dater son apparition plus finement que ça.

Il se trouve que la machine de test était à portée de main : le Mac d’un proche. C’est ce qui a permis de boucler en une soirée.

Playwright ne pouvait pas reproduire : le plantage n’était pas dans le moteur

Avant d’avoir le Mac sous la main, on a essayé de reproduire avec WebKit, le moteur de Safari, piloté par Playwright. Le WebKit livré avec Playwright 1.57 correspond à Safari 26.0, celui de Playwright 1.61 à Safari 26.5. Les deux sont gratuits à lancer, et en général c’est le bon réflexe.

Les 30 pages du sitemap se chargeaient sans erreur. En boucle, la page /notre-approche/ se figeait de temps en temps pendant le défilement, environ un essai sur quinze, et le processus WebKit ne répondait plus ensuite. On a passé du temps à désactiver les sections une par une avec une feuille injectée, à compter les gels :

js
const CSSX = {
  notesti: '.testimonial{view-timeline:none!important}.testimonial>*{animation:none!important}',
  nofooter: '.site-footer{view-timeline:none!important}.site-footer__wordmark{animation:none!important}',
};
await page.addInitScript(css => {
  const s = document.createElement('style');
  s.textContent = css;
  document.documentElement.appendChild(s);
}, CSSX[mode]);

Une première série désignait les témoignages (0 gel sur 30 sans eux). La seconde a montré des gels aussi sans eux. C’était du bruit : quatre navigateurs headless en parallèle sur la même machine, et des échantillons trop petits pour conclure. Deux autres pistes sont tombées dans la foulée : les liens Google qui ajoutent un fragment de texte (#:~:text=...) ne changeaient rien, et l’en-tête HTTP comme le <head> de l’accueil n’avaient rien d’exotique (pas de theme-color, pas de manifeste, pas de service worker).

Avec le recul, Playwright ne pouvait pas trouver ce bug, pour une raison que le visiteur nous a donnée sans le savoir.

Un onglet qui recharge et une application qui se ferme ne racontent pas la même panne

Le message suivant disait : « le site ne s’ouvre même pas et tout Safari se ferme d’un coup ».

Cette phrase vaut plus que toutes les mesures de la soirée. Safari découpe son travail en processus séparés. La page web (HTML, CSS, JavaScript) est rendue dans un processus de contenu web (com.apple.WebKit.WebContent). Quand ce processus plante, Safari affiche dans l’onglet « Un problème est survenu avec cette page web et elle a été rechargée ». L’application reste ouverte, les autres onglets aussi.

Quand toute l’application se ferme, c’est le processus principal de Safari qui est tombé. Notre page ne s’exécute pas dedans : il a reçu quelque chose de la page et s’est trompé en le traitant. Deux conséquences immédiates :

  • ce n’est pas une erreur JavaScript de notre côté, une exception JS ne fait jamais tomber l’application ;
  • Playwright lance le moteur WebKit dans sa propre application, pas Safari. Un bug du processus principal de Safari lui est invisible par construction.

La question à poser au visiteur n’est donc pas « quel navigateur ? », c’est « est-ce que l’onglet recharge, ou est-ce que Safari se ferme ? ». La réponse dit dans quel processus chercher.

Le rapport .ips pointait WebCore::blend et une adresse à 0xc

Quand Safari se ferme, macOS écrit un rapport de plantage. Au redémarrage, la fenêtre « Safari s’est fermé de manière inattendue » propose de le voir, et le fichier reste dans ~/Library/Logs/DiagnosticReports/, nommé Safari-2026-10-14-200224.ips dans notre cas. On peut aussi l’ouvrir depuis l’app Console, section « Rapports de plantage ».

Un .ips est une première ligne JSON (application, version, système), suivie d’un gros objet JSON. Pas besoin d’outil pour l’exploiter, quelques lignes de Python suffisent pour sortir l’exception et la pile du thread qui a planté :

python
import json, sys

raw = open(sys.argv[1], encoding="utf-8").read()
header, body = raw.split("\n", 1)
d = json.loads(body)
print(d["exception"])
images = d["usedImages"]
for f in d["threads"][d["faultingThread"]]["frames"][:12]:
    name = images[f["imageIndex"]].get("name", "?")
    print(name, f.get("symbol", "?"))

Le résultat, débarrassé des paramètres de templates :

text
EXC_BAD_ACCESS (SIGSEGV)  KERN_INVALID_ADDRESS at 0x000000000000000c
Thread 0 (com.apple.main-thread)

WebCore  WebCore::blend(...)::$_0 ... TransformOperation const& ...
WebCore  WebCore::blend(RefPtr<TransformOperation> const&, RefPtr<TransformOperation> const&, ...)
WebCore  WebCore::blend(AcceleratedEffectProperty, AcceleratedEffectValues&, ...)
WebCore  WebCore::KeyframeInterpolation::interpolateKeyframes(...)
WebCore  WebCore::AcceleratedEffect::apply(...)
WebKit   WebKit::RemoteAnimationStack::computeValues() const
WebKit   WebKit::RemoteLayerTreeNode::setAcceleratedEffectsAndBaseValues(...)
WebKit   WebKit::RemoteLayerTreeHost::updateLayerTree(...)
...
Safari   SafariMain

Trois informations en sortent sans connaître le code de WebKit :

  1. Le thread principal de Safari (com.apple.main-thread, jusqu’à SafariMain) : c’est bien l’application qui tombe, ce que le visiteur décrivait.
  2. Une animation interpolée hors de la page. RemoteAnimationStack, AcceleratedEffect et interpolateKeyframes indiquent que Safari calcule lui-même, côté application, les valeurs intermédiaires d’une animation reçue de la page. Les noms suffisent à le comprendre, sans aller plus loin dans les sources.
  3. Une transformation vide. blend mélange deux TransformOperation, et l’accès à l’adresse 0xc est la signature classique d’un pointeur nul auquel on ajoute un petit décalage : l’une des deux opérations n’existait pas.

Une transformation qui n’existe pas, dans une animation, c’est none.

Deux translate: none de suite dans l’animation des témoignages

Il restait à trouver l’animation. Un inventaire de toutes nos @keyframes qui animent transform, translate, scale ou rotate en a sorti seize. Une seule avait none des deux côtés d’un intervalle : le glissement des témoignages, présent sur 19 pages dont l’accueil et les pages de services, celles où Google envoie les visiteurs.

scss
@media (prefers-reduced-motion: no-preference) {
    @supports (animation-timeline: view(inline)) {
        .testimonial {
            view-timeline: --testimonial inline;

            > * {
                animation: testimonial-slide linear both;
                animation-timeline: --testimonial;
            }
        }
    }
}

@keyframes testimonial-slide {
    0% {
        opacity: 0;
        translate: 8% 0;
    }

    35%,
    65% {
        opacity: 1;
        translate: none;
    }

    100% {
        opacity: 0;
        translate: -8% 0;
    }
}

Chaque témoignage est une diapositive d’un carrousel horizontal aimanté (scroll-snap). Sa frise de vue horizontale (view-timeline: --testimonial inline) pilote l’animation de ses enfants : le témoignage qui entre glisse et apparaît, celui qui sort glisse et s’efface, et entre 35 % et 65 % de son passage, le témoignage centré reste immobile et opaque.

Cet intervalle immobile est exactement celui où l’animation passe de none à none. Et c’est l’état du premier témoignage dès le chargement de la page, centré dans le carrousel : pas besoin de défiler pour tomber dedans. D’où « à chaque clic ».

Nos autres animations ont none d’un seul côté (une étape from explicite et une arrivée implicite sur la valeur de base, qui vaut none). Celles-là n’ont jamais planté.

Trois pages de dix lignes ont confirmé la cause en deux minutes

Une pile d’appels désigne un suspect, elle ne prouve pas la cause. Avant de toucher au site en production, on a construit trois pages minimales, sans rien de notre thème, à ouvrir sur le Mac qui plantait :

html
<style>
.track { display: flex; overflow-x: auto; scroll-snap-type: x mandatory; }
.slide { flex: 0 0 100%; scroll-snap-align: center; view-timeline: --slide inline; }
.slide > * { animation: slide linear both; animation-timeline: --slide; }
@keyframes slide {
  0% { opacity: 0; translate: 8% 0; }
  35%, 65% { opacity: 1; translate: none; }
  100% { opacity: 0; translate: -8% 0; }
}
</style>
<div class="track">
  <div class="slide"><p>Témoignage 1</p></div>
  <div class="slide"><p>Témoignage 2</p></div>
</div>
Page Différence Safari 26.6.2
1 telle quelle : none à 35 % et 65 %, animation au défilement se ferme
2 translate: 0 0 à la place de none s’ouvre
3 none à 35 % et 65 %, mais animation classique en 4s infinite s’ouvre

La page 1 reproduit le plantage hors de notre site, la page 2 valide le correctif, la page 3 borne le risque : none vers none ne pose problème que sur une animation pilotée par le défilement.

translate: 0 0 au lieu de none, en production le soir même

Le correctif remplace la valeur, avec un commentaire pour que personne ne la remette par souci d’élégance :

scss
// Jamais `translate: none` entre deux étapes : Safari 26.6 (macOS 26.6.2)
// se ferme en entier en interpolant `none` vers `none` sur une animation
// pilotée par le défilement (WebCore::blend, processus de l'application).
@keyframes testimonial-slide {
    35%,
    65% {
        opacity: 1;
        translate: 0 0;
    }
}

none et 0 0 produisent le même rendu : aucun décalage. Mais 0 0 est une vraie translation, de zéro, et Safari a alors deux opérations à mélanger au lieu de deux absences. On ne perd rien, l’animation reste la même dans tous les navigateurs.

Le correctif est parti en production vers 22 h 30, le soir même de la mise en ligne. On a vérifié que la feuille servie contenait bien la nouvelle valeur, plutôt que de faire confiance au déploiement :

bash
css=$(curl -s https://mintfull.agency/ | grep -oE 'styles-[A-Za-z0-9_-]+\.css' | head -1)
curl -s "https://mintfull.agency/app/themes/mintfull/public/build/$css" \
  | grep -oE '35%,65%\{opacity:1;translate:[^}]*\}'
# 35%,65%{opacity:1;translate:0 0}

Puis le Mac de test a ouvert le site, sans plantage. La règle qu’on applique désormais : dans une animation pilotée par le défilement, jamais none aux deux bornes d’un intervalle de translate, scale, rotate ou transform. On écrit la valeur neutre (0 0, 1, 0deg). Pour auditer un thème existant, ce repérage suffit à trouver les candidates :

bash
grep -rnE '(translate|scale|rotate|transform):\s*none' resources/scss \
  | grep -v node_modules

Il faut ensuite regarder à la main celles qui tombent dans un @keyframes utilisé avec animation-timeline.

Caniuse et @supports disent « pris en charge », pas « sans bug »

La question qu’on s’est posée ensuite : pouvait-on l’anticiper ? Honnêtement, pas avec les outils habituels.

  • Les données de compatibilité de MDN indiquent animation-timeline, view-timeline et timeline-scope disponibles depuis Safari 26, sans aucune réserve.
  • @supports (animation-timeline: view(inline)) répond vrai sur Safari 26.6.2, et il a raison : la propriété est prise en charge. Elle plante dans un cas précis, ce qu’aucune requête de fonctionnalité ne peut détecter.
  • Nos recherches dans les notes de version de WebKit et dans les correctifs de sécurité de Safari 26.x n’ont trouvé aucune mention de ce plantage. Les notes listent des corrections de comportement sur les frises de défilement, pas de fermeture de l’application.

Ce qui aurait pu l’attraper, c’est un test sur de vrais Safari, dans plusieurs versions intermédiaires, pas seulement la dernière et pas un moteur seul. Et surtout, prendre au sérieux le plantage de l’iPad une semaine plus tôt : on avait le bug sous la main, et la mise à jour l’a effacé avant qu’on récupère le rapport.

On en a tiré une règle de recette : une fonctionnalité CSS de moins d’un an dans Safari passe par un test sur un appareil réel avant la mise en production, et tout plantage observé sur une seule machine mérite son .ips avant toute mise à jour.

Notre monitoring n’a rien vu : seul un visiteur pouvait prévenir

C’est la partie la plus inconfortable de l’histoire. Ce bug était invisible pour nous :

  • aucune erreur côté serveur, toutes les pages répondaient en 200 ;
  • aucune erreur JavaScript, puisque notre code n’a jamais planté ;
  • très probablement aucune visite enregistrée : notre script d’analyse est chargé en defer et Safari se fermait avant d’afficher la page.

Une partie des visiteurs sous Safari 26.6 arrivaient de Google, voyaient leur navigateur se fermer, et repartaient sans laisser de trace. Le seul capteur qui a fonctionné, c’est quelqu’un qui a pris cinq minutes pour nous écrire, alors qu’il ne nous connaissait pas.

C’est la même famille que le reCAPTCHA invisible qui bloquait la création de compte au checkout ou le BOM UTF-8 de trois octets qui cassait l’AJAX : une panne qui ne produit aucune erreur là où on regarde. La réponse n’est pas un outil de plus, c’est une habitude : quand un utilisateur décrit un symptôme qu’on ne reproduit pas, on lui demande le rapport de la machine qui plante, et on le lit. Elle a sa place dans la checklist de mise en ligne, à côté des parcours testés comme un vrai client : un parcours testé sur un seul Safari n’est testé que sur celui-là.

Partager cet article
Articles

Des articles pour faire pousser vos idées

D'autres retours d'expérience et méthodes à mettre en pratique sur votre site.

Bon, on en discute ?

Demandez votre diagnostic de performance gratuit : un échange de 30 minutes, vos Core Web Vitals relevés sur mobile et une synthèse écrite de ce qui mérite d’être corrigé en premier.

Discuter de mes besoins