From 0c1e8fa3febcafb55c15b48817f55e37b15928fd Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Fri, 20 Mar 2026 19:07:55 -0700 Subject: [PATCH 1/8] feat: use frozen diff in use_dialog for efficient reactive updates Replace setattr field-copy loop with patch_control(frozen=True) so only actual property deltas are sent to Flutter. This preserves widget identity via _migrate_state (e.g. TextField cursor position) and avoids full re-creation of structural children on every re-render. Also update use_dialog_multiple example with a list of files to demonstrate rename/delete on multiple items. --- .../apps/declarative/use_dialog_basic.py | 56 +++++++ .../apps/declarative/use_dialog_multiple.py | 148 ++++++++++++++++++ .../packages/flet/docs/types/usedialog.md | 1 + sdk/python/packages/flet/mkdocs.yml | 1 + sdk/python/packages/flet/src/flet/__init__.py | 2 + .../src/flet/components/hooks/use_dialog.py | 71 +++++++++ 6 files changed, 279 insertions(+) create mode 100644 sdk/python/examples/apps/declarative/use_dialog_basic.py create mode 100644 sdk/python/examples/apps/declarative/use_dialog_multiple.py create mode 100644 sdk/python/packages/flet/docs/types/usedialog.md create mode 100644 sdk/python/packages/flet/src/flet/components/hooks/use_dialog.py diff --git a/sdk/python/examples/apps/declarative/use_dialog_basic.py b/sdk/python/examples/apps/declarative/use_dialog_basic.py new file mode 100644 index 0000000000..a867d5c81c --- /dev/null +++ b/sdk/python/examples/apps/declarative/use_dialog_basic.py @@ -0,0 +1,56 @@ +import flet as ft + + +@ft.component +def App(): + show, set_show = ft.use_state(False) + deleting, set_deleting = ft.use_state(False) + + async def handle_delete(e): + set_deleting(True) + # Simulate async operation + import asyncio + + await asyncio.sleep(2) + set_deleting(False) + set_show(False) + + ft.use_dialog( + ft.AlertDialog( + modal=True, + title=ft.Text("Delete report.pdf?"), + content=ft.Text( + "Deleting, please wait..." if deleting else "This cannot be undone." + ), + actions=[ + ft.Button( + "Deleting..." if deleting else "Delete", + disabled=deleting, + on_click=handle_delete, + ), + ft.TextButton( + "Cancel", + on_click=lambda e: set_show(False), + disabled=deleting, + ), + ], + on_dismiss=lambda e: set_show(False), + ) + if show + else None + ) + + return ft.Column( + controls=[ + ft.Text("Declarative Dialog Example", size=24, weight=ft.FontWeight.BOLD), + ft.Text("Click the button to open a confirmation dialog."), + ft.Button( + "Delete File", + icon=ft.Icons.DELETE, + on_click=lambda e: set_show(True), + ), + ], + ) + + +ft.run(lambda page: page.render(App)) diff --git a/sdk/python/examples/apps/declarative/use_dialog_multiple.py b/sdk/python/examples/apps/declarative/use_dialog_multiple.py new file mode 100644 index 0000000000..d4764d62ac --- /dev/null +++ b/sdk/python/examples/apps/declarative/use_dialog_multiple.py @@ -0,0 +1,148 @@ +import asyncio + +import flet as ft + + +def build_rename_dialog(current_name, new_name, on_name_change, on_save, on_cancel): + return ft.AlertDialog( + modal=True, + title=ft.Text(f"Rename {current_name}"), + content=ft.TextField( + label="New name", + value=new_name, + on_change=on_name_change, + autofocus=True, + ), + actions=[ + ft.FilledButton("Save", on_click=on_save), + ft.TextButton("Cancel", on_click=on_cancel), + ], + ) + + +def build_delete_dialog(item_name, deleting, on_delete, on_cancel): + return ft.AlertDialog( + modal=True, + title=ft.Text(f"Delete {item_name}?"), + content=ft.Text( + "Deleting, please wait..." if deleting else "This action cannot be undone." + ), + actions=[ + ft.Button( + "Deleting..." if deleting else "Delete", + color=ft.Colors.WHITE, + bgcolor=ft.Colors.RED if not deleting else ft.Colors.GREY, + disabled=deleting, + on_click=on_delete, + ), + ft.TextButton("Cancel", on_click=on_cancel, disabled=deleting), + ], + ) + + +@ft.component +def FileItem(name, on_rename, on_delete): + return ft.Card( + content=ft.Container( + padding=20, + content=ft.Row( + controls=[ + ft.Icon(ft.Icons.DESCRIPTION), + ft.Text(name, size=16, expand=True), + ft.IconButton( + ft.Icons.EDIT, + tooltip="Rename", + on_click=on_rename, + ), + ft.IconButton( + ft.Icons.DELETE, + tooltip="Delete", + icon_color=ft.Colors.RED, + on_click=on_delete, + ), + ], + alignment=ft.MainAxisAlignment.START, + ), + ), + ) + + +INITIAL_FILES = ["report.pdf", "photo.jpg", "notes.txt"] + + +@ft.component +def App(): + files, set_files = ft.use_state(INITIAL_FILES) + target, set_target = ft.use_state(None) # file being acted on + new_name, set_new_name = ft.use_state("") + show_rename, set_show_rename = ft.use_state(False) + show_delete, set_show_delete = ft.use_state(False) + deleting, set_deleting = ft.use_state(False) + + async def handle_delete(e): + set_deleting(True) + await asyncio.sleep(1) + set_files([f for f in files if f != target]) + set_deleting(False) + set_show_delete(False) + + def handle_rename_save(e): + if new_name.strip(): + set_files([new_name.strip() if f == target else f for f in files]) + set_show_rename(False) + + def open_rename(name): + def handler(e): + set_target(name) + set_new_name(name) + set_show_rename(True) + + return handler + + def open_delete(name): + def handler(e): + set_target(name) + set_show_delete(True) + + return handler + + ft.use_dialog( + build_rename_dialog( + target, + new_name, + on_name_change=lambda e: set_new_name(e.control.value), + on_save=handle_rename_save, + on_cancel=lambda e: set_show_rename(False), + ) + if show_rename + else None + ) + + ft.use_dialog( + build_delete_dialog( + target, + deleting, + on_delete=handle_delete, + on_cancel=lambda e: set_show_delete(False), + ) + if show_delete + else None + ) + + return ft.Column( + controls=[ + ft.Text("Multiple Dialogs Example", size=24, weight=ft.FontWeight.BOLD), + ft.Text("Each file can be renamed or deleted."), + ] + + [ + FileItem( + name=f, + on_rename=open_rename(f), + on_delete=open_delete(f), + ) + for f in files + ], + ) + + +ft.run(lambda page: page.render(App)) diff --git a/sdk/python/packages/flet/docs/types/usedialog.md b/sdk/python/packages/flet/docs/types/usedialog.md new file mode 100644 index 0000000000..0d58a68285 --- /dev/null +++ b/sdk/python/packages/flet/docs/types/usedialog.md @@ -0,0 +1 @@ +{{ class_all_options("flet.use_dialog") }} diff --git a/sdk/python/packages/flet/mkdocs.yml b/sdk/python/packages/flet/mkdocs.yml index 3d403c4f3b..223b8de833 100644 --- a/sdk/python/packages/flet/mkdocs.yml +++ b/sdk/python/packages/flet/mkdocs.yml @@ -1002,6 +1002,7 @@ nav: - on_updated: types/onupdated.md - use_callback: types/usecallback.md - use_context: types/usecontext.md + - use_dialog: types/usedialog.md - use_effect: types/useeffect.md - use_memo: types/usememo.md - use_ref: types/useref.md diff --git a/sdk/python/packages/flet/src/flet/__init__.py b/sdk/python/packages/flet/src/flet/__init__.py index 45df642267..dbdaee2877 100644 --- a/sdk/python/packages/flet/src/flet/__init__.py +++ b/sdk/python/packages/flet/src/flet/__init__.py @@ -5,6 +5,7 @@ from flet.components.component_decorator import component from flet.components.hooks.use_callback import use_callback from flet.components.hooks.use_context import create_context, use_context +from flet.components.hooks.use_dialog import use_dialog from flet.components.hooks.use_effect import ( on_mounted, on_unmounted, @@ -1111,6 +1112,7 @@ "unwrap_component", "use_callback", "use_context", + "use_dialog", "use_effect", "use_memo", "use_ref", diff --git a/sdk/python/packages/flet/src/flet/components/hooks/use_dialog.py b/sdk/python/packages/flet/src/flet/components/hooks/use_dialog.py new file mode 100644 index 0000000000..9aa78f0c4a --- /dev/null +++ b/sdk/python/packages/flet/src/flet/components/hooks/use_dialog.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import weakref + +from flet.components.hooks.use_effect import use_effect +from flet.components.hooks.use_ref import use_ref +from flet.controls.context import context +from flet.controls.dialog_control import DialogControl + + +def use_dialog(dialog: DialogControl | None = None): + """ + Portal a [`DialogControl`][flet.DialogControl] to the page's dialog overlay. + + Call this hook inside a [`@component`][flet.component] function on every render. + Pass a [`DialogControl`][flet.DialogControl] instance to show the dialog, + or `None` to hide/remove it. The dialog content is updated + reactively on each re-render. + + The hook automatically sets `open=True` on the dialog when it is + added to the overlay and removes it when `None` is passed or the + component unmounts. + + Args: + dialog: A [`DialogControl`][flet.DialogControl] to display, or `None` + to hide it. + """ + ref = use_ref(None) + page = context.page + + prev = ref.current + if dialog is not None: + dialog.open = True + if prev is not None and prev in page._dialogs.controls: + # Frozen diff: compares prev and dialog field-by-field, + # sends only actual deltas, and transfers _i via _migrate_state + # so Flutter preserves widget identity (e.g. TextField cursor). + page.session.patch_control(control=dialog, prev_control=prev, frozen=True) + # _dataclass_added (called during frozen diff) skips _parent + # for the root control when parent=None. Set it manually so + # the page property chain works for event dispatch. + dialog._parent = weakref.ref(page._dialogs) + # Strip _frozen set by _dataclass_added during frozen diff + # so we can later set open=False for dismissal. + if hasattr(dialog, "_frozen"): + del dialog._frozen + idx = page._dialogs.controls.index(prev) + page._dialogs.controls[idx] = dialog + ref.current = dialog + else: + page._dialogs.controls.append(dialog) + ref.current = dialog + page.session.schedule_update(page._dialogs) + elif prev is not None and prev.open: + # Dismiss: patch the dialog directly (like pop_dialog does) + # so Flutter properly pops the dialog route. + prev.open = False + page.session.patch_control(prev) + + def _cleanup(): + d = ref.current + if d is not None: + if d.open: + d.open = False + page.session.patch_control(d) + if d in page._dialogs.controls: + page._dialogs.controls.remove(d) + page.session.schedule_update(page._dialogs) + ref.current = None + + use_effect(lambda: None, dependencies=[], cleanup=_cleanup) From 3d6bfc382f170272168108d4968dda781138f0a7 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Sat, 21 Mar 2026 09:35:05 -0700 Subject: [PATCH 2/8] AlertDialog: wait for dialog route completion before dismiss showDialog's future completes when the route is popped (before the exit animation finishes), causing the dismiss event to fire too early. Capture the ModalRoute in the dialog builder and wait for its .completed Future to finish before updating _open/open properties and triggering the 'dismiss' event. Adds a comment explaining the behavior and moves route capture into the builder. --- .../flet/lib/src/controls/alert_dialog.dart | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/flet/lib/src/controls/alert_dialog.dart b/packages/flet/lib/src/controls/alert_dialog.dart index 405b201b3f..908f8b35cb 100644 --- a/packages/flet/lib/src/controls/alert_dialog.dart +++ b/packages/flet/lib/src/controls/alert_dialog.dart @@ -96,6 +96,7 @@ class AlertDialogControl extends StatelessWidget { control.updateProperties({"_open": open}, python: false); WidgetsBinding.instance.addPostFrameCallback((_) { + ModalRoute? dialogRoute; showDialog( barrierDismissible: !modal, // Render the barrier in the dialog widget so it updates live. @@ -103,11 +104,19 @@ class AlertDialogControl extends StatelessWidget { useSafeArea: false, useRootNavigator: false, context: context, - builder: (context) => _createAlertDialog(context)).then((value) { - debugPrint("Dismissing AlertDialog(${control.id})"); - control.updateProperties({"_open": false}, python: false); - control.updateProperties({"open": false}); - control.triggerEvent("dismiss"); + builder: (context) { + dialogRoute ??= ModalRoute.of(context); + return _createAlertDialog(context); + }).then((value) { + // showDialog future completes on pop() — before the exit animation + // finishes. Wait for the route's transition to fully complete so + // the dismiss event fires after the closing animation ends. + (dialogRoute?.completed ?? Future.value()).then((_) { + debugPrint("Dismissing AlertDialog(${control.id})"); + control.updateProperties({"_open": false}, python: false); + control.updateProperties({"open": false}); + control.triggerEvent("dismiss"); + }); }); }); } else if (!open && lastOpen) { From c6939e8bac2b46f4f3f868751801067ffd2e95b2 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Sat, 21 Mar 2026 09:48:12 -0700 Subject: [PATCH 3/8] fix: defer use_dialog cleanup to preserve dismiss event dispatch Keep dismissed dialogs alive in _dialogs.controls and ref until the next render so they remain in session.__index (WeakValueDictionary) long enough for Flutter's dismiss event to reach Python handlers. Remove __prev_lists sync from deferred cleanup to avoid poisoning the diff state when other hooks append to _dialogs.controls in the same render cycle. Add chained dialogs example demonstrating on_dismiss-based sequencing. --- .../apps/declarative/use_dialog_chained.py | 82 +++++++++++++++++++ .../src/flet/components/hooks/use_dialog.py | 21 ++++- 2 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 sdk/python/examples/apps/declarative/use_dialog_chained.py diff --git a/sdk/python/examples/apps/declarative/use_dialog_chained.py b/sdk/python/examples/apps/declarative/use_dialog_chained.py new file mode 100644 index 0000000000..0e4412528d --- /dev/null +++ b/sdk/python/examples/apps/declarative/use_dialog_chained.py @@ -0,0 +1,82 @@ +import asyncio + +import flet as ft + + +@ft.component +def App(): + show_confirm, set_show_confirm = ft.use_state(False) + show_success, set_show_success = ft.use_state(False) + deleting, set_deleting = ft.use_state(False) + deleted, set_deleted = ft.use_state(False) + # Ref avoids stale closure — always holds the current value + should_chain = ft.use_ref(False) + + async def handle_delete(e): + set_deleting(True) + await asyncio.sleep(2) + set_deleting(False) + set_deleted(True) + should_chain.current = True + set_show_confirm(False) + + def on_confirm_dismiss(e): + print("Dialog dismissed") + if should_chain.current: + should_chain.current = False + set_show_success(True) + + ft.use_dialog( + ft.AlertDialog( + modal=True, + title=ft.Text("Delete report.pdf?"), + content=ft.Text( + "Deleting, please wait..." if deleting else "This cannot be undone." + ), + actions=[ + ft.Button( + "Deleting..." if deleting else "Delete", + disabled=deleting, + on_click=handle_delete, + ), + ft.TextButton( + "Cancel", + on_click=lambda e: set_show_confirm(False), + disabled=deleting, + ), + ], + on_dismiss=on_confirm_dismiss, + ) + if show_confirm + else None + ) + + ft.use_dialog( + ft.AlertDialog( + title=ft.Text("Done!"), + content=ft.Text("report.pdf has been deleted."), + actions=[ + ft.FilledButton("OK", on_click=lambda e: set_show_success(False)), + ], + ) + if show_success + else None + ) + + return ft.Column( + controls=[ + ft.Text("Chained Dialogs Example", size=24, weight=ft.FontWeight.BOLD), + ft.Text( + "File deleted." if deleted else "Click the button to delete the file." + ), + ft.Button( + "Delete File", + icon=ft.Icons.DELETE, + on_click=lambda e: set_show_confirm(True), + disabled=deleted, + ), + ], + ) + + +ft.run(lambda page: page.render(App)) diff --git a/sdk/python/packages/flet/src/flet/components/hooks/use_dialog.py b/sdk/python/packages/flet/src/flet/components/hooks/use_dialog.py index 9aa78f0c4a..d2542b2ea9 100644 --- a/sdk/python/packages/flet/src/flet/components/hooks/use_dialog.py +++ b/sdk/python/packages/flet/src/flet/components/hooks/use_dialog.py @@ -29,6 +29,20 @@ def use_dialog(dialog: DialogControl | None = None): page = context.page prev = ref.current + + # Clean up a previously dismissed dialog still in the overlay. + # Deferred to here (next render) so the dialog stays alive in + # session.__index (a WeakValueDictionary) long enough for Flutter's + # dismiss event to reach the Python handler. + # Do NOT sync __prev_lists here — other hooks may have already + # appended to _dialogs.controls in this render, and syncing would + # tell the diff system those additions already happened. The + # normal diff will handle the removal correctly. + if prev is not None and not prev.open and prev in page._dialogs.controls: + page._dialogs.controls.remove(prev) + ref.current = None + prev = None + if dialog is not None: dialog.open = True if prev is not None and prev in page._dialogs.controls: @@ -52,8 +66,11 @@ def use_dialog(dialog: DialogControl | None = None): ref.current = dialog page.session.schedule_update(page._dialogs) elif prev is not None and prev.open: - # Dismiss: patch the dialog directly (like pop_dialog does) - # so Flutter properly pops the dialog route. + # Dismiss: patch open=False so Flutter pops the dialog route. + # Keep prev in _dialogs.controls and ref.current so the dialog + # stays alive in session.__index (WeakValueDictionary) — Flutter + # needs the control present to dispatch the dismiss event. + # Cleanup happens on the next render (above). prev.open = False page.session.patch_control(prev) From bdea5dc25ea6f2b36a92451a63d86d9d31cf9daf Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Sat, 21 Mar 2026 10:00:27 -0700 Subject: [PATCH 4/8] fix: defer dismiss event to after closing animation in all dialog-like controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wait for TransitionRoute.completed before firing dismiss event in BottomSheet, CupertinoAlertDialog, CupertinoBottomSheet, DatePicker, DateRangePicker, and TimePicker — matching the fix already applied to AlertDialog. --- packages/flet/lib/src/controls/bottom_sheet.dart | 10 +++++++--- .../lib/src/controls/cupertino_alert_dialog.dart | 16 +++++++++++----- .../lib/src/controls/cupertino_bottom_sheet.dart | 14 ++++++++++---- packages/flet/lib/src/controls/date_picker.dart | 12 +++++++++--- .../flet/lib/src/controls/date_range_picker.dart | 12 +++++++++--- packages/flet/lib/src/controls/time_picker.dart | 10 ++++++++-- .../apps/declarative/use_dialog_chained.py | 1 - 7 files changed, 54 insertions(+), 21 deletions(-) diff --git a/packages/flet/lib/src/controls/bottom_sheet.dart b/packages/flet/lib/src/controls/bottom_sheet.dart index a33c752a83..1f92ab7577 100644 --- a/packages/flet/lib/src/controls/bottom_sheet.dart +++ b/packages/flet/lib/src/controls/bottom_sheet.dart @@ -33,9 +33,11 @@ class BottomSheetControl extends StatelessWidget { control.updateProperties({"_open": open}, python: false); WidgetsBinding.instance.addPostFrameCallback((_) { + ModalRoute? sheetRoute; showModalBottomSheet( context: context, builder: (context) { + sheetRoute ??= ModalRoute.of(context); var content = control.buildWidget("content"); if (content == null) { @@ -73,9 +75,11 @@ class BottomSheetControl extends StatelessWidget { shape: control.getOutlinedBorder("shape", Theme.of(context)), useSafeArea: control.getBool("use_safe_area", true)!) .then((value) { - control.updateProperties({"_open": false}, python: false); - control.updateProperties({"open": false}); - control.triggerEvent("dismiss"); + (sheetRoute?.completed ?? Future.value()).then((_) { + control.updateProperties({"_open": false}, python: false); + control.updateProperties({"open": false}); + control.triggerEvent("dismiss"); + }); }); }); } else if (open != lastOpen && lastOpen) { diff --git a/packages/flet/lib/src/controls/cupertino_alert_dialog.dart b/packages/flet/lib/src/controls/cupertino_alert_dialog.dart index 50fee68210..3c3326d614 100644 --- a/packages/flet/lib/src/controls/cupertino_alert_dialog.dart +++ b/packages/flet/lib/src/controls/cupertino_alert_dialog.dart @@ -75,6 +75,7 @@ class CupertinoAlertDialogControl extends StatelessWidget { control.updateProperties({"_open": open}, python: false); WidgetsBinding.instance.addPostFrameCallback((_) { + ModalRoute? dialogRoute; showDialog( barrierDismissible: !modal, // Render the barrier in the dialog widget so it updates live. @@ -82,11 +83,16 @@ class CupertinoAlertDialogControl extends StatelessWidget { useSafeArea: false, useRootNavigator: false, context: context, - builder: (context) => _createCupertinoAlertDialog()).then((value) { - debugPrint("Dismissing CupertinoAlertDialog(${control.id})"); - control.updateProperties({"_open": false}, python: false); - control.updateProperties({"open": false}); - control.triggerEvent("dismiss"); + builder: (context) { + dialogRoute ??= ModalRoute.of(context); + return _createCupertinoAlertDialog(); + }).then((value) { + (dialogRoute?.completed ?? Future.value()).then((_) { + debugPrint("Dismissing CupertinoAlertDialog(${control.id})"); + control.updateProperties({"_open": false}, python: false); + control.updateProperties({"open": false}); + control.triggerEvent("dismiss"); + }); }); }); } else if (!open && lastOpen) { diff --git a/packages/flet/lib/src/controls/cupertino_bottom_sheet.dart b/packages/flet/lib/src/controls/cupertino_bottom_sheet.dart index 6e84bc0798..9c9d958fa7 100644 --- a/packages/flet/lib/src/controls/cupertino_bottom_sheet.dart +++ b/packages/flet/lib/src/controls/cupertino_bottom_sheet.dart @@ -64,14 +64,20 @@ class CupertinoBottomSheetControl extends StatelessWidget { control.updateProperties({"_open": open}, python: false); WidgetsBinding.instance.addPostFrameCallback((_) { + ModalRoute? popupRoute; showCupertinoModalPopup( barrierDismissible: !control.getBool("modal", false)!, useRootNavigator: false, context: context, - builder: (context) => dialog).then((value) { - control.updateProperties({"_open": false}, python: false); - control.updateProperties({"open": false}); - control.triggerEvent("dismiss"); + builder: (context) { + popupRoute ??= ModalRoute.of(context); + return dialog; + }).then((value) { + (popupRoute?.completed ?? Future.value()).then((_) { + control.updateProperties({"_open": false}, python: false); + control.updateProperties({"open": false}); + control.triggerEvent("dismiss"); + }); }); }); } else if (open != lastOpen && lastOpen && Navigator.of(context).canPop()) { diff --git a/packages/flet/lib/src/controls/date_picker.dart b/packages/flet/lib/src/controls/date_picker.dart index eea5003a95..48c12bb332 100644 --- a/packages/flet/lib/src/controls/date_picker.dart +++ b/packages/flet/lib/src/controls/date_picker.dart @@ -81,14 +81,20 @@ class DatePickerControl extends StatelessWidget { control.updateProperties({"_open": open}, python: false); WidgetsBinding.instance.addPostFrameCallback((_) { + ModalRoute? dialogRoute; showDialog( barrierDismissible: !control.getBool("modal", false)!, barrierColor: control.getColor("barrier_color", context), useRootNavigator: false, context: context, - builder: (context) => createSelectDateDialog()).then((result) { - debugPrint("pickDate() completed"); - onClosed(result); + builder: (context) { + dialogRoute ??= ModalRoute.of(context); + return createSelectDateDialog(); + }).then((result) { + (dialogRoute?.completed ?? Future.value()).then((_) { + debugPrint("pickDate() completed"); + onClosed(result); + }); }); }); } diff --git a/packages/flet/lib/src/controls/date_range_picker.dart b/packages/flet/lib/src/controls/date_range_picker.dart index 7f7b8e4d41..ada8ed61eb 100644 --- a/packages/flet/lib/src/controls/date_range_picker.dart +++ b/packages/flet/lib/src/controls/date_range_picker.dart @@ -86,14 +86,20 @@ class DateRangePickerControl extends StatelessWidget { control.updateProperties({"_open": open}, python: false); WidgetsBinding.instance.addPostFrameCallback((_) { + ModalRoute? dialogRoute; showDialog>( barrierDismissible: !control.getBool("modal", false)!, barrierColor: control.getColor("barrier_color", context), useRootNavigator: false, context: context, - builder: (context) => createSelectDateDialog()).then((result) { - debugPrint("pickDate() completed"); - onClosed(result); + builder: (context) { + dialogRoute ??= ModalRoute.of(context); + return createSelectDateDialog(); + }).then((result) { + (dialogRoute?.completed ?? Future.value()).then((_) { + debugPrint("pickDate() completed"); + onClosed(result); + }); }); }); } diff --git a/packages/flet/lib/src/controls/time_picker.dart b/packages/flet/lib/src/controls/time_picker.dart index f7491774c3..fa3158fec0 100644 --- a/packages/flet/lib/src/controls/time_picker.dart +++ b/packages/flet/lib/src/controls/time_picker.dart @@ -76,13 +76,19 @@ class TimePickerControl extends StatelessWidget { control.updateProperties({"_open": open}, python: false); WidgetsBinding.instance.addPostFrameCallback((_) { + ModalRoute? dialogRoute; showDialog( barrierColor: control.getColor("barrier_color", context), barrierDismissible: !control.getBool("modal", false)!, useRootNavigator: false, context: context, - builder: (context) => createSelectTimeDialog()).then((result) { - onClosed(result); + builder: (context) { + dialogRoute ??= ModalRoute.of(context); + return createSelectTimeDialog(); + }).then((result) { + (dialogRoute?.completed ?? Future.value()).then((_) { + onClosed(result); + }); }); }); } diff --git a/sdk/python/examples/apps/declarative/use_dialog_chained.py b/sdk/python/examples/apps/declarative/use_dialog_chained.py index 0e4412528d..e913370cd9 100644 --- a/sdk/python/examples/apps/declarative/use_dialog_chained.py +++ b/sdk/python/examples/apps/declarative/use_dialog_chained.py @@ -21,7 +21,6 @@ async def handle_delete(e): set_show_confirm(False) def on_confirm_dismiss(e): - print("Dialog dismissed") if should_chain.current: should_chain.current = False set_show_success(True) From 00d1f9478e8d766848c2959427d55cb11d912f69 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Sat, 21 Mar 2026 10:05:09 -0700 Subject: [PATCH 5/8] chore: remove unused event args from use_dialog examples --- .../examples/apps/declarative/use_dialog_basic.py | 12 ++++++------ .../examples/apps/declarative/use_dialog_chained.py | 10 +++++----- .../examples/apps/declarative/use_dialog_multiple.py | 12 ++++++------ 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/sdk/python/examples/apps/declarative/use_dialog_basic.py b/sdk/python/examples/apps/declarative/use_dialog_basic.py index a867d5c81c..d446143005 100644 --- a/sdk/python/examples/apps/declarative/use_dialog_basic.py +++ b/sdk/python/examples/apps/declarative/use_dialog_basic.py @@ -1,3 +1,5 @@ +import asyncio + import flet as ft @@ -6,11 +8,9 @@ def App(): show, set_show = ft.use_state(False) deleting, set_deleting = ft.use_state(False) - async def handle_delete(e): + async def handle_delete(): set_deleting(True) # Simulate async operation - import asyncio - await asyncio.sleep(2) set_deleting(False) set_show(False) @@ -30,11 +30,11 @@ async def handle_delete(e): ), ft.TextButton( "Cancel", - on_click=lambda e: set_show(False), + on_click=lambda: set_show(False), disabled=deleting, ), ], - on_dismiss=lambda e: set_show(False), + on_dismiss=lambda: set_show(False), ) if show else None @@ -47,7 +47,7 @@ async def handle_delete(e): ft.Button( "Delete File", icon=ft.Icons.DELETE, - on_click=lambda e: set_show(True), + on_click=lambda: set_show(True), ), ], ) diff --git a/sdk/python/examples/apps/declarative/use_dialog_chained.py b/sdk/python/examples/apps/declarative/use_dialog_chained.py index e913370cd9..2bb79ae3dd 100644 --- a/sdk/python/examples/apps/declarative/use_dialog_chained.py +++ b/sdk/python/examples/apps/declarative/use_dialog_chained.py @@ -12,7 +12,7 @@ def App(): # Ref avoids stale closure — always holds the current value should_chain = ft.use_ref(False) - async def handle_delete(e): + async def handle_delete(): set_deleting(True) await asyncio.sleep(2) set_deleting(False) @@ -20,7 +20,7 @@ async def handle_delete(e): should_chain.current = True set_show_confirm(False) - def on_confirm_dismiss(e): + def on_confirm_dismiss(): if should_chain.current: should_chain.current = False set_show_success(True) @@ -40,7 +40,7 @@ def on_confirm_dismiss(e): ), ft.TextButton( "Cancel", - on_click=lambda e: set_show_confirm(False), + on_click=lambda: set_show_confirm(False), disabled=deleting, ), ], @@ -55,7 +55,7 @@ def on_confirm_dismiss(e): title=ft.Text("Done!"), content=ft.Text("report.pdf has been deleted."), actions=[ - ft.FilledButton("OK", on_click=lambda e: set_show_success(False)), + ft.FilledButton("OK", on_click=lambda: set_show_success(False)), ], ) if show_success @@ -71,7 +71,7 @@ def on_confirm_dismiss(e): ft.Button( "Delete File", icon=ft.Icons.DELETE, - on_click=lambda e: set_show_confirm(True), + on_click=lambda: set_show_confirm(True), disabled=deleted, ), ], diff --git a/sdk/python/examples/apps/declarative/use_dialog_multiple.py b/sdk/python/examples/apps/declarative/use_dialog_multiple.py index d4764d62ac..a119f18246 100644 --- a/sdk/python/examples/apps/declarative/use_dialog_multiple.py +++ b/sdk/python/examples/apps/declarative/use_dialog_multiple.py @@ -79,20 +79,20 @@ def App(): show_delete, set_show_delete = ft.use_state(False) deleting, set_deleting = ft.use_state(False) - async def handle_delete(e): + async def handle_delete(): set_deleting(True) await asyncio.sleep(1) set_files([f for f in files if f != target]) set_deleting(False) set_show_delete(False) - def handle_rename_save(e): + def handle_rename_save(): if new_name.strip(): set_files([new_name.strip() if f == target else f for f in files]) set_show_rename(False) def open_rename(name): - def handler(e): + def handler(): set_target(name) set_new_name(name) set_show_rename(True) @@ -100,7 +100,7 @@ def handler(e): return handler def open_delete(name): - def handler(e): + def handler(): set_target(name) set_show_delete(True) @@ -112,7 +112,7 @@ def handler(e): new_name, on_name_change=lambda e: set_new_name(e.control.value), on_save=handle_rename_save, - on_cancel=lambda e: set_show_rename(False), + on_cancel=lambda: set_show_rename(False), ) if show_rename else None @@ -123,7 +123,7 @@ def handler(e): target, deleting, on_delete=handle_delete, - on_cancel=lambda e: set_show_delete(False), + on_cancel=lambda: set_show_delete(False), ) if show_delete else None From 16b26053f1c5930af5d7414e4fba754d604604e7 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Sat, 21 Mar 2026 10:34:40 -0700 Subject: [PATCH 6/8] Fix use_dialog dismiss lifecycle --- .../examples/material/test_alert_dialog.py | 59 +++++++ .../src/flet/components/hooks/use_dialog.py | 27 +-- .../flet/src/flet/controls/base_page.py | 74 +++++--- .../packages/flet/tests/test_use_dialog.py | 166 ++++++++++++++++++ 4 files changed, 281 insertions(+), 45 deletions(-) create mode 100644 sdk/python/packages/flet/tests/test_use_dialog.py diff --git a/sdk/python/packages/flet/integration_tests/examples/material/test_alert_dialog.py b/sdk/python/packages/flet/integration_tests/examples/material/test_alert_dialog.py index 2d939c74b6..da4def8ca0 100644 --- a/sdk/python/packages/flet/integration_tests/examples/material/test_alert_dialog.py +++ b/sdk/python/packages/flet/integration_tests/examples/material/test_alert_dialog.py @@ -74,3 +74,62 @@ async def test_basic(flet_app_function: ftt.FletTestApp): "alert_dialog_flow", duration=2000, ) + + +@pytest.mark.asyncio(loop_scope="function") +async def test_use_dialog_dismiss_fires_after_animation( + flet_app_function: ftt.FletTestApp, +): + @ft.component + def App(): + show, set_show = ft.use_state(False) + status, set_status = ft.use_state("waiting") + + ft.use_dialog( + ft.AlertDialog( + modal=True, + title=ft.Text("Delete report.pdf?"), + content=ft.Text("This cannot be undone."), + actions=[ + ft.TextButton("Close", on_click=lambda: set_show(False)), + ], + on_dismiss=lambda: set_status("dismissed"), + ) + if show + else None + ) + + return ft.Column( + controls=[ + ft.Text(status), + ft.TextButton("Open", on_click=lambda: set_show(True)), + ] + ) + + flet_app_function.page.render(App) + await flet_app_function.tester.pump_and_settle() + + await flet_app_function.tester.tap( + await flet_app_function.tester.find_by_text("Open") + ) + await flet_app_function.tester.pump_and_settle() + + assert (await flet_app_function.tester.find_by_text("waiting")).count == 1 + assert ( + await flet_app_function.tester.find_by_text("Delete report.pdf?") + ).count == 1 + + await flet_app_function.tester.tap( + await flet_app_function.tester.find_by_text("Close") + ) + await flet_app_function.tester.pump() + + assert (await flet_app_function.tester.find_by_text("dismissed")).count == 0 + assert (await flet_app_function.tester.find_by_text("waiting")).count == 1 + + await flet_app_function.tester.pump_and_settle() + + assert (await flet_app_function.tester.find_by_text("dismissed")).count == 1 + assert ( + await flet_app_function.tester.find_by_text("Delete report.pdf?") + ).count == 0 diff --git a/sdk/python/packages/flet/src/flet/components/hooks/use_dialog.py b/sdk/python/packages/flet/src/flet/components/hooks/use_dialog.py index d2542b2ea9..a1abfc4c75 100644 --- a/sdk/python/packages/flet/src/flet/components/hooks/use_dialog.py +++ b/sdk/python/packages/flet/src/flet/components/hooks/use_dialog.py @@ -1,7 +1,5 @@ from __future__ import annotations -import weakref - from flet.components.hooks.use_effect import use_effect from flet.components.hooks.use_ref import use_ref from flet.controls.context import context @@ -30,30 +28,18 @@ def use_dialog(dialog: DialogControl | None = None): prev = ref.current - # Clean up a previously dismissed dialog still in the overlay. - # Deferred to here (next render) so the dialog stays alive in - # session.__index (a WeakValueDictionary) long enough for Flutter's - # dismiss event to reach the Python handler. - # Do NOT sync __prev_lists here — other hooks may have already - # appended to _dialogs.controls in this render, and syncing would - # tell the diff system those additions already happened. The - # normal diff will handle the removal correctly. - if prev is not None and not prev.open and prev in page._dialogs.controls: - page._dialogs.controls.remove(prev) + if prev is not None and prev not in page._dialogs.controls: ref.current = None prev = None if dialog is not None: dialog.open = True + page._prepare_dialog(dialog) if prev is not None and prev in page._dialogs.controls: # Frozen diff: compares prev and dialog field-by-field, # sends only actual deltas, and transfers _i via _migrate_state # so Flutter preserves widget identity (e.g. TextField cursor). page.session.patch_control(control=dialog, prev_control=prev, frozen=True) - # _dataclass_added (called during frozen diff) skips _parent - # for the root control when parent=None. Set it manually so - # the page property chain works for event dispatch. - dialog._parent = weakref.ref(page._dialogs) # Strip _frozen set by _dataclass_added during frozen diff # so we can later set open=False for dismissal. if hasattr(dialog, "_frozen"): @@ -69,20 +55,17 @@ def use_dialog(dialog: DialogControl | None = None): # Dismiss: patch open=False so Flutter pops the dialog route. # Keep prev in _dialogs.controls and ref.current so the dialog # stays alive in session.__index (WeakValueDictionary) — Flutter - # needs the control present to dispatch the dismiss event. - # Cleanup happens on the next render (above). + # needs the control present to dispatch the dismiss event. The + # wrapped dismiss handler removes it after Flutter signals close. prev.open = False page.session.patch_control(prev) def _cleanup(): d = ref.current if d is not None: + ref.current = None if d.open: d.open = False page.session.patch_control(d) - if d in page._dialogs.controls: - page._dialogs.controls.remove(d) - page.session.schedule_update(page._dialogs) - ref.current = None use_effect(lambda: None, dependencies=[], cleanup=_cleanup) diff --git a/sdk/python/packages/flet/src/flet/controls/base_page.py b/sdk/python/packages/flet/src/flet/controls/base_page.py index e76b550ce3..e09685e2a8 100644 --- a/sdk/python/packages/flet/src/flet/controls/base_page.py +++ b/sdk/python/packages/flet/src/flet/controls/base_page.py @@ -1,4 +1,5 @@ import logging +import weakref from dataclasses import dataclass, field from typing import ( TYPE_CHECKING, @@ -44,6 +45,9 @@ logger = logging.getLogger("flet") +_MANAGED_DIALOG_DISMISS_ORIGINAL = "_managed_dialog_dismiss_original" +_MANAGED_DIALOG_DISMISS_WRAPPER = "_managed_dialog_dismiss_wrapper" + if TYPE_CHECKING: from flet.controls.theme import Theme @@ -388,30 +392,8 @@ def show_dialog(self, dialog: DialogControl) -> None: if dialog in self._dialogs.controls: raise RuntimeError("Dialog is already opened") - original_on_dismiss = dialog.on_dismiss - - async def wrapped_on_dismiss(*args): - """ - Remove dialog from stack and forward dismiss event to original handler. - - Args: - *args: Dismiss event arguments passed by the framework. - """ - - if dialog in self._dialogs.controls: - self._dialogs.controls.remove(dialog) - self._dialogs.update() - dialog.on_dismiss = original_on_dismiss - e = args[0] - if ( - original_on_dismiss and (e.data is None or e.data) # e.data == True for - # TimePicker and DatePicker if they were dismissed without - # changing the value - ): - await dialog._trigger_event("dismiss", e) - dialog.open = True - dialog.on_dismiss = wrapped_on_dismiss + self._prepare_dialog(dialog) self._dialogs.controls.append(dialog) self._dialogs.update() @@ -436,6 +418,52 @@ def pop_dialog(self) -> Optional[DialogControl]: dialog.update() return dialog + def _prepare_dialog(self, dialog: DialogControl) -> None: + self._set_dialog_parent(dialog) + self._wrap_dialog_on_dismiss(dialog) + + def _set_dialog_parent(self, dialog: DialogControl) -> None: + dialog._parent = weakref.ref(self._dialogs) + + def _get_original_dialog_on_dismiss(self, dialog: DialogControl): + wrapper = getattr(dialog, _MANAGED_DIALOG_DISMISS_WRAPPER, None) + if wrapper is not None and dialog.on_dismiss is wrapper: + return getattr(dialog, _MANAGED_DIALOG_DISMISS_ORIGINAL, None) + return dialog.on_dismiss + + def _wrap_dialog_on_dismiss(self, dialog: DialogControl) -> None: + original_on_dismiss = self._get_original_dialog_on_dismiss(dialog) + + async def wrapped_on_dismiss(*args): + # Keep the dialog mounted until Flutter reports dismiss. Removing it + # earlier can drop the post-animation dismiss callback entirely. + self._restore_dialog_on_dismiss(dialog) + self._remove_dialog(dialog) + e = args[0] + if ( + original_on_dismiss and (e.data is None or e.data) + # e.data == True for TimePicker and DatePicker if they were + # dismissed without changing the value + ): + await dialog._trigger_event("dismiss", e) + + setattr(dialog, _MANAGED_DIALOG_DISMISS_ORIGINAL, original_on_dismiss) + setattr(dialog, _MANAGED_DIALOG_DISMISS_WRAPPER, wrapped_on_dismiss) + dialog.on_dismiss = wrapped_on_dismiss + + def _restore_dialog_on_dismiss(self, dialog: DialogControl) -> None: + original_on_dismiss = getattr(dialog, _MANAGED_DIALOG_DISMISS_ORIGINAL, None) + if hasattr(dialog, _MANAGED_DIALOG_DISMISS_ORIGINAL): + delattr(dialog, _MANAGED_DIALOG_DISMISS_ORIGINAL) + if hasattr(dialog, _MANAGED_DIALOG_DISMISS_WRAPPER): + delattr(dialog, _MANAGED_DIALOG_DISMISS_WRAPPER) + dialog.on_dismiss = original_on_dismiss + + def _remove_dialog(self, dialog: DialogControl) -> None: + if dialog in self._dialogs.controls: + self._dialogs.controls.remove(dialog) + self._dialogs.update() + async def show_drawer(self): """ Show the drawer. diff --git a/sdk/python/packages/flet/tests/test_use_dialog.py b/sdk/python/packages/flet/tests/test_use_dialog.py new file mode 100644 index 0000000000..6b93adaaf7 --- /dev/null +++ b/sdk/python/packages/flet/tests/test_use_dialog.py @@ -0,0 +1,166 @@ +import weakref + +import pytest + +import flet as ft +from flet.components.component import Component, Renderer +from flet.controls.base_control import BaseControl +from flet.controls.context import _context_page +from flet.controls.control_event import ControlEvent +from flet.messaging.protocol import configure_encode_object_for_msgpack + + +class FakeSession: + def __init__(self): + self.patch_calls: list[tuple[object, dict]] = [] + self.scheduled_updates: list[object] = [] + self.index: dict[int, object] = {} + + def patch_control(self, control, **kwargs): + self.patch_calls.append((control, kwargs)) + control_id = getattr(control, "_i", None) + if control_id is not None: + self.index[control_id] = control + + def schedule_update(self, control): + self.scheduled_updates.append(control) + + def schedule_effect(self, hook, is_cleanup): + pass + + async def after_event(self, control): + pass + + +def render_component(component: Component, page: ft.Page) -> None: + component._state.hook_cursor = 0 + token = _context_page.set(page) + try: + renderer = Renderer(component) + with renderer.with_context(), renderer._Frame(renderer, component): + component.fn(*component.args, **component.kwargs) + finally: + _context_page.reset(token) + + +def create_page() -> tuple[ft.Page, FakeSession]: + session = FakeSession() + page = ft.Page(sess=session) + page._dialogs._parent = weakref.ref(page) + return page, session + + +@pytest.mark.asyncio +async def test_use_dialog_waits_for_dismiss_before_removing_dialog(): + page, session = create_page() + show = True + dismiss_calls: list[str] = [] + + def body(): + ft.use_dialog( + ft.AlertDialog( + title=ft.Text("Hello"), + on_dismiss=lambda e: dismiss_calls.append(e.name), + ) + if show + else None + ) + + component = Component(fn=body, args=(), kwargs={}) + + render_component(component, page) + + assert session.scheduled_updates == [page._dialogs] + assert len(page._dialogs.controls) == 1 + + dialog = page._dialogs.controls[0] + session.patch_calls.clear() + session.scheduled_updates.clear() + + show = False + render_component(component, page) + + assert page._dialogs.controls == [dialog] + assert dialog.open is False + assert session.patch_calls == [(dialog, {})] + assert session.scheduled_updates == [] + + await dialog.on_dismiss(ControlEvent(control=dialog, name="dismiss", data=None)) + + assert page._dialogs.controls == [] + assert dismiss_calls == ["dismiss"] + assert session.patch_calls[-1] == (page._dialogs, {}) + + +@pytest.mark.asyncio +async def test_use_dialog_unmount_keeps_dialog_until_dismiss_event(): + page, session = create_page() + dismiss_calls: list[str] = [] + + def body(): + ft.use_dialog( + ft.AlertDialog( + title=ft.Text("Hello"), + on_dismiss=lambda e: dismiss_calls.append(e.name), + ) + ) + + component = Component(fn=body, args=(), kwargs={}) + render_component(component, page) + + dialog = page._dialogs.controls[0] + session.patch_calls.clear() + session.scheduled_updates.clear() + component._schedule_effect = lambda hook, is_cleanup=False: ( + hook.cleanup() if is_cleanup and hook.cleanup else None + ) + + component.will_unmount() + + assert page._dialogs.controls == [dialog] + assert dialog.open is False + assert session.patch_calls == [(dialog, {})] + assert session.scheduled_updates == [] + + await dialog.on_dismiss(ControlEvent(control=dialog, name="dismiss", data=None)) + + assert page._dialogs.controls == [] + assert dismiss_calls == ["dismiss"] + assert session.patch_calls[-1] == (page._dialogs, {}) + + +@pytest.mark.asyncio +async def test_show_dialog_still_removes_dialog_on_dismiss(): + page, session = create_page() + dismiss_calls: list[str] = [] + dialog = ft.AlertDialog( + title=ft.Text("Hello"), + on_dismiss=lambda e: dismiss_calls.append(e.name), + ) + + page.show_dialog(dialog) + + assert page._dialogs.controls == [dialog] + assert dialog.open is True + assert session.patch_calls == [(page._dialogs, {})] + + await dialog.on_dismiss(ControlEvent(control=dialog, name="dismiss", data=None)) + + assert page._dialogs.controls == [] + assert dismiss_calls == ["dismiss"] + assert session.patch_calls[-1] == (page._dialogs, {}) + + +def test_prepare_dialog_does_not_store_callbacks_in_serialized_fields(): + page, _ = create_page() + dialog = ft.AlertDialog( + title=ft.Text("Hello"), + on_dismiss=lambda e: None, + ) + + page._prepare_dialog(dialog) + + encoded = configure_encode_object_for_msgpack(BaseControl)(dialog) + + assert encoded["on_dismiss"] is True + assert "_internals" not in encoded From d97cbf530e3386a866669836ed19bf68edb1a95f Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Sat, 21 Mar 2026 10:47:50 -0700 Subject: [PATCH 7/8] Add declarative dialogs cookbook article --- .../flet/docs/cookbook/declarative-dialogs.md | 196 ++++++++++++++++++ sdk/python/packages/flet/mkdocs.yml | 1 + 2 files changed, 197 insertions(+) create mode 100644 sdk/python/packages/flet/docs/cookbook/declarative-dialogs.md diff --git a/sdk/python/packages/flet/docs/cookbook/declarative-dialogs.md b/sdk/python/packages/flet/docs/cookbook/declarative-dialogs.md new file mode 100644 index 0000000000..66245c1581 --- /dev/null +++ b/sdk/python/packages/flet/docs/cookbook/declarative-dialogs.md @@ -0,0 +1,196 @@ +# Declarative dialogs + +[`ft.use_dialog()`][flet.use_dialog] lets a component show and update dialogs declaratively. +Instead of imperatively calling [`page.show_dialog()`][flet.Page.show_dialog] and later +remembering to close or remove the dialog, you render a [`DialogControl`][flet.DialogControl] +from component state: + +- pass a dialog instance to show it; +- pass `None` to hide it. + +This keeps dialog logic in the same state flow as the rest of a declarative app: + +- state decides whether the dialog is visible; +- dialog content updates when state changes; +- there is no `page.update()` call in the component. + +## Basic pattern + +Call [`ft.use_dialog()`][flet.use_dialog] on every render. When the dialog should be open, +return a dialog control; otherwise return `None`. + +```python +import flet as ft + + +@ft.component +def App(): + show, set_show = ft.use_state(False) + + ft.use_dialog( + ft.AlertDialog( + modal=True, + title=ft.Text("Delete report.pdf?"), + content=ft.Text("This cannot be undone."), + actions=[ + ft.TextButton("Delete", on_click=lambda: set_show(False)), + ft.TextButton("Cancel", on_click=lambda: set_show(False)), + ], + on_dismiss=lambda: set_show(False), + ) + if show + else None + ) + + return ft.Column( + controls=[ + ft.TextButton("Open dialog", on_click=lambda: set_show(True)), + ] + ) + + +ft.run(lambda page: page.render(App)) +``` + +The important part is that `show` is the source of truth. The dialog is not opened by +mutating the page tree directly; it appears because the component renders it. + +## Updating dialog content from state + +Because the dialog is declarative, its content can react to state changes while it is open. +This is useful for confirmations, form validation, and async workflows: + +```python +import asyncio +import flet as ft + + +@ft.component +def App(): + show, set_show = ft.use_state(False) + deleting, set_deleting = ft.use_state(False) + + async def handle_delete(): + set_deleting(True) + await asyncio.sleep(2) + set_deleting(False) + set_show(False) + + ft.use_dialog( + ft.AlertDialog( + modal=True, + title=ft.Text("Delete report.pdf?"), + content=ft.Text( + "Deleting, please wait..." if deleting else "This cannot be undone." + ), + actions=[ + ft.Button( + "Deleting..." if deleting else "Delete", + disabled=deleting, + on_click=handle_delete, + ), + ft.TextButton( + "Cancel", + disabled=deleting, + on_click=lambda: set_show(False), + ), + ], + on_dismiss=lambda: set_show(False), + ) + if show + else None + ) + + return ft.TextButton("Delete file", on_click=lambda: set_show(True)) +``` + +This pattern works well with `asyncio` and other async APIs in Flet apps. For more +background, see [Async apps](async-apps.md). + +## Chaining dialogs + +You can call [`ft.use_dialog()`][flet.use_dialog] more than once in the same component. That +makes follow-up flows straightforward, for example: + +- confirmation dialog; +- async action; +- success dialog after the first dialog fully closes. + +```python +import flet as ft + + +@ft.component +def App(): + show_confirm, set_show_confirm = ft.use_state(False) + show_success, set_show_success = ft.use_state(False) + should_chain = ft.use_ref(False) + + def confirm_delete(): + should_chain.current = True + set_show_confirm(False) + + def on_confirm_dismiss(): + if should_chain.current: + should_chain.current = False + set_show_success(True) + + ft.use_dialog( + ft.AlertDialog( + title=ft.Text("Delete file?"), + actions=[ + ft.TextButton("Delete", on_click=confirm_delete), + ft.TextButton("Cancel", on_click=lambda: set_show_confirm(False)), + ], + on_dismiss=on_confirm_dismiss, + ) + if show_confirm + else None + ) + + ft.use_dialog( + ft.AlertDialog( + title=ft.Text("Done"), + content=ft.Text("The file was deleted."), + actions=[ + ft.TextButton("OK", on_click=lambda: set_show_success(False)), + ], + ) + if show_success + else None + ) + + return ft.TextButton("Open", on_click=lambda: set_show_confirm(True)) +``` + +[`ft.use_ref()`][flet.use_ref] is helpful here because the value survives re-renders without +causing another render by itself. + +## `on_dismiss` timing + +[`DialogControl.on_dismiss`][flet.DialogControl.on_dismiss] fires after the dialog close +animation completes, not immediately when `open` changes to `False`. This makes it safe to +start follow-up UI after the dialog has actually finished closing. + +Use `on_dismiss` for logic that should happen after the dialog is fully gone, such as: + +- opening the next dialog in a chain; +- resetting temporary dialog-local state; +- starting a follow-up animation or toast. + +## When to use `page.show_dialog()` instead + +[`ft.use_dialog()`][flet.use_dialog] is a better fit inside [`@ft.component`][flet.component] +functions and other declarative flows. + +[`page.show_dialog()`][flet.Page.show_dialog] is still a good option when: + +- the app is written imperatively; +- dialog lifecycle is handled outside the component tree; +- you need to trigger a dialog from existing page-level event code and do not want to convert + that part of the app to declarative style yet. + +In practice, the two APIs serve different styles: + +- use declarative dialogs when UI should follow component state; +- use imperative dialogs when UI is managed by direct page mutation. diff --git a/sdk/python/packages/flet/mkdocs.yml b/sdk/python/packages/flet/mkdocs.yml index 223b8de833..cc0468cdba 100644 --- a/sdk/python/packages/flet/mkdocs.yml +++ b/sdk/python/packages/flet/mkdocs.yml @@ -1021,6 +1021,7 @@ nav: - Colors: cookbook/colors.md - Control Refs: cookbook/control-refs.md - Custom Controls: cookbook/custom-controls.md + - Declarative dialogs: cookbook/declarative-dialogs.md - Drag and Drop: cookbook/drag-and-drop.md - Declarative vs Imperative CRUD app: cookbook/declarative-vs-imperative-crud-app.md - Encrypting sensitive data: cookbook/encrypting-sensitive-data.md From cde433c44127d3c3e589bf7dc8d703a825520da4 Mon Sep 17 00:00:00 2001 From: Feodor Fitsner Date: Sat, 21 Mar 2026 10:59:14 -0700 Subject: [PATCH 8/8] Add ft.run to declarative dialog examples Make code samples runnable by adding `ft.run(lambda page: page.render(App))` to the declarative dialogs cookbook. Updated two example blocks in sdk/python/packages/flet/docs/cookbook/declarative-dialogs.md so the App examples are executed when copied/run. --- sdk/python/packages/flet/docs/cookbook/declarative-dialogs.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/sdk/python/packages/flet/docs/cookbook/declarative-dialogs.md b/sdk/python/packages/flet/docs/cookbook/declarative-dialogs.md index 66245c1581..893afe715f 100644 --- a/sdk/python/packages/flet/docs/cookbook/declarative-dialogs.md +++ b/sdk/python/packages/flet/docs/cookbook/declarative-dialogs.md @@ -102,6 +102,8 @@ def App(): ) return ft.TextButton("Delete file", on_click=lambda: set_show(True)) + +ft.run(lambda page: page.render(App)) ``` This pattern works well with `asyncio` and other async APIs in Flet apps. For more @@ -161,6 +163,8 @@ def App(): ) return ft.TextButton("Open", on_click=lambda: set_show_confirm(True)) + +ft.run(lambda page: page.render(App)) ``` [`ft.use_ref()`][flet.use_ref] is helpful here because the value survives re-renders without