Vier Bugs töteten die Textauswahl auf iOS — und jeder verdeckte den nächsten
Ein Lexical-Editor in einem vaul-Drawer funktionierte überall, nur nicht auf einem echten iPhone: Die Auswahl-Griffe ließen sich nicht ziehen, das Scrollen verklemmte an den Rändern, die Toolbar tauchte unter die Tastatur. Raten half nicht, denn es gab keinen einzelnen Schuldigen — es waren vier unabhängige, und jeder maskierte den nächsten. Was funktionierte, war ein Bisektions-Prüfstand: dasselbe Gerüst mit einem Schalter pro Verdächtigem, getestet auf physischer Hardware. Dies ist die ganze Jagd — href, das die Auswahl vergiftet, vauls Drag-Handler, unser eigener Tap-Focus, ein Container, der unter dem Finger wandert — plus die Obduktion der Scroll-Sperre und die drei einmaligen scrollTop-Schreibvorgänge, die das Rubber-Band-Scrollen nativ anfühlen ließen, einschließlich des Abfangens eines Swipes mitten im Federn.
Der mobile Texteditor in landee ist Lexical in einem vaul-Drawer: Tipp auf einen Textblock, ein Vollbild-Drawer fährt hoch, man editiert, ein Häkchen schließt ihn. Auf dem Desktop und im Simulator war alles gut. Auf einem physischen iPhone war es eine Katastrophe mit mehreren unabhängigen Gesichtern: Die Auswahl-Griffe — die zwei blauen Pins, mit denen iOS eine Auswahl aufziehen lässt — ließen sich gar nicht ziehen, oder erst nach etwa zwei Sekunden Halten; zog man den rechten Griff nach links, kollabierte die ganze Auswahl; das Scrollen verklemmte am unteren Rand, bis man weiter nach unten zerrte, um es «auszuhaken»; und die Toolbar tauchte unter die Tastatur, sobald das Gerät eine Zusatzleiste darüber einblendete.
Dies ist eine Chronik der Heilung — der Weg, nicht nur die Fixes, denn übertragbar ist der Weg. Die Kernlektion: Wenn auf iOS «die Auswahl nicht funktioniert», gibt es keinen einzelnen Schuldigen zu finden. Es waren vier, unabhängig voneinander, und jeder maskierte den nächsten — nach jedem Fix änderten sich die Symptome gerade genug, um die nächste Hypothese falsch aussehen zu lassen.
Die Methode, die scheiterte, und die, die funktionierte
Die ersten Stunden gingen ans Raten: vielleicht die Transform-Ebene des Drawers, vielleicht die CSS-Maske auf dem Scroller, vielleicht Lexicals Auswahl-Kommandos. Jede Vermutung ergab einen plausiblen Patch und null Veränderung auf dem Gerät. Zwei dieser Patches erwiesen sich später selbst als Bugs: ein Schalldämpfer auf Lexicals SELECTION_CHANGE_COMMAND und eine Sperre über den Schreibmethoden von Selection.prototype, durch die Tippen am Zeilenanfang landete — ein Tap lieferte den Cursor nicht mehr aus.
Funktioniert hat die Bisektion auf einem isolierten Prüfstand: eine Debug-Seite mit demselben Gerüst wie der Editor — derselbe vaul-Drawer, dieselbe Spalte, derselbe Scroller — und einem Schalter für jeden Verdächtigen. Ein nacktes contenteditable war dort perfekt. Jeder Schalter fügte eine Schicht zurück, bis etwas brach. Vier Runden auf einem echten Telefon verurteilten genau vier Dinge und sprachen alles andere frei — die Body-Sperre, die Masken, die Transform-Ebenen, die Dialog-Rolle — Verdächtige, die man sonst tagelang weiter «repariert» hätte.
Zwei Regeln hielten den Prüfstand ehrlich. Nur ein physisches Gerät zählt: Der Simulator hat weder eine echte Tastatur noch die Zusatzleisten des Geräts darüber, und die Hälfte der Bugs lebt genau dort. Und nur Produktions-Builds zählen: Development-Bundles sind so schwer, dass WebKit den Tab wegen Speichermangel tötet — alles sieht kaputt aus, bevor die Hydration überhaupt fertig ist.
Schuldiger eins: href auf einem Link im contenteditable
Für iOS ist <a href> selbst in editierbarem Text interaktiv: Eine Berührung startet die Geste «Link antippen», und diese Geste sticht die Auswahl-Geste — sobald ein Griff einen Link berührt, kollabiert die Auswahl. Derselbe Anker ohne href ist bloß Text. -webkit-touch-callout: none rettet nicht. Und ein nacktes contenteditable ohne jede Editor-Bibliothek wird genauso vergiftet — Lexical war also unschuldig.
Der Fix entfernt href aus dem lebenden DOM des Editors, direkt nachdem Lexical einen Link-Knoten rendert oder aktualisiert. Verloren geht nirgends etwas: Navigation ist im Editiermodus ohnehin deaktiviert, die URL lebt im Lexical-Knoten und bleibt editierbar, und das gespeicherte HTML behält sein href — der Export läuft über exportDOM, das eigene Elemente baut und das lebende DOM nie sieht.
// 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]);Schuldiger zwei: vauls Drag-Handler auf dem Drawer-Inhalt
vaul lässt seine Zieh-zum-Schließen-Logik selbst mit handleOnly am Drawer-Inhalt hängen — und genau sie tötete das Greifen der Griffe. Der Beweis war eine Matrix auf dem Prüfstand: Lexical mit Links in einem falschen Drawer derselben Geometrie — perfekt; im echten vaul — tot. Dann, eine Änderung nach der anderen: vauls Stile entfernen (transform, will-change, touch-action) — weiterhin tot; Dialog-Rolle und Aria-Attribute entfernen — tot; den Body entsperren — tot; die Events stummschalten, sodass sie vauls Handler nicht mehr erreichen — vollständig lebendig.
Der Dämpfer ist stopPropagation, nicht preventDefault: Browser und Lexical hören alles, denn ihre Listener sitzen tiefer im Baum — nur vaul wird taub. Und er muss sowohl Pointer- als auch Touch-Events abdecken: Die Matrix zeigte, dass Pointer-Stille allein nicht genügt — die Griffe bleiben tot, solange vaul noch Touch hört.
{/* 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>Schuldiger drei: unser eigener Tap-to-Focus
Ein Helfer-Hook erzwang focus() bei jeder Berührung kürzer als 300 ms — und das schnelle Greifen eines Auswahl-Griffs ist genau so eine kurze Berührung. Das erklärt das legendäre Symptom «zwei Sekunden halten, dann zieht es»: Langes Drücken ist kein Tap, also feuerte focus() nie, und das Ziehen überlebte. Der Fix ist eine Zeile — focus() nur aufrufen, wenn der Editor den Fokus nicht schon enthält.
Schuldiger vier: ein Container, der unter dem Finger wandert
Die erste Architektur passte den ganzen Drawer an die Tastatur an. Während der Auswahl schwenkt iOS den Bildschirm — und der Drawer folgte dem Schwenk, der Text floh unter dem Finger weg, und WebKit brach das Ziehen ab. Der «offensichtliche» Fix — den Drawer einfrieren, solange ein Finger unten ist — scheiterte anders: Der Text verschob sich relativ zum Finger um genau den Schwenkbetrag, und die Auswahl landete eine Zeile daneben.
Die Regel daraus: Bewegen darf man nur, was nicht berührt wird. Drawer und Text stehen auf voller Höhe des Layout-Viewports — auf iOS drückt die Tastatur das Layout nicht zusammen, sie deckt nur seinen unteren Teil ab. Konkret:
- Nur die Toolbar jagt der Tastatur hinterher; der untere Abstand des Texts reist über eine CSS-Variable (
--keyboard-inset) — nichts bewegt sich. - Eine Frame-Schleife (
requestAnimationFrame), keine Events: iOS schwenkt während der Auswahl ohne verlässlichevisualViewport-Events, und nach dem Schließen der Tastatur setzt sichoffsetTopmanchmal nie zurück (eine iOS-26-Regression). Eine Schleife, die einfach jeden Frame hinsieht, braucht kein Signal. - Stile werden direkt ins DOM geschrieben, an React vorbei — über State würde jeder Schwenk-Frame den ganzen Lexical-Baum neu rendern.
- Der Wrapper scrollt, nicht das contenteditable. Lexicals eigener Playground macht es genauso (
editor-scroller): Ein fokussiertes contenteditable, das sich selbst scrollt, ist die schlimmste Konfiguration, die WebKit kennt — die Geste scrollt mal, startet mal eine Auswahl, füttert mal die Seite. - Eine halbe Sekunde nach dem Öffnen werden vauls
transform,will-changeundtouch-actionvom Drawer entfernt: Die Composited-Ebene verschiebt die Trefferfläche der Auswahl-Griffe, und die Öffnungsanimation braucht diese Stile nicht mehr.
Die Toolbar über der Tastatur — und über allem, was iOS über die Tastatur legt
«100svh minus Tastatur» ist auf einem iPhone eine Lüge: Die Adressleiste nimmt sich ihren eigenen Anteil, und das Gerät legt gern Zusatzleisten über die Tastatur. Die Wahrheit kennt visualViewport, und die Zahl, die die Toolbar braucht, ist der belegte Anteil am unteren Rand des Layout-Viewports:
// 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`);Eine Feinheit kostete einen Abend. Wandert der Fokus in ein Feld der Toolbar selbst — Schriftgröße, Link-URL — wechselt iOS den Tastaturtyp, und visualViewport liefert einige Dutzend Frames lang Übergangswerte. Sie sofort anzuwenden hieß, mit der Toolbar unter die Tastatur zu tauchen; die Schleife ganz einzufrieren (der vorige Versuch) machte sie genau dann blind, wenn sich die Höhe wirklich änderte. Der Kompromiss: Solange der Fokus in der Toolbar liegt, muss ein neuer Wert zehn Frames lang stabil stehen, bevor er angewandt wird; in allen anderen Fällen gilt er sofort, sodass die Toolbar auf der Tastatur-Animation mitfährt, statt ihr hinterherzulaufen.
Das Fokussieren dieser Felder braucht einen eigenen Trick. Erhält ein echtes Input in einem fixierten Vollbild-Container den Fokus, scrollt iOS die Seite, um das Feld zu zentrieren — die Seite hat nirgendwohin zu scrollen, und das Layout zerreißt: Der Drawer rutscht, die Toolbar verschwindet. vaul heilt das in seinem eigenen Fokus-Interceptor, den wir gerade stummgeschaltet hatten — also wird die Heilung von Hand nachgebaut: den nativen Fokus abbrechen, das Feld per Transform nach oben teleportieren, manuell fokussieren, im nächsten Frame zurücksetzen. Safari glaubt, das Feld sei «oben», und lässt die Seite in Ruhe.
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 });Die Scroll-Sperre: beide Ursachen waren unsere eigenen
Das Scrollen «verklemmte» unten: Gesten nach oben tot, bis man weiter nach unten zerrte, wie zum Aushaken. Der Prüfstand — diesmal mit echter Tastatur — sprach die gesamte Geometrie frei (den Abstandshalter, die Overlay-Toolbar, den Drawer selbst) und verurteilte zwei Dinge, beide von uns selbst:
- Der Tastatur-Abstand lebte im contenteditable. Unten war der sichtbare Streifen über der Tastatur eine leere editierbare Zone — und eine Berührung auf einem fokussierten contenteditable wertet iOS als Arbeit am Text, nicht als Scrollen. Der Platz für die Tastatur muss ein separater, nicht editierbarer Block nach dem contenteditable sein — nicht dessen Padding.
- Unsere eigene «Sicherung», gebaut gegen die erste Ursache. Nahe einer Kante wandert scrollTop in Bruchwerten, und ein Watchdog «korrigierte» ihn Frame für Frame mit programmatischen Schreibvorgängen — und kontinuierliche programmatische Scroll-Schreibvorgänge töten die menschliche Geste. Bei offener Tastatur wurde die Sicherung selbst zur Sperre.
Die Regel: kein preventDefault an den Kanten und keine scrollTop-Wächter, niemals. Ein schlichter overflow-y-auto-Wrapper mit dem Abstandshalter außerhalb des contenteditable scrollt perfekt — plus overscroll-contain, damit eine Kanten-Geste nicht die Seite füttert, die iOS dem fokussierten Feld zuliebe scrollbar macht.
<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>Drei einmalige Schreibvorgänge: das ganze Rubber-Band-Protokoll
Nach den Sperren blieb eine Familie von Eigenheiten — und alle drei Heilungen erwiesen sich als einzelne scrollTop-Schreibvorgänge. Nie Schleifen, nie Wächter. Erstens: Ein Scroller, der auf einer exakten Kante ruht (0 oder max), «klebt» manchmal — die Gegenrichtung ist tot, bis man über die Kante hinauszerrt. Die kanonische Heilung, seit einem Jahrzehnt dokumentiert (iNoBounce und Co.): Bei touchstart scrollTop einen Pixel von der Kante wegschubsen — bevor WebKit entscheidet, was das für eine Geste ist. Die Kante wird unerreichbar, und der Klebe-Zustand entsteht gar nicht erst.
Zweitens: Der Schwung kann den Scroller in die Kante tragen, wenn der Finger schon weg ist. Der touchstart-Schubs kommt dafür genau einen Wisch zu spät. Und ein sofortiger Schubs aus dem scroll-Handler würgte die native Feder ab — die Kante fühlte sich wie ein Aufprall an. Also ist der Beruhigungs-Schubs verzögert: Wenn die scroll-Events 140 ms lang verstummen, kein Finger unten ist und die Position eine exakte Kante ist, schiebt ein leiser Schreibvorgang sie einen Pixel hinein.
Drittens, das Härteste: Ein Wisch während der Feder-Animation wurde schlicht verschluckt. Das ist Plattform-Verhalten — solange die Rubber-Band-Animation läuft, hängt WebKit die Geste an nichts, bis sie fertig ist. Man wischte, nichts bewegte sich, man wischte erneut. Kein gefundener Artikel bietet eine Unterbrechung an; die Spur endet bei «warte, bis es fertig ist».
Der Einstieg: iOS gibt scrollTop während des Federns außerhalb der Grenzen preis — negativ oben, größer als max unten. Damit ist der Moment strikt erkennbar: st < 0 || st > max ist echtes Overscrollen und nie ruhiges Stehen an einer Kante. Bei touchstart in diesem Zustand unterbricht ein einziger geklemmter Schreibvorgang die Animation — und dieselbe Geste greift den Scroller. Eine Sicherung fährt mit: nur der erste Finger (touching === 1), denn eine Animation läuft nur, wenn kein Finger den Scroller hielt, und ein zweiter Finger darf den Text nicht unter dem ersten wegreißen.
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 });- Der Overflow-Toggle-Hammer (
overflow: hiddensetzen, klemmen, Reflow erzwingen, zurücksetzen) — zweimal auf dem Gerät verworfen. Seine erste Version feuerte zudem bei ruhigen Berührungen an der Kante (ein<= 0, wo ein< 0hingehörte) und zerbrach dabei das normale Scrollen. overscroll-behavior: none— beseitigt das Problem mitsamt der Feder: Der Schwung stirbt hart an der Kante, und die Kante fühlt sich abgehackt an.
Freigesprochen — nicht erneut jagen
- vauls
position: fixed-Body-Sperre - die CSS-Verlaufsmaske auf dem Scroller
- die Transform-Ebene für sich allein (sie zu entfernen ist richtig, aber der Killer war sie nicht)
- Dialog-Rolle, Aria- und Data-Attribute des Drawers
- Lexical selbst — ein nacktes contenteditable reproduziert sowohl das href-Gift als auch die Scroll-Sperre
- das Stummschalten von Lexicals
SELECTION_CHANGE_COMMAND— änderte nichts - die Sperre über
Selection.prototype-Schreibvorgängen — wurde selbst zum Bug: Der Tap lieferte den Cursor nicht mehr aus, und Tippen landete am Zeilenanfang
Ein Bonus-Gegner: native Auswahl gegen das URL-Feld
Das Bearbeiten eines Links trägt einen eingebauten Konflikt: Die native iOS-Auswahl lebt nur in einem fokussierten Editor, und das URL-Feld braucht den Fokus für sich — zusammen geht es nicht. Also wird der ausgewählte Text für die Dauer der Bearbeitung in einen Lexical-MarkNode gehüllt: eine echte Hervorhebung im DOM, der egal ist, wo der Fokus liegt. Mit demselben Mechanismus hebt Lexicals eigener Playground Kommentar-Ziele hervor.
Die Auswahl selbst wird kontinuierlich gespeichert, bei jeder Auswahl-Änderung — dasselbe lastSelection-Muster wie in Lexicals FloatingLinkEditorPlugin —, denn beim Knopfdruck zu speichern ist zu spät: Der Tap auf den Toolbar-Knopf kollabiert die Auswahl zuerst. Beim Anwenden stellt das Lösen der Markierung die echte Auswahl exakt an ihrer Stelle wieder her, und TOGGLE_LINK_COMMAND erledigt den Rest.
Fazit
- «Die Auswahl funktioniert auf iOS nicht» ist nicht ein Bug. Es waren vier unabhängige Ursachen, jede maskierte die nächste — Debugging mit einer einzelnen Hypothese kann darauf nicht konvergieren.
- Bisektion schlägt Deduktion. Ein isolierter Prüfstand mit einem Schalter pro Verdächtigem machte aus Tagen des Ratens vier Geräte-Runden — und sprach die Verdächtigen frei, die man sonst tagelang «repariert» hätte.
- Die Wahrheit sagt nur ein physisches Gerät: Der Simulator hat keine echte Tastatur und keine Zusatzleisten, und Dev-Bundles sterben am Speicher, bevor die Bugs überhaupt auftauchen.
- Nur bewegen, was nicht berührt wird: statischer Drawer, Toolbar auf einer Frame-Schleife, unterer Abstand über eine CSS-Variable.
- Nie gegen das iOS-Scrollen mit kontinuierlichen Schreibvorgängen oder preventDefault an den Kanten kämpfen — beide werden genau zu der Sperre, vor der sie schützen sollten. Jede überlebende Heilung ist ein einzelner scrollTop-Schreibvorgang in einem präzise erkannten Moment.
- Das volle Rubber-Band-Protokoll: ein Schubs bei touchstart an ruhender Kante, ein verzögerter Beruhigungs-Schubs nach dem Schwung und ein Klemmen bei touchstart mitten im Federn — erkennbar, weil iOS scrollTop außerhalb der Grenzen preisgibt.
Einen Fehler entdeckt?
Ein falscher Fakt, eine schiefe Übersetzung, etwas, das in diesem Artikel falsch klingt? Schreib mir — in deiner eigenen Sprache.