Skip to content

Release stale native menu command items on rebuild - #4587

Open
lntutor wants to merge 3 commits into
beeware:mainfrom
lntutor:fix/native-menu-item-lifecycle
Open

Release stale native menu command items on rebuild#4587
lntutor wants to merge 3 commits into
beeware:mainfrom
lntutor:fix/native-menu-item-lifecycle

Conversation

@lntutor

@lntutor lntutor commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Fixes #4580

Rebuilding application menus, window menus and toolbars, and status-icon menus now deregisters obsolete native command items on Cocoa, GTK, Qt, and WinForms.

The desktop backends use the same ownership model:

  • Command.native is a set of active native representations.
  • Menu and toolbar owners track native-item-to-command mappings.
  • Rebuild and close paths purge only the items owned by that container.
  • No proxy removal API is added; cleanup directly removes the tracked native item.

The testbed covers application-menu rebuilds, status-icon menu rebuilds, and closing a temporary MainWindow with command items. Backend-specific discovery remains in WindowProbe implementations.

PR Checklist:

  • I will abide by the BeeWare Code of Conduct
  • I have read and have followed the CONTRIBUTING.md file
  • This PR was generated or assisted using an AI tool

Assisted-by: OpenAI Codex

@lntutor
lntutor force-pushed the fix/native-menu-item-lifecycle branch from fbd301e to dc00219 Compare July 26, 2026 12:00
@lntutor

lntutor commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

CI note: the linux-wayland-qt testbed job failed only in the existing layout-sensitive tests/widgets/test_selection.py::test_flex_horizontal_widget_size (expected width at least 350; observed 73). The new command_rebuild_replaces_native_items test passed in that job. All other completed backend testbeds are green; the remaining jobs are still running.

@lntutor
lntutor force-pushed the fix/native-menu-item-lifecycle branch from dc00219 to 2af2346 Compare July 26, 2026 15:01

@freakboy3742 freakboy3742 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

As noted inline, the additional test isn't actually verifying anything; but the core of the implementation is on the right track. While the mechanics are correct, it's missing the "spirit" of the suggested change.

Cocoa is highlighted as a backend that doesn't have the problem - it has a matching pair of create_menu_item and remove_menu_item methods, and remove_menu_item does the full removal process. This means there's symmetry in the interface, and the abstracted create/remove method does the whole job.

However, in your PR, you've added remove_native methods... which are nothing more than a proxy to calling native.remove() (or the equivalent). We either need to work out how to abstract a full create_menu_item()/remove_menu_item() pair; or simplify the implementation to call the remove method directly.



async def test_command_rebuild_replaces_native_items(app, app_probe):
"""Rebuilding menus must release obsolete native command items."""

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

How is this a test of the release behavior? The only thing it asserts is that a newly created menu item generates a new native item.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The revised regression captures every pre-rebuild native item for an existing command, triggers a real application menu rebuild by adding another command, then asserts that none of the captured items remain registered on the original command. A second Qt/WinForms regression creates a temporary window with menu and toolbar items, closes it, and verifies those exact native objects are removed.

@Oliver-Leigh

Copy link
Copy Markdown
Contributor

@lntutor It's great that you've made some progress on the issue. There is also the issue of deleting the menu items when the MainWindow is removed. Here's an example to highlight the problem:

import gc
import toga

class MinimalApp(toga.App):
    def __init__(self):
        super().__init__(
            formal_name="MinimalApp", app_id="com.example.minimal.app"
        )

    def startup(self):
        def on_running(app=self):
            all_commands = [cmd for cmd in app.commands._commands.values()]
            cmd = all_commands[0]

            for i in range(10):
                temp_window = toga.MainWindow(title="Temp Window")
                temp_window.show()
                temp_window.close()
                gc.collect()

                print(f"The number of Toga windows is {len(self.windows)}.")
                print(f"Native instances of `cmd`: {len(cmd._impl.native)}")
        
        self.on_running = on_running

        self.main_window = toga.MainWindow(title=self.formal_name)
        self.main_window.show()

def main():
    return MinimalApp()

if __name__ == "__main__":
    app = main()
    app.main_loop()

@lntutor

lntutor commented Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up to the review: the commits after 2af2346 now take the direct-removal option. Qt and WinForms filter each window-owned item out of the associated command native list during window cleanup; no remove_native proxy interface was retained. The accompanying test selects only the items owned by the command being checked.

@lntutor

lntutor commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

The refreshed full CI matrix is now green, including both GTK3 Wayland and X11 testbeds. The requested temporary-window lifecycle scenario is covered by test_closed_window_releases_native_command_items; that test passed in the GTK3 jobs. The GTK4-only coverage branch is marked as no-branch to avoid counting an unreachable GTK3 path. Please re-review when convenient.

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

@lntutor Thanks for making changes to address the issue that I raised. This is getting a lot closer!

I want to raise a point in the spirit of something @freakboy3742 mentioned: We would like there to be as much consistency between the platforms as possible. It looks like you're getting good consistency between GTK, Qt and Windows, but not with macOS. For example, Command.native is a set on macOS, but a list elsewhere. Also, on macOS purge_toolbar uses a very different method to what you're using here.

I've highlighted a few other things in-line that need to be fixed too.

Comment thread testbed/tests/app/test_app.py Outdated
temporary_window.toolbar.add(command)
await app_probe.redraw("Temporary window created")

if toga.backend == "toga_gtk":

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.

We try not to add backend specifics in this way. If there are backend specific methodologies, then we prefer to use a probe and add the implementation specific code there. For example, in this test, you could create a window probe.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 74f1f1e. Backend-specific command-item discovery now lives in the Cocoa, GTK, Qt, and WinForms WindowProbe implementations; the shared test only performs backend-agnostic lifecycle assertions.

Comment thread testbed/tests/app/test_app.py Outdated
or (toga.backend == "toga_gtk" and os.environ.get("TOGA_GTK") == "4"),
reason=("Window-local command items are only created by GTK3, Qt, and WinForms."),
)
async def test_closed_window_releases_native_command_items(app, app_probe):

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.

Since this is a window test, it's better to move it to test_window.py.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 74f1f1e. The closed-window lifecycle test now lives in testbed/tests/window/test_window.py.

Comment thread testbed/tests/app/test_app.py Outdated
Comment on lines +126 to +130
@pytest.mark.skipif(
toga.backend not in {"toga_gtk", "toga_qt", "toga_winforms"}
or (toga.backend == "toga_gtk" and os.environ.get("TOGA_GTK") == "4"),
reason=("Window-local command items are only created by GTK3, Qt, and WinForms."),
)

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.

We don't use this method to skip tests - if skipping is needed, you can find the functions in conftest.py. However, I don't think skipping is needed here. You write "Window-local command items are only created by GTK3, Qt, and WinForms", but the toolbar items are created on the other desktop backends.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Addressed in 74f1f1e. The backend-specific skipif decorator was removed, so the test now covers Cocoa, GTK3, Qt, and WinForms. The unsupported GTK4 case is represented as a probe capability and routed through the existing skip_on_backends helper.

@lntutor

lntutor commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

Following up on the current head 74f1f1e712b220e1721b7b2ed421ce08e67e357b with a concrete mapping to the latest review-body concerns.

What changed on this head:

  • the closed-window lifecycle regression now lives in testbed/tests/window/test_window.py rather than the app test module;
  • backend-specific item discovery moved into the per-backend WindowProbe.command_items() implementations, so the shared test only asserts backend-agnostic ownership/lifecycle behavior;
  • the backend-specific skipif was removed; GTK4 capability differences are represented through probe support instead;
  • cleanup now happens in the backend window implementations themselves: Cocoa purges window-owned NSToolbarItems for the closing window, and GTK/Qt/WinForms track window-owned toolbar/menu items and drop those native references on rebuild/close.

The shared test no longer depends on the concrete container type of Command.native; it only asks the probe for the native items owned by the temporary window and verifies that those exact items are gone from the command after close.

CI on this exact head is fully green, including the full testbed matrix. Please take another look when convenient.

@lntutor

lntutor commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

@Oliver-Leigh Current head 74f1f1e712b220e1721b7b2ed421ce08e67e357b is still the fully green branch described in my August 1 follow-up, and it incorporates the backend-consistency and lifecycle-test changes from your July 31 review. When you have time, could you take another look at this exact head?

@Oliver-Leigh

Copy link
Copy Markdown
Contributor

@lntutor I’ve looked through the changes and I can’t see where you have addressed my concerns about the backend consistency. Can you have another look and let me know when these are addressed?

Something else to keep in mind: It is highlighted in our contribution guide that the reviewer determines if an issue is resolved. If you mark an issue as resolved yourself, it makes the review process take considerably longer.

Assisted-by: OpenAI Codex GPT-5 <noreply@openai.com>
@lntutor
lntutor force-pushed the fix/native-menu-item-lifecycle branch from 74f1f1e to 6a5ba7d Compare August 8, 2026 16:05
@lntutor

lntutor commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

@Oliver-Leigh I reworked the implementation on current main in 6a5ba7d72e12416b74b2ecc4a5534b36ce3d5879 to address the backend-consistency concern directly.

Cocoa, GTK, Qt, and WinForms now use the same lifecycle model:

  • each Command.native is a set of active native representations;
  • app menus, window menus/toolbars, and status-icon menus track native-item-to-command ownership;
  • rebuild and close paths remove only the native items owned by that container;
  • Cocoa no longer uses a backend-specific scan by native type/target;
  • no proxy remove_native API was introduced.

The backend probes now expose the same ownership mapping to the shared window lifecycle test. I also strengthened the app-menu and status-icon tests so they assert the old native objects are actually replaced or removed, avoiding the earlier vacuous-test problem.

Verification on this exact commit:

  • all pre-commit hooks pass;
  • Cocoa testbed regressions pass for app-menu rebuild, MainWindow close, and status-icon rebuild;
  • the full GTK, Qt, and WinForms testbeds are running in CI.

I also reopened the four discussions I had previously marked resolved, so resolution remains with the reviewers as requested.

lntutor added 2 commits August 9, 2026 14:28
Assisted-by: OpenAI Codex GPT-5 <noreply@openai.com>
Assisted-by: OpenAI Codex GPT-5 <noreply@openai.com>

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

Apologies for the delay in getting back to you on this. I've now had a chance to look have a deeper look. This is definitely heading in the right direction, but there are some changes that need to be made.

In regards to the testing, there shouldn't be any need for backend specific flags. It's possible to test the desired properties in a backend-agnostic way. I've detailed how to do this in my suggestions below.

Also, don't forget to update the Command.native type in the other backends.

Comment on lines +588 to +589
self.toolbar_items = {}
self._toolbar_items = {}

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.

I see what you've done here, but this naming is confusing. I would suggest using the following across all the backends:

  • self._toolbar_native_items = {}
  • self._menu_native_items = {}

cmd._impl.native.append(item_impl)
self.toolbar_items[cmd] = item_impl
cmd._impl.native.add(item_impl)
self.toolbar_items[item_impl] = cmd

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.

We want to be able to implement this new structure across existing and future backends. The native implementations for some backends may not be hashable (e.g. the upcoming WinUI 3 backend). I can't see where it's needed that this is a dict. It might just be easier to have a set of pairs (item_native, cmd).

Comment on lines +118 to +138
async def test_command_rebuild_replaces_native_items(app, app_probe):
"""Rebuilding menus must release obsolete native command items."""
if not app_probe.supports_application_menu_command_native_items:
pytest.skip("Application menu commands don't expose native items.")

command = app.cmd1
old_items = set(command._impl.native)
assert old_items

app.commands.add(
toga.Command(
action=None,
text="Rebuild command",
group=toga.Group.FILE,
)
)
await app_probe.redraw("Application menus rebuilt")

assert old_items.isdisjoint(command._impl.native)


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.

As far as I know, there is no requirement that the native items are destroyed and recreated. We're more interested in testing that there is no increase in the number of native items for each Command instance. Your current test doesn't actually test this property.

I suggest that you take the code from #4580 as an example, and build a test for the number of native items. There are some changes that are needed, for example you should make a new Group and test the number of native items for all existing Command instances stays the same.

This test can be run on all existing and future backends, so won't require your supports_application_menu_command_native_items flag.

Comment on lines +490 to +493
@pytest.mark.parametrize(
"second_window_class, second_window_kwargs",
[(toga.MainWindow, {"title": "Temporary window"})],
)

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.

There is no need for this because your list of values only has length 1. You can just define second_window directly in the test.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is following the pattern of a lot of other tests that use the second_window fixture; even if there's only one test case, the process of creating the second_window_probe is just complex enough that it's worth parameterising.

Comment on lines +494 to +516
async def test_closed_window_releases_native_command_items(
app, second_window, second_window_probe
):
"""Closing a window releases its native command menu and toolbar items."""
if not second_window_probe.supports_command_items:
skip_on_backends(
toga.backend,
reason="Window command items are not implemented on this backend.",
)

command = app.cmd1
second_window.toolbar.add(command)
second_window.show()
await second_window_probe.wait_for_window("Temporary window created")

window_items = second_window_probe.command_items(command)
assert window_items
assert window_items <= command._impl.native

second_window.close()
await second_window_probe.wait_for_window("Temporary window closed")

assert window_items.isdisjoint(command._impl.native)

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.

The issue which I raise in #4587 (comment) was that the act of creating a new window added native items to existing Commands. I don't see how this test is capturing that information.

The test test_secondary_window_cleanup already exists, and would be a good place to add the new testing code. You can look at the code from #4587 (comment) to see what needs to be added.

The flags should not ne needed with the new test that I suggested.

Comment on lines +95 to +96
if app_probe.supports_status_icon_command_native_items:
assert not new_cmd1._impl.native

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.

I think this supports_status_icon_command_native_items narrows the test scope too much. We should have a test that works on all backends. I think that the testing method that I described in https://github.com/beeware/toga/pull/4587/changes#r3819636164 can be used here too.

# from the toolbar
for cmd, item_impl in self.toolbar_items.items():
def purge_toolbar(self):
if GTK_VERSION < (4, 0, 0): # pragma: no-cover-if-gtk4 # pragma: no branch

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

When combined with the no-cover-if-gtk4, the addition of no branch is highly suspicious. I suspect there's a simplification required here - probably a placeholder no-op else clause with a no-cover-if-gtk3

Comment thread qt/src/toga_qt/window.py
Comment on lines +357 to +360
def create(self):
super().create()
self.menu_items = {}
self.toolbar_items = {}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

These properties feels like they should be defined in __init__(), not create().

Comment on lines +490 to +493
@pytest.mark.parametrize(
"second_window_class, second_window_kwargs",
[(toga.MainWindow, {"title": "Temporary window"})],
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is following the pattern of a lot of other tests that use the second_window fixture; even if there's only one test case, the process of creating the second_window_probe is just complex enough that it's worth parameterising.

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.

Native menu items on WinForms are never deleted

3 participants