Skip to main content
Tillbaka till bloggen
iOSSafariWebKitLexicalcontenteditableDebugging

Fyra buggar dödade textmarkeringen på iOS — och varje bugg gömde nästa

En Lexical-editor i en vaul-drawer fungerade överallt utom på en riktig iPhone: markeringshandtagen gick inte att dra, skrollningen låste sig vid kanterna, verktygsfältet dök under tangentbordet. Att gissa hjälpte inte, för det fanns ingen ensam skyldig — det var fyra oberoende, och var och en maskerade nästa. Det som fungerade var en bisektionsrigg: samma skelett med en strömbrytare per misstänkt, testad på en fysisk enhet. Här är hela jakten — href som förgiftar markeringen, vauls drag-hanterare, vår egen tap-focus, en container som rör sig under fingret — plus obduktionen av skroll-låset och de tre engångsskrivningarna av scrollTop som fick rubber-band att kännas nativt, inklusive att fånga ett svep mitt i studsen.

Publicerad 5 september 202615 min läsning

Den mobila texteditorn i landee är Lexical i en vaul-drawer: tryck på ett textblock, en helskärms-drawer glider upp, du redigerar, en bock stänger den. På desktop och i simulatorn var allt bra. På en fysisk iPhone var det en katastrof med flera oberoende ansikten: markeringshandtagen — de två blå stiften som iOS ger för att dra ut en markering — gick inte att dra alls, eller först efter ungefär två sekunders hållande; drog man höger handtag åt vänster kollapsade hela markeringen; skrollningen låste sig i botten tills man drog ännu längre ner för att «haka loss» den; och verktygsfältet dök under tangentbordet så fort enheten la en extra rad ovanför det.

Det här är krönikan om hur det botades — vägen, inte bara fixarna, för det är vägen som går att återanvända. Huvudlektionen: när «markeringen fungerar inte» på iOS finns det ingen enskild skyldig att hitta. De var fyra, oberoende av varandra, och var och en maskerade nästa — fixa en, och symptomen ändras precis lagom för att nästa hypotes ska se fel ut.

Stack: Next.js 16, React 19, Lexical 0.50, en drawer byggd på vaul. Allt nedan verifierades på en fysisk iPhone mot produktionsbyggen; kodexemplen är den skeppade koden, nedskuren till kärnan.

Metoden som misslyckades, och den som fungerade

De första timmarna gick åt till gissningar: kanske drawerns transform-lager, kanske CSS-masken på skrollaren, kanske Lexicals markeringskommandon. Varje gissning gav en trovärdig lapp och noll förändring på enheten. Två av lapparna visade sig senare vara buggar i sig: en ljuddämpare på Lexicals SELECTION_CHANGE_COMMAND, och en spärr över skrivmetoderna på Selection.prototype som fick skrivandet att landa i radens början — en tryckning levererade inte längre markören.

Det som fungerade var bisektion på en isolerad rigg: en felsökningssida med samma skelett som editorn — samma vaul-drawer, samma kolumn, samma skrollare — och en strömbrytare per misstänkt. Ett naket contenteditable var perfekt där. Varje brytare la tillbaka ett lager tills något gick sönder. Fyra ronder på en riktig telefon fällde exakt fyra saker och friade allt annat — body-låset, maskerna, transform-lagren, dialogrollen — misstänkta som annars hade «lagats» i dagar.

Två regler höll riggen ärlig. Bara en fysisk enhet räknas: simulatorn har varken riktigt tangentbord eller enhetens extra rader ovanför, och hälften av buggarna bor exakt där. Och bara produktionsbyggen räknas: utvecklingsbuntarna är så tunga att WebKit dödar fliken av minnesbrist, och allt ser trasigt ut innan hydreringen ens är klar.

Skyldig ett: href på en länk inuti contenteditable

För iOS är <a href> interaktiv även i redigerbar text: en beröring startar gesten «tryck på en länk», och den gesten går före markeringsgesten — i samma stund som ett handtag rör en länk kollapsar markeringen. Samma ankare utan href är bara text. -webkit-touch-callout: none räddar inte. Och ett naket contenteditable utan något bibliotek förgiftas exakt likadant — så Lexical var oskyldig.

Boten tar bort href ur editorns levande DOM direkt efter att Lexical ritar eller uppdaterar en länknod. Inget går förlorat någonstans: navigering är ändå avstängd i redigeringsläget, URL:en bor i Lexical-noden och förblir redigerbar, och det sparade HTML:et behåller sitt href — exporten går via exportDOM, som bygger egna element och aldrig ser det levande DOM:et.

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]);

Skyldig två: vauls drag-hanterare på drawerns innehåll

vaul håller sin dra-för-att-stänga-logik fäst vid drawerns innehåll även med handleOnly — och just den dödade handtagsgreppet. Beviset var en matris på riggen: Lexical med länkar i en fejkad drawer med samma geometri — perfekt; i riktiga vaul — dött. Sedan, en ändring i taget: ta bort vauls stilar (transform, will-change, touch-action) — fortfarande dött; ta bort dialogrollen och aria-attributen — dött; lås upp body — dött; tysta händelserna så att de inte når vauls hanterare — helt levande.

Dämparen är stopPropagation, inte preventDefault: webbläsaren och Lexical hör allt, för deras lyssnare sitter djupare i trädet — bara vaul blir döv. Och den måste täcka både pointer- och touch-händelser: matrisen visade att enbart pointer-tystnad inte räcker — handtagen förblir döda så länge vaul ännu hör 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>

Skyldig tre: vår egen tap-to-focus

En hjälp-hook tvingade fram focus() vid varje beröring kortare än 300 ms — och ett snabbt grepp om ett handtag är precis en kort beröring. Det förklarar det legendariska symptomet «håll i två sekunder, sen går det att dra»: ett långt tryck är ingen tap, så focus() avfyrades aldrig och dragningen överlevde. Boten är en rad — anropa focus() bara när editorn inte redan har fokus.

Skyldig fyra: en behållare som rör sig under fingret

Den första arkitekturen storleksändrade hela drawern för att rymmas ovanför tangentbordet. Under markering panorerar iOS skärmen — och drawern följde panoreringen, texten flydde undan fingret och WebKit släppte dragningen. Den «uppenbara» fixen — frys drawern medan ett finger är nere — misslyckades på ett annat sätt: texten förflyttades relativt fingret med exakt panoreringen, och markeringen hamnade en rad fel.

Regeln som föddes ur detta: man får bara flytta det som inte vidrörs. Drawern och texten står i layout-viewportens fulla höjd — på iOS trycker tangentbordet inte ihop layouten, det täcker bara dess nederdel. Konkret:

  • Bara verktygsfältet jagar tangentbordet; textens bottenmarginal reser via en CSS-variabel (--keyboard-inset) — inget rör sig.
  • En frame-loop (requestAnimationFrame), inte händelser: iOS panorerar under markering utan pålitliga visualViewport-händelser, och efter att tangentbordet stängts återställs offsetTop ibland aldrig (en iOS 26-regression). En loop som bara tittar varje frame behöver ingen signal.
  • Stilar skrivs rakt in i DOM, förbi React — via state skulle varje panoreringsframe rita om hela Lexical-trädet.
  • Omslaget skrollar, inte contenteditable. Lexicals egen playground gör likadant (editor-scroller): ett fokuserat contenteditable som skrollar sig självt är den värsta konfiguration WebKit känner — gesten skrollar ibland, startar ibland en markering, matar ibland sidan.
  • En halv sekund efter öppning tas vauls transform, will-change och touch-action bort från drawern: det kompositerade lagret förskjuter handtagens träffyta, och öppningsanimationen behöver inte längre stilarna.

Verktygsfältet ovanför tangentbordet — och ovanför allt iOS lägger ovanpå tangentbordet

«100svh minus tangentbordet» är en lögn på en iPhone: adressfältet tar sin egen bit, och enheten gillar att lägga extra rader ovanför tangentbordet. Sanningen känner visualViewport till, och talet verktygsfältet behöver är hur mycket av layout-viewportens botten som är upptagen:

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`);

En finess kostade en kväll. När fokus flyttar in i ett fält i själva verktygsfältet — teckenstorlek, länk-URL — byter iOS tangentbordstyp, och visualViewport rapporterar övergångsvärden i några dussin frames. Att tillämpa dem direkt dök verktygsfältet under tangentbordet; att frysa loopen helt (förra försöket) gjorde den blind precis när höjden faktiskt ändrades. Kompromissen: medan fokus är i verktygsfältet måste ett nytt värde stå stilla i tio frames innan det tillämpas; i alla andra fall gäller det direkt, så att fältet åker med tangentbordets animation i stället för att jaga den.

Att fokusera de fälten kräver ett eget trick. När en riktig input i en fixerad helskärmsbehållare får fokus skrollar iOS sidan för att centrera fältet — sidan har ingenstans att skrolla, och layouten slits sönder: drawern glider, fältet försvinner. vaul botar detta i sin egen fokus-interceptor, som vi just hade tystat — så boten återskapas för hand: avbryt det nativa fokuset, teleportera fältet uppåt med en transform, fokusera själv, sätt tillbaka nästa frame. Safari tror att fältet är «där uppe» och lämnar sidan i fred.

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 });

Skroll-låset: båda orsakerna var våra egna

Skrollningen «låste sig» i botten: gester uppåt döda tills man drog ännu längre ner, som för att haka loss. Riggen — den här gången med riktigt tangentbord — friade hela geometrin (distansen, överlägget, själva drawern) och fällde två saker, båda våra egna:

  • Tangentbordsmarginalen bodde inuti contenteditable. I botten var den synliga remsan ovanför tangentbordet en tom redigerbar zon — och en beröring på ett fokuserat contenteditable tolkar iOS som arbete med texten, inte som skrollning. Utrymmet för tangentbordet måste vara ett separat, icke-redigerbart block efter contenteditable — inte dess padding.
  • Vår egen «säkring», byggd mot den första orsaken. Nära en kant vandrar scrollTop i bråkvärden, och en vakthund per frame «rättade» den med programmatiska skrivningar — och kontinuerliga programmatiska skrollskrivningar dödar människans gest. Med tangentbordet öppet blev säkringen själv låset.

Regeln: inget preventDefault vid kanterna och inga scrollTop-vakter, någonsin. Ett enkelt overflow-y-auto-omslag med distansen utanför contenteditable skrollar perfekt — plus overscroll-contain, så att en kantgest inte matar sidan, som iOS gör skrollbar för det fokuserade fältets skull.

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>

Tre engångsskrivningar: hela rubber-band-protokollet

När låsen var borta återstod en familj av egenheter — och alla tre boten visade sig vara enstaka scrollTop-skrivningar. Aldrig loopar, aldrig vakter. Först: en skrollare som vilar på en exakt kant (0 eller max) «fastnar» ibland — motsatt gest är död tills man drar förbi kanten. Den kanoniska boten, dokumenterad i ett decennium (iNoBounce med sällskap): vid touchstart, knuffa scrollTop en pixel från kanten — innan WebKit bestämmer vilken gest det är. Kanten blir onåbar, och fastnat-läget uppstår aldrig.

Sedan: rörelsemängden kan bära skrollaren in i kanten när fingret redan är borta. Touchstart-knuffen är exakt ett svep för sen för det. Och en omedelbar knuff från scroll-hanteraren klippte den nativa fjädern — kanten kändes som att slå i en vägg. Så lugna-ner-knuffen är fördröjd: när scroll-händelserna tystnar i 140 ms utan finger nere och positionen är en exakt kant, flyttar en tyst skrivning den en pixel inåt.

Tredje, det svåraste: ett svep under studsanimationen åts helt enkelt upp. Det är plattformsbeteende — medan rubber-band-animationen spelar fäster WebKit gesten vid ingenting förrän den är klar. Man svepte, inget hände, man svepte igen. Ingen funnen artikel erbjuder ett avbrott; spåret slutar vid «vänta tills den är klar».

Ingången hittades: under studsen exponerar iOS scrollTop utanför gränserna — negativt upptill, större än max nertill. Det gör ögonblicket strikt detekterbart: st < 0 || st > max är äkta overscroll och aldrig stilla vila vid en kant. Vid touchstart i det läget avbryter en enda klampad skrivning animationen — och samma gest griper skrollaren. Med en säkring: bara första fingret (touching === 1), för en animation spelar bara när inget finger höll skrollaren, och ett andra finger får inte rycka texten under det första.

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 });
  • Overflow-toggle-hammaren (sätt overflow: hidden, klampa, tvinga en reflow, återställ) — förkastad två gånger på enheten. Dess första version avfyrades dessutom vid lugna beröringar vid kanten (ett <= 0 där ett < 0 hörde hemma) och bröt på köpet den normala skrollningen.
  • overscroll-behavior: none — tar bort problemet tillsammans med själva fjädern: rörelsemängden dör tvärt vid kanten, och kanten känns avhuggen.

Friade — jaga dem inte igen

  • vauls position: fixed-body-lås
  • CSS-toningsmasken på skrollaren
  • transform-lagret i sig (att ta bort det är rätt, men mördaren var det inte)
  • drawerns dialogroll, aria- och data-attribut
  • Lexical självt — ett naket contenteditable återskapar både href-giftet och skroll-låset
  • att tysta Lexicals SELECTION_CHANGE_COMMAND — ändrade ingenting
  • spärren över Selection.prototype-skrivningar — blev en egen bugg: trycket levererade inte längre markören och skrivandet gick till radens början

En bonusfiende: nativ markering mot URL-fältet

Att redigera en länk bär en inbyggd konflikt: den nativa iOS-markeringen lever bara i en fokuserad editor, och URL-fältet behöver fokuset självt — de kan inte samexistera. Så under redigeringen sveps den markerade texten in i en Lexical-MarkNode: en riktig markering i DOM som inte bryr sig om var fokuset är. Med samma mekanism markerar Lexicals egen playground kommentarsmål.

Själva markeringen sparas kontinuerligt, vid varje ändring — samma lastSelection-mönster som Lexicals FloatingLinkEditorPlugin — för att spara vid knapptryckningen är för sent: trycket på verktygsfältets knapp kollapsar markeringen först. Vid tillämpning återskapar lossandet av märket den riktiga markeringen exakt på dess plats, och TOGGLE_LINK_COMMAND gör resten.

Slutsatser

  • «Markeringen fungerar inte på iOS» är inte en bugg. Det fanns fyra oberoende orsaker, var och en maskerande nästa — enhypotesfelsökning kan inte konvergera på det.
  • Bisektion slår deduktion. En isolerad rigg med en brytare per misstänkt förvandlade dagar av gissningar till fyra enhetsronder — och friade de misstänkta som annars hade «lagats» i dagar.
  • Bara en fysisk enhet talar sanning: simulatorn har varken riktigt tangentbord eller extra rader, och dev-buntar dör av minne innan buggarna ens dyker upp.
  • Flytta bara det som inte vidrörs: statisk drawer, verktygsfält på en frame-loop, bottenmarginal via CSS-variabel.
  • Slåss aldrig mot iOS-skrollning med kontinuerliga skrivningar eller preventDefault vid kanterna — båda blir just det lås de skulle förhindra. Varje bot som överlevde är en enda scrollTop-skrivning i ett exakt detekterat ögonblick.
  • Hela rubber-band-protokollet: en knuff vid touchstart på en vilande kant, en fördröjd lugna-ner-knuff efter rörelsemängd och en klampning vid touchstart mitt i studsen — detekterbar för att iOS exponerar scrollTop utanför gränserna.

Hittade du ett fel?

Ett felaktigt faktum, en skev översättning, något som känns falskt i den här artikeln? Skriv till mig — på ditt eget språk.