Skip to content

The week before release, QA opens Safari

Peter-Paul Koch retired a workaround last week. For about fifteen years everyone has written this:

<meta name="viewport" content="width=device-width, initial-scale=1">

The second half of that tag was never for the web. It was for one device. Koch documented the bug in October 2013: with width=device-width alone, Safari on iOS did not resize the layout viewport when you rotated the phone — the page stayed at 320px on an iPhone, 768px on an iPad, however you held it. Adding initial-scale=1 made it react. In the same post he lists what he tested it against: Android WebKit, Chrome, Opera Mobile, BlackBerry 10, Firefox Android, Firefox OS. All of them already behaved. As he puts it, "one mindless copy later all other browsers supported this as well".

So the entire web has been carrying a second viewport parameter, for thirteen years, to work around one browser on one platform.

It is fixed now. What nobody can tell you is when. Koch's post gives no version and no date — it took a developer noticing in 2026 for anyone to check at all. Somewhere between then and now, WebKit quietly fixed it, and every boilerplate, every framework template, every MDN example went on shipping the workaround to a bug that no longer existed.

That gap is the thing I keep thinking about. Not the tag — the shape of it. A fix you carry so long that you stop remembering it is a fix, for a browser nobody re-tested. I have a list like that. Every entry on it is the same browser.

I want to be fair before I start. Three bugs is not a verdict on a rendering engine, I do not know what any of them cost to fix properly, and I am writing this on a Mac I like, in an OS whose design I genuinely admire. But all three cost us real time, all three surfaced at the worst possible moment, and all three were invisible in the browser we developed in.

One: the caret was four characters to the left

We were building a rich-text editor for displays. The architecture has two layers: a read-only view that renders the saved document, and, when you click into it, an editable layer mounted over the same content.

A user types "Peter's achievements were exceptional", wants to change exceptional, clicks just before the word. The caret is drawn exactly where they clicked. Then they type — and the characters land four letters earlier. The caret was drawn in one place and inserting in another.

Chrome fine. Firefox fine.

I did the obvious thing, which was also the wrong thing: I diffed the two layers property by property. font-family, font-size, font-weight, line-height, letter-spacing, word-spacing, font-kerning, font-stretch, font-variant, every box property. They matched. Computed styles, side by side, identical. And the two layers still did not lay out the same text the same way.

The fix, in the end, was two declarations:

.ProseMirror {
  font-variant-ligatures: none;
  font-feature-settings: 'liga' 0;
}

The layers agreed on every property I was comparing and disagreed about which glyphs to draw. Turn ligatures off in both, and they finally produce the same run of glyphs — so the position the caret is drawn at and the position the editor thinks it is at are the same number again.

The lesson I actually took: a computed-style diff compares the values you declared. Text layout is downstream of that — font fallback, glyph substitution, shaping — and none of it shows up in the panel where I was looking. I spent a week comparing the wrong list.

Two: the image that renders everywhere except where it matters

Featured content on a display shows a person's photo with a slow Ken Burns push — a wrapper that scales from 1 to 1.1 over several seconds:

@keyframes kb-zoom-in {
  from { transform: scale(1); }
  to   { transform: scale(1.1); }
}
.animated-wrapper {
  transform: translateZ(0); /* hardware acceleration */
}

It is not as simple as "an img inside that wrapper", which is what cost us the time. A plain image animates in it perfectly well. What breaks is two scales compounding — because the photo is already being scaled to fill its box:

.feature_content--image {
  width: 100%;
  height: 100%;
  object-fit: cover; /* scale one: the photo into the box */
}

and then the wrapper scales that again, from 1 to 1.1, in a composited layer. Either scale on its own is fine. Together, Safari renders nothing. Not a broken icon, not a flash, not a console error — an empty box where a face should be. The image loads. It simply never appears. The same markup animates correctly in Chrome and Firefox.

So every featured image on the product became a div:

<div
  className="feature_content--image"
  role="img"
  aria-label={altText || 'Profile image'}
  style={{ backgroundImage, backgroundSize, backgroundPosition }}
/>

role="img" and an aria-label are the honest minimum, and we did add them everywhere. But a div with a background is not an img, and the gap is bigger than the ARIA patch suggests. No srcset, so no per-viewport candidates. No native lazy loading. No width/height reserving the box. No right-click and save. Nothing left that degrades gracefully if the CSS does not arrive.

I wrote a whole post the other week about how much the img element does for you now, and how the right move is to stop hand-rolling replacements for it. This is a browser bug that made me delete all of that and hand-roll the replacement anyway.

Three: the canvas cache that took the app with it

The graphic editor is built on Fabric. A template is roughly forty objects — every text run, image, gradient, shape — and Fabric gives most of them their own backing canvas for caching.

On desktop this is survivable. On iOS, a user switching between templates, or flipping between modes, would crash the whole app after three to six switches. Not a frozen tab. Gone.

We were already unmounting and disposing the canvas on every navigation, which is the part that made it confusing. canvas.dispose() returns, the Fabric object is gone, the React tree is gone — and the memory is still held, because what costs you is the backing store, and the backing store belongs to the canvas element, which is still sitting there at its full pixel dimensions.

The trick is to shrink every canvas to a single pixel before letting go of it:

[
  canvas.lowerCanvasEl,
  canvas.cacheCanvasEl,
  canvas.upperCanvasEl,
  canvas.contextCache.canvas,
  canvas.contextContainer.canvas,
  canvas.contextTop.canvas,
].forEach((el) => {
  el.width = 1;
  el.height = 1;
  el.getContext('2d')?.clearRect(0, 0, 1, 1);
});

Assigning to width or height resets a canvas's drawing buffer, and a 1×1 buffer is the smallest thing you can hand back. That is the whole idea. The helper it grew into — CanvasCleaner — then walks every object on the canvas, turns off objectCaching, and does the same to each object's _cacheCanvas, its _filteredEl, its clipPath's cache, recursing into groups, before finally calling dispose(). It is about ninety lines to say "no, really, give the memory back".

Nothing about it is clever. It is long because there are that many places a canvas can hide.

Interop 2027 is open

Interop 2027 is taking proposals until 23 September — the process where the browser vendors agree on a shared list of things to fix and then get publicly scored on how far they get.

I am going to spend an evening on it before the window closes: read what people have already put forward and back the ones that cover gaps like these, and write my own up if nobody has.

Three bugs is not a grudge. It is a list — and for once there is somewhere to put it.

All notes