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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/flet/lib/src/controls/container.dart
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ class ContainerControl extends StatelessWidget with FletStoreMixin {
Widget? container;

var onAnimationEnd = control.getBool("on_animation_end", false)!
? () => control.triggerEvent("animation_end" "container")
? () => control.triggerEvent("animation_end", "container")
: null;
if ((onClick || url != null || onLongPress || onHover || onTapDown) &&
ink &&
Expand Down
25 changes: 19 additions & 6 deletions packages/flet/lib/src/controls/page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ class _PageControlState extends State<PageControl> with WidgetsBindingObserver {
bool _keyboardHandlerSubscribed = false;
String? _prevViewRoutes;

// Ensures that the first_frame lifecycle event is fired only once.
bool _firstFrameEventSent = false;

final Map<int, MultiView> _multiViews = <int, MultiView>{};
bool _registeredFromMultiViews = false;
List<DeviceOrientation>? _appliedDeviceOrientations;
Expand All @@ -72,6 +75,7 @@ class _PageControlState extends State<PageControl> with WidgetsBindingObserver {

WidgetsBinding.instance.addObserver(this);
_updateMultiViews();
_scheduleFirstFrameNotification();

_routeParser = RouteParser();

Expand Down Expand Up @@ -252,6 +256,18 @@ class _PageControlState extends State<PageControl> with WidgetsBindingObserver {
}
}

/// Schedules a one-time callback to emit the `first_frame` lifecycle event
/// after the first Flutter frame is rendered for this page.
void _scheduleFirstFrameNotification() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _firstFrameEventSent) {
return;
}
_firstFrameEventSent = true;
widget.control.triggerEventWithoutSubscribers("first_frame");
});
}

void _routeChanged() {
FletBackend.of(context).onRouteUpdated(_routeState.route);
}
Expand Down Expand Up @@ -341,8 +357,7 @@ class _PageControlState extends State<PageControl> with WidgetsBindingObserver {
var appStatus = context
.select<FletBackend, ({bool isLoading, String error})>((backend) =>
(isLoading: backend.isLoading, error: backend.error));
var appStartupScreenMessage =
backend.appStartupScreenMessage ?? "";
var appStartupScreenMessage = backend.appStartupScreenMessage ?? "";
var formattedErrorMessage =
backend.formatAppErrorMessage(appStatus.error);

Expand Down Expand Up @@ -503,14 +518,12 @@ class _PageControlState extends State<PageControl> with WidgetsBindingObserver {

var backend = FletBackend.of(context);
var showAppStartupScreen = backend.showAppStartupScreen ?? false;
var appStartupScreenMessage =
backend.appStartupScreenMessage ?? "";
var appStartupScreenMessage = backend.appStartupScreenMessage ?? "";

var appStatus =
context.select<FletBackend, ({bool isLoading, String error})>(
(backend) => (isLoading: backend.isLoading, error: backend.error));
var formattedErrorMessage =
backend.formatAppErrorMessage(appStatus.error);
var formattedErrorMessage = backend.formatAppErrorMessage(appStatus.error);

var views = widget.control.children("views");
List<Page<dynamic>> pages = [];
Expand Down
63 changes: 63 additions & 0 deletions sdk/python/packages/flet/src/flet/controls/page.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import asyncio
import inspect
import logging
import sys
import threading
Expand Down Expand Up @@ -424,6 +425,18 @@ class Page(BasePage):
"""
TBD
"""

on_first_frame: Optional[ControlEventHandler["Page"]] = None
"""
Called once after the client renders the very first frame.

Useful for starting implicit animations or other work that must wait until
the initial layout completes.

Pair with [`Page.post_frame_callback()`][flet.Page.post_frame_callback] to register
callbacks without wiring up an explicit event handler.
"""

_services: list[Service] = field(default_factory=list)
_user_services: ServiceRegistry = field(default_factory=lambda: ServiceRegistry())

Expand All @@ -447,6 +460,8 @@ def __post_init__(
self.__last_route = None
self.__query: QueryString = QueryString(self)
self.__authorization: Optional[Authorization] = None
self.__first_frame_callbacks: list[Callable[[], Any]] = []
self.__first_frame_fired = False

def get_control(self, id: int) -> Optional[BaseControl]:
"""
Expand Down Expand Up @@ -523,8 +538,56 @@ def before_event(self, e: ControlEvent):
if view_index is not None:
e.view = views[view_index]

elif e.name == "first_frame":
self.__handle_first_frame_event()

return super().before_event(e)

def post_frame_callback(self, callback: Callable[[], Any]):
"""
Schedule a callable to run immediately after the page finishes
rendering its very first frame.

Args:
callback: A synchronous function or coroutine function to execute after the
initial layout completes. Already-rendered pages trigger the callback
immediately.

Raises:
TypeError: If `callback` is not callable.
"""
if not callable(callback):
raise TypeError("callback must be callable")

if self.__first_frame_fired:
self.__run_first_frame_callback(callback)
else:
self.__first_frame_callbacks.append(callback)

def __handle_first_frame_event(self):
"""Drain and execute callbacks when the Flutter client signals first frame."""
if self.__first_frame_fired:
return

self.__first_frame_fired = True
callbacks = self.__first_frame_callbacks[:]
self.__first_frame_callbacks.clear()
for cb in callbacks:
self.__run_first_frame_callback(cb)
Comment on lines +562 to +576
Copy link

Copilot AI Nov 21, 2025

Choose a reason for hiding this comment

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

Potential race condition: The __first_frame_callbacks list and __first_frame_fired flag are accessed without synchronization. If post_frame_callback() is called from a different thread while __handle_first_frame_event() is executing, the callback could be:

  1. Added to the list after it's been copied but before it's cleared (line 573-574), causing it to be lost
  2. Checked against __first_frame_fired between lines 569-572, seeing False, then the flag gets set to True before appending (line 565), causing the callback to never execute

Consider using a lock (e.g., self._lock if available, or a new dedicated lock) to protect access to both __first_frame_fired and __first_frame_callbacks.

Copilot uses AI. Check for mistakes.

def __run_first_frame_callback(self, callback: Callable[[], Any]):
"""Execute a queued callback asynchronously, awaiting it if needed."""

async def _runner():
try:
result = callback()
if inspect.isawaitable(result):
await result
except Exception:
logger.exception("Error running post_frame_callback callback")
Copy link

Copilot AI Nov 21, 2025

Choose a reason for hiding this comment

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

The error message in the exception handler is redundant and unclear. It says "Error running post_frame_callback callback" where "post_frame_callback" and "callback" are repeated. Consider simplifying to "Error running post-frame callback" or "Error in post_frame_callback".

Suggested change
logger.exception("Error running post_frame_callback callback")
logger.exception("Error running post-frame callback")

Copilot uses AI. Check for mistakes.

self.run_task(_runner)

def run_task(
self,
handler: Callable[InputT, Awaitable[RetT]],
Expand Down
Loading