Compare commits

...
1 Commits
Author SHA1 Message Date
Petar Petrov 8cc701f778 Prompt for unsaved changes on history back navigation 2026-09-01 14:44:43 +03:00
12 changed files with 453 additions and 64 deletions
+111 -5
View File
@@ -42,6 +42,7 @@ export const updateHistoryState = (patch: Record<string, unknown>) => {
*/
export const replaceCurrentUrl = (url: string) => {
mainWindow.history.replaceState(mainWindow.history.state, "", url);
rememberCurrentEntry();
};
/**
@@ -100,6 +101,9 @@ export const unregisterUnsavedChangesGuard = (guard: UnsavedChangesGuard) => {
unsavedChangesGuards.delete(guard);
};
const dirtyGuards = (): UnsavedChangesGuard[] =>
[...unsavedChangesGuards].filter((guard) => guard.isDirty());
let pendingUnsavedPrompt: Promise<boolean> | undefined;
/**
@@ -109,6 +113,43 @@ let pendingUnsavedPrompt: Promise<boolean> | undefined;
*/
let committedNavigations = 0;
interface HistoryEntry {
path: string;
/** Path of the entry behind this one, stamped by `performNavigation`. */
from?: string;
}
const readEntry = (): HistoryEntry => ({
path: currentPath(),
from: mainWindow.history.state?.from,
});
/**
* How far a pop moved from `entry`, signed, or undefined when it cannot be told:
* the entry behind us is the one our `from` names, the one ahead is the one
* whose `from` names us. A stack with the same path on both sides matches both,
* and back is then by far the likelier press.
*/
const popStep = (entry: HistoryEntry): number | undefined => {
if (currentPath() === entry.from) {
return -1;
}
return mainWindow.history.state?.from === entry.path ? 1 : undefined;
};
let currentEntry: HistoryEntry = readEntry();
const rememberCurrentEntry = () => {
currentEntry = readEntry();
};
/** Where the pops held while a prompt is open left us. */
let heldEntry: HistoryEntry | undefined;
let heldSteps = 0;
/** The entry `goBack()` asked to leave; the pop it triggers is not prompted. */
let popRequestedFromPath: string | undefined;
/**
* Asks each dirty guard whether navigation may proceed. Returns true when
* nothing is dirty or every prompt was confirmed. Concurrent navigations
@@ -117,16 +158,14 @@ let committedNavigations = 0;
* its save action) cannot deadlock on its own promise.
*/
const ensureUnsavedChangesConfirmed = (): Promise<boolean> => {
const dirtyGuards = [...unsavedChangesGuards].filter((guard) =>
guard.isDirty()
);
if (!dirtyGuards.length) {
const guards = dirtyGuards();
if (!guards.length) {
return Promise.resolve(true);
}
if (!pendingUnsavedPrompt) {
pendingUnsavedPrompt = (async () => {
try {
for (const guard of dirtyGuards) {
for (const guard of guards) {
// eslint-disable-next-line no-await-in-loop
if (!(await guard.prompt())) {
return false;
@@ -185,6 +224,7 @@ const performNavigation = async (path: string, options?: NavigateOptions) => {
);
}
rememberCurrentEntry();
fireEvent(mainWindow, "location-changed", {
replace,
});
@@ -235,9 +275,75 @@ export const goBack = async (fallbackPath?: string): Promise<void> => {
// Read after closing dialogs: their history entries are popped by then, so
// this is the state of the page entry.
if (canGoBack()) {
popRequestedFromPath = currentEntry.path;
mainWindow.history.back();
return;
}
await performNavigation(fallbackPath || "/", { replace: true });
};
/**
* Handles a history pop before the router acts on it. A pop cannot be cancelled,
* so one away from a page with unsaved changes holds the route and prompts;
* `resume` runs once the user agrees to leave. The prompt must add no history
* entry of its own, or the entries the pop left would be truncated.
*/
export const handleHistoryPop = (resume: () => void): void => {
if (heldEntry) {
const heldStep = popStep(heldEntry);
if (heldStep !== undefined) {
heldSteps += heldStep;
heldEntry = readEntry();
}
return;
}
if (currentPath() === currentEntry.path) {
// A dialog's history entry was popped, not a page.
rememberCurrentEntry();
resume();
return;
}
const step = popStep(currentEntry);
if (
popRequestedFromPath === currentEntry.path ||
// Not a pop we could undo, so do not hold it.
step === undefined ||
!dirtyGuards().length
) {
popRequestedFromPath = undefined;
rememberCurrentEntry();
committedNavigations += 1;
resume();
return;
}
heldSteps = step;
heldEntry = readEntry();
const navigationsAtPop = committedNavigations;
ensureUnsavedChangesConfirmed().then(
(confirmed) => {
const steps = heldSteps;
heldEntry = undefined;
if (committedNavigations !== navigationsAtPop) {
// A navigation landed while the prompt was open.
return;
}
if (!confirmed) {
// go(0) would reload the document, and at zero we are already back.
if (steps) {
mainWindow.history.go(-steps);
}
return;
}
rememberCurrentEntry();
committedNavigations += 1;
resume();
},
() => {
heldEntry = undefined;
}
);
};
+3
View File
@@ -2,6 +2,8 @@ import type { TemplateResult } from "lit";
import { fireEvent } from "../../common/dom/fire_event";
interface BaseDialogBoxParams {
/** Whether back closes the dialog. Off while a navigation is in flight. */
addHistory?: boolean;
confirmText?: string;
text?: string | TemplateResult;
title?: string;
@@ -61,6 +63,7 @@ const showDialogHelper = (
fireEvent(element, "show-dialog", {
dialogTag: "dialog-box",
dialogImport: loadGenericDialog,
addHistory: dialogParams.addHistory,
dialogParams: {
...dialogParams,
...extra,
+5 -3
View File
@@ -4,7 +4,7 @@ import { html } from "lit";
import { customElement, state } from "lit/decorators";
import { storage } from "../common/decorators/storage";
import { isNavigationClick } from "../common/dom/is-navigation-click";
import { navigate } from "../common/navigate";
import { handleHistoryPop, navigate } from "../common/navigate";
import type { LocalizeFunc } from "../common/translations/localize";
import { decodeMoreInfoUrl } from "../common/url/more-info-query-params";
import { extractSearchParamsObject } from "../common/url/search-params";
@@ -180,8 +180,10 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
window.addEventListener("location-changed", () => updateRoute());
// Handle history changes
window.addEventListener("popstate", () => updateRoute());
// A pop away from a page with unsaved changes is held until the user agrees.
window.addEventListener("popstate", () =>
handleHistoryPop(() => updateRoute())
);
// Restore a more-info dialog deep-linked in the current URL, if any.
window.addEventListener("location-changed", () =>
@@ -7,6 +7,8 @@ export const loadAutomationSaveDialog = () =>
import("./dialog-automation-save");
interface BaseRenameDialogParams {
/** Whether back closes the dialog. Off while a navigation is in flight. */
addHistory?: boolean;
entityRegistryUpdate?: EntityRegistryUpdate;
entityRegistryEntry?: EntityRegistryEntry;
onClose: () => void;
@@ -52,5 +54,6 @@ export const showAutomationSaveDialog = (
dialogTag: "ha-dialog-automation-save",
dialogImport: loadAutomationSaveDialog,
dialogParams,
addHistory: dialogParams.addHistory,
});
};
@@ -869,13 +869,14 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
this.errors = undefined;
}
protected async confirmUnsavedChanged(): Promise<boolean> {
protected async confirmUnsavedChanged(addHistory = true): Promise<boolean> {
if (!this.isDirtyState) {
return true;
}
return new Promise<boolean>((resolve) => {
showAutomationSaveDialog(this, {
addHistory,
config: this.config!,
domain: "automation",
updateConfig: async (config, entityRegistryUpdate) => {
@@ -261,15 +261,16 @@ export const AutomationScriptEditorMixin = <TConfig extends BaseEditorConfig>(
};
protected async promptDiscardChanges() {
return this.confirmUnsavedChanged();
return this.confirmUnsavedChanged(false);
}
/**
* Asks whether unsaved changes should be discarded.
* Subclasses must override this to show a confirmation dialog.
* @param addHistory false while a navigation is already in flight.
* @returns true to proceed (discard/save changes), false to cancel.
*/
protected confirmUnsavedChanged(): Promise<boolean> {
protected confirmUnsavedChanged(_addHistory = true): Promise<boolean> {
return Promise.resolve(true);
}
+3 -2
View File
@@ -1125,11 +1125,12 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
goBack("/config/scene/dashboard");
}
private async _confirmUnsavedChanged(): Promise<boolean> {
private async _confirmUnsavedChanged(addHistory = true): Promise<boolean> {
if (!this.isDirtyState) {
return true;
}
const confirmed = await showConfirmationDialog(this, {
addHistory,
title: this.hass!.localize(
"ui.panel.config.scene.editor.unsaved_confirm_title"
),
@@ -1408,7 +1409,7 @@ export class HaSceneEditor extends DirtyStateProviderMixin<number>()(
}
protected async promptDiscardChanges() {
return this._confirmUnsavedChanged();
return this._confirmUnsavedChanged(false);
}
static get styles(): CSSResultGroup {
+2 -1
View File
@@ -777,13 +777,14 @@ export class HaScriptEditor extends SubscribeMixin(
this.errors = undefined;
}
protected async confirmUnsavedChanged(): Promise<boolean> {
protected async confirmUnsavedChanged(addHistory = true): Promise<boolean> {
if (!this.isDirtyState) {
return true;
}
return new Promise<boolean>((resolve) => {
showAutomationSaveDialog(this, {
addHistory,
config: this.config!,
domain: "script",
updateConfig: async (config, entityRegistryUpdate) => {
+21 -14
View File
@@ -17,6 +17,7 @@ import {
showConfirmationDialog,
} from "../../dialogs/generic/show-dialog-box";
import { DirtyStateProviderMixin } from "../../mixins/dirty-state-provider-mixin";
import { PreventUnsavedMixin } from "../../mixins/prevent-unsaved-mixin";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import type { Lovelace } from "./types";
@@ -34,7 +35,7 @@ const strategyStruct = type({
@customElement("hui-editor")
class LovelaceFullConfigEditor extends DirtyStateProviderMixin<string>()(
LitElement
PreventUnsavedMixin(LitElement)
) {
@property({ type: Boolean }) public narrow = false;
@@ -171,17 +172,17 @@ class LovelaceFullConfigEditor extends DirtyStateProviderMixin<string>()(
this._config = ev.detail.isValid ? ev.detail.value : undefined;
this._yamlError = ev.detail.errorMsg;
this._updateDirtyState(this.yamlEditor.yaml);
if (this.isDirtyState && !window.onbeforeunload) {
window.onbeforeunload = () => true;
} else if (!this.isDirtyState && window.onbeforeunload) {
window.onbeforeunload = null;
}
}
private async _closeEditor() {
/**
* Also closes the editor: it is a panel state rather than a route, so leaving
* by a navigation never reaches `_closeEditor` and the panel can be cached.
*/
private async _confirmDiscard(addHistory: boolean): Promise<boolean> {
if (
this.isDirtyState &&
!(await showConfirmationDialog(this, {
addHistory,
text: this.hass.localize(
"ui.panel.lovelace.editor.raw_editor.confirm_unsaved_changes"
),
@@ -189,13 +190,20 @@ class LovelaceFullConfigEditor extends DirtyStateProviderMixin<string>()(
confirmText: this.hass!.localize("ui.common.leave"),
}))
) {
return;
return false;
}
window.onbeforeunload = null;
if (this.closeEditor) {
this.closeEditor();
}
this._markDirtyStateClean();
this.closeEditor?.();
return true;
}
protected async promptDiscardChanges(): Promise<boolean> {
return this._confirmDiscard(false);
}
private async _closeEditor() {
await this._confirmDiscard(true);
}
private async _resetConfig() {
@@ -210,7 +218,7 @@ class LovelaceFullConfigEditor extends DirtyStateProviderMixin<string>()(
});
return;
}
window.onbeforeunload = null;
this._markDirtyStateClean();
if (this.closeEditor) {
this.closeEditor();
}
@@ -290,7 +298,6 @@ class LovelaceFullConfigEditor extends DirtyStateProviderMixin<string>()(
),
});
}
window.onbeforeunload = null;
this._markDirtyStateClean();
this._saving = false;
}
+249 -35
View File
@@ -7,6 +7,7 @@ import type {
import {
canGoBack,
goBack,
handleHistoryPop,
navigate,
registerUnsavedChangesGuard,
unregisterUnsavedChangesGuard,
@@ -22,6 +23,41 @@ const setEntry = (path: string, state: unknown = null) => {
window.history.replaceState(state, "", path);
};
const registeredGuards: UnsavedChangesGuard[] = [];
// Registering through this keeps the cleanup able to reach the module-level
// registry, which outlives the test that filled it.
const trackGuard = <T extends UnsavedChangesGuard>(guard: T): T => {
registerUnsavedChangesGuard(guard);
registeredGuards.push(guard);
return guard;
};
const registerGuard = (isDirty: boolean, promptResult = true) =>
trackGuard({
isDirty: vi.fn(() => isDirty),
prompt: vi.fn(async () => promptResult),
});
// A guard whose prompt stays open until the test answers it.
const trackDeferredGuard = (isDirty: () => boolean = () => true) => {
let answer!: (value: boolean) => void;
const guard = trackGuard({
isDirty,
prompt: vi.fn(
() =>
new Promise<boolean>((resolve) => {
answer = resolve;
})
),
});
return { guard, answerPrompt: (value: boolean) => answer(value) };
};
afterEach(() => {
registeredGuards.splice(0).forEach(unregisterUnsavedChangesGuard);
});
describe("navigate", () => {
beforeEach(() => {
setEntry("/config");
@@ -64,45 +100,10 @@ describe("navigate", () => {
});
describe("unsaved changes guard", () => {
const registeredGuards: UnsavedChangesGuard[] = [];
// Registering through this keeps afterEach able to clean up the module-level
// registry, which outlives the test that filled it.
const trackGuard = <T extends UnsavedChangesGuard>(guard: T): T => {
registerUnsavedChangesGuard(guard);
registeredGuards.push(guard);
return guard;
};
const registerGuard = (isDirty: boolean, promptResult = true) =>
trackGuard({
isDirty: vi.fn(() => isDirty),
prompt: vi.fn(async () => promptResult),
});
// A guard whose prompt stays open until the test answers it.
const trackDeferredGuard = (isDirty: () => boolean) => {
let answer!: (value: boolean) => void;
const guard = trackGuard({
isDirty,
prompt: vi.fn(
() =>
new Promise<boolean>((resolve) => {
answer = resolve;
})
),
});
return { guard, answerPrompt: (value: boolean) => answer(value) };
};
beforeEach(() => {
setEntry("/config");
});
afterEach(() => {
registeredGuards.splice(0).forEach(unregisterUnsavedChangesGuard);
});
it("navigates without prompting when no guard is dirty", async () => {
const guard = registerGuard(false);
@@ -238,3 +239,216 @@ describe("goBack", () => {
expect(window.location.pathname).toEqual("/");
});
});
describe("handleHistoryPop", () => {
// What the browser leaves behind before the router hears about a pop.
const pop = (path: string, from = "/config") => {
window.history.replaceState({ from }, "", path);
const resume = vi.fn();
handleHistoryPop(resume);
return resume;
};
// The editor is pushed from the dashboard, so the entry behind it is BACK and
// any entry ahead of it records the editor as its own `from`.
const EDITOR = "/config/automation/edit/1";
const BACK = "/config/automation/dashboard";
const popForward = (path: string) => pop(path, EDITOR);
// Lets the answered prompt's continuation run, so "did not act" is assertable.
const flush = () =>
new Promise<void>((resolve) => {
setTimeout(resolve);
});
let go: ReturnType<typeof vi.spyOn>;
beforeEach(async () => {
vi.restoreAllMocks();
go = vi.spyOn(window.history, "go").mockImplementation(() => undefined);
vi.spyOn(window.history, "back").mockImplementation(() => undefined);
// Seed the tracked entry the way the app does.
setEntry("/config/automation/dashboard");
await navigate("/config/automation/edit/1");
});
it("routes a pop when nothing is dirty", () => {
const guard = registerGuard(false);
expect(pop(BACK)).toHaveBeenCalledOnce();
expect(guard.prompt).not.toHaveBeenCalled();
});
it("holds the route and prompts on a pop away from a dirty page", async () => {
const guard = registerGuard(true, false);
const resume = pop(BACK);
await vi.waitFor(() => expect(guard.prompt).toHaveBeenCalledOnce());
await flush();
expect(resume).not.toHaveBeenCalled();
});
it("does not prompt for a pop that stays on the same path", () => {
const guard = registerGuard(true, false);
// A dialog closing pops its own entry without changing the URL.
expect(pop("/config/automation/edit/1")).toHaveBeenCalledOnce();
expect(guard.prompt).not.toHaveBeenCalled();
});
it("guards a forward traversal and declines it backwards", async () => {
const guard = registerGuard(true, false);
const resume = popForward("/history");
await vi.waitFor(() => expect(guard.prompt).toHaveBeenCalledOnce());
expect(resume).not.toHaveBeenCalled();
// Undoing a forward means going back, not forward again.
await vi.waitFor(() => expect(go).toHaveBeenCalledWith(-1));
});
it("reads a pop as back when both neighbours share a path", async () => {
const guard = registerGuard(true, false);
// editor -> dashboard -> editor: the dashboard is both the entry behind us
// and, by path, one that could be ahead. Treating it as forward would leave
// a real back press unguarded.
const resume = pop(BACK, EDITOR);
await vi.waitFor(() => expect(guard.prompt).toHaveBeenCalledOnce());
expect(resume).not.toHaveBeenCalled();
await vi.waitFor(() => expect(go).toHaveBeenCalledWith(1));
});
it("does not reload when the held pops cancel out", async () => {
const { guard, answerPrompt } = trackDeferredGuard();
pop(BACK);
await vi.waitFor(() => expect(guard.prompt).toHaveBeenCalledOnce());
// Forward again while the prompt is up, back to where we started.
pop(EDITOR, BACK);
answerPrompt(false);
await flush();
// go(0) would reload the document and lose the edits it just protected.
expect(go).not.toHaveBeenCalled();
});
it("does not guard a jump over several entries", async () => {
const guard = registerGuard(true, false);
// A long press on back skips past the entry the editor came from.
expect(pop("/config")).toHaveBeenCalledOnce();
await flush();
expect(guard.prompt).not.toHaveBeenCalled();
expect(go).not.toHaveBeenCalled();
});
it("routes once when the prompt is confirmed", async () => {
registerGuard(true, true);
const resume = pop(BACK);
await vi.waitFor(() => expect(resume).toHaveBeenCalledOnce());
expect(go).not.toHaveBeenCalled();
});
it("returns to the page when the prompt is declined", async () => {
registerGuard(true, false);
const resume = pop(BACK);
await vi.waitFor(() => expect(go).toHaveBeenCalledWith(1));
expect(resume).not.toHaveBeenCalled();
});
it("undoes every pop taken while the prompt was open", async () => {
const { guard, answerPrompt } = trackDeferredGuard();
pop(BACK);
await vi.waitFor(() => expect(guard.prompt).toHaveBeenCalledOnce());
// Impatient second back press while the prompt is still up.
const resume = pop("/config");
answerPrompt(false);
await vi.waitFor(() => expect(go).toHaveBeenCalledWith(2));
expect(guard.prompt).toHaveBeenCalledOnce();
expect(resume).not.toHaveBeenCalled();
});
it("does not count a held pop it cannot account for", async () => {
const { guard, answerPrompt } = trackDeferredGuard();
pop(BACK);
await vi.waitFor(() => expect(guard.prompt).toHaveBeenCalledOnce());
// Cannot be undone, so it must not inflate what declining restores.
pop("/history");
answerPrompt(false);
await vi.waitFor(() => expect(go).toHaveBeenCalledWith(1));
});
it("tracks the entry it loaded on, so a pop after a reload is guarded", async () => {
// A reload keeps the entry's state but re-evaluates the module, so the
// entry has to be read at init rather than left with no `from`.
vi.resetModules();
setEntry(EDITOR, { from: BACK });
const fresh = await import("../../src/common/navigate");
const guard = { isDirty: () => true, prompt: vi.fn(async () => false) };
fresh.registerUnsavedChangesGuard(guard);
window.history.replaceState({ from: "/config" }, "", BACK);
const resume = vi.fn();
fresh.handleHistoryPop(resume);
await vi.waitFor(() => expect(guard.prompt).toHaveBeenCalledOnce());
expect(resume).not.toHaveBeenCalled();
fresh.unregisterUnsavedChangesGuard(guard);
});
it("does not prompt for the pop goBack asked for", async () => {
const guard = registerGuard(true, false);
await goBack();
expect(pop(BACK)).toHaveBeenCalledOnce();
expect(guard.prompt).not.toHaveBeenCalled();
});
it("drops a pop once a navigation has moved the app on", async () => {
let dirty = true;
const { guard, answerPrompt } = trackDeferredGuard(() => dirty);
const resume = pop(BACK);
await vi.waitFor(() => expect(guard.prompt).toHaveBeenCalledOnce());
// The prompt's save action can clean the page and navigate on its own.
dirty = false;
await navigate("/config/areas");
answerPrompt(true);
await flush();
expect(resume).not.toHaveBeenCalled();
expect(window.location.pathname).toEqual("/config/areas");
});
it("shares one prompt with a navigation, and the pop wins", async () => {
const { guard, answerPrompt } = trackDeferredGuard();
// The back press opens the prompt, so it is the one that gets answered.
const resume = pop(BACK);
await vi.waitFor(() => expect(guard.prompt).toHaveBeenCalledOnce());
const navigation = navigate("/config/areas");
answerPrompt(true);
expect(await navigation).toBe(false);
expect(resume).toHaveBeenCalledOnce();
expect(window.location.pathname).toEqual("/config/automation/dashboard");
});
});
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import type { ShowDialogParams } from "../../../src/dialogs/make-dialog-manager";
import { showConfirmationDialog } from "../../../src/dialogs/generic/show-dialog-box";
// The unsaved-changes guard needs its prompt to add no history entry, or the
// entries a pop left are truncated before declining can restore them. Losing
// this pass-through breaks that silently.
const firedDetail = (params: Parameters<typeof showConfirmationDialog>[1]) => {
const element = document.createElement("div");
let detail: ShowDialogParams<unknown> | undefined;
element.addEventListener("show-dialog", (ev) => {
detail = (ev as CustomEvent<ShowDialogParams<unknown>>).detail;
});
showConfirmationDialog(element, params);
return detail;
};
describe("showConfirmationDialog", () => {
it("passes addHistory through to the dialog manager", () => {
expect(firedDetail({ text: "x", addHistory: false })?.addHistory).toBe(
false
);
});
it("leaves addHistory unset when the caller does not ask", () => {
// The manager then defaults it to true, so back closes the dialog.
expect(firedDetail({ text: "x" })?.addHistory).toBeUndefined();
});
});
+21 -1
View File
@@ -1,6 +1,6 @@
import { LitElement } from "lit";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { navigate } from "../../src/common/navigate";
import { handleHistoryPop, navigate } from "../../src/common/navigate";
import { DirtyStateProviderMixin } from "../../src/mixins/dirty-state-provider-mixin";
import { PreventUnsavedMixin } from "../../src/mixins/prevent-unsaved-mixin";
@@ -126,6 +126,26 @@ describe("PreventUnsavedMixin", () => {
expect(window.location.pathname).toEqual("/config/scene/dashboard");
});
it("prompts on a history pop away from the page", async () => {
// Reach the editor while still clean, so the entry records the page behind it.
setEntry("/config/automation/dashboard");
const element = await mountClean();
await navigate("/config/automation/edit/1234");
element.promptResponse = false;
element.setValue({ name: "Bedroom" });
await element.updateComplete;
// Fake the browser popping back to the page behind the editor.
window.history.replaceState(null, "", "/config/automation/dashboard");
const resume = vi.fn();
handleHistoryPop(resume);
await vi.waitFor(() => expect(element.promptCalls).toBe(1));
expect(resume).not.toHaveBeenCalled();
});
it("arms beforeunload only while dirty", async () => {
const element = await mountDirty();