Google Docs in a clean-room browser

With the news that Google would be phasing in a new version of Google Docs I thought we ought to get the current version working in Flow. Because of many bug fixes in Flow for other websites, Google Docs now seemed to load mostly fine, though it rendered without word wrap and you couldn’t actually type into it.

Screenshot of Flow editing a document in Google Docs.

Like Google Mail, Google Docs almost entirely consists of obfuscated JavaScript, some of it common between the two. Its HTML structure is quite simple, though it uses hidden iframes for various purposes (one for key input and another for calculating word widths, amongst others). Over the course of the couple of months I identified over 30 distinct issues that needed fixing, and I’ll discuss the more interesting ones in the second section of this blog.

When I tried Google Docs many months ago some obscure exceptions were thrown to the console. Since then, some fixes we have done for other websites have meant these have almost entirely gone away. It’s not obvious which, but it’s always helpful – fix a bug for a test case or website and various other websites start working better without spending time specifically on them.

As with Gmail, I believe Flow is the only browser engine written after Google Docs that can run Google Docs. While still not quite perfect, it is definitely very usable – this blog has been created, written and edited entirely in Google Docs using Flow on a Raspberry Pi 400. The grammar checking and even the collaborative working features that are so useful work flawlessly (these all use XHR). Spell checking works but the underlines don’t appear as we don’t yet support SVGPatternElement.

Happily, the preview of the new canvas-based Google Docs also seems to render correctly in Flow, though their example page isn’t editable. When the full version has rolled out we’ll try it out and look to fix any bugs.

A detailed look

As I mentioned earlier, each paragraph of text was displayed as one long line without word wrap. There are many ways to calculate the width of letters or words using HTML, canvas or even SVG methods. I couldn’t immediately think of an API that we didn’t implement so assumed it was most likely a bug. With some debugging it was apparent that our implementation of Element.getClientRects() ignored inline elements. Using Safari’s inspector, I could see that Google Docs appends the characters to a <span> element before calling that API, and Flow was always returning a zero-sized box.

Pleasingly, as well as fixing word wrapping, fixing Element.getClientRects() also meant clicking the mouse would now position the caret in the correct position – keyboard input didn’t work, but it was beginning to look like a word processor.

Loading Google Docs popped up a couple of banner warnings about missing fonts and it being used on an unsupported browser. When dismissing the banners, the banner disappeared and then the whole page reloaded causing the popup banner to re-appear. Since text input didn’t work, I assumed these were getting in the way of the events. (It turns out that wasn’t true because of the complex way Google Docs handles focus.)

The banner has an HTMLAnchorElement, with an href of ‘#’, and activating that reloaded the current page. This turned out to be a bug in History.setURL() not setting the cached document base, and while the URLs only differed in the query part, they didn’t match and so the navigate algorithm fetched the same document again.

The missing fonts popup banner was primarily due to our FontFaceSet interface (documents.fonts) not being ‘set-like’. Thankfully an exception on the console pointed this out (‘e.Lb(...).fonts.has is not a function’), but even after fixing that it still appeared occasionally. This intermittent bug turned out to be caused by another hidden iframe with a ‘javascript:’ src attribute. Flow was setting the Document’s URL to be the javascript: URL when it was run, rather than the initiating document. The web fonts are specified as relative URLs and so resolved against javascript: rather than https:. Depending on timing, the JavaScript in the src attribute could be run before another bit of JavaScript called and if so, the fonts would fail to load.

Text input still didn’t work, which was because Google Docs usually keeps focus in a hidden iframe. This is in an unrelated part of the Document from where the text is displayed. To maintain the focus in this iframe, it listens to the focus events. Flow wasn’t sending focus events in the correct order, especially when moving between iframes. This was due to some remaining legacy code from Flow’s SVG heritage. The SVG 1.2 Tiny specification’s handling of focus is quite different to HTML’s. In particular, it states not to navigate to an element with display:none, and there must always be a focused object defaulting to the Document. The focus handling in and out of iframes (important to Google Docs) wasn’t very clear to me. All browsers have slightly different states as focus changes from one to another. After a total overhaul of Flow’s focus code, keyboard input was largely now working, though a significant amount of work was needed on key mappings. 

Next was a bug that was so frustrating I almost considered giving up with getting Google Docs functioning. Pressing some keys inserted one letter, but other keys inserted that letter twice. After several days of putting it off, I suddenly thought to put the letters in alphabetical order. I immediately realised it was only the lower-case letters p to z that were duplicated – it wasn’t timing or random. That meant there must have been a conscious decision in the JavaScript for those letters to be handled differently.

Flow was inserting the duplicate keys twice, once in the keypress event handler, and again in the beforeinput handler. All other browsers called preventDefault() on the keypress Event, but Flow didn’t for these duplicated keys. Breakpointing preventDefault() in Safari’s web inspector stopped on the line:

fad(g) || !(!hr || pr.lq && pr.Mh(“65”) || 112 > e || 123 < e || d && 45 == e || (s9c(this.V, a.preventDefault());

For preventDefault() to be called every previous part of the line must be false, and Flow must have been returning early. Now, the variable ‘e’ contains the ascii character being inserted. The letter ‘p’ is 112 in ascii, and ‘z’ is 123. This range check was failing, so I needed to figure out why the range check was happening. It clearly wasn’t being called on other browsers. This means ‘hr’ was true in Flow, and pr.lq && pr.Mh(“65”) must be false. Both hr and pr are on the global, and it was easy to spot that hr is true if Google Docs parses the user agent string and thinks it’s FireFox. pr.Mh() seems to compare the Firefox version number with 65, so if it’s less than or equal to Firefox 65, the character matches that range and preventDefault() is not called. I’ve no idea what the purpose of this is, but clearly I needed to stop it thinking Flow was older than Firefox 65.

A lot of debugging, and all it needed was our user agent to claim we’re like Firefox 68, not 62. Firefox 65’s change log does say it changes the way it handles keypress events, but I can’t imagine what the purpose of checking the letters p to z would be.

Other performance bugs included optimising animations to not trigger a render when they weren’t actually animating. The caret flash is a fade, but 2/3rds of the time is either fully on or off. There’s also an animation in the right-side panel that I’ve barely ever seen, when you explore a selection. Flow was continually animating this, despite its parent being hidden with display:none.

The resolution of the icons was also a problem because the Raspberry Pi has a maximum texture size of 2048×2048. The icons render to a 7208-pixel high image, which Flow was initially downscaling to 2048. This caused them to be rendered at very low resolution, appearing blurred. Larger images are now tiled with multiple textures if needed.

Another input bug that I spotted while typing this blog was that ctrl-b would insert a bold ‘b’. This was slightly curious, but I soon realised the keypress event was making the text bold. The following input event was inserting a ‘b’. The fix seems to be to suppress any input event if the keypress event was generated while the OS’s modifier key is held down.

Finally, the right mouse button didn’t bring up a menu. This was simply fixed by adding support for the contextmenu event.

Written in Google Docs, using Flow on a Raspberry Pi 400.

If you’d like to automatically receive our posts by email please join our mailing list.