Repository: beeware/toga

Issue:
Widget IDs are not cleared when a window closes
### Describe the bug

When a window is created upon the click of a button or other widget in a main window, IDs of widgets within that sub-window are not cleared, causing window creation and displaying to fail when the window is closed and re-opened.

### Steps to reproduce

1. Create any main window with some widget that instantiates and shows another window when the widget is pressed, clicked, etc.
2. The sub-window should use widget IDs, such as paths (I use this format for determining which widget to update when a network event is received in my application).
3. Close the window and try to re-open it. You will get an error along the lines of `KeyError: "There is already a widget with the id '/devices/0/inputs/0/sends/0/Gain/value'"`


### Expected behavior

Widget IDs should be cleared when the window is either closed, deleted, or otherwise ceases to exist.

### Screenshots

_No response_

### Environment

- Operating System: Windows 10 22H2 (AMD64) build 19045.4291
- Python version: 3.12.2
- Software versions:
  - Briefcase: 0.3.17
  - Toga: 0.4.2+


### Logs

```
[19:49:53] Error in handler: "There is already a widget with the id '/devices/0/inputs/0/sends/0/Gain/value'"                                                      subprocess.py:681
           Traceback (most recent call last):                                                                                                                      subprocess.py:681
             File "C:\Users\ethin\source\venvs\uaccess\Lib\site-packages\toga\handlers.py", line 80, in _handler                                                   subprocess.py:681
               result = handler(interface, *args, **kwargs)                                                                                                        subprocess.py:681
                        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^                                                                                                        subprocess.py:681
             File "C:\Users\ethin\source\uaccess\src\uaccess\app.py", line 216, in open_sends                                                                      subprocess.py:681
               self.sends_dialogs[widget.id].build()                                                                                                               subprocess.py:681
             File "C:\Users\ethin\source\uaccess\src\uaccess\dialogs\sends_dialog.py", line 27, in build                                                           subprocess.py:681
               self.sends_content.add(edit)                                                                                                                        subprocess.py:681
             File "C:\Users\ethin\source\venvs\uaccess\Lib\site-packages\toga\widgets\base.py", line 100, in add                                                   subprocess.py:681
               child.window = self.window                                                                                                                          subprocess.py:681
               ^^^^^^^^^^^^                                                                                                                                        subprocess.py:681
             File "C:\Users\ethin\source\venvs\uaccess\Lib\site-packages\toga\widgets\base.py", line 227, in window                                                subprocess.py:681
               window.app.widgets._add(self)                                                                                                                       subprocess.py:681
             File "C:\Users\ethin\source\venvs\uaccess\Lib\site-packages\toga\app.py", line 192, in _add                                                           subprocess.py:681
               raise KeyError(f"There is already a widget with the id {widget.id!r}")                                                                              subprocess.py:681
           KeyError: "There is already a widget with the id '/devices/0/inputs/0/sends/0/Gain/value'"                                                              subprocess.py:681
```


### Additional context

_No response_


Discussion:
Thanks for the report. If you're able to provide actual code for a minimal reproduction example, rather than a "word description" of the problem, that would be a lot more helpful for debugging purposes, as it guarantees we're looking at the same problem you are.
The repro is something along the lines:

```python
import toga

class SendsDialog(toga.Window):
	def __init__(self, device_id, input_id):
		super().__init__(title="Edit Sends", size=(400, 200))
		self.sends_content = toga.Box()
		self.input = input_id
		self.device = device_id
		self.content = self.sends_content

	def build(self):
		path = f"/devices/0/inputs/0/sends/0/Gain/value"
		sendname = "AUX 1"
		val = -144.0
		default = -144.0
		min = -144.0
		max = 12.0
		label = toga.Label(f"{sendname} Gain")
		edit = toga.NumberInput(id=path, step=1.0, min=min, max = max, value = val if val is not None else default, on_change=self.on_prop_float_change)
		self.sends_content.add(label)
		self.sends_content.add(edit)
		self.sends_content.add(toga.Button("&Close", on_press=self.close_window))

	async def on_prop_float_change(self, widget, *args, **kwargs):
		pass # Do nothing

	def close_window(self, widget, *args, **kwargs):
		self.close()

class Repro(toga.App):
	def startup(self):
		self.on_exit = self.handle_exit
		self.main_window = toga.MainWindow(title=f"{self.formal_name} [Loading]")
		self.main_container = toga.Box(
			style=Pack(direction=COLUMN, padding=10),
		)
		self.sends_btn = toga.Button("&Sends", on_press=self.open_sends)
		self.main_container.content.append(self.sends_btn)
		self.main_window.content = self.main_container
		self.main_window.show()

	async def close_app(self, widget, **kwargs):
		self.exit()

	def open_sends(self, widget, *args, **kwargs):
		dialog = SendsDialog(0, 0)
		dialog.build()
		dialog.show()

def main():
	return Repro()
```

Resolving patch:
diff --git a/core/src/toga/window.py b/core/src/toga/window.py
--- a/core/src/toga/window.py
+++ b/core/src/toga/window.py
@@ -272,6 +272,8 @@ def close(self) -> None:
         undefined, except for :attr:`closed` which can be used to check if the window
         was closed.
         """
+        if self.content:
+            self.content.window = None
         self.app.windows.discard(self)
         self._impl.close()
         self._closed = True


Test patch:
diff --git a/core/tests/test_window.py b/core/tests/window/test_window.py
similarity index 89%
rename from core/tests/test_window.py
rename to core/tests/window/test_window.py
--- a/core/tests/test_window.py
+++ b/core/tests/window/test_window.py
@@ -381,6 +381,90 @@ def test_screen_position(window, app):
     assert window.screen_position == (100, 100)
 
 
+def test_widget_id_reusablity(window, app):
+    """Widget IDs can be reused after the associated widget's window is closed."""
+    # Common IDs
+    CONTENT_WIDGET_ID = "sample_window_content"
+    LABEL_WIDGET_ID = "sample_label"
+
+    label_widget = toga.Label(text="Sample Label", id=LABEL_WIDGET_ID)
+    second_window_content = toga.Box(id=CONTENT_WIDGET_ID, children=[label_widget])
+
+    third_window_content = toga.Box(children=[])
+
+    # A widget ID is only "used" when it is part of a visible layout;
+    # creating a widget and *not* putting it in a layout isn't an problem.
+    try:
+        new_label_widget = toga.Label(text="New Label", id=LABEL_WIDGET_ID)
+    except KeyError:
+        pytest.fail("Widget IDs that aren't part of a layout can be re-used.")
+
+    # Create 2 new visible windows
+    second_window = toga.Window()
+    second_window.show()
+
+    third_window = toga.Window()
+    third_window.show()
+
+    # Assign the window content widget to the second window
+    second_window.content = second_window_content
+    assert CONTENT_WIDGET_ID in app.widgets
+    assert LABEL_WIDGET_ID in app.widgets
+
+    # CONTENT_WIDGET_ID is in use, so a widget with that ID can't be assigned to a window.
+    with pytest.raises(
+        KeyError,
+        match=r"There is already a widget with the id 'sample_label'",
+    ):
+        third_window.content = new_label_widget
+    assert CONTENT_WIDGET_ID not in third_window.widgets
+    assert LABEL_WIDGET_ID not in third_window.widgets
+
+    # Adding content that has a child with a re-used ID should raise an error
+    with pytest.raises(
+        KeyError,
+        match=r"There is already a widget with the id 'sample_label'",
+    ):
+        third_window.content = toga.Box(children=[new_label_widget])
+    assert CONTENT_WIDGET_ID not in third_window.widgets
+    assert LABEL_WIDGET_ID not in third_window.widgets
+
+    # Adding a child with a re-used ID should raise an error.
+    third_window.content = third_window_content
+    with pytest.raises(
+        KeyError,
+        match=r"There is already a widget with the id 'sample_label'",
+    ):
+        third_window_content.add(new_label_widget)
+    assert CONTENT_WIDGET_ID not in third_window.widgets
+    assert LABEL_WIDGET_ID not in third_window.widgets
+
+    # Creating a new widget with same widget ID should not raise KeyError
+    try:
+        another_label_widget = toga.Label(text="Another Label", id=LABEL_WIDGET_ID)
+    except KeyError:
+        pytest.fail("Widget IDs that aren't part of a layout can be re-used.")
+
+    # If a widget using an ID is being *replaced*, the ID can be re-used.
+    try:
+        second_window.content = another_label_widget
+    except KeyError:
+        pytest.fail("Widget IDs that are replaced can be re-used.")
+
+    # Close Window 2
+    second_window.close()
+    assert CONTENT_WIDGET_ID not in app.widgets
+    assert LABEL_WIDGET_ID not in app.widgets
+
+    # Now that second_window has been closed, the duplicate ID can be used
+    try:
+        third_window_content.add(new_label_widget)
+    except KeyError:
+        pytest.fail("Widget IDs that are replaced can be re-used.")
+
+    third_window.close()
+
+
 def test_info_dialog(window, app):
     """An info dialog can be shown."""
     on_result_handler = Mock()