diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 9612868383ea..c473a786b9bf 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -2859,6 +2859,29 @@ class ConfigFlow(ConfigEntryBaseFlow): """Return subentries supported by this handler.""" return {} + @callback + def async_update_title_placeholders( + self, title_placeholders: Mapping[str, str] + ) -> None: + """Update title placeholders for the discovery notification and notify listeners. + + This updates the flow context title_placeholders and notifies listeners + (such as the frontend) to reload the flow state, updating the discovery + notification title. + + Only call this method when the flow is not progressing to a new step + (e.g., from a callback that receives updated data). If the flow is + progressing to a new step, set title_placeholders directly in context + before returning the step result, as the step change will trigger + listener notification automatically. + """ + # Context is typed as TypedDict but is mutable dict at runtime + current_placeholders = cast( + dict[str, str], self.context.setdefault("title_placeholders", {}) + ) + current_placeholders.update(title_placeholders) + self.async_notify_flow_changed() + @callback def _async_abort_entries_match( self, match_dict: dict[str, Any] | None = None diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index d9e58a8dda8b..51dcd3fa13e2 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -432,11 +432,7 @@ class FlowManager(abc.ABC, Generic[_FlowContextT, _FlowResultT, _HandlerT]): != result.get("description_placeholders") ) ): - # Tell frontend to reload the flow state. - self.hass.bus.async_fire_internal( - EVENT_DATA_ENTRY_FLOW_PROGRESSED, - {"handler": flow.handler, "flow_id": flow_id, "refresh": True}, - ) + flow.async_notify_flow_changed() return result @@ -886,6 +882,17 @@ class FlowHandler(Generic[_FlowContextT, _FlowResultT, _HandlerT]): {"handler": self.handler, "flow_id": self.flow_id, "progress": progress}, ) + @callback + def async_notify_flow_changed(self) -> None: + """Notify listeners that the flow has changed. + + This notifies listeners (such as the frontend) to reload the flow state. + """ + self.hass.bus.async_fire_internal( + EVENT_DATA_ENTRY_FLOW_PROGRESSED, + {"handler": self.handler, "flow_id": self.flow_id, "refresh": True}, + ) + @callback def async_show_progress_done(self, *, next_step_id: str) -> _FlowResultT: """Mark the progress done.""" diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 4619d49584a8..7549274af4da 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -9434,3 +9434,74 @@ async def test_create_entry_existing_unique_id( "working in Home Assistant 2026.3, please create a bug report at https:" ) assert (log_text in caplog.text) == expected_log + + +async def test_async_update_title_placeholders(hass: HomeAssistant) -> None: + """Test async_update_title_placeholders updates context and notifies listeners.""" + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + VERSION = 1 + + async def async_step_user(self, user_input=None): + """Test user step.""" + self.context["title_placeholders"] = {"initial": "value"} + return self.async_show_form(step_id="user") + + mock_integration(hass, MockModule("comp")) + mock_platform(hass, "comp.config_flow", None) + + with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): + result = await hass.config_entries.flow.async_init( + "comp", context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + # Get the flow to check initial title_placeholders + flow = hass.config_entries.flow.async_get(result["flow_id"]) + assert flow["context"]["title_placeholders"] == {"initial": "value"} + + # Get the flow instance to call methods + flow_instance = hass.config_entries.flow._progress[result["flow_id"]] + + # Capture events to verify frontend notification + events = async_capture_events( + hass, data_entry_flow.EVENT_DATA_ENTRY_FLOW_PROGRESSED + ) + + # Update title placeholders + flow_instance.async_update_title_placeholders({"name": "updated"}) + await hass.async_block_till_done() + + # Verify placeholders were updated (preserving existing values) + flow = hass.config_entries.flow.async_get(result["flow_id"]) + assert flow["context"]["title_placeholders"] == { + "initial": "value", + "name": "updated", + } + + # Verify frontend was notified + assert len(events) == 1 + assert events[0].data == { + "handler": "comp", + "flow_id": result["flow_id"], + "refresh": True, + } + + # Update again with overlapping key + flow_instance.async_update_title_placeholders( + {"initial": "new_value", "another": "key"} + ) + await hass.async_block_till_done() + + # Verify placeholders were updated correctly + flow = hass.config_entries.flow.async_get(result["flow_id"]) + assert flow["context"]["title_placeholders"] == { + "initial": "new_value", + "name": "updated", + "another": "key", + } + + # Verify frontend was notified again + assert len(events) == 2