12 KiB
Dirty Store
Source: src/lib/client/stores/dirty.ts (~146 lines)
Nav guard: src/lib/client/ui/modal/DirtyModal.svelte
The dirty store is how forms in Profilarr detect unsaved changes and prompt
the user before navigation. It's a single global store with a snapshot-based
model: you hand it the server data, mutate a working copy, and it tells you
whether the copy differs from the snapshot. When it's dirty, DirtyModal
intercepts navigation and asks the user to confirm or discard.
Table of Contents
Where State Lives
Profilarr is server-first. +page.server.ts and +layout.server.ts load
functions own the current data for every page, mutations go through form
actions, and a successful mutation ends in redirect(303, ...) so SvelteKit
re-runs the relevant loads. Client stores exist only for things the server
cannot own: unsaved form edits (the dirty store, covered below), user
preferences, realtime job status, alerts, list filters, and onboarding
progress. If you're adding new state, check whether a load function or route
param can express it before reaching for a store.
Model
The store keeps three writable stores plus a pending-navigation resolver:
originalSnapshotholds astructuredCloneof the last server-known state.currentDataholds the working copy the form mutates.isNewModeis a flag intended for create mode, described in the API section and tracked in Outstanding Tasks.resolveNavigationis a Promise resolver used by the nav guard.
isDirty is a derived store that runs deepEquals(original, current) and
inverts the result. The equality check is recursive, ignores key order in
objects, and is order-sensitive for arrays. Array order sensitivity is
deliberate: quality profile qualities and custom format conditions are
ordered lists, and reordering them should count as a change. If you need to
track a naturally unordered collection, sort it before snapshotting or model
it as an object keyed by id.
// src/lib/client/stores/dirty.ts:23-43
function deepEquals(a: unknown, b: unknown): boolean {
if (a === b) return true;
if (typeof a !== typeof b) return false;
if (a === null || b === null) return a === b;
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
return a.every((item, i) => deepEquals(item, b[i]));
}
if (typeof a === 'object' && typeof b === 'object') {
const aObj = a as Record<string, unknown>;
const bObj = b as Record<string, unknown>;
const aKeys = Object.keys(aObj);
const bKeys = Object.keys(bObj);
if (aKeys.length !== bKeys.length) return false;
return aKeys.every((key) => deepEquals(aObj[key], bObj[key]));
}
return false;
}
Two consequences of this model:
- Change-and-change-back is not dirty. If a user types into a field and
then restores the original value,
isDirtygoes back tofalse. The snapshot never mutates, so any round-trip matches it. - You do not need to track individual field changes. Anything reachable from the working copy is compared as one unit.
Navigation Integration
DirtyModal is the bridge between the store and SvelteKit routing. It
registers a beforeNavigate hook that cancels the navigation if the form is
dirty, waits for the user's decision, and either replays the navigation with
goto() or leaves the user on the current page.
<!-- src/lib/client/ui/modal/DirtyModal.svelte:14-24 -->
<script lang="ts">
import { beforeNavigate, goto } from '$app/navigation';
let pendingNavigationUrl: string | null = null;
beforeNavigate(async (navigation) => {
if ($isDirty) {
navigation.cancel();
pendingNavigationUrl = navigation.to?.url.pathname || null;
const shouldNavigate = await confirmNavigation();
if (shouldNavigate && pendingNavigationUrl) {
goto(pendingNavigationUrl);
}
pendingNavigationUrl = null;
}
});
</script>
Two things to know about the current placement:
DirtyModalis currently included per feature layout, not once at the root. For example,quality-profiles/[databaseId]/[id]/+layout.sveltemounts it for all three profile tabs, and the database config page mounts it in the page itself. The plan is to move it tosrc/routes/+layout.svelteonce so every form is protected automatically; see Outstanding Tasks.- There is no
beforeunloadhandler today. Closing the browser tab, refreshing, or following an external link skips the dirty check entirely. Adding one is tracked in Outstanding Tasks.
Form Lifecycle
Every dirty-aware form follows the same loop:
- Initialize on mount. Pick
initEditorinitCreatebased on whether you have server data. PreferonMountover$: initEdit(data)so the snapshot is taken exactly once at a predictable point, and so edits are not silently thrown away if an upstream reactive dependency changes. - Mutate the working copy via
update()or a reactive derivation from$current. - Save with
use:enhance. On success, callinitEdit($current)to promote the working copy to the new baseline. - Clean up on unmount by calling
clear()fromonDestroy(or the cleanup return fromonMount).
The save step is the one most easily gotten wrong. After a successful save,
the working copy already holds the right data. Calling initEdit($current)
re-snapshots it, which flips isDirty back to false and re-arms change
detection for the next edit. Forgetting this step leaves the form in a
permanently dirty state.
<form
method="POST"
use:enhance={() => {
isSaving = true;
return async ({ result, update: formUpdate }) => {
isSaving = false;
if (result.type === 'success' && result.data?.success) {
alertStore.add('success', 'Saved');
initEdit($current); // reset baseline
}
await formUpdate();
};
}}
>
<!-- ... -->
</form>
Some existing pages use $: initEdit(initialData) instead of onMount;
that pattern is being phased out as part of the form lifecycle cleanup in
Outstanding Tasks.
Patterns
Roughly five shapes of dirty-aware form live in the codebase. They all use the same API; the differences are in how they model their working copy.
Simple form (component-based)
Regex and delay profile forms live in reusable components that accept a
mode prop and branch between initCreate and initEdit.
<!-- src/routes/regular-expressions/[databaseId]/components/RegularExpressionForm.svelte -->
onMount(() => {
if (mode === 'create') {
initCreate(initialData ?? defaults);
} else {
initEdit(initialData);
}
});
The working copy is a flat object, fields bind to derived reads like
$: name = ($current.name ?? '') as string, and edits go through
update('name', newValue). After a successful save the component calls
initEdit($current) from the enhance handler.
Ordered collection (all-or-nothing save)
Quality profile qualities are an ordered list of quality items and groups with position indices. The working copy stores the entire ordered array:
// src/routes/quality-profiles/[databaseId]/[id]/qualities/+page.svelte
initialData = { orderedItems: [...] };
initEdit(initialData);
// mutation via update()
update('orderedItems', reorderedArray);
Because deepEquals is order-sensitive, drag-and-drop reordering immediately
flips isDirty to true. The save submits the full ordered array; the server
replaces the entire list in one transaction. This shape suits
full-replacement ops where the server cannot reliably diff.
Nested score map (diff-based save)
Quality profile scoring stores a nested map:
customFormatScores[cfName][arrType] = score. The working copy holds the
full map, but the form submits only the diff against the initial snapshot:
// scoring/+page.svelte
$: customFormatScoresPayload = JSON.stringify(
buildCustomFormatScoresArray(customFormatScores, initialData.customFormatScores)
);
The dirty store still deep-compares the full map to decide whether to enable the save button, but the wire format is a diff. This keeps payloads small on a page with hundreds of scores while still getting correct dirty detection for free.
Draft workflow (staging before commit)
Custom format conditions let users build a new condition in a "draft" slot before committing it to the main conditions array. The working copy has two top-level fields:
interface ConditionsFormData {
conditions: KeyedCondition[];
draftConditions: KeyedCondition[];
}
Drafts are part of the store, so adding or editing a draft marks the form
dirty. A "Save" action is blocked while any draft is pending, and
confirmDraft() moves the draft from draftConditions to conditions via
two update() calls. Each condition carries a stable _key for Svelte's
keyed-each semantics; this matters because re-renders during editing would
otherwise lose focus and cursor position.
Compound form (explicit lifecycle)
The database config page edits manifest and readme together, aliases the
store's update to avoid shadowing, and uses explicit onMount / onDestroy
lifecycle hooks:
// src/routes/databases/[id]/config/+page.svelte
import {
isDirty,
initEdit,
update as dirtyUpdate,
resetFromServer,
clear as clearDirty
} from '$lib/client/stores/dirty';
onMount(() => {
if (manifest) initEdit({ manifest, readme });
});
onDestroy(() => {
clearDirty();
});
It currently holds a local let manifest = data.manifest alongside the
store, which is why it needs resetFromServer instead of plain
initEdit($current) after save. Collapsing that duplication is part of
Outstanding Tasks.
Outstanding Tasks
These are known improvements to the dirty store and its surrounding conventions. They're listed here so the doc reflects intent as well as current state.
- Make
DirtyModalglobal. Move the single<DirtyModal />instance tosrc/routes/+layout.svelteand remove every per-feature-layout import of it. The store is already a singleton; there is no benefit to mounting the modal multiple times, and per-layout placement means new features can silently ship without nav-guard protection. - Fix
initCreatesoisNewModeactually flips totrue. The file header comment says "New mode = always dirty" and the derived store has a matchingif ($isNew) return truebranch, butinitCreateatdirty.ts:70-74setsisNewModetofalseand is therefore indistinguishable frominitEdit. Setting it totruerestores the intended "create forms start dirty so the save button is enabled from the first render" behavior. - Add a
beforeunloadhandler toDirtyModal. Today, closing the tab or refreshing silently drops unsaved edits. A minimalwindow.addEventListener('beforeunload', ...)that setsevent.preventDefault()while$isDirtyis true gives users the browser's native "are you sure" prompt for free. - Standardize form init and cleanup. Some pages use
onMount+onDestroy(clear), others use$: initEdit(initialData)and rely on the next page'sinitEditto overwrite state. Pick one (recommended:onMount+onDestroy(clear)) and migrate existing forms. Consistency here also makes the$: initEditre-snapshot footgun go away. - Refactor the database config page to derive from
$currentinstead of holding a locallet manifest. Once it no longer duplicates state,resetFromServerhas no callers and can be removed from the store's API. - Implement sidebar collapse.
src/lib/client/stores/sidebar.tsexports asidebarCollapsedwritable withtoggle,collapse, andexpandmethods, but no component imports it. Wire it up to the page nav, or delete the file until it's needed. - Write
frontend/preferences.md. The persisted UI preference stores (theme,accent,font,navIcons,mobileNav) deserve their own short doc.