Compare commits

...
Author SHA1 Message Date
Maarten Lakerveld 175636d20d Keep dialogs on the remaining default-route redirects
The energy panel, the energy config tabs and the media browser fill in
their default route with a replace, which closed a dialog restored from
the URL and dropped the query the restore needs. They now pass
keepDialogs and keep the query, like the router and dashboard redirects.

The more-info restore runs on every location change, so those redirects
re-showed the dialog that was already open. That hoisted it above any
dialog opened on top of it and reset its view. Skip the restore while
the more-info dialog is open.
2026-11-05 15:33:09 +01:00
TimothyandClaude Opus 5.5 da47ac9c16 Keep navigate async when keeping dialogs
Skipping the await committed the navigation synchronously, so a navigate()
called right after it saw the committed count change and was dropped as
stale.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 12:49:22 +00:00
TimothyandClaude Opus 5.5 3c49abb492 Merge remote-tracking branch 'origin/dev' into fix/dialog_not_open_when_using_navigate
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 12:49:07 +00:00
Timothy 9494db6462 Apply suggestions 2026-11-05 11:14:43 +01:00
Timothy 06461704ad Keep dialogs while navigating 2026-11-04 12:23:49 +01:00
12 changed files with 100 additions and 19 deletions

No files matched your search

+13 -2
View File
@@ -16,6 +16,13 @@ declare global {
export interface NavigateOptions {
replace?: boolean;
data?: Record<string, unknown>;
/**
* Leave open dialogs open. For a replace that only canonicalizes the current
* URL, like a router filling in its default page: a dialog restored from the
* URL, such as a more-info deep link, may already be open at that point, and
* the entry keeps its state so back still closes it.
*/
keepDialogs?: boolean;
}
// max time to wait for dialogs to close before navigating
@@ -196,7 +203,8 @@ const buildHistoryState = (
};
const performNavigation = async (path: string, options?: NavigateOptions) => {
const canProceed = await ensureDialogsClosed(Date.now());
const keepDialogs = options?.keepDialogs || false;
const canProceed = await (keepDialogs || ensureDialogsClosed(Date.now()));
if (!canProceed) {
return false;
}
@@ -215,7 +223,10 @@ const performNavigation = async (path: string, options?: NavigateOptions) => {
const { history } = mainWindow;
if (replace) {
if (replace && keepDialogs) {
// Same entry under its canonical URL: its state, dialog included, stays.
history.replaceState(history.state, "", path);
} else if (replace) {
// A replaced entry keeps its predecessor, so it keeps `from`.
const { root, from } = history.state ?? {};
const data = root ? { root: true } : options?.data;
+3
View File
@@ -85,6 +85,9 @@ const _removeOpenDialogTag = (dialogTag: string) => {
export const getTopOpenDialogTag = (): string | undefined =>
OPEN_DIALOG_TAGS[OPEN_DIALOG_TAGS.length - 1];
export const isDialogOpen = (dialogTag: string): boolean =>
OPEN_DIALOG_STACK.some((dialog) => dialog.dialogTag === dialogTag);
/** The URL of the last dialog's entry, when the dialog went back to close. */
let closedDialogUrl: { pathname: string; search: string } | undefined;
+2
View File
@@ -104,6 +104,7 @@ export class HassRouterPage extends ReactiveElement {
const queryParams = window.location.search;
navigate(`${route.prefix}/${defaultPage}${queryParams}`, {
replace: true,
keepDialogs: true,
});
}
@@ -134,6 +135,7 @@ export class HassRouterPage extends ReactiveElement {
if (route) {
navigate(`${route.prefix}/${result}${location.search}`, {
replace: true,
keepDialogs: true,
});
}
}
+4
View File
@@ -15,6 +15,7 @@ import type { WindowWithPreloads } from "../data/preloads";
import type { RecorderInfo } from "../data/recorder";
import { getRecorderInfo } from "../data/recorder";
import { showHttpPendingConfigDialog } from "../dialogs/http-pending-config/show-dialog-http-pending-config";
import { isDialogOpen } from "../dialogs/make-dialog-manager";
import { showMoreInfoDialog } from "../dialogs/more-info/show-ha-more-info-dialog";
import "../resources/custom-card-support";
import { HassElement } from "../state/hass-element";
@@ -327,6 +328,9 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
// Wait for the next render to ensure the view is fully loaded
// because the more info dialog is closed when the url changes
afterNextRender(() => {
if (isDialogOpen("ha-more-info-dialog")) {
return;
}
showMoreInfoDialog(this, {
entityId,
view,
+1 -1
View File
@@ -189,7 +189,7 @@ class PartialPanelResolver extends HassRouterPage {
const { pathname, search, hash } = mainWindow.location;
navigate(
`${pathname.replace(/^\/hacs/, "/marketplace")}${search}${hash}`,
{ replace: true }
{ replace: true, keepDialogs: true }
);
return undefined;
}
+4 -1
View File
@@ -97,7 +97,10 @@ class HaConfigEnergy extends LitElement {
if (changedProperties.has("route")) {
const tab = this.route.path.substring(1);
if (!tab || !TABS.some((t) => t.path.endsWith(`/${tab}`))) {
navigate(`${this.route.prefix}/electricity`, { replace: true });
navigate(`${this.route.prefix}/electricity${location.search}`, {
replace: true,
keepDialogs: true,
});
}
}
}
+4 -1
View File
@@ -93,7 +93,10 @@ class PanelEnergy extends LitElement {
const validPaths = views.map((view) => view.path);
const viewPath: string | undefined = this.route!.path.split("/")[1];
if (!viewPath || !validPaths.includes(viewPath)) {
navigate(`${this.route!.prefix}/${validPaths[0]}`, { replace: true });
navigate(`${this.route!.prefix}/${validPaths[0]}${location.search}`, {
replace: true,
keepDialogs: true,
});
} else {
// Force hui-root to re-process the route by creating a new route object
this.route = { ...this.route! };
+8 -2
View File
@@ -291,6 +291,12 @@ export class LovelacePanel extends LitElement {
this._fetchConfig(true);
}
private _redirectToHome() {
// Keeps the query and any open dialog: a more-info deep link may be
// restored from the URL around this redirect.
navigate(`/home${location.search}`, { replace: true, keepDialogs: true });
}
private async _fetchConfig(forceDiskRefresh: boolean) {
this._loading = true;
@@ -300,7 +306,7 @@ export class LovelacePanel extends LitElement {
// If no mode, redirect to /home as there is no "lovelace" dashboard
if (!confMode) {
navigate("/home", { replace: true });
this._redirectToHome();
return;
}
@@ -361,7 +367,7 @@ export class LovelacePanel extends LitElement {
const dashboards = await fetchDashboards(this.hass!);
const dashboard = dashboards.find((d) => d.url_path === "lovelace");
if (!dashboard) {
navigate("/home", { replace: true });
this._redirectToHome();
return;
}
}
+13 -8
View File
@@ -32,6 +32,7 @@ import { UndoRedoController } from "../../common/controllers/undo-redo-controlle
import { fireEvent } from "../../common/dom/fire_event";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { ctrlOrCmdLabel } from "../../common/keyboard/ctrl-or-cmd";
import type { NavigateOptions } from "../../common/navigate";
import { goBack, navigate, replaceCurrentUrl } from "../../common/navigate";
import type { LocalizeKeys } from "../../common/translations/localize";
import { constructUrlCurrentPath } from "../../common/url/construct-url";
@@ -831,7 +832,12 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
if (!viewPath && views.length) {
newSelectView = views.findIndex(this._isVisible);
this._navigateToView(views[newSelectView].path || newSelectView, true);
// Filling in the default view keeps the entry as is: a dialog restored
// from the URL, like a more-info deep link, can already be open.
this._navigateToView(views[newSelectView].path || newSelectView, {
replace: true,
keepDialogs: true,
});
} else if (viewPath === "hass-unused-entities") {
newSelectView = "hass-unused-entities";
} else if (viewPath) {
@@ -865,10 +871,9 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
viewPath === "hass-unused-entities"
) {
newSelectView = views.findIndex(this._isVisible);
this._navigateToView(
views[newSelectView].path || newSelectView,
true
);
this._navigateToView(views[newSelectView].path || newSelectView, {
replace: true,
});
}
}
@@ -1197,7 +1202,7 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
});
}
private _navigateToView(path: string | number, replace?: boolean) {
private _navigateToView(path: string | number, options?: NavigateOptions) {
if (this.embedded) {
fireEvent(this, "view-navigate", { path: String(path) });
@@ -1208,7 +1213,7 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
const currentUrl = `${location.pathname}${location.search}`;
if (currentUrl !== url) {
navigate(url, { replace });
navigate(url, options);
}
}
@@ -1278,7 +1283,7 @@ class HUIRoot extends KeyboardShortcutMixin(LitElement) {
newCurView = indexAfterMove(curView, oldIndex, newIndex);
// Views without a path are addressed by their index in the URL
if (newCurView !== curView && !this.config.views[curView].path) {
this._navigateToView(newCurView, true);
this._navigateToView(newCurView, { replace: true });
}
}
lovelace.saveConfig(moveView(lovelace.config, oldIndex, newIndex));
@@ -191,7 +191,10 @@ class PanelMediaBrowser extends LitElement {
}
if (this.route.path === "") {
navigate(`/media-browser/${this._entityId}`, { replace: true });
navigate(`/media-browser/${this._entityId}${location.search}`, {
replace: true,
keepDialogs: true,
});
return;
}
+43 -2
View File
@@ -12,10 +12,15 @@ import {
registerUnsavedChangesGuard,
unregisterUnsavedChangesGuard,
} from "../../src/common/navigate";
import { closeAllDialogs } from "../../src/dialogs/make-dialog-manager";
// navigate() closes open dialogs before touching history.
// navigate() closes open dialogs before touching history. A closed dialog
// drops its state from the entry, which is what navigate() waits for.
vi.mock("../../src/dialogs/make-dialog-manager", () => ({
closeAllDialogs: vi.fn(async () => true),
closeAllDialogs: vi.fn(async () => {
window.history.replaceState(null, "");
return true;
}),
}));
// Fabricates a raw entry like a document load, which navigate() never produces.
@@ -56,6 +61,7 @@ const trackDeferredGuard = (isDirty: () => boolean = () => true) => {
afterEach(() => {
registeredGuards.splice(0).forEach(unregisterUnsavedChangesGuard);
vi.mocked(closeAllDialogs).mockClear();
});
describe("navigate", () => {
@@ -97,6 +103,41 @@ describe("navigate", () => {
expect(window.history.state).toBeNull();
expect(canGoBack()).toBe(false);
});
it("closes an open dialog before replacing the entry", async () => {
setEntry("/home", { dialog: "ha-more-info-dialog" });
await navigate("/home/overview", { replace: true });
expect(closeAllDialogs).toHaveBeenCalledOnce();
expect(window.location.pathname).toEqual("/home/overview");
expect(window.history.state).toBeNull();
});
it("keeps an open dialog and the entry state when asked to", async () => {
setEntry("/home", { dialog: "ha-more-info-dialog", from: "/" });
await navigate("/home/overview", { replace: true, keepDialogs: true });
expect(closeAllDialogs).not.toHaveBeenCalled();
expect(window.location.pathname).toEqual("/home/overview");
expect(window.history.state).toEqual({
dialog: "ha-more-info-dialog",
from: "/",
});
});
it("does not drop a navigation right after one that keeps dialogs", async () => {
setEntry("/home");
const results = await Promise.all([
navigate("/home/overview", { replace: true, keepDialogs: true }),
navigate("/config"),
]);
expect(results).toEqual([true, true]);
expect(window.location.pathname).toEqual("/config");
});
});
describe("unsaved changes guard", () => {
+1 -1
View File
@@ -45,7 +45,7 @@ describe("partial-panel-resolver", () => {
// The Marketplace loads its translations for the path it is at
expect(navigate).toHaveBeenCalledWith(
"/marketplace/repository/1?category=theme#top",
{ replace: true }
{ replace: true, keepDialogs: true }
);
});