Tom MacWright

tom@macwright.com

Activation

In the course of building Placemark, I’ve been learning about a corner of web standards that’s pretty underdiscussed and underdocumented. It’s odd enough that even MDN, the gospel for web standards documentation, doesn’t mention it very often.

The thing is called user activation. It’s existed in a de-facto form for years, but only recently earned itself a web standard within the HTML spec.

The essence of user activation is that there are certain APIs that do disruptive or annoying things like opening pop-up windows or saving a file that shouldn’t be callable arbitrarily. Classically, it’s annoying to open a browser window and get a pop-up ad.

To crack down on pop-up ads and other annoyances, browsers implemented restrictions to these APIs, mostly in the form of tying them to the “click” event. Calling window.open on a setTimeout is forbidden, but calling window.open within the event handler of a click on a button is totally fine. Unfortunately, every browser did something slightly different, which prompted the folks at Google to propose a new standard with consistent behavior.

How this gets you

Why has user activation been a bugbear for my daily development of Placemark?

Placemark uses many of these new APIs, like native file access and the new clipboard APIs.

It also does a lot of computing in the browser. At the time of writing, 18 different file formats are supported, and some of them require large modules to convert. Plus, this is a modern web application, so I’m trying to offload more and more CPU-heavy work to WebWorkers, so the tab doesn’t grind to a halt when things are being converted.

So, to keep the initial JavaScript payload under control, some actions like saving a file might lazy-load a module. And to keep the tab interactive rather than blocking it with CPU-heavy tasks, some operations are offloaded into WebWorkers, which makes them asynchronous.

And so you get the perfect storm of advanced APIs and asynchrony around when they’re called. So I’ve run into a lot of bugs rooted in user activation. If lazy-loading and off-thread computation take too long, the file won’t be saved or opened, and the clipboard won’t be written.

Safari, different and worse

Chrome’s new “v2” behavior with activation mostly works. There are frustrating omissions in it, like the timeout in the specification called the transient activation duration that is “a constant number” of milliseconds, and then is referred to as “at most a few seconds” and the answer for how many seconds it is would probably require me cloning and grepping the Chromium source code, which I don’t have the patience to do right now.

Edit: Eric Lawrence wrote about this and nailed down that number to 5 seconds.

The problem, though, is that Safari hasn’t followed Chrome’s lead. The smart stuff that Chrome is doing, Safari isn’t.

Safari’s implementation of user activation is not sophisticated. If it’s documented precisely anywhere, I haven’t found that documentation, but the observed behavior is something like:

you have user activation if you’re calling the function within the same stacktrace as a “click” event handler, and in the same tick or within a tiny time threshold.

So unfortunately if you’re building a web application and you can’t throw out Safari support, you have to build to the lowest common denominator, which is Safari’s de-facto activation threshold.

The workaround

For the clipboard and file APIs, there’s a workaround that can sate both Chrome and Safari. Let’s say you have a button that’ll write an API response into a user’s clipboard:

button.onclick = async () => {
  const response = await fetch('');
  const type = "text/plain";
  const blob = new Blob([await response.text()], { type });
  const data = [new ClipboardItem({ [type]: blob })];
  await navigator.clipboard.write(data)
}

So, you’re doing a lot of asynchronous work in between getting the “click” event and writing to the clipboard. This should work, but this kind of code will cause chaos down the line in Safari.

Instead, you need to really dig into those MDN documentation pages and figure out that most of the methods that require user activation also accept Promises as inputs, specifically as a workaround to the more brutish kind of user activation rules. So this could would look more like:

button.onclick = () => {
  const type = "text/plain";
  const data = [new ClipboardItem({
    [type]: new Promise(async resolve => {
      const response = await fetch('');
      return resolve(response.blob());
    })
  })];
  navigator.clipboard.write(data)
}

Ta-da! You’re calling navigator.clipboard.write in a way that makes Safari happy, and putting the asynchronous work into a Promise that’s resolved later.

It’s not great

This workaround works, generally, but the state of affairs isn’t great. Sure, you can pass a Promise as an argument to clipboard.write and when you’re working with the File System Access API, but if you’re doing something like what I’m doing, this is unequivocally worse.

If you’re lazy-loading libraries, like your “KML converter” library from another part of your codebase, you might need one part of the library to get a list of valid extensions or something else that you need to provide to the showOpenFilePicker or write or whatever call, on the same tick, synchronously, but then you want to lazy-load the rest of the library to run the conversion asynchronously.

If you’re calling a method to save a file up-front and then awaiting an asynchronous value, then failure gets weird. If the file isn’t converted or the API request fails or anything else, you’ve still shown the user a file selection dialog before reporting the failure.

Finally, it’s just really easy to forget the rules here, add a little abstraction and a little asynchronous code, and break clipboard or file or some other API in Safari.


I hope that Safari adopts the proposed standard for user activation soon! Until then, it’s a mysterious, underdocumented quirk that’ll really sneak up you.