From e67df73c4e5173850f5ee2ebb5b58f0c8445dbd2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 3 Sep 2025 17:21:28 +0200 Subject: [PATCH] Clarify behavior of ConfigEntry.async_on_state_change (#151628) --- homeassistant/config_entries.py | 8 ++++- tests/test_config_entries.py | 62 +++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index f5ccf9c3143..37b4fbe60e6 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1178,7 +1178,13 @@ class ConfigEntry[_DataT = Any]: @callback def async_on_state_change(self, func: CALLBACK_TYPE) -> CALLBACK_TYPE: - """Add a function to call when a config entry changes its state.""" + """Add a function to call when a config entry changes its state. + + Note: async_on_unload listeners are called before the state is changed to + NOT_LOADED when unloading a config entry. This means the passed function + will not be called after a config entry has been unloaded, the last call + will be after the state is changed to UNLOAD_IN_PROGRESS. + """ if self._on_state_change is None: self._on_state_change = [] self._on_state_change.append(func) diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 9a62fd421b7..a051e09066e 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -4784,6 +4784,68 @@ async def test_entry_state_change_calls_listener( assert entry.state is target_state +@pytest.mark.parametrize( + ("source_state", "target_state", "transition_method_name", "call_count"), + [ + ( + config_entries.ConfigEntryState.NOT_LOADED, + config_entries.ConfigEntryState.LOADED, + "async_setup", + 2, + ), + ( + config_entries.ConfigEntryState.LOADED, + config_entries.ConfigEntryState.NOT_LOADED, + "async_unload", + 1, + ), + ( + config_entries.ConfigEntryState.LOADED, + config_entries.ConfigEntryState.LOADED, + "async_reload", + 1, + ), + ], +) +async def test_entry_state_change_wrapped_in_on_unload( + hass: HomeAssistant, + manager: config_entries.ConfigEntries, + source_state: config_entries.ConfigEntryState, + target_state: config_entries.ConfigEntryState, + transition_method_name: str, + call_count: int, +) -> None: + """Test listeners get called on entry state changes. + + This test wraps the listener in async_on_unload, the expectation is that + `async_on_unload` is called before the state changes to NOT_LOADED so the + listener is not called when the entry is unloaded. + """ + entry = MockConfigEntry(domain="comp", state=source_state) + entry.add_to_hass(hass) + + mock_integration( + hass, + MockModule( + "comp", + async_setup=AsyncMock(return_value=True), + async_setup_entry=AsyncMock(return_value=True), + async_unload_entry=AsyncMock(return_value=True), + ), + ) + mock_platform(hass, "comp.config_flow", None) + hass.config.components.add("comp") + + mock_state_change_callback = Mock() + entry.async_on_unload(entry.async_on_state_change(mock_state_change_callback)) + + transition_method = getattr(manager, transition_method_name) + await transition_method(entry.entry_id) + + assert len(mock_state_change_callback.mock_calls) == call_count + assert entry.state is target_state + + async def test_entry_state_change_listener_removed( hass: HomeAssistant, manager: config_entries.ConfigEntries,