Skip to content

feat: warn before leaving system configuration with unsaved changes - #218

Merged
Bccorb merged 1 commit into
mainfrom
feat/unsaved-changes-guard
Jul 30, 2026
Merged

feat: warn before leaving system configuration with unsaved changes#218
Bccorb merged 1 commit into
mainfrom
feat/unsaved-changes-guard

Conversation

@Bccorb

@Bccorb Bccorb commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Closes #138.

This is the issue I deferred out of #207, because doing it properly needs a router change and I did not want that buried in a nine-issue PR.

The problem

The configuration screen stages every edit, across every section, into one draft behind a single Save. There was no unload handler and no in-app navigation guard, so navigating away, using the browser back button, or refreshing discarded all of it silently. The sticky bar reported unsaved changes but did nothing to protect them.

Two mechanisms

Neither covers the other's cases, so the guard uses both:

  • beforeunload — reload, tab close, navigation to another origin. The router never sees these.
  • useBlocker — in-app navigation and the back button. These never reach beforeunload.

The blocker settles with window.confirm rather than the app's own ConfirmProvider dialog. The blocker has to resolve synchronously against the pending navigation, and an awaited dialog lets the route change through before the answer arrives. The in-app dialog stays where it can be awaited safely, such as the Discard button added in #207.

The router change

useBlocker throws outside a data router, and the app mounted <BrowserRouter> + <Routes>. So App.tsx now builds a createBrowserRouter route table.

What is deliberately unchanged: every path, the basename from runtime config, RequireAuth, PublicAuthRoute, the not-found catch-all inside the shell, and the error boundary keyed on the pathname.

What moved: AuthProvider, SessionExpiryHandler, and ErrorBoundary are now a root route element. They have to sit inside the router but outside the routes, and the router is created once at module scope, so they cannot wrap RouterProvider.

main.tsx gets simpler: the BrowserRouter and its basename move into App.

SystemConfig's tests render through createMemoryRouter for the same reason the app needs a data router. Their assertions are unchanged.

Checks

npm run lint, npm run typecheck, npm run format:check, npm test (403 tests), and npm run build all pass. I ran the build as well as the tests here, since the router change affects app bootstrap and there are no App-level tests to catch a wiring mistake.

Four new tests: navigation allowed when nothing is staged, the operator kept on the screen when they decline, navigation proceeding when they accept, and beforeunload marking a reload as needing confirmation only while dirty.

Worth a careful look

This is the one PR in this batch that changes app bootstrap rather than a screen. The behaviour should be identical, but it is the change I would most want a second pair of eyes on, particularly the /console basename build (npm run build:console) against a real deployment.

The configuration screen stages every edit across every section into a single
draft behind one Save. There was no unload handler and no in-app navigation
guard, so navigating away, using the back button, or refreshing threw all of it
away with no prompt. The sticky bar reported unsaved changes but did nothing to
protect them.

The guard uses two mechanisms because neither covers the other's cases.
beforeunload handles a reload, a tab close, and navigation to another origin,
none of which the router ever sees. useBlocker handles in-app navigation and the
back button, which never reach beforeunload.

It settles the blocker with window.confirm rather than the app's own dialog. The
blocker has to resolve synchronously against the pending navigation, and an
awaited dialog lets the route change through before the answer arrives. The
in-app dialog stays where it can be awaited safely, such as the discard button.

This is why the app now mounts createBrowserRouter instead of BrowserRouter:
useBlocker throws outside a data router. The route table, the basename, and the
RequireAuth and PublicAuthRoute guards are unchanged. AuthProvider,
SessionExpiryHandler, and the error boundary moved into a root route element,
since they have to sit inside the router but outside the routes, and the router
itself is created once at module scope.

SystemConfig's tests now render through a memory data router for the same
reason. Their assertions are unchanged.
@Bccorb
Bccorb merged commit 784d8cd into main Jul 30, 2026
2 checks passed
@Bccorb
Bccorb deleted the feat/unsaved-changes-guard branch July 30, 2026 04:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Unsaved system configuration changes are discarded without warning

1 participant