Cuatro bugs mataban la selección de texto en iOS — y cada uno escondía al siguiente
Un editor Lexical dentro de un drawer de vaul funcionaba en todas partes menos en un iPhone real: los tiradores de selección no se arrastraban, el scroll se bloqueaba en los bordes, la barra de herramientas se hundía bajo el teclado. Adivinar no arreglaba nada, porque no había un único culpable — eran cuatro independientes, y cada uno enmascaraba al siguiente. Lo que funcionó fue un banco de bisección: el mismo esqueleto con un interruptor por sospechoso, probado en un dispositivo físico. Esta es la cacería completa — el href que envenena la selección, los drag handlers de vaul, nuestro propio tap-focus, un contenedor que se mueve bajo el dedo — más la autopsia del bloqueo de scroll y las tres escrituras únicas de scrollTop que hicieron que el rubber-band se sintiera nativo, incluida la captura de un swipe en plena rebote.
El editor de texto móvil de landee es Lexical dentro de un drawer de vaul: tocas un bloque de texto, sube un drawer a pantalla completa, editas, una marca de verificación lo cierra. En escritorio y en el simulador todo iba bien. En un iPhone físico era un desastre con varias caras independientes: los tiradores de selección — los dos pines azules con los que iOS deja estirar una selección — no se arrastraban en absoluto, o solo tras mantenerlos unos dos segundos; arrastrar el tirador derecho hacia la izquierda colapsaba toda la selección; el scroll se bloqueaba en el borde inferior hasta que tirabas aún más abajo para «desengancharlo»; y la barra de herramientas se hundía bajo el teclado cada vez que el dispositivo ponía una barra accesoria encima.
Esta es la crónica de cómo se curó — el camino, no solo los arreglos, porque lo transferible es el camino. La lección principal: cuando «la selección no funciona» en iOS, no hay un único culpable que encontrar. Eran cuatro, independientes entre sí, y cada uno enmascaraba al siguiente — arreglabas uno y los síntomas cambiaban justo lo suficiente para que la siguiente hipótesis pareciera falsa.
El método que falló y el que funcionó
Las primeras horas se fueron en conjeturas: quizá la capa transform del drawer, quizá la máscara CSS del scroller, quizá los comandos de selección de Lexical. Cada conjetura producía un parche plausible y cero cambios en el dispositivo. Dos de esos parches resultaron luego ser bugs por sí mismos: un silenciador del SELECTION_CHANGE_COMMAND de Lexical y una barrera sobre los métodos de escritura de Selection.prototype que hacía que lo tecleado aterrizara al principio de la línea — el tap dejaba de entregar el cursor.
Lo que funcionó fue la bisección en un banco aislado: una página de depuración con el mismo esqueleto que el editor — el mismo drawer de vaul, la misma columna, el mismo scroller — y un botón-interruptor por cada sospechoso. Un contenteditable desnudo allí era perfecto. Cada interruptor devolvía una capa hasta que algo se rompía. Cuatro rondas en un teléfono real condenaron exactamente a cuatro y absolvieron todo lo demás — el bloqueo del body, las máscaras, las capas transform, el rol de diálogo — sospechosos que de otro modo se habrían seguido «arreglando» durante días.
Dos restricciones mantuvieron honesto el banco. Solo cuenta un dispositivo físico: el simulador no tiene ni teclado real ni los paneles accesorios del dispositivo encima, y la mitad de los bugs vive exactamente ahí. Y solo cuentan builds de producción: los bundles de desarrollo pesan tanto que WebKit mata la pestaña por memoria, y todo parece roto antes de que la hidratación siquiera termine.
Culpable uno: href en un enlace dentro de contenteditable
Para iOS, <a href> es interactivo incluso dentro de texto editable: un toque inicia el gesto «tocar un enlace», y ese gesto manda sobre el gesto de selección — en cuanto un tirador toca un enlace, la selección colapsa. El mismo anchor sin href es solo texto. -webkit-touch-callout: none no salva. Y un contenteditable desnudo sin ninguna biblioteca se envenena exactamente igual — así que Lexical era inocente.
La cura elimina href del DOM vivo del editor justo después de que Lexical renderice o actualice un nodo de enlace. No se pierde nada en ningún sitio: la navegación está desactivada en modo edición de todos modos, la URL vive en el nodo de Lexical y sigue editable, y el HTML guardado conserva su href — la exportación pasa por exportDOM, que construye sus propios elementos y nunca ve el DOM vivo.
// 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]);Culpable dos: los drag handlers de vaul sobre el contenido del drawer
vaul mantiene su lógica de arrastrar-para-cerrar adjunta al contenido del drawer incluso con handleOnly — y esa lógica mataba el agarre de los tiradores. La prueba fue una matriz en el banco: Lexical con enlaces dentro de un drawer falso de la misma geometría — perfecto; dentro del vaul real — muerto. Luego, un cambio cada vez: quitar los estilos de vaul (transform, will-change, touch-action) — sigue muerto; quitar el rol de diálogo y los atributos aria — muerto; desbloquear el body — muerto; silenciar los eventos para que no lleguen a los handlers de vaul — completamente vivo.
El silenciador es stopPropagation, no preventDefault: el navegador y Lexical lo oyen todo, porque sus listeners están más profundos en el árbol — solo vaul se queda sordo. Y debe cubrir tanto los eventos pointer como los touch: la matriz mostró que el silencio pointer por sí solo no basta — los tiradores siguen muertos mientras vaul aún oiga 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>Culpable tres: nuestro propio tap-to-focus
Un hook auxiliar forzaba focus() en cada toque de menos de 300 ms — y el agarre rápido de un tirador es precisamente un toque corto. Eso explica el síntoma legendario «mantenlo dos segundos y entonces se arrastra»: una pulsación larga no es un tap, así que focus() nunca disparaba y el arrastre sobrevivía. La cura es una línea — llamar a focus() solo cuando el editor no contiene ya el foco.
Culpable cuatro: un contenedor que se mueve bajo el dedo
La primera arquitectura redimensionaba todo el drawer para caber sobre el teclado. Durante la selección iOS panea la pantalla — y el drawer seguía el paneo, el texto huía de debajo del dedo y WebKit soltaba el arrastre. El arreglo «obvio» — congelar el drawer mientras hay un dedo abajo — falló de otra manera: el texto se desplazaba respecto al dedo exactamente lo paneado, y la selección aterrizaba una línea más allá.
La regla que salió de esto: solo se puede mover lo que no se está tocando. El drawer y el texto están a la altura completa del viewport de layout — en iOS el teclado no comprime el layout, solo cubre su parte inferior. En concreto:
- Solo la barra de herramientas persigue al teclado; el margen inferior del texto viaja por una variable CSS (
--keyboard-inset) — no se mueve nada. - Un bucle de frames (
requestAnimationFrame), no eventos: iOS panea durante la selección sin eventos fiables devisualViewport, y tras cerrar el tecladooffsetTopa veces no se restablece nunca (una regresión de iOS 26). Un bucle que simplemente mira cada frame no necesita señal. - Los estilos se escriben directo al DOM, saltándose React — pasar por el estado re-renderizaría todo el árbol de Lexical en cada frame de paneo.
- Se desplaza el envoltorio, no el contenteditable. El propio playground de Lexical hace lo mismo (
editor-scroller): un contenteditable con foco que se desplaza a sí mismo es la peor configuración que WebKit conoce — el gesto a veces desplaza, a veces inicia una selección, a veces alimenta a la página. - Medio segundo después de abrir, se quitan del drawer el
transform,will-changeytouch-actionde vaul: la capa compuesta desplaza la zona táctil de los tiradores, y la animación de apertura ya no necesita esos estilos.
La barra sobre el teclado — y sobre lo que iOS ponga sobre el teclado
«100svh menos el teclado» es mentira en un iPhone: la barra de direcciones toma su propia porción, y al dispositivo le gusta poner paneles accesorios sobre el teclado. La verdad la sabe visualViewport, y el número que necesita la barra es cuánto del fondo del viewport de layout está ocupado:
// 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`);Una sutileza costó una tarde. Cuando el foco pasa a un campo de la propia barra — tamaño de fuente, URL del enlace — iOS cambia el tipo de teclado, y visualViewport reporta números transitorios durante unas docenas de frames. Aplicarlos de inmediato hundía la barra bajo el teclado; congelar el bucle por completo (el intento anterior) la dejaba ciega justo cuando la altura cambiaba de verdad. El compromiso: mientras el foco está dentro de la barra, un valor nuevo debe mantenerse estable diez frames antes de aplicarse; en cualquier otro caso se aplica al instante, y la barra viaja con la animación del teclado en vez de perseguirla.
Enfocar esos campos necesita su propio truco. Cuando un input real dentro de un contenedor fijo a pantalla completa recibe el foco, iOS desplaza la página para centrar el campo — la página no tiene adónde desplazarse y el layout se desgarra: el drawer se desliza, la barra desaparece. vaul cura esto en su propio interceptor de foco, que acabábamos de silenciar — así que la cura se reproduce a mano: cancelar el foco nativo, teletransportar el campo hacia arriba con un transform, enfocarlo manualmente, devolverlo al siguiente frame. Safari cree que el campo está «arriba» y deja la página en paz.
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 });El bloqueo de scroll: ambas causas eran nuestras
El scroll se «bloqueaba» abajo: los gestos hacia arriba muertos hasta tirar aún más abajo, como desenganchándolo. El banco — esta vez con teclado real — absolvió toda la geometría (el espaciador, la barra superpuesta, el propio drawer) y condenó dos cosas, ambas nuestras:
- El margen para el teclado vivía dentro del contenteditable. Abajo, la franja visible sobre el teclado era una zona editable vacía — y un toque sobre un contenteditable con foco iOS lo trata como trabajo con el texto, no como scroll. El espacio para el teclado debe ser un bloque separado y no editable después del contenteditable — no su padding.
- Nuestro propio «seguro», construido contra la primera causa. Cerca de un borde, scrollTop deambula en valores fraccionarios, y un vigilante por frame lo «corregía» con escrituras programáticas — y las escrituras de scroll programáticas continuas matan el gesto humano. Con el teclado abierto, el seguro se convertía él mismo en el bloqueo.
La regla: nada de preventDefault en los bordes y nada de vigilantes de scrollTop, nunca. Un envoltorio simple con overflow-y-auto y el espaciador fuera del contenteditable se desplaza perfectamente — más overscroll-contain, para que un gesto de borde no alimente a la página, que iOS hace desplazable por el bien del campo enfocado.
<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>Tres escrituras únicas: todo el protocolo del rubber-band
Sin los bloqueos quedó una familia de rarezas — y las tres curas resultaron ser escrituras únicas de scrollTop. Nunca bucles, nunca vigilantes. Primera: un scroller apoyado en un borde exacto (0 o max) a veces «se pega» — el gesto inverso está muerto hasta tirar más allá del borde. La cura canónica, documentada hace una década (iNoBounce y compañía): en touchstart, empujar scrollTop un píxel fuera del borde — antes de que WebKit decida qué gesto es. El borde se vuelve inalcanzable y el estado «pegado» nunca se forma.
Segunda: la inercia puede llevar el scroller al borde cuando el dedo ya no está. El empujón de touchstart llega un swipe tarde para eso. Y un empujón inmediato desde el handler de scroll cortaba el muelle nativo — el borde se sentía como chocar contra una pared. Así que el empujón de asentamiento va diferido: cuando los eventos de scroll callan 140 ms sin dedo abajo y la posición es un borde exacto, una escritura silenciosa la mueve un píxel adentro.
Tercera, la más dura: un swipe durante la animación de rebote simplemente se lo comía. Es comportamiento de plataforma — mientras suena la animación del rubber-band, WebKit no engancha el gesto a nada hasta que termina. Deslizabas, nada se movía, deslizabas otra vez. Ningún artículo encontrado ofrece una interrupción; el rastro termina en «espera a que acabe».
La entrada apareció: durante el rebote iOS expone scrollTop fuera de los límites — negativo arriba, mayor que max abajo. Eso hace el momento estrictamente detectable: st < 0 || st > max es un overscroll genuino y nunca un reposo tranquilo en un borde. En touchstart en ese estado, una única escritura acotada interrumpe la animación — y el mismísimo gesto agarra el scroller. Con un seguro: solo el primer dedo (touching === 1), porque una animación solo suena cuando ningún dedo sostenía el scroller, y un segundo dedo no debe arrancar el texto de debajo del primero.
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 });- El martillo del overflow-toggle (poner
overflow: hidden, acotar, forzar un reflow, restaurar) — rechazado dos veces en el dispositivo. Su primera versión además disparaba en toques tranquilos en el borde (un<= 0donde correspondía un< 0) y de paso rompía el scroll normal. overscroll-behavior: none— elimina el problema junto con el propio muelle: la inercia muere en seco en el borde, y el borde se siente cercenado.
Absueltos — no volver a cazarlos
- el bloqueo del body con
position: fixedde vaul - la máscara CSS de desvanecido en el scroller
- la capa transform por sí sola (quitarla es correcto, pero no era la asesina)
- el rol de diálogo, los atributos aria y data del drawer
- el propio Lexical — un contenteditable desnudo reproduce tanto el veneno del href como el bloqueo de scroll
- silenciar el
SELECTION_CHANGE_COMMANDde Lexical — no cambió nada - la barrera sobre las escrituras de
Selection.prototype— se convirtió en su propio bug: el tap dejó de entregar el cursor y lo tecleado iba al principio de la línea
Un enemigo extra: la selección nativa contra el campo de URL
Editar un enlace lleva un conflicto incorporado: la selección nativa de iOS vive solo en un editor con foco, y el campo de URL necesita el foco para sí — no pueden coexistir. Así que durante la edición el texto seleccionado se envuelve en un MarkNode de Lexical: un resaltado real en el DOM al que no le importa dónde está el foco. El propio playground de Lexical resalta los objetivos de comentarios con el mismo mecanismo.
La selección en sí se guarda continuamente, en cada cambio de selección — el mismo patrón lastSelection que el FloatingLinkEditorPlugin de Lexical — porque guardarla al pulsar el botón es tarde: el tap en el botón de la barra colapsa la selección primero. Al aplicar, soltar la marca recrea la selección real exactamente en su sitio, y TOGGLE_LINK_COMMAND hace el resto.
Conclusiones
- «La selección no funciona en iOS» no es un solo bug. Hubo cuatro causas independientes, cada una enmascarando a la siguiente — la depuración de hipótesis única no puede converger ahí.
- La bisección vence a la deducción. Un banco aislado con un interruptor por sospechoso convirtió días de conjeturas en cuatro rondas de dispositivo — y absolvió a los sospechosos que de otro modo se habrían «arreglado» durante días.
- Solo un dispositivo físico dice la verdad: el simulador no tiene teclado real ni paneles accesorios, y los bundles de desarrollo mueren por memoria antes de que los bugs siquiera aparezcan.
- Mover solo lo que no se toca: drawer estático, barra en un bucle de frames, margen inferior por variable CSS.
- Nunca pelear contra el scroll de iOS con escrituras continuas ni preventDefault en los bordes — ambos se convierten en el mismísimo bloqueo que debían prevenir. Cada cura superviviente es una única escritura de scrollTop en un momento detectado con precisión.
- El protocolo completo del rubber-band: un empujón en touchstart en un borde en reposo, un empujón de asentamiento diferido tras la inercia y una acotación en touchstart en pleno rebote — detectable porque iOS expone scrollTop fuera de los límites.
¿Ves un error?
¿Un dato incorrecto, una traducción torpe, algo que suena falso en este artículo? Escríbeme — en tu propio idioma.