Gestern hatte ich mal wieder ein Problem, das auf den ersten Blick schwer verständlich war. Am Ende ergab alles Sinn, und weil der Fehler leicht zu machen ist, schreibe ich das hier mal auf.
Das Problem
Es gab Nutzerberichte, denen zufolge eine Seite mobil nicht zu benutzen sei. Genauer genommen ging es um ein Formular. Sobald man in einem Formularfeld etwas tippte, sprang die Seite, so dass man das Feld nicht mehr sehen konnte.
Als Testnutzer mit einem Desktop-Browser ließ sich das nicht nachvollziehen. Allerdings hatten wir gerade ein anderes Projekt getestet, und dort hatte ein Kollege ein ähnliches Verhalten festgestellt, ebenfalls mit dem Handy. Es war also klar, dass das nicht nur am konkreten Smartphone eines einzelnen Nutzers lag, sondern dass wir hier ein Problem hatten.
Im Screenshot sieht man nicht, dass das Formularfeld fokussiert ist. Sobald man etwas tippte, sprang das Feld an den unteren Bildschirmrand. Scrollte man wieder höher und tippte den nächsten Buchstaben, sprang es wieder runter.
Die Fehlersuche
Bugs, die sich nicht verlässlich reproduzieren lassen, sind mit die schlimmsten. Sobald man ein Problem immer reproduzieren kann, kann man den Anwendungsfall zum kleinstmöglichen Beispiel-Szenario vereinfachen und kommt dann meist auch dahinter, woran es liegt. Ein Bug, der nur mobil auftritt, ist natürlich ebenfalls nicht schön. Zumal wenn es nur am iPhone passiert – das muss ja erst mal haben.
Hier hatte ich einfach Glück, dass auch mir beim Testen am Desktop dieses Verhalten vereinzelt aufgefallen war. Einmal konnte ich es mehrfach reproduzieren, total zuverlässig. Beim nächsten Versuch war es dann wieder weg. Letztlich war es Glück, dass mir auffiel, dass dies nur passierte, wenn ich die Größe des Browserfensters geändert hatte. Sobald ich das tat, sprang die Seite. Jetzt blieb nur noch, die eigentliche Ursache zu finden.
Mit etwas testen stellte es sich so dar:
- Die Seite sprang, sobald man etwas ins Feld tippte, nicht wenn das Feld den Fokus erhielt. Es war also kein onFocus-Event-Handler.
- Der Fokus wurde dabei nicht verändert, sondern blieb im Eingabefeld. Es war also auch kein Script, welches den Fokus irgendwo anders hin setzte.
- An den betroffenen Eingabefeldern gab es keine Event-Handler (sieht man in den Entwicklertools im Firefox).
- Ein andere onScroll-Event wurde ausgelöst, also verursachte etwas auf der Seite ein Scrollen.
Es lag nahe, dass das Problem irgendwie an einem onResize-Handler lag, der Code ausführt, wenn man die Viewport-Größe ändert. Mobil kann man das zwar nicht machen, aber beim Fokussieren eines Eingabefeldes blendet das System ja die Bildschirm-Tastatur ein und ändert damit ebenfalls die Viewport-Größe.
So viele Stellen, die einen onResize-Handler benutzen, gibt es in unserem Produkt gar nicht. Nächster Schritt: In jede dieser Methoden einen Output einbauen („console log“) um zu sehen, ob sie aufgerufen werden. Auf den ersten Blick fiel nichts weiter auf, keine dieser Methoden lief, während man in das Eingabefeld tippte. Wieso auch, das Tippen ändert ja nichts an der Größe des Browserfensters.
An dieser Stelle ein kleiner Tipp: Zumindest im Firefox werden gleiche Ausgaben in der Konsole zusammengefasst und durch eine kleine blaue Zahl rechts dargestellt. Wenn man da nicht drauf achtet, kann man leicht denken, dass die Meldung nur einmal geloggt wurde!
An diesem Punkt fiel mir zum Glück auf, dass einer der onResize-Handler zwar nicht beim Tippen im Eingabefeld, aber doch mehr als einmal aufgerufen wurde. Ein Grund, zu schauen, was da genau passierte.
Die Funktion tut folgendes: Die Seite hat verschiedene Header, welche mit „position: fixed“ immer am oberen Bildschirm-Rand angezeigt werden (das Drupal-Adminmenü sowie der fixe Seiten-Header). Wenn man nun zu einem bestimmten Element scrollen will, kann es passieren, dass dieses vom Browser am oberen Bildschirmrand platziert wird, aber unter den fix positionierten Headern. Um das zu vermeiden gibt es das CSS-Attribut scroll-padding-top. Man sagt dem Browser damit, dass er immer x Pixel tiefer als angegeben scrollen soll (mit x = Höhe der festen Header).
Hier der vereinfachte Code der Funktion:
addScrollPaddingTopFromHeaders(html) { let offset; // Get the existing offset, which might be already set by Drupal. // @see web/core/modules/toolbar/js/views/ToolbarVisualView.js. // For guest users, this is only an empty String. offset = 0; if (html.style.scrollPaddingTop) { offset = parseFloat(html.style.scrollPaddingTop.slice(0, -2)); } // Add offset for our header and a little extra for comfort. const header = document.querySelector(".header"); if (header) { offset += parseFloat(header.offsetHeight ?? 0) + 20; } // Use the offset as top padding to any scroll on the html element. html.style.scrollPaddingTop = offset + "px"; },
Auf den ersten und leider auch zweiten Blick sieht das Ok aus. Drupal setzt bereits ein Offset für die Drupal-Toolbar, welche nur eingeloggte Nutzer sehen. Für den eigenen Seiten-Header addieren wir noch etwas dazu und setzen die Summe dann als neuen ScrollPaddingTop-Wert.
Das Problem
Das passt auch super, wenn man das nur einmal macht. Die Funktion lief jedoch zusätzlich zur einmaligen Ausführung auch als onResize-Handler! Und was passiert dann? Wir nehmen einen initialen Wert, addieren etwas und setzen den Wert dann wieder. Dann nehmen wir den bereits geänderten Wert, addieren wieder etwas, speichern den neuen Wert…
Letztlich führte das dazu, dass statt 150 Pixeln Scroll-Padding Werte von 500, 600, 1000… Pixeln gesetzt wurden. Der Browser fühlte sich nun scheinbar beim Tippen genötigt, durch dieses Scroll-Padding die Position des sichtbaren Bereichs zu verändern. Das Eingabefeld blieb im Firefox ganz knapp sichtbar, auf dem iPhone war das offenbar nicht der Fall.
Hier gab es gleich zwei Fehler:
- Das resize-Event wird je nach Browser mehr als einmal ausgelöst. Selbst wenn man das Fenster nur kurz anfasst und kleiner zieht, gab es im Firefox fünf, sechs Events dazu. In Chrome sollen es wohl noch mehr sein. Theoretisch reicht es völlig, den Event-Handler nur einmal am Ende laufen zu lassen. Ich habe dann am Ende auch eine Lösung eingebaut, welche über eine Variable und einen Timeout den eigentlichen Handler 250 Millisekunden nach dem Ende des resize-Events ausführt. Siehe z.B. diesen Stack-Overflow-Eintrag.
- Dann war es aber auch prinzipiell falsch, den veränderten Wert als Basis zu nehmen, wenn man gleichzeitig auf ein Ändern der Bildschirm-Größe reagieren will (welches ja die Höhe des Headers beeinflussen kann, man möchte da also durchaus drauf reagieren). Das kann so nicht funktionieren.
Letztlich habe ich das so umgeschrieben, dass ich den von Drupal schon ermittelten Wert nicht benutze, sondern einfach schnell selber die möglichen Toolbars prüfe und deren Höhe addiere. Zusammen mit der Timeout-Lösung läuft das nun wesentlich weniger oft, das Scrollen an die richtige Position funktioniert und beim Tippen in den Textfeldern passieren keine Sprünge mehr.
Fazit
Das hat länger gedauert zu finden als schön war. Mir war aber auch der Zusammenhang zwischen dem Scroll-Padding-Wert und dem Tippen im Eingabefeld nicht klar. Hier bestand ein Teil des Bug-Verhaltens aus etwas, was der Browser selber macht und wo man deswegen auch per Textsuche (nach „scrollTo“ oder so) oder Konsolenausgabe nicht weiterkommt.
Also merke: Wer ScrollPadding-Werte setzt, sollte sicher sein, dass diese korrekt sind!