Client actions: perform gesture-gated work without a round trip to Python - #6803
Open
FeodorFitsner wants to merge 10 commits into
Open
Client actions: perform gesture-gated work without a round trip to Python#6803FeodorFitsner wants to merge 10 commits into
FeodorFitsner wants to merge 10 commits into
Conversation
Browsers permit gesture-gated APIs - opening a file picker, writing to the clipboard, navigator.share, window.open - only while user activation is live, and activation does not survive an async gap. Flet's services round-trip through Python, so those calls run in a websocket message handler with no activation: silently ignored on iOS Safari, allowed by the more lenient Chrome and Firefox. Button.url already avoided this by being declarative and running inside onPressed. This generalizes that into a client action: a control's new 'action' property names a service method the client invokes itself, synchronously, from the gesture callback. runClientActions() resolves the target service via controlsIndex and calls invokeMethod() without awaiting. Control.hasInvokeMethodListeners guards the case where the service is not mounted yet, since invokeMethod() would otherwise await a listener and consume the gesture. url behavior is unchanged - it moves into the shared runControlActions() helper alongside actions.
PickFiles is the fix for #3710: it opens the file dialog from inside the tap, which is the only moment WebKit permits one. The selection cannot be returned to a caller that does not exist, so it arrives at the new FilePicker.on_result event; the files stay on the FilePicker service, so upload() is unchanged. The dispatcher marks gesture-initiated invocations with _from_gesture so a service can tell the two entry points apart. FilePickerService uses it twice: to emit on_result only for the gesture path, and to fail fast when pick_files() is called from Python on a browser that has already reported it will not open the dialog - previously that hung until the 3600s invoke-method timeout with no error at all. isGestureGatedDialogBlocked() only reports true for Apple/WebKit browsers with no live user activation. Chrome and Firefox allow programmatic file input clicks outside a gesture, so treating them as blocked would break apps that work today.
Adds the Client actions cookbook article, reference pages for ClientAction, OpenUrl, CopyToClipboard, ShareText, PickFiles and FilePickerResultEvent, and one runnable example per action alongside the existing examples for the service it exercises. The FilePicker, Clipboard, Share and UrlLauncher pages each gain a warning explaining why the imperative call does nothing in a web app on iOS, and show the action form instead. The FilePicker usage snippet also had an unterminated string.
The Dart side already calls this enum MessageAction - protocol/message.dart - and the Python docstring said so, which left the two languages naming the same wire format differently. It also covers server-to-client frames (PATCH_CONTROL, SESSION_CRASHED) as much as client-to-server ones, so 'client action' was never quite right. The name is now needed elsewhere: ft.ClientAction is the public base class for actions a control performs on the client. Both are unambiguous in their own module, but two classes with one name is a trap for whoever edits them next. Internal throughout - the enum has never been exported from flet.
Completes the alignment started by the MessageAction rename. Dart calls this frame Message (protocol/message.dart), so leaving the Python class as ClientMessage left the pair half-matched - a reader who learns MessageAction maps to MessageAction would reasonably assume ClientMessage maps to ClientMessage, and be wrong. 'Client' was inaccurate for the same reason it was on the enum: the frame carries PATCH_CONTROL and SESSION_CRASHED, which travel to the client, as often as it carries events coming from one. The 'body' field keeps its name. Dart calls it payload, but body anchors PatchControlBody, InvokeMethodRequestBody and SessionCrashedBody, so renaming it would either cascade through all of them or trade one mismatch for a worse one. The class docstring used both words for that single field; it now says body throughout. Internal - neither name has ever been exported from flet.
UrlTarget.BLANK serialized as "blank" while SELF, PARENT and TOP all carry the leading underscore the HTML spec defines. window.open() treats an unrecognized target as an ordinary window *name*, so the first BLANK link opened a tab called "blank" and every later one reused it - close enough to working that it went unnoticed. The same mismatch meant openWebBrowser()'s check for "_blank" never matched, so BLANK never upgraded to LaunchMode.externalApplication on non-web platforms. Introduced in #5382; not in any stable release, so the enum value is simply corrected rather than accepting both spellings.
Ten controls carried an identical action field and a nine-line docstring. Follows the AdaptiveControl pattern exactly: a @control(kw_only=True) base contributing one field to whichever controls opt in. kw_only matters here - without it the inherited field would take the first positional slot and ft.Button("Open") would stop binding to content. TextSpan inherits ActionControl directly rather than listing it after Control, which ActionControl already derives from. Net -111 lines across the ten controls, and one place to edit when the wording changes.
_shared_service() cached the services actions target in page._internals,
keyed by service class. _internals is not skipped by the msgpack encoder -
it is how Button ships its resolved style to the client - so the cache
went on the wire and the encoder tried to read _i off a class object:
AttributeError: type object 'Clipboard' has no attribute '_i'
Any page holding a CopyToClipboard, OpenUrl or ShareText action failed to
render at all.
The cache is now a module-level WeakKeyDictionary keyed by page, so it
never touches control state, and the page is released normally.
Adds tests/test_client_actions.py, which covers the serialization path
through page.add() rather than the action object alone - the earlier
check stubbed out _shared_service and so exercised none of this.
Makes the 'Open in this tab' button's intent explicit by passing `target=ft.UrlTarget.SELF` to `ft.OpenUrl`.
Contributor
There was a problem hiding this comment.
Pull request overview
This PR introduces client actions so gesture-gated browser operations (file picker, clipboard write, share sheet, opening new tabs) can execute synchronously on the client during the originating user gesture, avoiding iOS/WebKit popup/permission blocking and silent no-ops.
Changes:
- Add Python
ClientActionAPI (OpenUrl,CopyToClipboard,PickFiles,ShareText) andActionControl.actionsupport across existing “URL-capable” controls, plusFilePicker.on_resultevent for gesture-initiated picks. - Add Dart-side synchronous dispatch (
runClientActions/runControlActions) and supporting plumbing (hasInvokeMethodListeners, WebKit gesture-gate detection, FilePicker gesture path result event). - Rename internal Python protocol types (
ClientAction→MessageAction,ClientMessage→Message), add docs/examples, and add serialization-focused tests.
Reviewed changes
Copilot reviewed 62 out of 63 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| website/sidebars.yml | Adds navigation entries for client actions docs/types. |
| website/docs/types/sharetext.md | New API reference stub for ShareText. |
| website/docs/types/pickfiles.md | New API reference stub for PickFiles. |
| website/docs/types/openurl.md | New API reference stub for OpenUrl. |
| website/docs/types/filepickerresultevent.md | New API reference stub for FilePickerResultEvent. |
| website/docs/types/copytoclipboard.md | New API reference stub for CopyToClipboard. |
| website/docs/types/clientaction.md | New API reference stub for ClientAction. |
| website/docs/services/urllauncher.md | Adds web/iOS note and client-action example section. |
| website/docs/services/share.md | Adds web warning and client-action example section. |
| website/docs/services/filepicker.md | Adds web/iOS warning and client-action picking section. |
| website/docs/services/clipboard.md | Adds web warning and client-action example section. |
| website/docs/cookbook/client-actions.md | New cookbook page explaining gesture-gated ops and actions. |
| website/docs/controls/actioncontrol.md | New reference page for ActionControl. |
| sdk/python/packages/flet/tests/test_use_dialog.py | Updates protocol enum rename usage in tests. |
| sdk/python/packages/flet/tests/test_session_disconnect_buffering.py | Updates protocol message/enum rename usage in tests. |
| sdk/python/packages/flet/tests/test_client_actions.py | New tests covering action serialization and control carriage. |
| sdk/python/packages/flet/src/flet/messaging/session.py | Switches to Message / MessageAction types. |
| sdk/python/packages/flet/src/flet/messaging/pyodide_connection.py | Switches to Message / MessageAction types. |
| sdk/python/packages/flet/src/flet/messaging/protocol.py | Renames protocol frame types and updates docs/typing. |
| sdk/python/packages/flet/src/flet/messaging/flet_socket_server.py | Switches to Message / MessageAction types. |
| sdk/python/packages/flet/src/flet/messaging/flet_dart_bridge_server.py | Switches to Message / MessageAction types. |
| sdk/python/packages/flet/src/flet/messaging/connection.py | Updates connection interface type to Message. |
| sdk/python/packages/flet/src/flet/controls/types.py | Fixes UrlTarget.BLANK to \"_blank\". |
| sdk/python/packages/flet/src/flet/controls/services/file_picker.py | Adds FilePickerResultEvent + FilePicker.on_result. |
| sdk/python/packages/flet/src/flet/controls/material/text_button.py | Adds ActionControl inheritance for action support. |
| sdk/python/packages/flet/src/flet/controls/material/outlined_button.py | Adds ActionControl inheritance for action support. |
| sdk/python/packages/flet/src/flet/controls/material/list_tile.py | Adds ActionControl inheritance for action support. |
| sdk/python/packages/flet/src/flet/controls/material/icon_button.py | Adds ActionControl inheritance for action support. |
| sdk/python/packages/flet/src/flet/controls/material/floating_action_button.py | Adds ActionControl inheritance for action support. |
| sdk/python/packages/flet/src/flet/controls/material/container.py | Adds ActionControl inheritance for action support. |
| sdk/python/packages/flet/src/flet/controls/material/button.py | Adds ActionControl inheritance for action support. |
| sdk/python/packages/flet/src/flet/controls/cupertino/cupertino_list_tile.py | Adds ActionControl inheritance for action support. |
| sdk/python/packages/flet/src/flet/controls/cupertino/cupertino_button.py | Adds ActionControl inheritance for action support. |
| sdk/python/packages/flet/src/flet/controls/core/text_span.py | Moves TextSpan to ActionControl base for actions. |
| sdk/python/packages/flet/src/flet/controls/client_action.py | New Python client-action types and per-page shared service cache. |
| sdk/python/packages/flet/src/flet/controls/action_control.py | New ActionControl base adding action property (kw-only). |
| sdk/python/packages/flet/src/flet/init.py | Exports new action APIs and FilePickerResultEvent. |
| sdk/python/packages/flet-web/src/flet_web/fastapi/flet_app.py | Updates protocol enum/message type rename usage. |
| sdk/python/examples/services/url_launcher/open_url_action/pyproject.toml | New runnable example project metadata for OpenUrl action. |
| sdk/python/examples/services/url_launcher/open_url_action/main.py | New runnable example using OpenUrl action. |
| sdk/python/examples/services/share/share_text_action/pyproject.toml | New runnable example project metadata for ShareText action. |
| sdk/python/examples/services/share/share_text_action/main.py | New runnable example using ShareText action. |
| sdk/python/examples/services/file_picker/pick_files_action/pyproject.toml | New runnable example project metadata for PickFiles action. |
| sdk/python/examples/services/file_picker/pick_files_action/main.py | New runnable example using PickFiles + on_result + upload. |
| sdk/python/examples/services/clipboard/copy_action/pyproject.toml | New runnable example project metadata for CopyToClipboard action. |
| sdk/python/examples/services/clipboard/copy_action/main.py | New runnable example using CopyToClipboard action. |
| packages/flet/lib/src/utils/text.dart | Adds optional BuildContext to enable span actions; runs actions on tap. |
| packages/flet/lib/src/utils/platform_utils_web.dart | Adds isGestureGatedDialogBlocked() for WebKit userActivation gating. |
| packages/flet/lib/src/utils/platform_utils_non_web.dart | Adds non-web stub for isGestureGatedDialogBlocked(). |
| packages/flet/lib/src/utils/client_actions.dart | New client-side dispatcher for url+actions (runClientActions / runControlActions). |
| packages/flet/lib/src/services/file_picker.dart | Adds gesture-path handling, fail-fast gating check, emits result event. |
| packages/flet/lib/src/models/control.dart | Adds hasInvokeMethodListeners to avoid async gaps for gestures. |
| packages/flet/lib/src/controls/text.dart | Passes context to text span parser for action support. |
| packages/flet/lib/src/controls/list_tile.dart | Routes activation through runControlActions(). |
| packages/flet/lib/src/controls/icon_button.dart | Routes activation through runControlActions(). |
| packages/flet/lib/src/controls/floating_action_button.dart | Routes activation through runControlActions(). |
| packages/flet/lib/src/controls/cupertino_list_tile.dart | Routes activation through runControlActions(). |
| packages/flet/lib/src/controls/cupertino_button.dart | Routes activation through runControlActions(). |
| packages/flet/lib/src/controls/container.dart | Routes activation through runControlActions() and uses hasControlActions. |
| packages/flet/lib/src/controls/button.dart | Routes activation through runControlActions(). |
| packages/flet/CHANGELOG.md | Documents new Dart-side client-action infrastructure and FilePicker changes. |
| CHANGELOG.md | Adds release notes for client actions, FilePicker on_result, and UrlTarget fix. |
| .gitignore | Ignores uploaded example files under sdk/python/examples/**/examples/. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
+43
to
+44
| page = context.page | ||
| services = _shared_services.setdefault(page, {}) |
ndonkoHenri
reviewed
Aug 30, 2026
|
|
||
|
|
||
| @dataclass | ||
| class PickFiles(ClientAction): |
Contributor
There was a problem hiding this comment.
Will it make sense to group these new client action classes with the existing ones they belong to?
For example PickFiles in file_picker.py?
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #3710. Fixes #1579.
The problem
Browsers only let a page open a file picker, write to the clipboard, show a share sheet or open a new tab while they are still handling the user's click or key press. Sending that click to Python and acting on the reply takes longer than the permission lasts.
WebKit enforces this and reports nothing; Chrome and Firefox allow the same calls. So an app worked on Android and desktop and silently did nothing on iPhone and iPad — which made a browser rule look like a Flet bug.
FilePickerlooked especially broken becausesave_file()kept working: it clicks a download link, which isn't gated.Button.urlalready avoided this by being declarative —openWebBrowser(url)runs insideonPressed, before anything reaches Python. This generalizes that.The approach
A client action names a service method the client invokes itself, synchronously, from the gesture callback:
Two findings kept this small.
Control.invokeMethodcalls its listener synchronously when one is registered, and every service's_invokeMethodreaches its gated call before its own firstawait. So one dispatcher reuses all existing service code — no per-action Dart handler, no registry, no duplicated logic.FilePickerService._filesanduploadFilesare untouched, soupload()works exactly as before.What's included
API —
ft.OpenUrl,ft.CopyToClipboard,ft.PickFiles,ft.ShareText, and anactionproperty on the ten controls that carryurltoday.ClientActionis the public base class for typing; the underlying(service, method, args)triple stays internal.ActionControl(a@control(kw_only=True)base, followingAdaptiveControl) contributes the property to all ten controls — ~110 lines lighter than repeating it, with one place to edit the docs.kw_onlymatters: without it the inherited field would claim the first positional slot andft.Button("Open")would stop binding tocontent.FilePicker.on_result— a gesture-initiated pick has no caller to return to, so the selection arrives as an event. Picked files stay on theFilePicker, so they pass straight toupload().Fail-fast —
pick_files()called from Python on a browser that has already reported it won't open a dialog now raises immediately instead of hanging for its full one-hour timeout. Gated to Apple/WebKit with no livenavigator.userActivation, because Chrome and Firefox permit programmatic file-input clicks outside a gesture and treating them as blocked would break apps that work today.Bug fix:
UrlTarget.BLANK— its value was"blank"whileSELF/PARENT/TOPall carry the spec's leading underscore, so it reachedwindow.open()as an ordinary window name: the first such link opened a tab calledblankand every later one reused it. TheLaunchMode.externalApplicationupgrade never fired either. Introduced in #5382, never released.Two internal renames — the protocol enum
ClientAction→MessageActionandClientMessage→Message, matching the Dart names they were already documented as mirroring, and freeingClientActionfor the public class. Neither was ever exported.Docs — a Client actions cookbook article, reference pages for all new types, and one runnable example per action beside its service's existing examples. The
FilePicker,Clipboard,ShareandUrlLauncherpages each gain a warning explaining why the imperative call does nothing in a web app on iOS.Testing
New
tests/test_client_actions.py(8 tests) covers serialization throughpage.add()rather than the action object alone — that path caught a real bug where the service cache lived inpage._internals, which is sent to the client, so a page holding any action failed to render withAttributeError: type object 'Clipboard' has no attribute '_i'. Confirmed the test fails without the fix. Full suite: 255 passed, 8 skipped. Dart analyzer clean.Reviewer notes
urlsemantics are unchanged. Rather than loweringurlto anOpenUrlaction at serialization, both go through one Dart helper (runControlActions) —openWebBrowserstill leaves all ten controls, buturlis provably identical because it's the same call.Clipboard.getstill prompts on Safari whatever we do. An action makes the read possible, not invisible.urlis still duplicated across 11 controls and could fold into the same base class in a follow-up; the mechanics are now proven.Summary by Sourcery
Enable gesture-gated browser operations to run on the client without a round trip to Python.
New Features:
Bug Fixes:
Enhancements:
Documentation:
Tests: