diff --git a/packages/reflex-base/news/6586.bugfix.md b/packages/reflex-base/news/6586.bugfix.md new file mode 100644 index 00000000000..76eda9e21e5 --- /dev/null +++ b/packages/reflex-base/news/6586.bugfix.md @@ -0,0 +1 @@ +Fix `rx.set_clipboard` failing with `NotAllowedError` on Safari and iOS when returned from a backend event handler. diff --git a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js index c07b4dfcbac..ced131833c9 100644 --- a/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js +++ b/packages/reflex-base/src/reflex_base/.templates/web/utils/state.js @@ -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. @@ -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" @@ -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(); + } + event_actions = _events.reduce( (acc, e) => ({ ...acc, ...e.event_actions }), event_actions ?? {}, diff --git a/packages/reflex-base/src/reflex_base/event/__init__.py b/packages/reflex-base/src/reflex_base/event/__init__.py index 97a8af96ba1..3d6e01b20b3 100644 --- a/packages/reflex-base/src/reflex_base/event/__init__.py +++ b/packages/reflex-base/src/reflex_base/event/__init__.py @@ -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, ) diff --git a/tests/integration/test_server_side_event.py b/tests/integration/test_server_side_event.py index 94ea793d135..bb486e9b2a5 100644 --- a/tests/integration/test_server_side_event.py +++ b/tests/integration/test_server_side_event.py @@ -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 @@ -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, + ), ) @@ -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" + ) diff --git a/tests/units/test_app.py b/tests/units/test_app.py index 2461ce18afc..2fdcb0e7684 100644 --- a/tests/units/test_app.py +++ b/tests/units/test_app.py @@ -2090,7 +2090,7 @@ def test_app_wrap_compile_theme( "return (" + ("jsx(StrictMode,{}," if react_strict_mode else "") + "jsx(ErrorBoundary,{" - """fallbackRender:((event_args) => (jsx("div", ({css:({ ["height"] : "100%", ["width"] : "100%", ["position"] : "absolute", ["backgroundColor"] : "#fff", ["color"] : "#000", ["display"] : "flex", ["alignItems"] : "center", ["justifyContent"] : "center" })}), (jsx("div", ({css:({ ["display"] : "flex", ["flexDirection"] : "column", ["gap"] : "0.5rem", ["maxWidth"] : "min(80ch, 90vw)", ["borderRadius"] : "0.25rem", ["padding"] : "1rem" })}), (jsx("div", ({css:({ ["opacity"] : "0.5", ["display"] : "flex", ["gap"] : "4vmin", ["alignItems"] : "center" })}), (jsx("svg", ({className:"lucide lucide-frown-icon lucide-frown",fill:"none",stroke:"currentColor","stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",viewBox:"0 0 24 24",width:"25vmin",xmlns:"http://www.w3.org/2000/svg"}), (jsx("circle", ({cx:"12",cy:"12",r:"10"}))), (jsx("path", ({d:"M16 16s-1.5-2-4-2-4 2-4 2"}))), (jsx("line", ({x1:"9",x2:"9.01",y1:"9",y2:"9"}))), (jsx("line", ({x1:"15",x2:"15.01",y1:"9",y2:"9"}))))), (jsx("h2", ({css:({ ["fontSize"] : "5vmin", ["fontWeight"] : "bold" })}), "An error occurred while rendering this page.")))), (jsx("p", ({css:({ ["opacity"] : "0.75", ["marginBlock"] : "1rem" })}), "This is an error with the application itself. Refreshing the page might help.")), (jsx("div", ({css:({ ["width"] : "100%", ["background"] : "color-mix(in srgb, currentColor 5%, transparent)", ["maxHeight"] : "15rem", ["overflow"] : "auto", ["borderRadius"] : "0.4rem" })}), (jsx("div", ({css:({ ["padding"] : "0.5rem" })}), (jsx("pre", ({css:({ ["wordBreak"] : "break-word", ["whiteSpace"] : "pre-wrap" })}), event_args.error.name + \': \' + event_args.error.message + \'\\n\' + event_args.error.stack)))))), (jsx("button", ({css:({ ["padding"] : "0.35rem 1.35rem", ["marginBlock"] : "0.5rem", ["marginInlineStart"] : "auto", ["background"] : "color-mix(in srgb, currentColor 15%, transparent)", ["borderRadius"] : "0.4rem", ["width"] : "fit-content", ["&:hover"] : ({ ["background"] : "color-mix(in srgb, currentColor 25%, transparent)" }), ["&:active"] : ({ ["background"] : "color-mix(in srgb, currentColor 35%, transparent)" }) }),onClick:((_e) => (addEvents([(ReflexEvent("_call_function", ({ ["function"] : (() => (navigator?.["clipboard"]?.["writeText"](event_args.error.name + \': \' + event_args.error.message + \'\\n\' + event_args.error.stack))), ["callback"] : null }), ({ })))], [_e], ({ }))))}), "Copy")), (jsx("hr", ({css:({ ["borderColor"] : "currentColor", ["opacity"] : "0.25" })}))), (jsx(ReactRouterLink, ({to:"https://reflex.dev"}), (jsx("div", ({css:({ ["display"] : "flex", ["alignItems"] : "baseline", ["justifyContent"] : "center", ["fontFamily"] : "monospace", ["--default-font-family"] : "monospace", ["gap"] : "0.5rem" })}), "Built with ", (jsx("svg", ({"aria-label":"Reflex",css:({ ["fill"] : "currentColor" }),height:"12",role:"img",width:"56",xmlns:"http://www.w3.org/2000/svg"}), (jsx("path", ({d:"M0 11.5999V0.399902H8.96V4.8799H6.72V2.6399H2.24V4.8799H6.72V7.1199H2.24V11.5999H0ZM6.72 11.5999V7.1199H8.96V11.5999H6.72Z"}))), (jsx("path", ({d:"M11.2 11.5999V0.399902H17.92V2.6399H13.44V4.8799H17.92V7.1199H13.44V9.3599H17.92V11.5999H11.2Z"}))), (jsx("path", ({d:"M20.16 11.5999V0.399902H26.88V2.6399H22.4V4.8799H26.88V7.1199H22.4V11.5999H20.16Z"}))), (jsx("path", ({d:"M29.12 11.5999V0.399902H31.36V9.3599H35.84V11.5999H29.12Z"}))), (jsx("path", ({d:"M38.08 11.5999V0.399902H44.8V2.6399H40.32V4.8799H44.8V7.1199H40.32V9.3599H44.8V11.5999H38.08Z"}))), (jsx("path", ({d:"M47.04 4.8799V0.399902H49.28V4.8799H47.04ZM53.76 4.8799V0.399902H56V4.8799H53.76ZM49.28 7.1199V4.8799H53.76V7.1199H49.28ZM47.04 11.5999V7.1199H49.28V11.5999H47.04ZM53.76 11.5999V7.1199H56V11.5999H53.76Z"}))), (jsx("title", ({}), "Reflex"))))))))))))),""" + """fallbackRender:((event_args) => (jsx("div", ({css:({ ["height"] : "100%", ["width"] : "100%", ["position"] : "absolute", ["backgroundColor"] : "#fff", ["color"] : "#000", ["display"] : "flex", ["alignItems"] : "center", ["justifyContent"] : "center" })}), (jsx("div", ({css:({ ["display"] : "flex", ["flexDirection"] : "column", ["gap"] : "0.5rem", ["maxWidth"] : "min(80ch, 90vw)", ["borderRadius"] : "0.25rem", ["padding"] : "1rem" })}), (jsx("div", ({css:({ ["opacity"] : "0.5", ["display"] : "flex", ["gap"] : "4vmin", ["alignItems"] : "center" })}), (jsx("svg", ({className:"lucide lucide-frown-icon lucide-frown",fill:"none",stroke:"currentColor","stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",viewBox:"0 0 24 24",width:"25vmin",xmlns:"http://www.w3.org/2000/svg"}), (jsx("circle", ({cx:"12",cy:"12",r:"10"}))), (jsx("path", ({d:"M16 16s-1.5-2-4-2-4 2-4 2"}))), (jsx("line", ({x1:"9",x2:"9.01",y1:"9",y2:"9"}))), (jsx("line", ({x1:"15",x2:"15.01",y1:"9",y2:"9"}))))), (jsx("h2", ({css:({ ["fontSize"] : "5vmin", ["fontWeight"] : "bold" })}), "An error occurred while rendering this page.")))), (jsx("p", ({css:({ ["opacity"] : "0.75", ["marginBlock"] : "1rem" })}), "This is an error with the application itself. Refreshing the page might help.")), (jsx("div", ({css:({ ["width"] : "100%", ["background"] : "color-mix(in srgb, currentColor 5%, transparent)", ["maxHeight"] : "15rem", ["overflow"] : "auto", ["borderRadius"] : "0.4rem" })}), (jsx("div", ({css:({ ["padding"] : "0.5rem" })}), (jsx("pre", ({css:({ ["wordBreak"] : "break-word", ["whiteSpace"] : "pre-wrap" })}), event_args.error.name + \': \' + event_args.error.message + \'\\n\' + event_args.error.stack)))))), (jsx("button", ({css:({ ["padding"] : "0.35rem 1.35rem", ["marginBlock"] : "0.5rem", ["marginInlineStart"] : "auto", ["background"] : "color-mix(in srgb, currentColor 15%, transparent)", ["borderRadius"] : "0.4rem", ["width"] : "fit-content", ["&:hover"] : ({ ["background"] : "color-mix(in srgb, currentColor 25%, transparent)" }), ["&:active"] : ({ ["background"] : "color-mix(in srgb, currentColor 35%, transparent)" }) }),onClick:((_e) => (addEvents([(ReflexEvent("_set_clipboard", ({ ["content"] : event_args.error.name + \': \' + event_args.error.message + \'\\n\' + event_args.error.stack }), ({ })))], [_e], ({ }))))}), "Copy")), (jsx("hr", ({css:({ ["borderColor"] : "currentColor", ["opacity"] : "0.25" })}))), (jsx(ReactRouterLink, ({to:"https://reflex.dev"}), (jsx("div", ({css:({ ["display"] : "flex", ["alignItems"] : "baseline", ["justifyContent"] : "center", ["fontFamily"] : "monospace", ["--default-font-family"] : "monospace", ["gap"] : "0.5rem" })}), "Built with ", (jsx("svg", ({"aria-label":"Reflex",css:({ ["fill"] : "currentColor" }),height:"12",role:"img",width:"56",xmlns:"http://www.w3.org/2000/svg"}), (jsx("path", ({d:"M0 11.5999V0.399902H8.96V4.8799H6.72V2.6399H2.24V4.8799H6.72V7.1199H2.24V11.5999H0ZM6.72 11.5999V7.1199H8.96V11.5999H6.72Z"}))), (jsx("path", ({d:"M11.2 11.5999V0.399902H17.92V2.6399H13.44V4.8799H17.92V7.1199H13.44V9.3599H17.92V11.5999H11.2Z"}))), (jsx("path", ({d:"M20.16 11.5999V0.399902H26.88V2.6399H22.4V4.8799H26.88V7.1199H22.4V11.5999H20.16Z"}))), (jsx("path", ({d:"M29.12 11.5999V0.399902H31.36V9.3599H35.84V11.5999H29.12Z"}))), (jsx("path", ({d:"M38.08 11.5999V0.399902H44.8V2.6399H40.32V4.8799H44.8V7.1199H40.32V9.3599H44.8V11.5999H38.08Z"}))), (jsx("path", ({d:"M47.04 4.8799V0.399902H49.28V4.8799H47.04ZM53.76 4.8799V0.399902H56V4.8799H53.76ZM49.28 7.1199V4.8799H53.76V7.1199H49.28ZM47.04 11.5999V7.1199H49.28V11.5999H47.04ZM53.76 11.5999V7.1199H56V11.5999H53.76Z"}))), (jsx("title", ({}), "Reflex"))))))))))))),""" """onError:((_error, _info) => (addEvents([(ReflexEvent("reflex___state____state.reflex___state____frontend_event_exception_state.handle_frontend_exception", ({ ["info"] : ((((_error?.["name"]+": ")+_error?.["message"])+"\\n")+_error?.["stack"]), ["component_stack"] : _info?.["componentStack"] }), ({ })))], [_error, _info], ({ }))))""" + "}," + "jsx(RadixThemesColorModeProvider,{}," @@ -2335,7 +2335,7 @@ def page(): + ("jsx(StrictMode,{}," if react_strict_mode else "") + "jsx(RadixThemesBox,{}," "jsx(ErrorBoundary,{" - """fallbackRender:((event_args) => (jsx("div", ({css:({ ["height"] : "100%", ["width"] : "100%", ["position"] : "absolute", ["backgroundColor"] : "#fff", ["color"] : "#000", ["display"] : "flex", ["alignItems"] : "center", ["justifyContent"] : "center" })}), (jsx("div", ({css:({ ["display"] : "flex", ["flexDirection"] : "column", ["gap"] : "0.5rem", ["maxWidth"] : "min(80ch, 90vw)", ["borderRadius"] : "0.25rem", ["padding"] : "1rem" })}), (jsx("div", ({css:({ ["opacity"] : "0.5", ["display"] : "flex", ["gap"] : "4vmin", ["alignItems"] : "center" })}), (jsx("svg", ({className:"lucide lucide-frown-icon lucide-frown",fill:"none",stroke:"currentColor","stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",viewBox:"0 0 24 24",width:"25vmin",xmlns:"http://www.w3.org/2000/svg"}), (jsx("circle", ({cx:"12",cy:"12",r:"10"}))), (jsx("path", ({d:"M16 16s-1.5-2-4-2-4 2-4 2"}))), (jsx("line", ({x1:"9",x2:"9.01",y1:"9",y2:"9"}))), (jsx("line", ({x1:"15",x2:"15.01",y1:"9",y2:"9"}))))), (jsx("h2", ({css:({ ["fontSize"] : "5vmin", ["fontWeight"] : "bold" })}), "An error occurred while rendering this page.")))), (jsx("p", ({css:({ ["opacity"] : "0.75", ["marginBlock"] : "1rem" })}), "This is an error with the application itself. Refreshing the page might help.")), (jsx("div", ({css:({ ["width"] : "100%", ["background"] : "color-mix(in srgb, currentColor 5%, transparent)", ["maxHeight"] : "15rem", ["overflow"] : "auto", ["borderRadius"] : "0.4rem" })}), (jsx("div", ({css:({ ["padding"] : "0.5rem" })}), (jsx("pre", ({css:({ ["wordBreak"] : "break-word", ["whiteSpace"] : "pre-wrap" })}), event_args.error.name + \': \' + event_args.error.message + \'\\n\' + event_args.error.stack)))))), (jsx("button", ({css:({ ["padding"] : "0.35rem 1.35rem", ["marginBlock"] : "0.5rem", ["marginInlineStart"] : "auto", ["background"] : "color-mix(in srgb, currentColor 15%, transparent)", ["borderRadius"] : "0.4rem", ["width"] : "fit-content", ["&:hover"] : ({ ["background"] : "color-mix(in srgb, currentColor 25%, transparent)" }), ["&:active"] : ({ ["background"] : "color-mix(in srgb, currentColor 35%, transparent)" }) }),onClick:((_e) => (addEvents([(ReflexEvent("_call_function", ({ ["function"] : (() => (navigator?.["clipboard"]?.["writeText"](event_args.error.name + \': \' + event_args.error.message + \'\\n\' + event_args.error.stack))), ["callback"] : null }), ({ })))], [_e], ({ }))))}), "Copy")), (jsx("hr", ({css:({ ["borderColor"] : "currentColor", ["opacity"] : "0.25" })}))), (jsx(ReactRouterLink, ({to:"https://reflex.dev"}), (jsx("div", ({css:({ ["display"] : "flex", ["alignItems"] : "baseline", ["justifyContent"] : "center", ["fontFamily"] : "monospace", ["--default-font-family"] : "monospace", ["gap"] : "0.5rem" })}), "Built with ", (jsx("svg", ({"aria-label":"Reflex",css:({ ["fill"] : "currentColor" }),height:"12",role:"img",width:"56",xmlns:"http://www.w3.org/2000/svg"}), (jsx("path", ({d:"M0 11.5999V0.399902H8.96V4.8799H6.72V2.6399H2.24V4.8799H6.72V7.1199H2.24V11.5999H0ZM6.72 11.5999V7.1199H8.96V11.5999H6.72Z"}))), (jsx("path", ({d:"M11.2 11.5999V0.399902H17.92V2.6399H13.44V4.8799H17.92V7.1199H13.44V9.3599H17.92V11.5999H11.2Z"}))), (jsx("path", ({d:"M20.16 11.5999V0.399902H26.88V2.6399H22.4V4.8799H26.88V7.1199H22.4V11.5999H20.16Z"}))), (jsx("path", ({d:"M29.12 11.5999V0.399902H31.36V9.3599H35.84V11.5999H29.12Z"}))), (jsx("path", ({d:"M38.08 11.5999V0.399902H44.8V2.6399H40.32V4.8799H44.8V7.1199H40.32V9.3599H44.8V11.5999H38.08Z"}))), (jsx("path", ({d:"M47.04 4.8799V0.399902H49.28V4.8799H47.04ZM53.76 4.8799V0.399902H56V4.8799H53.76ZM49.28 7.1199V4.8799H53.76V7.1199H49.28ZM47.04 11.5999V7.1199H49.28V11.5999H47.04ZM53.76 11.5999V7.1199H56V11.5999H53.76Z"}))), (jsx("title", ({}), "Reflex"))))))))))))),""" + """fallbackRender:((event_args) => (jsx("div", ({css:({ ["height"] : "100%", ["width"] : "100%", ["position"] : "absolute", ["backgroundColor"] : "#fff", ["color"] : "#000", ["display"] : "flex", ["alignItems"] : "center", ["justifyContent"] : "center" })}), (jsx("div", ({css:({ ["display"] : "flex", ["flexDirection"] : "column", ["gap"] : "0.5rem", ["maxWidth"] : "min(80ch, 90vw)", ["borderRadius"] : "0.25rem", ["padding"] : "1rem" })}), (jsx("div", ({css:({ ["opacity"] : "0.5", ["display"] : "flex", ["gap"] : "4vmin", ["alignItems"] : "center" })}), (jsx("svg", ({className:"lucide lucide-frown-icon lucide-frown",fill:"none",stroke:"currentColor","stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",viewBox:"0 0 24 24",width:"25vmin",xmlns:"http://www.w3.org/2000/svg"}), (jsx("circle", ({cx:"12",cy:"12",r:"10"}))), (jsx("path", ({d:"M16 16s-1.5-2-4-2-4 2-4 2"}))), (jsx("line", ({x1:"9",x2:"9.01",y1:"9",y2:"9"}))), (jsx("line", ({x1:"15",x2:"15.01",y1:"9",y2:"9"}))))), (jsx("h2", ({css:({ ["fontSize"] : "5vmin", ["fontWeight"] : "bold" })}), "An error occurred while rendering this page.")))), (jsx("p", ({css:({ ["opacity"] : "0.75", ["marginBlock"] : "1rem" })}), "This is an error with the application itself. Refreshing the page might help.")), (jsx("div", ({css:({ ["width"] : "100%", ["background"] : "color-mix(in srgb, currentColor 5%, transparent)", ["maxHeight"] : "15rem", ["overflow"] : "auto", ["borderRadius"] : "0.4rem" })}), (jsx("div", ({css:({ ["padding"] : "0.5rem" })}), (jsx("pre", ({css:({ ["wordBreak"] : "break-word", ["whiteSpace"] : "pre-wrap" })}), event_args.error.name + \': \' + event_args.error.message + \'\\n\' + event_args.error.stack)))))), (jsx("button", ({css:({ ["padding"] : "0.35rem 1.35rem", ["marginBlock"] : "0.5rem", ["marginInlineStart"] : "auto", ["background"] : "color-mix(in srgb, currentColor 15%, transparent)", ["borderRadius"] : "0.4rem", ["width"] : "fit-content", ["&:hover"] : ({ ["background"] : "color-mix(in srgb, currentColor 25%, transparent)" }), ["&:active"] : ({ ["background"] : "color-mix(in srgb, currentColor 35%, transparent)" }) }),onClick:((_e) => (addEvents([(ReflexEvent("_set_clipboard", ({ ["content"] : event_args.error.name + \': \' + event_args.error.message + \'\\n\' + event_args.error.stack }), ({ })))], [_e], ({ }))))}), "Copy")), (jsx("hr", ({css:({ ["borderColor"] : "currentColor", ["opacity"] : "0.25" })}))), (jsx(ReactRouterLink, ({to:"https://reflex.dev"}), (jsx("div", ({css:({ ["display"] : "flex", ["alignItems"] : "baseline", ["justifyContent"] : "center", ["fontFamily"] : "monospace", ["--default-font-family"] : "monospace", ["gap"] : "0.5rem" })}), "Built with ", (jsx("svg", ({"aria-label":"Reflex",css:({ ["fill"] : "currentColor" }),height:"12",role:"img",width:"56",xmlns:"http://www.w3.org/2000/svg"}), (jsx("path", ({d:"M0 11.5999V0.399902H8.96V4.8799H6.72V2.6399H2.24V4.8799H6.72V7.1199H2.24V11.5999H0ZM6.72 11.5999V7.1199H8.96V11.5999H6.72Z"}))), (jsx("path", ({d:"M11.2 11.5999V0.399902H17.92V2.6399H13.44V4.8799H17.92V7.1199H13.44V9.3599H17.92V11.5999H11.2Z"}))), (jsx("path", ({d:"M20.16 11.5999V0.399902H26.88V2.6399H22.4V4.8799H26.88V7.1199H22.4V11.5999H20.16Z"}))), (jsx("path", ({d:"M29.12 11.5999V0.399902H31.36V9.3599H35.84V11.5999H29.12Z"}))), (jsx("path", ({d:"M38.08 11.5999V0.399902H44.8V2.6399H40.32V4.8799H44.8V7.1199H40.32V9.3599H44.8V11.5999H38.08Z"}))), (jsx("path", ({d:"M47.04 4.8799V0.399902H49.28V4.8799H47.04ZM53.76 4.8799V0.399902H56V4.8799H53.76ZM49.28 7.1199V4.8799H53.76V7.1199H49.28ZM47.04 11.5999V7.1199H49.28V11.5999H47.04ZM53.76 11.5999V7.1199H56V11.5999H53.76Z"}))), (jsx("title", ({}), "Reflex"))))))))))))),""" """onError:((_error, _info) => (addEvents([(ReflexEvent("reflex___state____state.reflex___state____frontend_event_exception_state.handle_frontend_exception", ({ ["info"] : ((((_error?.["name"]+": ")+_error?.["message"])+"\\n")+_error?.["stack"]), ["component_stack"] : _info?.["componentStack"] }), ({ })))], [_error, _info], ({ }))))""" + "}," + 'jsx(RadixThemesText,{as:"p"},' diff --git a/tests/units/test_event.py b/tests/units/test_event.py index 500dd542097..37f5951908c 100644 --- a/tests/units/test_event.py +++ b/tests/units/test_event.py @@ -328,6 +328,27 @@ def test_set_value(): ) +def test_set_clipboard(): + """Test set_clipboard emits a _set_clipboard event carrying the content. + + The frontend resolves this against a clipboard write armed during the user + gesture, which is required for set_clipboard to work on WebKit when returned + from a backend handler. + """ + spec = event.set_clipboard("hello") + assert isinstance(spec, EventSpec) + assert spec.handler.fn.__qualname__ == "_set_clipboard" + assert spec.args[0][0].equals(Var(_js_expr="content")) + assert spec.args[0][1].equals(LiteralVar.create("hello")) + assert ( + format.format_event(spec) == 'ReflexEvent("_set_clipboard", {content:"hello"})' + ) + spec = event.set_clipboard(Var(_js_expr="message")) + assert ( + format.format_event(spec) == 'ReflexEvent("_set_clipboard", {content:message})' + ) + + def test_remove_cookie(): """Test the event remove_cookie.""" spec = event.remove_cookie("testkey")