Quatre bugs tuaient la sélection de texte sur iOS — et chacun cachait le suivant
Un éditeur Lexical dans un drawer vaul fonctionnait partout sauf sur un vrai iPhone : les poignées de sélection refusaient de glisser, le défilement se verrouillait aux bords, la barre d'outils plongeait sous le clavier. Deviner ne réparait rien, car il n'y avait pas de coupable unique — ils étaient quatre, indépendants, et chacun masquait le suivant. Ce qui a marché : un banc de bissection — le même squelette avec un interrupteur par suspect, testé sur un appareil physique. Voici la chasse complète — le href qui empoisonne la sélection, les gestionnaires de drag de vaul, notre propre tap-focus, un conteneur qui bouge sous le doigt — plus l'autopsie du verrou de défilement et les trois écritures uniques de scrollTop qui ont rendu le rubber-band naturel, y compris la capture d'un swipe en plein rebond.
L'éditeur de texte mobile de landee, c'est Lexical dans un drawer vaul : on touche un bloc de texte, un drawer plein écran monte, on édite, une coche le ferme. Sur desktop et dans le simulateur, tout allait bien. Sur un iPhone physique, c'était un désastre à plusieurs visages indépendants : les poignées de sélection — les deux épingles bleues avec lesquelles iOS laisse étirer une sélection — ne se laissaient pas glisser du tout, ou seulement après deux secondes de maintien ; tirer la poignée droite vers la gauche effondrait toute la sélection ; le défilement se verrouillait en bas jusqu'à ce qu'on tire encore plus bas pour le « décrocher » ; et la barre d'outils plongeait sous le clavier dès que l'appareil posait une barre accessoire au-dessus.
Voici la chronique de la guérison — le chemin, pas seulement les correctifs, car c'est le chemin qui se transfère. La leçon principale : quand « la sélection ne marche pas » sur iOS, il n'y a pas un coupable unique à trouver. Ils étaient quatre, indépendants les uns des autres, et chacun masquait le suivant — on en corrige un, et les symptômes changent juste assez pour faire paraître fausse l'hypothèse suivante.
La méthode qui a échoué, et celle qui a marché
Les premières heures sont parties en conjectures : peut-être la couche transform du drawer, peut-être le masque CSS du scroller, peut-être les commandes de sélection de Lexical. Chaque conjecture produisait un patch plausible et zéro changement sur l'appareil. Deux de ces patchs se sont révélés plus tard être des bugs à part entière : un silencieux sur le SELECTION_CHANGE_COMMAND de Lexical, et une barrière sur les méthodes d'écriture de Selection.prototype qui faisait atterrir la frappe en début de ligne — le tap ne livrait plus le curseur.
Ce qui a marché, c'est la bissection sur un banc isolé : une page de débogage au même squelette que l'éditeur — le même drawer vaul, la même colonne, le même scroller — et un bouton-interrupteur pour chaque suspect. Un contenteditable nu y était parfait. Chaque interrupteur rajoutait une couche jusqu'à ce que quelque chose casse. Quatre manches sur un vrai téléphone ont condamné exactement quatre choses et acquitté tout le reste — le verrou du body, les masques, les couches transform, le rôle de dialogue — des suspects qu'on aurait sinon continué à « réparer » pendant des jours.
Deux contraintes gardaient le banc honnête. Seul un appareil physique compte : le simulateur n'a ni vrai clavier ni les panneaux accessoires de l'appareil au-dessus, et la moitié des bugs vivent exactement là. Et seuls les builds de production comptent : les bundles de développement sont si lourds que WebKit tue l'onglet par manque de mémoire, et tout a l'air cassé avant même la fin de l'hydratation.
Coupable un : href sur un lien dans un contenteditable
Pour iOS, <a href> est interactif même dans du texte éditable : un toucher démarre le geste « taper un lien », et ce geste prime sur le geste de sélection — dès qu'une poignée touche un lien, la sélection s'effondre. Le même anchor sans href n'est que du texte. -webkit-touch-callout: none ne sauve pas. Et un contenteditable nu sans aucune bibliothèque est empoisonné exactement pareil — Lexical était donc innocent.
Le remède retire href du DOM vivant de l'éditeur juste après que Lexical rende ou mette à jour un nœud de lien. Rien n'est perdu nulle part : la navigation est de toute façon désactivée en mode édition, l'URL vit dans le nœud Lexical et reste éditable, et le HTML sauvegardé garde son href — l'export passe par exportDOM, qui construit ses propres éléments et ne voit jamais le DOM vivant.
// In the editor's live DOM links live without href — otherwise iOS
// kills the selection handles. The saved HTML is untouched: exportDOM
// builds its own elements and never sees this DOM.
useEffect(() => {
const strip = (keys: Map<string, unknown>) => {
for (const [key, kind] of keys) {
if (kind === "destroyed") continue;
const dom = editor.getElementByKey(key);
if (dom instanceof HTMLAnchorElement) dom.removeAttribute("href");
}
};
const unregisterLink = editor.registerMutationListener(LinkNode, strip, {
skipInitialization: false,
});
const unregisterAuto = editor.registerMutationListener(AutoLinkNode, strip, {
skipInitialization: false,
});
return () => {
unregisterLink();
unregisterAuto();
};
}, [editor]);Coupable deux : les gestionnaires de drag de vaul sur le contenu du drawer
vaul garde sa logique tirer-pour-fermer attachée au contenu du drawer même avec handleOnly — et c'est elle qui tuait la prise des poignées. La preuve : une matrice sur le banc. Lexical avec liens dans un faux drawer de même géométrie — parfait ; dans le vrai vaul — mort. Puis, un changement à la fois : retirer les styles de vaul (transform, will-change, touch-action) — toujours mort ; retirer le rôle de dialogue et les attributs aria — mort ; déverrouiller le body — mort ; couper les événements pour qu'ils n'atteignent plus les gestionnaires de vaul — pleinement vivant.
Le silencieux, c'est stopPropagation, pas preventDefault : le navigateur et Lexical entendent tout, car leurs écouteurs sont plus profonds dans l'arbre — seul vaul devient sourd. Et il doit couvrir à la fois les événements pointer et touch : la matrice a montré que le silence pointer seul ne suffit pas — les poignées restent mortes tant que vaul entend encore le touch.
{/* stopPropagation, not preventDefault: the browser and Lexical hear
everything (their listeners are deeper in the tree) — only vaul's
drag logic on the drawer content goes deaf. Both pointer AND touch:
pointer-silence alone leaves the handles dead. */}
<div
onPointerDown={(e) => e.stopPropagation()}
onPointerMove={(e) => e.stopPropagation()}
onPointerUp={(e) => e.stopPropagation()}
onPointerCancel={(e) => e.stopPropagation()}
onPointerOut={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
onTouchMove={(e) => e.stopPropagation()}
onTouchEnd={(e) => e.stopPropagation()}
>
{/* the editor column */}
</div>Coupable trois : notre propre tap-to-focus
Un hook utilitaire forçait focus() à chaque toucher de moins de 300 ms — et la prise rapide d'une poignée est précisément un toucher court. D'où le symptôme légendaire « maintiens deux secondes, alors ça glisse » : un appui long n'est pas un tap, donc focus() ne partait jamais et le glissement survivait. Le remède tient en une ligne — n'appeler focus() que si l'éditeur ne contient pas déjà le focus.
Coupable quatre : un conteneur qui bouge sous le doigt
La première architecture redimensionnait tout le drawer pour tenir au-dessus du clavier. Pendant la sélection, iOS panote l'écran — et le drawer suivait le panotage, le texte fuyait sous le doigt et WebKit lâchait le glissement. Le correctif « évident » — geler le drawer tant qu'un doigt est posé — a échoué autrement : le texte se décalait par rapport au doigt d'exactement le panotage, et la sélection atterrissait une ligne à côté.
La règle née de tout ça : on ne peut bouger que ce qui n'est pas touché. Le drawer et le texte se tiennent à la pleine hauteur du viewport de layout — sur iOS le clavier ne comprime pas le layout, il n'en couvre que le bas. Concrètement :
- Seule la barre d'outils poursuit le clavier ; la marge basse du texte voyage par une variable CSS (
--keyboard-inset) — rien ne bouge. - Une boucle de frames (
requestAnimationFrame), pas des événements : iOS panote pendant la sélection sans événementsvisualViewportfiables, et après la fermeture du clavieroffsetTopne se réinitialise parfois jamais (une régression d'iOS 26). Une boucle qui regarde simplement à chaque frame n'a besoin d'aucun signal. - Les styles s'écrivent droit dans le DOM, en contournant React — passer par l'état re-rendrait tout l'arbre Lexical à chaque frame de panotage.
- C'est l'enveloppe qui défile, pas le contenteditable. Le playground de Lexical fait pareil (
editor-scroller) : un contenteditable focalisé qui se défile lui-même est la pire configuration que WebKit connaisse — le geste tantôt défile, tantôt démarre une sélection, tantôt nourrit la page. - Une demi-seconde après l'ouverture, on retire du drawer les
transform,will-changeettouch-actionde vaul : la couche composite décale la zone tactile des poignées, et l'animation d'ouverture n'a plus besoin de ces styles.
La barre au-dessus du clavier — et de tout ce qu'iOS pose au-dessus du clavier
« 100svh moins le clavier » est un mensonge sur iPhone : la barre d'adresse prend sa part, et l'appareil aime poser des panneaux accessoires au-dessus du clavier. La vérité, c'est visualViewport qui la connaît, et le nombre dont la barre a besoin, c'est la part occupée du bas du viewport de layout :
// Inside the requestAnimationFrame loop. Layout coordinates — the same
// space the toolbar's absolute position lives in. On iOS the keyboard
// never compresses layout; it only covers the bottom of it.
const inset = Math.max(
0,
Math.round(window.innerHeight - vv.height - vv.offsetTop),
);
toolbarHost.style.bottom = `${inset}px`;
drawer.style.setProperty("--keyboard-inset", `${inset + toolbarHeight}px`);Une subtilité a coûté une soirée. Quand le focus passe dans un champ de la barre elle-même — taille de police, URL du lien — iOS change le type de clavier, et visualViewport rapporte des nombres transitoires pendant quelques dizaines de frames. Les appliquer immédiatement plongeait la barre sous le clavier ; geler la boucle entièrement (la tentative précédente) la rendait aveugle exactement quand la hauteur changeait vraiment. Le compromis : tant que le focus est dans la barre, une nouvelle valeur doit tenir dix frames avant d'être appliquée ; dans tous les autres cas elle s'applique immédiatement, et la barre voyage avec l'animation du clavier au lieu de la poursuivre.
Donner le focus à ces champs demande son propre tour. Quand un vrai input dans un conteneur fixe plein écran reçoit le focus, iOS fait défiler la page pour centrer le champ — la page n'a nulle part où défiler, et la mise en page se déchire : le drawer glisse, la barre disparaît. vaul soigne ça dans son propre intercepteur de focus, que nous venions de couper — alors le remède est reproduit à la main : annuler le focus natif, téléporter le champ vers le haut par transform, le focaliser soi-même, le remettre au frame suivant. Safari croit que le champ est « en haut » et laisse la page tranquille.
const onTouchEnd = (event: TouchEvent) => {
const target = event.target as HTMLElement;
if (
!(target instanceof HTMLInputElement) ||
target === document.activeElement
) {
return;
}
// Cancel the native focus (it drags the page scroll along) and focus
// ourselves while the field is "up top".
event.preventDefault();
target.style.transform = "translateY(-2000px)";
target.focus();
requestAnimationFrame(() => {
target.style.transform = "";
});
};
// passive: false — without it preventDefault has no power.
toolbarHost.addEventListener("touchend", onTouchEnd, { passive: false });Le verrou de défilement : les deux causes étaient les nôtres
Le défilement se « verrouillait » en bas : gestes vers le haut morts jusqu'à tirer encore plus bas, comme pour décrocher. Le banc — cette fois avec un vrai clavier — a acquitté toute la géométrie (l'espaceur, la barre en surimpression, le drawer lui-même) et condamné deux choses, toutes deux à nous :
- La marge pour le clavier vivait dans le contenteditable. En bas, la bande visible au-dessus du clavier était une zone éditable vide — et un toucher sur un contenteditable focalisé, iOS le traite comme du travail sur le texte, pas comme du défilement. La place pour le clavier doit être un bloc séparé et non éditable après le contenteditable — pas son padding.
- Notre propre « sécurité », montée contre la première cause. Près d'un bord, scrollTop erre en valeurs fractionnaires, et un chien de garde par frame le « corrigeait » par des écritures programmatiques — or les écritures de défilement programmatiques continues tuent le geste humain. Clavier ouvert, la sécurité devenait elle-même le verrou.
La règle : aucun preventDefault aux bords et aucun chien de garde de scrollTop, jamais. Une simple enveloppe overflow-y-auto avec l'espaceur hors du contenteditable défile parfaitement — plus overscroll-contain, pour qu'un geste de bord ne nourrisse pas la page, qu'iOS rend défilable au bénéfice du champ focalisé.
<div
ref={scrollerRef}
className="min-h-0 flex-1 overflow-y-auto overscroll-contain"
>
<ContentEditable className="p-5 outline-none" />
{/* Room for the keyboard as a SEPARATE non-editable block, not as
padding of the contenteditable: editable padding at the bottom
turns the scroll gesture into "working with text" and locks the
scroller. */}
<div aria-hidden style={{ height: "var(--keyboard-inset, 0px)" }} />
</div>Trois écritures uniques : tout le protocole du rubber-band
Les verrous partis, il restait une famille de bizarreries — et les trois remèdes se sont révélés être des écritures uniques de scrollTop. Jamais des boucles, jamais des gardes. Premièrement : un scroller posé sur un bord exact (0 ou max) « colle » parfois — le geste inverse est mort jusqu'à tirer au-delà du bord. Le remède canonique, documenté depuis dix ans (iNoBounce et compagnie) : au touchstart, pousser scrollTop d'un pixel hors du bord — avant que WebKit décide de quel geste il s'agit. Le bord devient inatteignable, et l'état « collé » ne se forme jamais.
Deuxièmement : l'élan peut porter le scroller dans le bord quand le doigt n'y est plus. La poussée du touchstart arrive un swipe trop tard pour ça. Et une poussée immédiate depuis le handler scroll coupait le ressort natif — le bord se sentait comme un mur. Donc la poussée d'apaisement est différée : quand les événements scroll se taisent 140 ms sans doigt posé et que la position est un bord exact, une écriture discrète la déplace d'un pixel vers l'intérieur.
Troisièmement, le plus dur : un swipe pendant l'animation de rebond était simplement avalé. C'est un comportement de plateforme — tant que l'animation du rubber-band joue, WebKit n'attache le geste à rien jusqu'à ce qu'elle finisse. On balayait, rien ne bougeait, on balayait encore. Aucun article trouvé ne propose d'interruption ; la piste s'arrête à « attendez la fin ».
L'entrée s'est trouvée : pendant le rebond, iOS expose scrollTop hors des limites — négatif en haut, supérieur à max en bas. Le moment devient strictement détectable : st < 0 || st > max est un vrai overscroll et jamais un repos tranquille sur un bord. Au touchstart dans cet état, une seule écriture bornée interrompt l'animation — et le même geste attrape le scroller. Avec une sécurité : seulement le premier doigt (touching === 1), car une animation ne joue que si aucun doigt ne tenait le scroller, et un deuxième doigt ne doit pas arracher le texte de sous le premier.
const nudge = () => {
const max = scroller.scrollHeight - scroller.clientHeight;
if (max <= 1) return;
if (scroller.scrollTop <= 0) scroller.scrollTop = 1;
else if (scroller.scrollTop >= max) scroller.scrollTop = max - 1;
};
let touching = 0;
let settleTimer = 0;
const touchBegin = () => {
touching += 1;
window.clearTimeout(settleTimer);
const max = scroller.scrollHeight - scroller.clientHeight;
const st = scroller.scrollTop;
// Strictly OUT of bounds — a finger landing mid-bounce, not resting
// at an edge. One clamped write interrupts the animation, and the
// same gesture grabs the scroller. Only for the first finger: an
// animation only plays when no finger held the scroller.
if (touching === 1 && max > 1 && (st < 0 || st > max)) {
scroller.scrollTop = st < 0 ? 1 : max - 1;
return;
}
nudge();
};
const touchFinish = () => {
touching = Math.max(0, touching - 1);
};
// Deferred, not immediate: an instant nudge mid-bounce cut the native
// spring short and the edge felt like hitting a wall. Wait for the
// scroll stream to go quiet, then one quiet write.
const onScroll = () => {
if (touching > 0) return;
window.clearTimeout(settleTimer);
settleTimer = window.setTimeout(nudge, 140);
};
scroller.addEventListener("touchstart", touchBegin, { passive: true });
scroller.addEventListener("touchend", touchFinish, { passive: true });
scroller.addEventListener("touchcancel", touchFinish, { passive: true });
scroller.addEventListener("scroll", onScroll, { passive: true });- Le marteau overflow-toggle (poser
overflow: hidden, borner, forcer un reflow, restaurer) — rejeté deux fois sur l'appareil. Sa première version tirait en plus sur les touchers tranquilles au bord (un<= 0là où il fallait un< 0) et cassait au passage le défilement normal. overscroll-behavior: none— supprime le problème avec le ressort lui-même : l'élan meurt net au bord, et le bord semble tranché.
Acquittés — ne plus les chasser
- le verrou du body
position: fixedde vaul - le masque CSS de fondu sur le scroller
- la couche transform à elle seule (la retirer est juste, mais la tueuse n'était pas elle)
- le rôle de dialogue, les attributs aria et data du drawer
- Lexical lui-même — un contenteditable nu reproduit et le poison du href et le verrou de défilement
- couper le
SELECTION_CHANGE_COMMANDde Lexical — n'a rien changé - la barrière sur les écritures de
Selection.prototype— devenue un bug à part entière : le tap ne livrait plus le curseur et la frappe partait en début de ligne
Un ennemi bonus : la sélection native contre le champ d'URL
Éditer un lien porte un conflit intégré : la sélection native d'iOS ne vit que dans un éditeur focalisé, et le champ d'URL a besoin du focus pour lui-même — ils ne peuvent coexister. Alors, le temps de l'édition, le texte sélectionné est enveloppé dans un MarkNode Lexical : un vrai surlignage dans le DOM, indifférent à l'endroit du focus. Le playground de Lexical surligne les cibles de commentaires avec le même mécanisme.
La sélection elle-même est sauvegardée en continu, à chaque changement — le même motif lastSelection que le FloatingLinkEditorPlugin de Lexical — car la sauver au moment du clic est trop tard : le tap sur le bouton de la barre effondre d'abord la sélection. À l'application, relâcher la marque recrée la vraie sélection exactement à sa place, et TOGGLE_LINK_COMMAND fait le reste.
À retenir
- « La sélection ne marche pas sur iOS » n'est pas un seul bug. Il y avait quatre causes indépendantes, chacune masquant la suivante — un débogage à hypothèse unique ne peut pas converger là-dessus.
- La bissection bat la déduction. Un banc isolé avec un interrupteur par suspect a transformé des jours de conjectures en quatre manches sur appareil — et acquitté les suspects qu'on aurait sinon « réparés » pendant des jours.
- Seul un appareil physique dit la vérité : le simulateur n'a ni vrai clavier ni panneaux accessoires, et les bundles de dev meurent par la mémoire avant même l'apparition des bugs.
- Ne bouger que ce qui n'est pas touché : drawer statique, barre sur boucle de frames, marge basse par variable CSS.
- Ne jamais combattre le défilement iOS par des écritures continues ou du preventDefault aux bords — les deux deviennent le verrou même qu'ils devaient prévenir. Chaque remède survivant est une seule écriture de scrollTop à un moment précisément détecté.
- Le protocole complet du rubber-band : une poussée au touchstart sur un bord au repos, une poussée d'apaisement différée après l'élan, et un bornage au touchstart en plein rebond — détectable parce qu'iOS expose scrollTop hors des limites.
Vous voyez une erreur ?
Un fait erroné, une traduction bancale, quelque chose qui sonne faux dans cet article ? Écrivez-moi — dans votre propre langue.