"Unsaved changes" banner appears after creating a group/user — users think save failed #527
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Context
The admin "new group" and "new user" forms both register an inline
beforeNavigateguard that cancels navigation while a localisDirtyflag istrue. The server actions complete withthrow redirect(303, …). Whenuse:enhancefollows that redirect via client-sidegoto(),beforeNavigatefires —isDirtyis stilltrue, so navigation is cancelled and the "unsaved changes" amber banner is shown.The POST itself succeeded: the record is in the DB, and a manual reload reveals the new group/user in the list. But from the user's perspective the save looks like it failed.
The sibling edit page (
admin/groups/[id]/+page.svelte) does not have this problem because it uses the sharedcreateUnsavedWarning()hook + clears dirty state onform?.success. The two/newpages predate that refactor and still use the inline copy.User-facing impact
/admin/groups/new, fill in name, tick permissions/admin/groups/new, amber "Du hast ungespeicherte Änderungen" banner appearsReproducible 100% of the time on both pages.
Root cause
frontend/src/routes/admin/groups/new/+page.svelte:25-31The
oninputhandler at line 89 setsisDirty = true.use:enhanceat line 88 has no callback, so on aredirectresult it callsgoto('/admin/groups')internally — which triggers the guard above and gets cancelled.Identical pattern in
frontend/src/routes/admin/users/new/+page.svelte:15-21(same bug — confirmed the actionthrows redirect(303, '/admin/users')at+page.server.ts:42).Affected files
frontend/src/routes/admin/groups/new/+page.sveltefrontend/src/routes/admin/users/new/+page.svelteFix
Refactor both pages to match the edit-page pattern:
isDirty/showUnsavedWarning/discardTarget/beforeNavigateblock withcreateUnsavedWarning()from$lib/shared/hooks/useUnsavedWarning.svelte.<UnsavedWarningBanner onDiscard={unsaved.discard} />from$lib/shared/primitives/UnsavedWarningBanner.svelte.oninput={unsaved.markDirty}on the form.enhancecallback that clears dirty state when the server returns a redirect, beforeupdate()runs:Step 4 is the actual bug fix; steps 1–3 are cleanup that makes both
/newpages consistent with[id]/+page.svelte.Acceptance criteria (Gherkin)
Test plan
frontend/src/routes/admin/groups/newwith a spec that submits the form, asserts the form action returns a redirect, and asserts noUnsavedWarningBanneris rendered. Use the existing pattern inadmin/groups/[id]/page.svelte.spec.tsas a reference.admin/users/new.admin@familyarchive.local/admin123): create a group, confirm redirect to/admin/groups, confirm new group appears in the list without reload.Out of scope
[id]/+page.svelte) — those already work correctly./newpages: a grep forbeforeNavigateinsrc/routesshows only these two inline copies remain.👨💻 Felix Brandt — Senior Fullstack Developer
Observations
if (result.type === 'redirect') unsaved.clearOnSuccess()beforeawait update()) is the actual bug fix; steps 1–3 are the consistency cleanup.clearOnSuccess()setsisDirty = falsebeforeupdate()triggers the redirect navigation. By the time SvelteKit internally callsgoto(),isDirtyis alreadyfalseand thebeforeNavigateguard lets navigation through. The fix depends on this ordering.[id]/+page.svelte) clears dirty via$effect(() => { if (form?.success) unsaved.clearOnSuccess(); }). That approach works because the edit action returns asuccessflag. The new pages throwredirect(303, ...)—formis never updated — so the$effectapproach is structurally impossible here. The enhance callback is the correct mechanism.Recommendations
users/new/page.svelte.spec.tscurrently has no mock for$app/navigationat all. IntroducingcreateUnsavedWarning()will cause the spec to fail at import time becausebeforeNavigatewon't be defined. Add the samevi.mock('$app/navigation', () => ({ beforeNavigate: vi.fn(), goto: vi.fn() }))that the edit page spec uses.groups/new/page.svelte.test.tsmocksbeforeNavigate: () => {}(a no-op, not tracked). That's compatible for rendering tests but blocks the new unsaved-warning tests. The new spec file should usevi.fn()so the registered callback can be captured and invoked, exactly as in[id]/page.svelte.spec.ts.admin/groups/[id]/page.svelte.spec.ts" but the edit spec clears dirty viarerender({ form: { success: true } }), which won't work for redirect-based flows. Theenhancemock in that file (enhance: () => () => {}) discards the callback entirely. The new spec needs a mock that captures the SubmitFunction so the redirect result can be simulated. Concrete mock structure:🏗️ Markus Keller — Application Architect
Observations
createUnsavedWarning) that already exists for this purpose. DRY is applied correctly.$effect(() => { if (form?.success) unsaved.clearOnSuccess(); })— server action returns{ success: true },formis updated,$effectfires.if (result.type === 'redirect') unsaved.clearOnSuccess()— server action throwsredirect(303, ...),formis never updated.These are different server-side flow shapes, not inconsistency. No unification is needed or appropriate.
beforeNavigateregistration,isDirty,showUnsavedWarning, anddiscardTargetstate in one place. The banner is a standalone component with a singleonDiscardprop. The composition pattern is clean.Recommendations
grepforbeforeNavigateinsrc/routesshowed only these two files — confirm this holds after the fix to avoid a third copy appearing unnoticed.🧪 Sara Holt — QA Engineer
Observations
admin/groups/[id]/page.svelte.spec.ts) tests dirty-state clearing by re-rendering withform: { success: true }, which triggers a$effect. That mechanism does not exist in the new pages (noform.success, only a redirect). The test for this case cannot reuse that pattern.groups/new/page.svelte.test.tsalready has basic coverage (rendering, checkboxes, error banner). The new spec file should extend coverage with the unsaved-warning behavior, not duplicate the rendering tests.users/new/page.svelte.spec.tshas no mock for$app/navigationat all. OncecreateUnsavedWarning()is introduced (which callsbeforeNavigateat module load), the spec will throw at import time.Recommendations
vi.mock('$app/navigation', () => ({ beforeNavigate: vi.fn(), goto: vi.fn() }))— not the no-op version inpage.svelte.test.ts.$app/formsmock must capture theSubmitFunctioncallback to enable redirect simulation. See Felix's comment for the concrete mock structure.groups/newandusers/new):goto()called with the target URL{ type: 'redirect' }result → warning hidden,isDirtyfalse{ type: 'failure' }result → warning still shown (dirty preserved)form.erroris set) must leave the form dirty so the guard still protects unsaved work.useUnsavedWarning.svelte.test.ts) already coversclearOnSuccess()directly — the component spec can stay slim by delegating hook-level assertions to that file.🔐 Nora "NullX" Steiner — Application Security Engineer
Observations
beforeNavigateguard is purely client-side UX — it cannot prevent the form POST from reaching the server, and its bug (showing the banner after a successful redirect) has no security implications.admin/groups/new/+page.server.tsandadmin/users/new/+page.server.tsalready enforce server-side authorization. The groups page has an implicit auth gate via session-based access; the users page has an explicithasAdmincheck in theloadfunction. Neither is touched by this fix.Recommendations
🎨 Leonie Voss — UX Designer & Accessibility Strategist
Observations
Recommendations
UnsavedWarningBannercomponent uses the i18n keyperson_discard_changes()for the "Discard" button. This key name implies it belongs to the persons domain. The message text itself is presumably generic enough to read naturally in the admin context — verify the German string (Verwerfen?) is appropriate here and consider renaming the key tobtn_discard_changesin a follow-up if it ever causes confusion./admin/groups(or/admin/users) without any amber banner. This is the correct, clean success state.⚙️ Tobias Wendt — DevOps & Platform Engineer
Observations
Recommendations
📋 Elicit — Requirements Engineer
Observations
/admin/users/new.Recommendations
form.erroris shown), and the user then tries to navigate away? The failure path does not callclearOnSuccess(), soisDirtyremainstrueand the guard should still trigger. This is the correct behavior — confirm it's covered in the spec.admin/groups/[id]/page.svelte.spec.tsas the pattern, but the mechanism for clearing dirty state is different between the edit and create flows (see Felix and Sara's comments). Clarify in the test plan that theenhancecallback approach — notrerenderwithform.success— is how the redirect-clearing case is tested.