Skip to content

Client actions: perform gesture-gated work without a round trip to Python - #6803

Open
FeodorFitsner wants to merge 10 commits into
mainfrom
feat/client-actions
Open

Client actions: perform gesture-gated work without a round trip to Python#6803
FeodorFitsner wants to merge 10 commits into
mainfrom
feat/client-actions

Conversation

@FeodorFitsner

@FeodorFitsner FeodorFitsner commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

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. FilePicker looked especially broken because save_file() kept working: it clicks a download link, which isn't gated.

Button.url already avoided this by being declarative — openWebBrowser(url) runs inside onPressed, 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:

picker = ft.FilePicker(on_result=handle_result)
page.services.append(picker)

ft.Button("Upload", action=ft.PickFiles(picker, allow_multiple=True))
ft.Button("Copy",   action=ft.CopyToClipboard(token))
ft.Button("Open",   action=ft.OpenUrl("https://flet.dev", target=ft.UrlTarget.BLANK))
ft.Button("Share",  action=ft.ShareText("Check out Flet"))

Two findings kept this small. Control.invokeMethod calls its listener synchronously when one is registered, and every service's _invokeMethod reaches its gated call before its own first await. So one dispatcher reuses all existing service code — no per-action Dart handler, no registry, no duplicated logic. FilePickerService._files and uploadFiles are untouched, so upload() works exactly as before.

What's included

APIft.OpenUrl, ft.CopyToClipboard, ft.PickFiles, ft.ShareText, and an action property on the ten controls that carry url today. ClientAction is the public base class for typing; the underlying (service, method, args) triple stays internal.

ActionControl (a @control(kw_only=True) base, following AdaptiveControl) contributes the property to all ten controls — ~110 lines lighter than repeating it, with one place to edit the docs. kw_only matters: without it the inherited field would claim the first positional slot and ft.Button("Open") would stop binding to content.

FilePicker.on_result — a gesture-initiated pick has no caller to return to, so the selection arrives as an event. Picked files stay on the FilePicker, so they pass straight to upload().

Fail-fastpick_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 live navigator.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" while SELF/PARENT/TOP all carry the spec's leading underscore, so it reached window.open() as an ordinary window name: the first such link opened a tab called blank and every later one reused it. The LaunchMode.externalApplication upgrade never fired either. Introduced in #5382, never released.

Two internal renames — the protocol enum ClientActionMessageAction and ClientMessageMessage, matching the Dart names they were already documented as mirroring, and freeing ClientAction for 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, Share and UrlLauncher pages 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 through page.add() rather than the action object alone — that path caught a real bug where the service cache lived in page._internals, which is sent to the client, so a page holding any action failed to render with AttributeError: 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

  • url semantics are unchanged. Rather than lowering url to an OpenUrl action at serialization, both go through one Dart helper (runControlActions) — openWebBrowser still leaves all ten controls, but url is provably identical because it's the same call.
  • Action arguments are static, known before the tap. That's the browser's rule, not Flet's; the clipboard example shows updating the action as a value changes.
  • Clipboard.get still prompts on Safari whatever we do. An action makes the read possible, not invisible.
  • url is 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:

  • Add client actions for opening URLs, copying to the clipboard, picking files, and sharing text directly within the originating user gesture.
  • Add an action property to supported interactive controls and expose file-picker results through a new on_result event.

Bug Fixes:

  • Make gesture-gated browser operations work reliably on iOS and fail fast when file picking is unavailable outside a user gesture.
  • Correct UrlTarget.BLANK to use the standard _blank target.

Enhancements:

  • Centralize client-side execution of control URLs and actions while preserving existing URL behavior.
  • Rename internal messaging protocol types to distinguish them from the new public ClientAction API.

Documentation:

  • Add client-action cookbook guidance, API reference pages, examples, and platform-specific service warnings.

Tests:

  • Add serialization and control coverage for client actions, including service reuse and URL target handling.

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`.

@sourcery-ai sourcery-ai Bot left a comment

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.

Hey - I've reviewed your changes and they look great!


Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying flet-website-v2 with  Cloudflare Pages  Cloudflare Pages

Latest commit: a8ec802
Status:🚫  Build failed.

View logs

Copilot AI left a comment

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.

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 ClientAction API (OpenUrl, CopyToClipboard, PickFiles, ShareText) and ActionControl.action support across existing “URL-capable” controls, plus FilePicker.on_result event 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 (ClientActionMessageAction, ClientMessageMessage), 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, {})


@dataclass
class PickFiles(ClientAction):

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.

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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

FilePicker not opening dialog on web for iOS copy to clipboard fails on Safari

3 participants