Skip to main content
Înapoi la blog
iOSSafariWebKitLexicalcontenteditableDebugging

Patru bug-uri omorau selecția de text pe iOS — și fiecare îl ascundea pe următorul

Un editor Lexical într-un drawer vaul funcționa peste tot, mai puțin pe un iPhone real: mânerele de selecție nu se trăgeau, derularea se bloca la margini, bara de instrumente se scufunda sub tastatură. Ghicitul nu repara nimic, pentru că nu exista un singur vinovat — erau patru, independenți, și fiecare îl masca pe următorul. Ce a funcționat: un stand de bisecție — același schelet cu un comutator pentru fiecare suspect, testat pe un dispozitiv fizic. Iată vânătoarea completă — href-ul care otrăvește selecția, handlerele de drag ale vaul, propriul nostru tap-focus, un container care se mișcă sub deget — plus autopsia blocajului de scroll și cele trei scrieri unice de scrollTop care au făcut rubber-band-ul să pară nativ, inclusiv prinderea unui swipe în plin recul.

Publicat 5 septembrie 202615 min de citit

Editorul de text mobil din landee este Lexical într-un drawer vaul: atingi un bloc de text, urcă un drawer pe tot ecranul, editezi, o bifă îl închide. Pe desktop și în simulator totul era în regulă. Pe un iPhone fizic era un dezastru cu mai multe fețe independente: mânerele de selecție — cele două ace albastre cu care iOS te lasă să întinzi o selecție — nu se trăgeau deloc, sau doar după vreo două secunde de ținut; trăgeai mânerul drept spre stânga și toată selecția se prăbușea; derularea se bloca jos până trăgeai și mai jos ca s-o «desprinzi»; iar bara de instrumente se scufunda sub tastatură ori de câte ori dispozitivul punea o bandă accesorie deasupra ei.

Aceasta e cronica vindecării — drumul, nu doar reparațiile, pentru că drumul e partea transferabilă. Lecția principală: când «selecția nu merge» pe iOS, nu există un singur vinovat de găsit. Erau patru, independenți unul de altul, și fiecare îl masca pe următorul — repari unul, iar simptomele se schimbă exact cât să pară falsă următoarea ipoteză.

Stack: Next.js 16, React 19, Lexical 0.50, un drawer construit pe vaul. Tot ce urmează a fost verificat pe un iPhone fizic cu build-uri de producție; fragmentele de cod sunt codul livrat, tăiat la esență.

Metoda care a eșuat și cea care a funcționat

Primele ore s-au dus pe ghicit: poate stratul transform al drawer-ului, poate masca CSS de pe scroller, poate comenzile de selecție ale Lexical. Fiecare bănuială producea un petic plauzibil și zero schimbări pe dispozitiv. Două dintre petice s-au dovedit apoi bug-uri ele însele: un amortizor pe SELECTION_CHANGE_COMMAND al Lexical și o barieră peste metodele de scriere ale Selection.prototype, din cauza căreia tastarea ateriza la începutul rândului — atingerea nu mai livra cursorul.

A funcționat bisecția pe un stand izolat: o pagină de depanare cu același schelet ca editorul — același drawer vaul, aceeași coloană, același scroller — și un buton-comutator pentru fiecare suspect. Un contenteditable gol era perfect acolo. Fiecare comutator adăuga înapoi câte un strat, până se rupea ceva. Patru runde pe un telefon real au condamnat exact patru lucruri și au achitat tot restul — blocarea body-ului, măștile, straturile transform, rolul de dialog — suspecți care altfel ar fi fost «reparați» zile întregi.

Două constrângeri au ținut standul cinstit. Contează doar un dispozitiv fizic: simulatorul nu are nici tastatură reală, nici panourile accesorii ale dispozitivului deasupra ei, iar jumătate dintre bug-uri trăiesc exact acolo. Și contează doar build-urile de producție: bundle-urile de dezvoltare sunt atât de grele încât WebKit omoară tabul din lipsă de memorie, și totul pare stricat înainte ca hidratarea să se termine.

Vinovatul unu: href pe un link în contenteditable

Pentru iOS, <a href> e interactiv chiar și în text editabil: o atingere pornește gestul «atinge un link», iar acel gest are prioritate față de gestul de selecție — în clipa în care un mâner atinge un link, selecția se prăbușește. Aceeași ancoră fără href e doar text. -webkit-touch-callout: none nu salvează. Iar un contenteditable gol, fără nicio bibliotecă, se otrăvește exact la fel — deci Lexical era nevinovat.

Leacul elimină href din DOM-ul viu al editorului imediat după ce Lexical desenează sau actualizează un nod de link. Nu se pierde nimic nicăieri: navigarea e oricum dezactivată în modul de editare, URL-ul trăiește în nodul Lexical și rămâne editabil, iar HTML-ul salvat își păstrează href-ul — exportul trece prin exportDOM, care își construiește propriile elemente și nu vede niciodată DOM-ul viu.

text-editor.tsx
// 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]);

Vinovatul doi: handlerele de drag ale vaul pe conținutul drawer-ului

vaul își ține logica trage-ca-să-închizi atașată de conținutul drawer-ului chiar și cu handleOnly — și exact ea omora prinderea mânerelor. Dovada a fost o matrice pe stand: Lexical cu linkuri într-un drawer fals cu aceeași geometrie — perfect; în vaul-ul real — mort. Apoi, câte o schimbare pe rând: scoase stilurile vaul (transform, will-change, touch-action) — tot mort; scos rolul de dialog și atributele aria — mort; deblocat body-ul — mort; amuțite evenimentele ca să nu mai ajungă la handlerele vaul — complet viu.

Amortizorul e stopPropagation, nu preventDefault: browserul și Lexical aud tot, pentru că ascultătorii lor stau mai adânc în arbore — doar vaul surzește. Și trebuie să acopere și evenimentele pointer, și touch: matricea a arătat că liniștea pointer singură nu ajunge — mânerele rămân moarte cât timp vaul încă aude touch.

text-editor.tsx
{/* 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>

Vinovatul trei: propriul nostru tap-to-focus

Un hook ajutător forța focus() la fiecare atingere mai scurtă de 300 ms — iar prinderea rapidă a unui mâner e exact o atingere scurtă. Asta explică simptomul legendar «ține două secunde și abia atunci se trage»: o apăsare lungă nu e tap, deci focus() nu pornea și tragerea supraviețuia. Leacul e un rând — apelează focus() doar când editorul nu conține deja focusul.

Vinovatul patru: un container care se mișcă sub deget

Prima arhitectură redimensiona tot drawer-ul ca să încapă deasupra tastaturii. În timpul selecției iOS panoramează ecranul — iar drawer-ul urma panoramarea, textul fugea de sub deget și WebKit abandona tragerea. Reparația «evidentă» — îngheață drawer-ul cât timp un deget e jos — a eșuat altfel: textul se deplasa față de deget exact cu panoramarea, iar selecția ateriza cu un rând alături.

Regula născută din asta: poți mișca doar ce nu e atins. Drawer-ul și textul stau pe toată înălțimea viewport-ului de layout — pe iOS tastatura nu comprimă layout-ul, doar îi acoperă partea de jos. Concret:

  • Doar bara de instrumente urmărește tastatura; marginea de jos a textului călătorește printr-o variabilă CSS (--keyboard-inset) — nu se mișcă nimic.
  • O buclă de cadre (requestAnimationFrame), nu evenimente: iOS panoramează în timpul selecției fără evenimente visualViewport de încredere, iar după închiderea tastaturii offsetTop uneori nu se mai resetează deloc (o regresie iOS 26). O buclă care pur și simplu se uită la fiecare cadru nu are nevoie de semnal.
  • Stilurile se scriu direct în DOM, ocolind React — prin state, fiecare cadru de panoramare ar redesena tot arborele Lexical.
  • Derulează învelișul, nu contenteditable-ul. Playground-ul propriu al Lexical face la fel (editor-scroller): un contenteditable focalizat care se derulează singur e cea mai rea configurație pe care o cunoaște WebKit — gestul ba derulează, ba pornește o selecție, ba hrănește pagina.
  • La o jumătate de secundă după deschidere, de pe drawer se scot transform, will-change și touch-action ale vaul: stratul compozitat deplasează zona de atingere a mânerelor, iar animația de deschidere nu mai are nevoie de acele stiluri.

Bara deasupra tastaturii — și deasupra a tot ce pune iOS deasupra tastaturii

«100svh minus tastatura» e o minciună pe iPhone: bara de adrese își ia felia ei, iar dispozitivului îi place să pună panouri accesorii deasupra tastaturii. Adevărul îl știe visualViewport, iar numărul de care are nevoie bara e cât din partea de jos a viewport-ului de layout e ocupat:

text-editor.tsx
// 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`);

O subtilitate a costat o seară. Când focusul trece într-un câmp al barei înseși — mărimea fontului, URL-ul linkului — iOS schimbă tipul tastaturii, iar visualViewport raportează valori tranzitorii timp de câteva zeci de cadre. Aplicate imediat, scufundau bara sub tastatură; înghețată bucla de tot (încercarea anterioară), orbea exact când înălțimea chiar se schimba. Compromisul: cât timp focusul e în bară, o valoare nouă trebuie să stea stabilă zece cadre înainte să fie aplicată; în orice alt caz se aplică pe loc, iar bara merge cu animația tastaturii în loc s-o urmărească.

Focalizarea acelor câmpuri cere propriul truc. Când un input real dintr-un container fix pe tot ecranul primește focus, iOS derulează pagina ca să centreze câmpul — pagina nu are unde derula, iar layout-ul se sfâșie: drawer-ul alunecă, bara dispare. vaul vindecă asta în propriul interceptor de focus, pe care tocmai îl amuțiserăm — așa că leacul e reprodus manual: anulează focusul nativ, teleportează câmpul în sus cu un transform, focalizează-l singur, pune-l la loc în cadrul următor. Safari crede că câmpul e «sus» și lasă pagina în pace.

text-editor.tsx
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 });

Blocajul de scroll: ambele cauze erau ale noastre

Derularea se «bloca» jos: gesturile în sus moarte până trăgeai și mai jos, ca pentru a desprinde. Standul — de data asta cu tastatură reală — a achitat toată geometria (distanțierul, bara suprapusă, drawer-ul însuși) și a condamnat două lucruri, ambele ale noastre:

  • Marginea pentru tastatură trăia în contenteditable. Jos, banda vizibilă de deasupra tastaturii era o zonă editabilă goală — iar o atingere pe un contenteditable focalizat iOS o tratează ca lucru cu textul, nu ca derulare. Locul pentru tastatură trebuie să fie un bloc separat, needitabil, după contenteditable — nu padding-ul lui.
  • Propria noastră «siguranță», construită împotriva primei cauze. Lângă o margine, scrollTop umblă în valori fracționare, iar un câine de pază per cadru îl «corecta» cu scrieri programatice — iar scrierile programatice continue de scroll omoară gestul omului. Cu tastatura deschisă, siguranța devenea ea însăși blocajul.

Regula: niciun preventDefault la margini și niciun paznic de scrollTop, niciodată. Un înveliș simplu overflow-y-auto cu distanțierul în afara contenteditable-ului derulează perfect — plus overscroll-contain, ca un gest de margine să nu hrănească pagina, pe care iOS o face derulabilă de dragul câmpului focalizat.

text-editor.tsx
<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>

Trei scrieri unice: tot protocolul rubber-band

Odată blocajele dispărute, a rămas o familie de ciudățenii — și toate cele trei leacuri s-au dovedit scrieri singulare de scrollTop. Niciodată bucle, niciodată paznici. Prima: un scroller așezat pe o margine exactă (0 sau max) uneori «se lipește» — gestul invers e mort până tragi dincolo de margine. Leacul canonic, documentat de un deceniu (iNoBounce și compania): la touchstart, împinge scrollTop cu un pixel de la margine — înainte ca WebKit să decidă ce gest e. Marginea devine de neatins, iar starea «lipit» nu se mai formează deloc.

A doua: inerția poate duce scroller-ul în margine când degetul deja nu mai e. Împinsul de la touchstart întârzie exact cu un swipe pentru asta. Iar un împins imediat din handlerul de scroll tăia arcul nativ — marginea se simțea ca izbirea de un zid. Așa că împinsul de liniștire e amânat: când evenimentele scroll tac 140 ms fără deget jos și poziția e o margine exactă, o scriere tăcută o mută un pixel înăuntru.

A treia, cea mai grea: un swipe în timpul animației de recul era pur și simplu înghițit. E comportament de platformă — cât timp cântă animația rubber-band, WebKit nu atașează gestul de nimic până nu se termină. Glisai, nimic nu se mișca, glisai din nou. Niciun articol găsit nu oferă o întrerupere; urma se termină la «așteaptă să se termine».

Intrarea s-a găsit: în timpul reculului iOS expune scrollTop în afara limitelor — negativ sus, mai mare decât max jos. Asta face momentul strict detectabil: st < 0 || st > max e overscroll autentic și niciodată odihnă liniștită pe o margine. La touchstart în acea stare, o singură scriere limitată întrerupe animația — și exact același gest prinde scroller-ul. Cu o siguranță: doar primul deget (touching === 1), pentru că o animație cântă doar când niciun deget nu ținea scroller-ul, iar un al doilea deget nu are voie să smulgă textul de sub primul.

text-editor.tsx
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 });
  • Ciocanul overflow-toggle (pune overflow: hidden, limitează, forțează un reflow, restaurează) — respins de două ori pe dispozitiv. Prima lui versiune trăgea în plus și la atingerile liniștite pe margine (un <= 0 unde trebuia < 0) și strica pe drum derularea normală.
  • overscroll-behavior: none — elimină problema împreună cu arcul însuși: inerția moare sec la margine, iar marginea pare retezată.

Achitați — nu-i mai vâna

  • blocarea body-ului cu position: fixed a vaul
  • masca CSS de estompare de pe scroller
  • stratul transform de unul singur (să-l scoți e corect, dar ucigașul nu era el)
  • rolul de dialog, atributele aria și data ale drawer-ului
  • Lexical însuși — un contenteditable gol reproduce și otrava href, și blocajul de scroll
  • amuțirea SELECTION_CHANGE_COMMAND a Lexical — n-a schimbat nimic
  • bariera peste scrierile Selection.prototype — a devenit propriul bug: atingerea nu mai livra cursorul, iar tastarea mergea la începutul rândului

Un dușman bonus: selecția nativă contra câmpului de URL

Editarea unui link poartă un conflict încorporat: selecția nativă iOS trăiește doar într-un editor focalizat, iar câmpul de URL are nevoie de focus pentru sine — nu pot coexista. Așa că, pe durata editării, textul selectat e învelit într-un MarkNode Lexical: o evidențiere reală în DOM, căreia nu-i pasă unde e focusul. Cu același mecanism playground-ul Lexical evidențiază țintele comentariilor.

Selecția însăși se salvează continuu, la fiecare schimbare — același tipar lastSelection ca în FloatingLinkEditorPlugin al Lexical — pentru că salvatul la apăsarea butonului e prea târziu: atingerea butonului din bară prăbușește selecția mai întâi. La aplicare, eliberarea marcajului recreează selecția reală exact la locul ei, iar TOGGLE_LINK_COMMAND face restul.

Concluzii

  • «Selecția nu merge pe iOS» nu e un singur bug. Au fost patru cauze independente, fiecare mascând-o pe următoarea — depanarea cu o singură ipoteză nu poate converge acolo.
  • Bisecția bate deducția. Un stand izolat cu un comutator per suspect a transformat zile de ghicit în patru runde pe dispozitiv — și i-a achitat pe suspecții care altfel ar fi fost «reparați» zile întregi.
  • Doar un dispozitiv fizic spune adevărul: simulatorul nu are nici tastatură reală, nici panouri accesorii, iar bundle-urile de dev mor de memorie înainte ca bug-urile să apară.
  • Mișcă doar ce nu e atins: drawer static, bară pe buclă de cadre, margine de jos prin variabilă CSS.
  • Nu te lupta niciodată cu derularea iOS prin scrieri continue sau preventDefault la margini — ambele devin exact blocajul pe care trebuiau să-l prevină. Fiecare leac supraviețuitor e o singură scriere de scrollTop într-un moment detectat precis.
  • Protocolul rubber-band complet: un împins la touchstart pe o margine în repaus, un împins de liniștire amânat după inerție și o limitare la touchstart în plin recul — detectabilă pentru că iOS expune scrollTop în afara limitelor.

Ai găsit o greșeală?

Un fapt greșit, o traducere stângace, ceva ce sună fals în acest articol? Scrie-mi — în limba ta.