Split out of #453 (@DCarlson12), by agreement on that issue. The synchronous Func<string> half stays there and can land on its own; this covers the asynchronous variant, which is a materially different piece of work.
Why this is not just another parameter
clipboard.js currently does:
export async function copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// textarea + document.execCommand fallback
}
}
Clipboard writes require transient user activation. Today Value is a string already in hand, so the click handler runs essentially straight into writeText and the activation is still live.
If we await a consumer-supplied Func<Task<string>> first — a fetch, a database read, anything genuinely async — several browsers, Safari most strictly, treat the activation as spent by the time writeText is called and reject the write.
The failure is quiet, which is the dangerous part. The rejection is caught by the existing catch, which falls through to the document.execCommand('copy') fallback — and that is also gesture-gated (and deprecated), so it fails too. The user clicks copy, sees the success state, and gets nothing on the clipboard.
The actual fix is in the JS layer
Hand the promise to ClipboardItem so the browser keeps the activation alive across the await, rather than awaiting first and writing second:
navigator.clipboard.write([
new ClipboardItem({ 'text/plain': textPromise })
]);
Work involved:
- Restructure
copyToClipboard to accept a promise-producing callback rather than a resolved string, so the .NET side can be invoked inside the ClipboardItem rather than before the write.
- Feature-detect:
ClipboardItem support for promise values differs across browsers, and navigator.clipboard.write is not universally available where writeText is. Needs a path for browsers that only support the resolved-string form — accepting that those will fail on a genuinely slow func, and deciding whether that surfaces as an error rather than a silent no-op.
- Revisit the
execCommand fallback. It cannot rescue an expired activation, so treating it as the catch-all for every failure hides this class of bug. It should probably only run for the insecure-context case it was written for.
- Decide what a failed copy reports. Today failure is invisible; with an async source, failure becomes much more likely and the component should probably surface it.
Testing
This cannot be verified by unit test or by building — activation semantics only exist in a real browser, and they differ per engine. Needs manual verification in Safari specifically, plus Chrome and Firefox, with a deliberately slow func (a second or more) rather than an immediately-resolved task, which will pass everywhere and prove nothing.
Depends on
#453 — the sync ValueFunc should land first. It settles the parameter naming and the precedence rules (see below), which this variant then follows.
Shared API decisions, settled on #453
Both halves need these, so recording them here too:
Value is currently [Parameter, EditorRequired]. Adding func-based parameters means dropping EditorRequired, or the analyzer nags anyone who sets only a func. That is an API-surface snapshot change.
- "First non-null" precedence needs tightening —
Value is a string, so a non-null empty string would win a literal reading and silently copy nothing. Precedence should key on !string.IsNullOrEmpty(Value), and the docs should state plainly what happens when both a value and a func are set.
Split out of #453 (@DCarlson12), by agreement on that issue. The synchronous
Func<string>half stays there and can land on its own; this covers the asynchronous variant, which is a materially different piece of work.Why this is not just another parameter
clipboard.jscurrently does:Clipboard writes require transient user activation. Today
Valueis a string already in hand, so the click handler runs essentially straight intowriteTextand the activation is still live.If we
awaita consumer-suppliedFunc<Task<string>>first — a fetch, a database read, anything genuinely async — several browsers, Safari most strictly, treat the activation as spent by the timewriteTextis called and reject the write.The failure is quiet, which is the dangerous part. The rejection is caught by the existing
catch, which falls through to thedocument.execCommand('copy')fallback — and that is also gesture-gated (and deprecated), so it fails too. The user clicks copy, sees the success state, and gets nothing on the clipboard.The actual fix is in the JS layer
Hand the promise to
ClipboardItemso the browser keeps the activation alive across the await, rather than awaiting first and writing second:Work involved:
copyToClipboardto accept a promise-producing callback rather than a resolved string, so the .NET side can be invoked inside theClipboardItemrather than before the write.ClipboardItemsupport for promise values differs across browsers, andnavigator.clipboard.writeis not universally available wherewriteTextis. Needs a path for browsers that only support the resolved-string form — accepting that those will fail on a genuinely slow func, and deciding whether that surfaces as an error rather than a silent no-op.execCommandfallback. It cannot rescue an expired activation, so treating it as the catch-all for every failure hides this class of bug. It should probably only run for the insecure-context case it was written for.Testing
This cannot be verified by unit test or by building — activation semantics only exist in a real browser, and they differ per engine. Needs manual verification in Safari specifically, plus Chrome and Firefox, with a deliberately slow func (a second or more) rather than an immediately-resolved task, which will pass everywhere and prove nothing.
Depends on
#453 — the sync
ValueFuncshould land first. It settles the parameter naming and the precedence rules (see below), which this variant then follows.Shared API decisions, settled on #453
Both halves need these, so recording them here too:
Valueis currently[Parameter, EditorRequired]. Adding func-based parameters means droppingEditorRequired, or the analyzer nags anyone who sets only a func. That is an API-surface snapshot change.Valueis astring, so a non-null empty string would win a literal reading and silently copy nothing. Precedence should key on!string.IsNullOrEmpty(Value), and the docs should state plainly what happens when both a value and a func are set.