UnsavedChangesGuard
Description
UnsavedChangesGuard is a static helper that stops an editor's unsaved changes from being lost without the user choosing that. While anything is tracked it warns on tab close and reload through beforeunload, and when called from Router.OnBeforeNavigate it blocks in-app navigation and shows a "Save and leave / Leave without saving / Stay here" dialog. Its companion TabSaveIndicator marks Pivot tabs that hold unsaved changes and registers a save handler for each, so the guard can save a tab without knowing what kind of editor it holds.
Use UnsavedChangesGuard.Track(...) for a single editor (a form, a settings panel, a modal) and add TabSaveIndicator when editors are hosted as tabs.
Usage
Register the editor with Track(key, name, isDirty, save) while it is on screen and call Forget(key) when it is removed. CanNavigateAway(target, from) returns true when nothing would be lost; otherwise it cancels the navigation, asks the user, and re-issues the navigation itself if the user chooses to leave.
Router keeps a single OnBeforeNavigate handler, so call the guard from inside the handler the app already registers and pass its current state as from, so that navigating to the route already showing is not treated as leaving it:
Router.OnBeforeNavigate((newState, currentState, isBack) =>
UnsavedChangesGuard.CanNavigateAway(newState, currentState));
For editors hosted as Pivot tabs, call UnsavedChangesGuard.TrackOpenTabs() when the hosting view mounts and ForgetOpenTabs() when it is removed. Each tab uses TabSaveIndicator.Title(id, text) as its title, calls MarkDirty(id) / MarkClean(id) from its own change tracking, and registers its save with OnSave(id, saveAsync).
API reference
public static class UnsavedChangesGuardStops an editor carrying unsaved changes from being thrown away without the user saying so. Two ways of losing one are covered: reloading / closing the browser tab — a beforeunload listener asks the browser to show its native "leave site?" prompt; navigating to another route — CanNavigateAway (wire it into OnBeforeNavigate) cancels the navigation, asks whether to save, and only then lets it through. There are two ways to feed it. A modal or a standalone editor registers itself with Track and drops out with Forget. A view hosting editors as pivot tabs instead calls TrackOpenTabs, which reads the dirty tabs straight off the DOM via TabSaveIndicator — tabs come and go faster than registrations can be kept honest, and a torn-down tab can't leave a stale entry behind that way. Closing a modal directly (its own close button, a light-dismiss click) is NOT covered here — that path has to ask its own confirmation before the modal hides, since by the time it is gone there is nothing left to intercept.
- Namespace
- Tesserae
Methods
| Name | Description |
|---|---|
| Track | Registers a single editor for as long as it is on screen. key identifies it (re-opening the same editor replaces its entry), name is what the user is told has unsaved changes, and save returns whether the save actually went through. Always pair this with Forget on the editor's close / removal path. |
| Forget | Drops a Tracked editor — call it when that editor closes. |
| TrackOpenTabs | Starts watching the open editor tabs — call it when the hosting view is mounted. |
| ForgetOpenTabs | Stops watching the open editor tabs — call it when the hosting view is removed. |
| CanNavigateAway | A OnBeforeNavigate answer. Returns true when nothing would be lost; otherwise cancels this navigation and asks the user what to do with the unsaved editors, re-issuing the navigation once they have decided. from is the route being left (the handler's second argument) — pass it so that going nowhere isn't mistaken for leaving. |
| CollectUnsaved | Every editor that currently holds changes the user has not saved. |
| RefreshUnloadListener | The beforeunload listener only needs to exist while something is being watched, so adding and removing it follows the two registration paths. |
| SaveAllAsync | Saves every unsaved editor, stopping at the first one that refuses. Editors say what went wrong themselves, but not all of them do — so name the one we stopped on, otherwise "Save and leave" would look like it did nothing. |
| SameRoute | Whether two full URLs address the same route. Only the hash carries the route, and the browser may hand back a differently-spelled absolute URL for the same one — it percent-encodes whatever we navigated to, so a route we confirmed as "#/view/My page" comes back as "#/view/My%20page". Compare the decoded hashes, or the guard would ask again about the navigation the user just confirmed and never let it through. |
| DecodeRoute | Percent-decodes a route, leaving it alone if it isn't valid encoding. |
public static void Track(string key, Func<string> name, Func<bool> isDirty, Func<Task<bool>> save)Registers a single editor for as long as it is on screen. key identifies it (re-opening the same editor replaces its entry), name is what the user is told has unsaved changes, and save returns whether the save actually went through. Always pair this with Forget on the editor's close / removal path.
public static void Forget(string key)Drops a Tracked editor — call it when that editor closes.
public static void TrackOpenTabs()Starts watching the open editor tabs — call it when the hosting view is mounted.
public static void ForgetOpenTabs()Stops watching the open editor tabs — call it when the hosting view is removed.
private static List<UnsavedEditor> CollectUnsaved()Every editor that currently holds changes the user has not saved.
private static void RefreshUnloadListener()The beforeunload listener only needs to exist while something is being watched, so adding and removing it follows the two registration paths.
private static async Task<bool> SaveAllAsync(List<UnsavedEditor> unsaved)Saves every unsaved editor, stopping at the first one that refuses. Editors say what went wrong themselves, but not all of them do — so name the one we stopped on, otherwise "Save and leave" would look like it did nothing.
private static bool SameRoute(string a, string b)Whether two full URLs address the same route. Only the hash carries the route, and the browser may hand back a differently-spelled absolute URL for the same one — it percent-encodes whatever we navigated to, so a route we confirmed as "#/view/My page" comes back as "#/view/My%20page". Compare the decoded hashes, or the guard would ask again about the navigation the user just confirmed and never let it through.
public static class TabSaveIndicatorToggles a CSS class on a Pivot tab title element so it can show an "unsaved changes" marker: a dot standing in for the close cross on a closeable tab, or beside the label on a tab without one. Both are drawn from tss.pivot.css.
Remarks
An editor hosted as a pivot tab calls MarkDirty / MarkClean from inside its own validation / save handling. The host gives the tab title element an id (any string that is stable for as long as the tab is open) and the editor references that same id, so the host and the editor don't need to pass tab/component references back and forth. An editor also registers its save handler here via OnSave, which is what lets UnsavedChangesGuard offer to save the tab instead of only offering to discard it.
- Namespace
- Tesserae
Methods
| Name | Description |
|---|---|
| TabId | Builds a stable tab indicator id from an item type and uid, e.g. tab-endpoint-{uid}. |
| Title | A pivot tab title that carries tabIndicatorId, so MarkDirty can find it. Same styling as PivotTitle — a title built from anything else (a bare TextBlock, say) gets none of the tab strip's padding and sits wrong in the strip. |
| IsDirty | Whether the tab currently shows the unsaved-changes indicator. |
| OnSave | Registers the editor's save handler for tabIndicatorId. It must return whether the save actually succeeded — an editor that refuses to save (incomplete form, compile error, …) returns false and is expected to tell the user why. |
| Forget | Drops the registered save handler — call this when the tab closes. |
| CanSave | Whether an editor registered a save handler for this tab. |
| SaveAsync | Runs the tab's registered save handler. Returns false when the tab has no handler or the editor reported the save didn't go through. |
| DirtyTabIds | The ids of every tab currently showing the unsaved-changes indicator. Reading them back off the DOM (rather than a registry we'd have to keep in sync) means a tab that was torn down can't leave a stale entry behind. |
| TitleOf | The tab's visible title, used when telling the user which editors are unsaved. |
public static string TabId(string itemType, object uid)Builds a stable tab indicator id from an item type and uid, e.g. tab-endpoint-{uid}.
| Overload | |
|---|---|
| Title(string, string) | A pivot tab title that carries tabIndicatorId, so MarkDirty can find it. Same styling as PivotTitle — a title built from anything else (a bare TextBlock, say) gets none of the tab strip's padding and sits wrong in the strip. |
| Title(string, string, UIcons) | A pivot tab title with an icon that carries tabIndicatorId. See Title. |
Title(string, string)
public static Func<IComponent> Title(string tabIndicatorId, string text)A pivot tab title that carries tabIndicatorId, so MarkDirty can find it. Same styling as PivotTitle — a title built from anything else (a bare TextBlock, say) gets none of the tab strip's padding and sits wrong in the strip.
Parameters
- tabIndicatorId string
- text string
public static bool IsDirty(string tabIndicatorId)Whether the tab currently shows the unsaved-changes indicator.
public static void OnSave(string tabIndicatorId, Func<Task<bool>> saveAsync)Registers the editor's save handler for tabIndicatorId. It must return whether the save actually succeeded — an editor that refuses to save (incomplete form, compile error, …) returns false and is expected to tell the user why.
public static void Forget(string tabIndicatorId)Drops the registered save handler — call this when the tab closes.
public static bool CanSave(string tabIndicatorId)Whether an editor registered a save handler for this tab.
public static Task<bool> SaveAsync(string tabIndicatorId)Runs the tab's registered save handler. Returns false when the tab has no handler or the editor reported the save didn't go through.
public static string[] DirtyTabIds()The ids of every tab currently showing the unsaved-changes indicator. Reading them back off the DOM (rather than a registry we'd have to keep in sync) means a tab that was torn down can't leave a stale entry behind.