Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/reflex-base/news/6586.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix `rx.set_clipboard` failing with `NotAllowedError` on Safari and iOS when returned from a backend event handler.
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,59 @@ function urlFrom(string) {
return undefined;
}

/**
* Detect WebKit-based browsers (Safari, iOS) excluding Chromium/Edge, which
* report AppleWebKit in their UA string but do not need the clipboard workaround.
* @returns True if running on a WebKit browser that requires the workaround.
*/
const isWebKit = () =>
typeof navigator !== "undefined" &&
/AppleWebKit/.test(navigator.userAgent) &&
!/Chrome|Chromium|Edg/.test(navigator.userAgent);

// Holds the deferred resolver for a clipboard write armed during a user gesture.
// See armClipboard / the "_set_clipboard" branch in applyEvent.
let clipboardResolver = null;

/**
* Arm a clipboard write during the current user gesture so that a value computed
* by the backend can be written after a WebSocket round-trip.
*
* WebKit only honors navigator.clipboard.write inside the user-activation window
* of the originating gesture. We satisfy that by issuing the write synchronously
* here, backed by a Promise that resolves later when a "_set_clipboard" event
* arrives. If the gesture produces no clipboard event, the Promise is rejected,
* which aborts the write and leaves the system clipboard untouched.
*/
const armClipboard = () => {
if (
typeof ClipboardItem === "undefined" ||
!navigator?.clipboard?.write ||
!navigator.userActivation?.isActive
) {
return;
}
// Abandon any previously armed write so its pending promise does not linger.
clipboardResolver?.reject?.();

let resolve;
let reject;
const text = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
clipboardResolver = { resolve, reject };

navigator.clipboard
.write([
new ClipboardItem({
"text/plain": text.then((t) => new Blob([t], { type: "text/plain" })),
}),
])
// Rejection is expected when an armed write goes unused; ignore it.
.catch(() => {});
};

/**
* Handle frontend event or send the event to the backend via Websocket.
* @param event The event to send.
Expand Down Expand Up @@ -331,6 +384,18 @@ export const applyEvent = async (event, socket, navigate, params) => {
return;
}

if (event.name == "_set_clipboard") {
const content = event.payload.content;
if (clipboardResolver) {
// Resolve a write armed during the originating gesture (WebKit workaround).
clipboardResolver.resolve(content);
clipboardResolver = null;
} else if (navigator?.clipboard?.writeText) {
navigator.clipboard.writeText(content).catch((e) => console.error(e));
}
return;
}

if (
event.name == "_call_function" &&
typeof event.payload.function !== "string"
Expand Down Expand Up @@ -957,6 +1022,13 @@ export const useEventLoop = (
const addEvents = useCallback((events, args, event_actions) => {
const _events = events.filter((e) => e !== undefined && e !== null);

if (isWebKit()) {
// Arm a clipboard write inside the active user gesture so set_clipboard
// returned from a backend handler still works on WebKit. No-op unless a
// user activation is in effect, so programmatic dispatches are unaffected.
armClipboard();
}
Comment on lines +1025 to +1030
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 armClipboard fires on every user gesture on WebKit, not just clipboard ones

addEvents calls armClipboard() unconditionally for every event on Safari/iOS (guarded only by isWebKit()). Any user interaction — typing in a form field, navigating, clicking unrelated buttons — that has an active userActivation will trigger navigator.clipboard.write() with a pending promise. When no _set_clipboard event follows, that write is rejected and .catch(() => {}) silently swallows it, so the clipboard is never modified. However, some WebKit/iOS versions surface a transient clipboard-access indicator even for in-flight writes that ultimately fail, which could be disorienting to users clicking unrelated elements. Consider checking whether the outgoing event batch actually contains or is likely to produce a _set_clipboard response before arming — or document that the arm-for-every-gesture trade-off was explicitly evaluated against iOS clipboard UI behaviour.


event_actions = _events.reduce(
(acc, e) => ({ ...acc, ...e.event_actions }),
event_actions ?? {},
Expand Down
17 changes: 11 additions & 6 deletions packages/reflex-base/src/reflex_base/event/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1482,18 +1482,23 @@ def remove_session_storage(key: str) -> EventSpec:
def set_clipboard(content: str | Var[str]) -> EventSpec:
"""Set the text in content in the clipboard.

On WebKit (Safari/iOS), ``navigator.clipboard.writeText`` must be invoked
within the user-activation window of the originating gesture. When this event
is returned from a backend handler it runs after a WebSocket round-trip, by
which point activation has expired. The frontend works around this by arming a
deferred ``ClipboardItem`` write during the gesture and resolving it with this
content when the event arrives; see ``armClipboard`` in ``utils/state.js``.

Args:
content: The text to add to clipboard.

Returns:
EventSpec: An event to set some content in the clipboard.
"""
return run_script(
Var("navigator")
.to(dict)
.clipboard.to(dict)
.writeText.to(FunctionVar)
.call(content)
return server_side(
"_set_clipboard",
inspect.signature(set_clipboard),
content=content,
)


Expand Down
81 changes: 81 additions & 0 deletions tests/integration/test_server_side_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ def set_value_return(self):
def set_value_return_c(self):
return rx.set_value("c", "")

@rx.event
def copy_secret(self):
return rx.set_clipboard("the secret")

app = rx.App()

@app.add_page
Expand Down Expand Up @@ -84,6 +88,11 @@ def index():
id="focus_input",
on_click=rx.set_focus("focus_target"),
),
rx.el.button(
"Copy secret",
id="copy_secret",
on_click=SSState.copy_secret,
),
)


Expand Down Expand Up @@ -210,3 +219,75 @@ def test_set_focus(driver):
driver.execute_script("return document.activeElement?.id") == "focus_target"
)
)


def test_set_clipboard(driver):
"""Call set_clipboard from a backend handler via the non-WebKit fallback path.

On a non-WebKit user agent, _set_clipboard writes directly via writeText. The
system clipboard is permission-gated and unavailable in headless CI, so
writeText is stubbed to capture the value, exercising the round-trip wiring
(backend handler -> _set_clipboard event -> clipboard write).

Args:
driver: selenium WebDriver open to the app
"""
btn = driver.find_element(By.ID, "copy_secret")
assert btn

driver.execute_script(
"window.__copied = null;"
"navigator.clipboard.writeText = (t) => { window.__copied = t; "
"return Promise.resolve(); };"
)
btn.click()

assert AppHarness.poll_for_or_raise_timeout(
lambda: driver.execute_script("return window.__copied") == "the secret"
)


# A WebKit user agent (no Chrome/Chromium/Edg token) so isWebKit() returns true
# and addEvents arms a deferred clipboard write, just as on Safari/iOS.
_SAFARI_USER_AGENT = (
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) "
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"
)


def test_set_clipboard_webkit_arming(driver):
"""Exercise the WebKit arm-and-resolve path for a backend-returned set_clipboard.

armClipboard is gated behind isWebKit() and needs ClipboardItem,
navigator.clipboard.write, and an active user activation. Chrome provides all
three, so spoofing the user agent makes headless Chrome run the same code path
Safari does: arm a deferred ClipboardItem write inside the click gesture, then
resolve it when the _set_clipboard event returns from the backend. ClipboardItem
and clipboard.write are stubbed to capture the deferred value, so this asserts
the arming branch ran (the writeText fallback is never touched).

Args:
driver: selenium WebDriver open to the app
"""
driver.execute_cdp_cmd(
"Network.setUserAgentOverride", {"userAgent": _SAFARI_USER_AGENT}
)

btn = driver.find_element(By.ID, "copy_secret")
assert btn

# Capture the deferred Blob promise handed to ClipboardItem; resolve it to text
# only once set_clipboard arms and the backend round-trip resolves it.
driver.execute_script("""
window.__armed = null;
window.ClipboardItem = function (data) { this._text = data['text/plain']; };
navigator.clipboard.write = async (items) => {
const blob = await items[0]._text;
window.__armed = await blob.text();
};
""")
btn.click()

assert AppHarness.poll_for_or_raise_timeout(
lambda: driver.execute_script("return window.__armed") == "the secret"
)
Loading
Loading