DEV Community

IcyZip
IcyZip

Posted on AI-assisted

A Paste button is a capability negotiation, not a clipboard read

I recently fixed a Paste button that appeared to work, briefly moved focus, and then changed nothing.

The most reliable reproduction was simple:

  1. put text in a shared text area;
  2. press Select all;
  3. press Paste in desktop Firefox;
  4. approve the browser's clipboard confirmation.

The selected text should have been replaced. Instead, the interface flickered and kept the old value. The bug looked like a failed clipboard read, but it was really two bugs crossing an asynchronous boundary: the toolbar button took focus from the text area, and the application stopped waiting before the browser finished asking for permission.

That led to a broader lesson: a web Paste button is not a thin wrapper around navigator.clipboard.readText(). It is a capability negotiation involving user activation, permission behavior, focus, selection state, asynchronous browser UI, platform differences, and a safe fallback when any of those pieces are missing.

Disclosure: I used AI editorial assistance to prepare this article from implementation and test notes. The behavior described here was checked against the deployed implementation and automated browser coverage. Real Safari acceptance is still outstanding and is not claimed below.

API presence is not capability

It is tempting to use one test:

const canPaste = Boolean(navigator.clipboard?.readText);
Enter fullscreen mode Exit fullscreen mode

That only proves that an object and method exist. It does not prove that a call made from this page, in this browser, under this permission model, will return clipboard text.

The cases I needed to distinguish were closer to these:

  • the Clipboard API is absent;
  • readText() exists and works from a user action;
  • the browser displays its own confirmation and resolves later;
  • the Permissions API reports prompt or granted;
  • the browser rejects the clipboard-read permission name;
  • an embedded WebView exposes part of the API but cannot complete the flow;
  • the read resolves to an empty string;
  • the read rejects or never resolves in a useful time.

Those are product states, not just exceptions for the console.

On desktop, the current interface enables direct Paste only when the read API exists. If it does not, the button is disabled and the user gets native-paste guidance instead of a control that silently fails.

On Android, the rule is deliberately stricter. Direct Paste is shown only when all of these are true:

  • the client is not an embedded Android WebView;
  • navigator.clipboard.readText exists;
  • the Permissions API accepts a clipboard-read query;
  • the permission state is prompt or granted;
  • a previous direct read has not already proved the path unusable.

If the browser cannot demonstrate that capability, the direct Paste action stays hidden. The text editor still works and native paste remains available. Hiding one incompatible shortcut is better than presenting a button that promises an operation the browser will not perform.

Preserve selection before focus moves

Clicking a toolbar button changes focus. Once that happens, reading selectionStart and selectionEnd from the text area may no longer describe the selection the user intended to replace.

The fix is to capture the selection before the click finishes moving focus:

let pendingSelection = null;

pasteButton.addEventListener("pointerdown", () => {
  pendingSelection = {
    start: textArea.selectionStart,
    end: textArea.selectionEnd
  };
});

async function pasteFromClipboard() {
  const selection = pendingSelection ?? currentSelection();
  pendingSelection = null;

  const text = await readClipboardWithTimeout(15000);
  // Apply the result to the saved selection, not the current focus state.
}
Enter fullscreen mode Exit fullscreen mode

The production handler also listens to mouse and touch start events for the browser/device combinations covered by the UI. The important part is not the exact event list. It is taking the snapshot before awaiting anything and before trusting focus to remain where it was.

This matters even more when the browser opens a permission surface. A confirmation that takes 1.2 seconds is not slow from a human perspective, but it is long enough for a 700 ms application timeout to declare failure and discard the intended selection.

The current read helper allows up to 15 seconds. That is not permission to freeze the UI: the Paste button becomes pending and cannot be clicked repeatedly. It is simply a realistic window for a person to read and answer browser-owned UI.

On Android, the clipboard read begins before the application focuses another element. That preserves the transient user activation browsers often require for protected APIs.

Empty and failed reads must be non-destructive

A Paste action has three materially different outcomes:

  1. clipboard text was returned;
  2. the returned text was empty;
  3. the application could not obtain a result.

Only the first outcome should alter the editor.

For a successful non-empty read, the application replaces exactly the saved range. A collapsed range inserts at the caret. A full selection replaces everything. The caret then moves to the end of the inserted text, and the new value is synchronized to the paired browser.

For an empty string, the application leaves the value and selection untouched and reports that the clipboard contained no text. Treating an empty result as permission to delete the selection would turn a read ambiguity into data loss.

For rejection or timeout, it also preserves the value, restores the selection and focus, and shows native-paste guidance. On Android, that failed direct path is remembered so the unusable shortcut can be hidden instead of failing repeatedly.

Copy needs the same defensive treatment in reverse. Pressing Copy with no selection and an empty editor should not overwrite a useful system clipboard with an empty string. If modern clipboard writing rejects, the implementation can attempt a carefully contained legacy copy fallback and then report whether the operation actually succeeded. document.execCommand("copy") is not a modern API, but as a fallback its success value is more honest than always showing a green checkmark.

Undo and redo belong to the data model

Once toolbar actions can replace or clear synchronized text, native browser history is no longer enough. The paired editor needs a history that understands application-level changes.

The current implementation keeps up to 100 snapshots. Each snapshot contains:

text value
selection start
selection end
Enter fullscreen mode Exit fullscreen mode

Local typing, Paste, Clear, and remote text changes enter that history. Undo and Redo restore both the value and the saved selection, mark the change as a local edit, and send the restored value to the peer. If the user undoes and then makes a new edit, the abandoned redo branch is removed.

This gives predictable behavior across the pair:

type A -> clear -> undo       => A on both browsers
redo                          => empty on both browsers
undo -> paste B -> redo       => B remains; old redo branch is gone
remote change C -> undo       => previous value on both browsers
redo                          => C on both browsers
Enter fullscreen mode Exit fullscreen mode

The key decision is that Undo is not a purely local visual trick. In a shared editor, restoring an older state is itself a new synchronized action.

Test the awkward outcomes, not only success

A mocked readText() that immediately returns "hello" proves very little. The useful matrix includes the states that browsers and people actually create:

  • insert at a caret;
  • replace a partial selection;
  • replace Select all after a delayed clipboard response;
  • return an empty string without changing text or selection;
  • reject the read and restore focus plus native-paste guidance;
  • copy selected text;
  • copy the whole field when no range is selected;
  • refuse to copy when both selection and field are empty;
  • fall back when modern clipboard writing rejects;
  • disable or hide Paste when the required capability is unavailable;
  • transition Android permission from prompt to granted or denied;
  • undo and redo Clear, Paste, branched edits, and remote changes;
  • keep the paired browser synchronized after every accepted change.

For deterministic coverage, the browser tests inject a clipboard adapter whose promise can resolve immediately, resolve after a delay, return an empty string, or reject. One regression test deliberately waits 1.2 seconds after Select all before returning replacement text. That reproduces the focus-and-timeout interaction without needing a human to click a permission prompt on every run.

The suite also exercises the real operating-system clipboard through Firefox WebDriver: copy text with the app button, paste it with the native shortcut, then replace selected text through the app's Paste button and verify that the paired browser receives the result.

Android coverage separately checks prompt, granted, denied, missing permission-query support, missing Clipboard API, and WebView-like conditions. A physical older Android Firefox device remains useful because it exposed exactly the partial-capability case that desktop emulation made easy to overlook.

Chromium and Firefox are covered by the current automated matrix. Android capability boundaries have automated and physical-device evidence. A real Safari run has not yet been completed, so the implementation keeps the same conservative failure behavior but I would not label Safari proven from simulation alone.

The product lesson

Protected browser APIs should shape the interface, not sit behind an optimistic button.

For clipboard actions, that means:

  • detect the capability you need, not just the property you recognize;
  • consume user activation before moving focus;
  • save selection before crossing an asynchronous boundary;
  • wait long enough for human permission UI;
  • make pending state visible and prevent duplicate actions;
  • keep empty, rejected, and timed-out reads non-destructive;
  • remove or disable controls that cannot work in the current environment;
  • include programmatic edits and remote updates in Undo/Redo;
  • test delayed and failed paths with the same seriousness as success.

The surprising part of this bug was how little of it was about moving a string. Reliable Paste behavior required treating permission, focus, selection, synchronization, history, and platform capability as one interaction.

The deployed example behind these notes is the shared text panel in IcyZip. It pairs two open browsers for live text or one file, so clipboard mistakes are immediately visible on both sides—which made them worth fixing properly.

Top comments (0)