Skip to content

Commit 01117eb

Browse files
committed
Prompt for unsaved changes on programmatic navigation
1 parent fb35194 commit 01117eb

7 files changed

Lines changed: 398 additions & 43 deletions

File tree

src/common/navigate.ts

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,60 @@ const ensureDialogsClosed = async (timestamp: number): Promise<boolean> => {
8080
return ensureDialogsClosed(timestamp);
8181
};
8282

83+
/**
84+
* Lets a page with unsaved changes (e.g. the automation editor) veto
85+
* navigation. `isDirty` is read live at navigation time; `prompt` resolves
86+
* true when navigation may proceed.
87+
*/
88+
export interface UnsavedChangesGuard {
89+
isDirty(): boolean;
90+
prompt(): Promise<boolean>;
91+
}
92+
93+
const unsavedChangesGuards = new Set<UnsavedChangesGuard>();
94+
95+
export const registerUnsavedChangesGuard = (guard: UnsavedChangesGuard) => {
96+
unsavedChangesGuards.add(guard);
97+
};
98+
99+
export const unregisterUnsavedChangesGuard = (guard: UnsavedChangesGuard) => {
100+
unsavedChangesGuards.delete(guard);
101+
};
102+
103+
let pendingUnsavedPrompt: Promise<boolean> | undefined;
104+
105+
/**
106+
* Asks each dirty guard whether navigation may proceed. Returns true when
107+
* nothing is dirty or every prompt was confirmed. Concurrent navigations
108+
* share one pending prompt instead of stacking dialogs; the dirty check runs
109+
* before joining it, so a navigation triggered from inside a prompt (e.g. by
110+
* its save action) cannot deadlock on its own promise.
111+
*/
112+
const ensureUnsavedChangesConfirmed = (): Promise<boolean> => {
113+
const dirtyGuards = [...unsavedChangesGuards].filter((guard) =>
114+
guard.isDirty()
115+
);
116+
if (!dirtyGuards.length) {
117+
return Promise.resolve(true);
118+
}
119+
if (!pendingUnsavedPrompt) {
120+
pendingUnsavedPrompt = (async () => {
121+
try {
122+
for (const guard of dirtyGuards) {
123+
// eslint-disable-next-line no-await-in-loop
124+
if (!(await guard.prompt())) {
125+
return false;
126+
}
127+
}
128+
return true;
129+
} finally {
130+
pendingUnsavedPrompt = undefined;
131+
}
132+
})();
133+
}
134+
return pendingUnsavedPrompt;
135+
};
136+
83137
const buildHistoryState = (
84138
data: Record<string, unknown> | undefined,
85139
from?: string
@@ -91,7 +145,7 @@ const buildHistoryState = (
91145
return { ...state, from };
92146
};
93147

94-
export const navigate = async (path: string, options?: NavigateOptions) => {
148+
const performNavigation = async (path: string, options?: NavigateOptions) => {
95149
const canProceed = await ensureDialogsClosed(Date.now());
96150
if (!canProceed) {
97151
return false;
@@ -130,6 +184,15 @@ export const navigate = async (path: string, options?: NavigateOptions) => {
130184
return true;
131185
};
132186

187+
export const navigate = async (path: string, options?: NavigateOptions) => {
188+
// Only guard actual departures: navigating to the current path keeps the
189+
// page, and any unsaved state on it, mounted.
190+
if (path !== currentPath() && !(await ensureUnsavedChangesConfirmed())) {
191+
return false;
192+
}
193+
return performNavigation(path, options);
194+
};
195+
133196
/**
134197
* Whether the previous history entry is a page this app navigated away from.
135198
* `history.length` cannot answer this: a login redirect goes through
@@ -142,6 +205,9 @@ export const canGoBack = (): boolean =>
142205
/**
143206
* Navigate back to the page we came from, falling back to a path when the
144207
* previous entry is not ours (deep link, login redirect, fresh tab).
208+
* Deliberately not guarded against unsaved changes: pages with such a guard
209+
* confirm in their own back handlers, and delete flows leave through here
210+
* after the edited item is already gone.
145211
*/
146212
export const goBack = async (fallbackPath?: string): Promise<void> => {
147213
const canProceed = await ensureDialogsClosed(Date.now());
@@ -156,5 +222,5 @@ export const goBack = async (fallbackPath?: string): Promise<void> => {
156222
return;
157223
}
158224

159-
await navigate(fallbackPath || "/", { replace: true });
225+
await performNavigation(fallbackPath || "/", { replace: true });
160226
};

src/mixins/prevent-unsaved-mixin.ts

Lines changed: 15 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import type { LitElement, PropertyValues } from "lit";
2-
import { isNavigationClick } from "../common/dom/is-navigation-click";
2+
import type { UnsavedChangesGuard } from "../common/navigate";
3+
import {
4+
registerUnsavedChangesGuard,
5+
unregisterUnsavedChangesGuard,
6+
} from "../common/navigate";
37
import type { Constructor } from "../types";
48

59
export const PreventUnsavedMixin = <T extends Constructor<LitElement>>(
@@ -9,45 +13,34 @@ export const PreventUnsavedMixin = <T extends Constructor<LitElement>>(
913
/** Provided by `DirtyStateProviderMixin`. */
1014
declare isDirtyState: boolean;
1115

12-
private _handleClick = async (e: MouseEvent) => {
13-
// get the right target, otherwise the composedPath would return <home-assistant> in the new event
14-
const target = e.composedPath()[0];
15-
if (!isNavigationClick(e)) {
16-
return;
17-
}
16+
private _handleUnload = (e: BeforeUnloadEvent) => e.preventDefault();
1817

19-
const result = await this.promptDiscardChanges();
20-
if (result) {
21-
this._removeListeners();
22-
if (target) {
23-
const newEvent = new MouseEvent(e.type, e);
24-
target.dispatchEvent(newEvent);
25-
}
26-
}
18+
private _unsavedChangesGuard: UnsavedChangesGuard = {
19+
isDirty: () => this.isDirtyState,
20+
prompt: () => this.promptDiscardChanges(),
2721
};
2822

29-
private _handleUnload = (e: BeforeUnloadEvent) => e.preventDefault();
23+
public connectedCallback(): void {
24+
super.connectedCallback();
3025

31-
private _removeListeners() {
32-
window.removeEventListener("click", this._handleClick, true);
33-
window.removeEventListener("beforeunload", this._handleUnload);
26+
registerUnsavedChangesGuard(this._unsavedChangesGuard);
3427
}
3528

3629
protected willUpdate(changedProperties: PropertyValues<this>): void {
3730
super.willUpdate(changedProperties);
3831

3932
if (this.isDirtyState && this.isConnected) {
40-
window.addEventListener("click", this._handleClick, true);
4133
window.addEventListener("beforeunload", this._handleUnload);
4234
} else {
43-
this._removeListeners();
35+
window.removeEventListener("beforeunload", this._handleUnload);
4436
}
4537
}
4638

4739
public disconnectedCallback(): void {
4840
super.disconnectedCallback();
4941

50-
this._removeListeners();
42+
unregisterUnsavedChangesGuard(this._unsavedChangesGuard);
43+
window.removeEventListener("beforeunload", this._handleUnload);
5144
}
5245

5346
protected async promptDiscardChanges(): Promise<boolean> {

src/panels/config/automation/ha-automation-editor.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -896,10 +896,15 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
896896
return;
897897
}
898898

899+
this.yamlErrors = undefined;
899900
resolve(true);
900901
},
901902
onClose: () => resolve(false),
902-
onDiscard: () => resolve(true),
903+
onDiscard: () => {
904+
this.yamlErrors = undefined;
905+
this._markDirtyStateClean();
906+
resolve(true);
907+
},
903908
entityRegistryUpdate: this.entityRegistryUpdate,
904909
entityRegistryEntry: this.registryEntry,
905910
title: this.hass.localize(

src/panels/config/scene/ha-scene-editor.ts

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1115,20 +1115,24 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
11151115
}
11161116

11171117
private async _confirmUnsavedChanged(): Promise<boolean> {
1118-
if (this.isDirtyState) {
1119-
return showConfirmationDialog(this, {
1120-
title: this.hass!.localize(
1121-
"ui.panel.config.scene.editor.unsaved_confirm_title"
1122-
),
1123-
text: this.hass!.localize(
1124-
"ui.panel.config.scene.editor.unsaved_confirm_text"
1125-
),
1126-
confirmText: this.hass!.localize("ui.common.leave"),
1127-
dismissText: this.hass!.localize("ui.common.stay"),
1128-
destructive: true,
1129-
});
1118+
if (!this.isDirtyState) {
1119+
return true;
1120+
}
1121+
const confirmed = await showConfirmationDialog(this, {
1122+
title: this.hass!.localize(
1123+
"ui.panel.config.scene.editor.unsaved_confirm_title"
1124+
),
1125+
text: this.hass!.localize(
1126+
"ui.panel.config.scene.editor.unsaved_confirm_text"
1127+
),
1128+
confirmText: this.hass!.localize("ui.common.leave"),
1129+
dismissText: this.hass!.localize("ui.common.stay"),
1130+
destructive: true,
1131+
});
1132+
if (confirmed) {
1133+
this._markDirtyStateClean();
11301134
}
1131-
return true;
1135+
return confirmed;
11321136
}
11331137

11341138
private async _duplicate() {

src/panels/config/script/ha-script-editor.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -804,10 +804,15 @@ export class HaScriptEditor extends SubscribeMixin(
804804
return;
805805
}
806806

807+
this.yamlErrors = undefined;
807808
resolve(true);
808809
},
809810
onClose: () => resolve(false),
810-
onDiscard: () => resolve(true),
811+
onDiscard: () => {
812+
this.yamlErrors = undefined;
813+
this._markDirtyStateClean();
814+
resolve(true);
815+
},
811816
entityRegistryUpdate: this.entityRegistryUpdate,
812817
entityRegistryEntry: this.registryEntry,
813818
title: this.hass.localize(

test/common/navigate.test.ts

Lines changed: 140 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
1-
import { beforeEach, describe, expect, it, vi } from "vitest";
2-
3-
import type { NavigateOptions } from "../../src/common/navigate";
4-
import { canGoBack, goBack, navigate } from "../../src/common/navigate";
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
import type {
4+
NavigateOptions,
5+
UnsavedChangesGuard,
6+
} from "../../src/common/navigate";
7+
import {
8+
canGoBack,
9+
goBack,
10+
navigate,
11+
registerUnsavedChangesGuard,
12+
unregisterUnsavedChangesGuard,
13+
} from "../../src/common/navigate";
514

615
// navigate() closes open dialogs before touching history.
716
vi.mock("../../src/dialogs/make-dialog-manager", () => ({
@@ -54,6 +63,133 @@ describe("navigate", () => {
5463
});
5564
});
5665

66+
describe("unsaved changes guard", () => {
67+
const registeredGuards: UnsavedChangesGuard[] = [];
68+
69+
const registerGuard = (isDirty: boolean, promptResult = true) => {
70+
const guard = {
71+
isDirty: vi.fn(() => isDirty),
72+
prompt: vi.fn(async () => promptResult),
73+
};
74+
registerUnsavedChangesGuard(guard);
75+
registeredGuards.push(guard);
76+
return guard;
77+
};
78+
79+
beforeEach(() => {
80+
setEntry("/config");
81+
});
82+
83+
afterEach(() => {
84+
registeredGuards.splice(0).forEach(unregisterUnsavedChangesGuard);
85+
});
86+
87+
it("navigates without prompting when no guard is dirty", async () => {
88+
const guard = registerGuard(false);
89+
90+
expect(await navigate("/config/areas")).toBe(true);
91+
92+
expect(window.location.pathname).toEqual("/config/areas");
93+
expect(guard.prompt).not.toHaveBeenCalled();
94+
});
95+
96+
it("prompts a dirty guard and navigates when confirmed", async () => {
97+
const guard = registerGuard(true, true);
98+
99+
expect(await navigate("/config/areas")).toBe(true);
100+
101+
expect(guard.prompt).toHaveBeenCalledOnce();
102+
expect(window.location.pathname).toEqual("/config/areas");
103+
});
104+
105+
it("leaves history untouched when the prompt is declined", async () => {
106+
const guard = registerGuard(true, false);
107+
const listener = vi.fn();
108+
window.addEventListener("location-changed", listener);
109+
110+
expect(await navigate("/config/areas")).toBe(false);
111+
112+
window.removeEventListener("location-changed", listener);
113+
expect(guard.prompt).toHaveBeenCalledOnce();
114+
expect(window.location.pathname).toEqual("/config");
115+
expect(listener).not.toHaveBeenCalled();
116+
});
117+
118+
it("does not prompt when navigating to the current path", async () => {
119+
const guard = registerGuard(true, false);
120+
121+
expect(await navigate("/config")).toBe(true);
122+
123+
expect(guard.prompt).not.toHaveBeenCalled();
124+
});
125+
126+
it("skips a pending prompt once no guard is dirty anymore", async () => {
127+
let dirty = true;
128+
let resolvePrompt!: (value: boolean) => void;
129+
const guard: UnsavedChangesGuard = {
130+
isDirty: () => dirty,
131+
prompt: vi.fn(
132+
() =>
133+
new Promise<boolean>((resolve) => {
134+
resolvePrompt = resolve;
135+
})
136+
),
137+
};
138+
registerUnsavedChangesGuard(guard);
139+
registeredGuards.push(guard);
140+
141+
const first = navigate("/config/areas");
142+
dirty = false;
143+
144+
expect(await navigate("/config/devices/dashboard")).toBe(true);
145+
expect(window.location.pathname).toEqual("/config/devices/dashboard");
146+
147+
resolvePrompt(true);
148+
expect(await first).toBe(true);
149+
expect(guard.prompt).toHaveBeenCalledOnce();
150+
});
151+
152+
it("shares one pending prompt between concurrent navigations", async () => {
153+
let resolvePrompt!: (value: boolean) => void;
154+
const guard: UnsavedChangesGuard = {
155+
isDirty: () => true,
156+
prompt: vi.fn(
157+
() =>
158+
new Promise<boolean>((resolve) => {
159+
resolvePrompt = resolve;
160+
})
161+
),
162+
};
163+
registerUnsavedChangesGuard(guard);
164+
registeredGuards.push(guard);
165+
166+
const first = navigate("/config/areas");
167+
const second = navigate("/config/devices/dashboard");
168+
resolvePrompt(true);
169+
170+
expect(await Promise.all([first, second])).toEqual([true, true]);
171+
expect(guard.prompt).toHaveBeenCalledOnce();
172+
});
173+
174+
it("stops prompting once the guard is unregistered", async () => {
175+
const guard = registerGuard(true, false);
176+
unregisterUnsavedChangesGuard(guard);
177+
178+
expect(await navigate("/config/areas")).toBe(true);
179+
180+
expect(guard.prompt).not.toHaveBeenCalled();
181+
});
182+
183+
it("does not prompt for goBack's fallback navigation", async () => {
184+
const guard = registerGuard(true, false);
185+
186+
await goBack("/config/cloud/account");
187+
188+
expect(window.location.pathname).toEqual("/config/cloud/account");
189+
expect(guard.prompt).not.toHaveBeenCalled();
190+
});
191+
});
192+
57193
describe("goBack", () => {
58194
beforeEach(() => {
59195
setEntry("/config/cloud/remote");

0 commit comments

Comments
 (0)