Introduction
We recently faced a weird bug at my previous company: our app's copy button decided to take a vacation, but only on iPhones. If you're aware of Safari's quirky ways with the Clipboard API, feel free to skip. Otherwise carry on, this write-up might save you some time in the future.
The Feature: A Simple "Save and Copy"
Let's set the scene. We'd introduced a handy modal where users could update a setting and then instantly copy a generated link. Think Google Sheets' share functionality: you tweak sharing permissions, then grab the link.
Our process was straightforward:
- A server call to update the setting.
- A copy operation to grab the shiny new link.
A dumbed-down version of the code for our CTA handler looked like:
const handleUpdateSetting = async () => {
const updatedLink = await saveSetting(updateValue); // Save the setting and get the link
if (window.navigator.clipboard) {
await window.navigator.clipboard.writeText(updatedLink); // Attempt to copy
}
};
"Easy peasy," I thought. I figured I had a solid grasp of both operations. Boy, was I wrong.
The Problem: "Settings Saved, But Link Could Not Be Copied"
During testing, iPhones consistently failed to copy the link. Thankfully, our toast messages were on point: "Settings saved but link could not be copied." Our logs also showed a NotAllowedError
for the copy operation.
Most of our development and testing happened in Chrome. I did not test on Safari, as it was just the clipboard the API, which I already knew right? While browsers adopted the Clipboard API, they did so with a keen eye on user security, aiming to prevent scripts from copying text without explicit user intent. The weird thing was, this wasn't an unprompted copy, and we had dozens of other places where this exact writeText
call worked perfectly.
The Culprit: Transient User Activation
A glance into the Clipboard API documentation quickly unearthed the root cause. Different browsers, it turns out, have varying security requirements for this API:
- Secure Contexts Only: (Universal) The API needs a secure context.
- clipboard-write Permission: (Chromium specific) Browsers like Chrome require explicit permission.
- Transient Activation: (Firefox, Safari – BINGO!) This was our problem.
So, what exactly is transient activation? Imagine a quick window of opportunity after a user directly interacts with your page – a button click, a mouse movement, a menu selection. Certain features, like Element.requestFullscreen()
and our problematic Clipboard.writeText()
, are "gated" by this transient activation. They'll only work if executed within that brief window.
Every user agent has a property transient activation duration – a short, fixed period after a user interaction during which this "activation" is valid. In our case, Clipboard.writeText
was being executed after a Promise resolved (the saveSetting call). Depending on network conditions, this delay could be anywhere from a hundred milliseconds to a few seconds - well past Safari's transient activation window it seems.
The Fix and the Takeaways
Honestly, this was a fundamental user agent limitation, and there was no clever code trick to bypass it. While navigation.userActivation.isActive()
can tell you if transient activation is present, you can't update it.
Our most appropriate solution was to separate the "save" and "copy" actions. We implemented a separate "Copy Link" button that remained disabled while the save operation was in progress. Once the settings were saved, and the link was ready, the "Copy Link" button would become enabled again, allowing the user to explicitly click it to copy.
Nevertheless, this small bug provided some valuable lessons:
- Cross-browser testing is non-negotiable. Never assume an API behaves identically across all browsers, even if you think you know it.
- Robust E2E tests need broad coverage. We had Playwright tests, but they were only running against Chromium. A major miss!
- Logging never hurts. Our detailed logs quickly pointed us to the correct error, even before I had to grab an iPhone.
Have you ever encountered a similar browser-specific gotcha? Share your war stories in the comments!