Skip to content

Fix window.maximized reverting to unmaximized on macOS when set with page.title#6712

Open
davidlawson wants to merge 1 commit into
flet-dev:mainfrom
davidlawson:fix/window-maximized-title-race
Open

Fix window.maximized reverting to unmaximized on macOS when set with page.title#6712
davidlawson wants to merge 1 commit into
flet-dev:mainfrom
davidlawson:fix/window-maximized-title-race

Conversation

@davidlawson

@davidlawson davidlawson commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Description

Setting page.window.maximized = True during app startup on macOS intermittently causes the window to maximize and then immediately animate back to its unmaximized frame. In a real production app this happened on ~58% of launches (7 of 12 in a controlled loop); the failure rate scales with how much work is in the first patch, so trivial apps rarely show it while apps with a non-trivial first render show it often.

Root cause

A typical startup sets page.title and page.window.maximized = True in the same synchronous block, so they land in a single PATCH_CONTROL message. Applying that patch fires two independent listeners on WindowService:

  1. update() (window.dart:96) — the window control's own patch changed.
  2. _onPageChanged() (window.dart:103) — a different control (the parent page) changed, specifically its title.

Both call _scheduleWindowUpdate(), so one patch enqueues two _updateWindow() runs chained on _pendingWindowUpdate.

  • Run 1 reaches the maximize block (window.dart:318): maximized (true) != _maximizedawait maximizeWindow() → on macOS this is windowManager.maximize()NSWindow.zoom(_:), an animated resize (~300-400ms; see WindowManager.swift:532 windowShouldZoom/:540 windowDidResize in the window_manager plugin, v0.5.2).
  • _updateWindow always re-reads every property live from control/parent — so run 2 is inherently redundant, replaying the exact same already-applied values. But it still executes, and its own maximizeWindow()/unmaximizeWindow() guards (isMaximized()) can read state from mid-animation, when the OS hasn't finished zooming yet.
  • Crucially, NSWindow.zoom(_:) is a toggle — calling it a second time on a window that's still (or already) zoomed animates it back to its pre-zoom frame. Whether the timing lines up for this to bite is what makes the bug intermittent.

The two-runs-per-patch behavior is the actual bug: _updateWindow was clearly designed to be called once per settle-point (it reconciles every property from current live state in one pass), and nothing about a page-level title change needs its own separate pass when a window-level update for the same patch is already queued.

Fix

_scheduleWindowUpdate() now coalesces calls that land before the previously-scheduled run has actually started (as opposed to completed), via an _updateScheduled flag reset at the top of the run:

void _scheduleWindowUpdate() {
  if (_updateScheduled) {
    return;
  }
  _updateScheduled = true;
  _pendingWindowUpdate = _pendingWindowUpdate.catchError((_) {}).then((_) {
    _updateScheduled = false;
    if (_initWindowStateCompleter.isCompleted) {
      return _updateWindow(control.backend);
    }
    return _initWindowStateCompleter.future
        .then((_) => _updateWindow(control.backend));
  });
}

Because update() and _onPageChanged() both fire synchronously while one patch is being applied, the second call reliably observes _updateScheduled == true and no-ops — collapsing the pair into the single run _updateWindow was already built to handle. A _scheduleWindowUpdate() call that arrives later, after the current run has already started, is unaffected: it's still chained via .then() onto the in-flight run's future, so it can't execute concurrently with (or interrupt) a native operation that's already in progress. Event-driven state refreshes for legitimate cases (the user drags/moves the window, focus changes, etc.) are untouched — this only removes the duplicate scheduling, not any event handling.

Testing

No existing Dart test harness covers WindowService (packages/flet/test/ only has transport/utils suites, and this class wraps native window_manager platform-channel calls), so this was verified at the application level:

  • Before the fix, a wire-protocol trace of a real desktop Flet app (Python side) confirmed maximized: true is sent to the client exactly once per launch, yet the window visibly reverted on 7 of 12 launches — proving the bug is entirely client-side.
  • A minimal standalone repro (title + maximized = True + a moderately sized first render, to widen the race window) failed 2 of 8 runs and showed the tell-tale double maximize window event for a single maximized = True assignment on every run, pass or fail:
    import asyncio
    import flet as ft
    
    
    async def main(page: ft.Page):
        events = []
    
        def on_window_event(e: ft.WindowEvent):
            if e.type in (ft.WindowEventType.MAXIMIZE, ft.WindowEventType.UNMAXIMIZE):
                events.append(e.type.value)
                print(f"window event: {e.type.value}", flush=True)
    
        page.window.on_event = on_window_event
    
        page.title = "Maximize race repro"
        page.window.maximized = True
    
        page.render(
            lambda: ft.Column(
                controls=[
                    ft.Row(controls=[ft.Text(f"cell {r}.{c}") for c in range(10)])
                    for r in range(100)
                ],
                scroll=ft.ScrollMode.AUTO,
            )
        )
    
        await asyncio.sleep(4.0)
        print(f"RESULT: {'FAIL' if events and events[-1] == 'unmaximize' else 'OK'} "
              f"(events: {events})", flush=True)
        await page.window.destroy()
    
    
    ft.run(main)
  • After the fix, the same real desktop app was re-run 8/8 clean (previously 7/12 failed under the same harness), and dart analyze/dart format --output=none pass on the changed file.

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • This change requires a documentation update

Checklist

  • I signed the CLA.
  • I have performed a self-review of my own code.
  • My code follows the style guidelines of this project.
  • I have commented my code, particularly in hard-to-understand areas.
  • My changes generate no new warnings.
  • New and existing tests pass locally with my changes.
  • I have made corresponding documentation changes, if applicable.
  • I have added changelog entries for user-facing changes, if applicable.
  • I have updated release guide pages and website/sidebars.yml for breaking changes, removals, and deprecations, if applicable.

Summary by Sourcery

Coalesce window update scheduling to prevent duplicate window state reconciliations for a single patch, avoiding intermittent maximize/unmaximize races on macOS when title and window properties change together.

Bug Fixes:

  • Prevent page.window.maximized = True on macOS from intermittently reverting to the previous unmaximized state when applied in the same patch as page.title by ensuring only one window update run is scheduled per patch.

Enhancements:

  • Introduce an _updateScheduled guard in WindowService to avoid redundant _updateWindow executions when multiple callers schedule updates from the same patch.

Documentation:

  • Add unreleased changelog entry documenting the macOS window maximize race fix when page.title and page.window.maximized are set together.

…page.title

WindowService.update() and _onPageChanged() both call
_scheduleWindowUpdate(), so a patch that changes both the window and
the page title in one go schedules two sequential _updateWindow()
runs. The second run is pure redundant re-application (_updateWindow
always re-reads live property values), but if the first run already
started an animated native operation - macOS NSWindow.zoom for
maximizeWindow()/unmaximizeWindow() - the second run's isMaximized()
guard can still read pre-animation state and reissue the same call
while the window is mid-zoom. zoom: toggles an already-zoomed window,
so page.window.maximized = True can silently animate itself right
back to unmaximized moments after being applied.

Coalesce calls to _scheduleWindowUpdate() that land before the
previously-scheduled run has actually started, via an
_updateScheduled flag reset when the run begins. This collapses any
same-tick burst of calls into the single run _updateWindow was
already designed to handle, without changing event-driven state
refreshes for legitimate cases (user moves/focuses the window, etc).

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

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.

1 participant