feat(ux): replace individual save buttons with single "unsaved changes bar" (#1757)

This commit is contained in:
Elias Schneider
2026-09-19 15:23:06 +00:00
committed by GitHub
parent 818333fb38
commit 63050add4e
58 changed files with 1916 additions and 1184 deletions
@@ -1,4 +1,5 @@
<script lang="ts">
import { replaceState } from '$app/navigation';
import { page } from '$app/state';
import * as Alert from '$lib/components/ui/alert';
import { Button } from '$lib/components/ui/button';
@@ -28,7 +29,7 @@
function onDismiss() {
const url = new URL(page.url);
url.searchParams.delete('emailVerificationState');
history.replaceState(null, '', url.toString());
replaceState(url, page.state);
emailVerificationState = null;
}
@@ -40,10 +40,25 @@
let unit = $state<DurationUnit>(preferredUnit(input.value));
let amount = $state(formatAmount(input.value / minutesPerUnit[unit]));
// The minutes that `amount` and `unit` currently represent. Only a value that arrives from
// outside (e.g. after discarding changes) re-derives them, so that switching the unit isn't
// undone by the rounding of the displayed amount.
let displayedMinutes = input.value;
$effect(() => {
const minutes = input.value;
if (Object.is(minutes, displayedMinutes)) return;
displayedMinutes = minutes;
if (!Number.isFinite(minutes)) return;
unit = preferredUnit(minutes);
amount = formatAmount(minutes / minutesPerUnit[unit]);
});
function updateAmount(event: Event) {
amount = (event.currentTarget as HTMLInputElement).value;
input.value = amount === '' ? Number.NaN : Number(amount) * minutesPerUnit[unit];
displayedMinutes = amount === '' ? Number.NaN : Number(amount) * minutesPerUnit[unit];
input.value = displayedMinutes;
}
function updateUnit(value: string | undefined) {
@@ -85,7 +85,15 @@
{@render children()}
{:else if input}
{#if type === 'date'}
<DatePicker {id} bind:value={input.value as Date} />
<DatePicker
{id}
bind:value={
() => input.value as Date | undefined,
(value) => {
input.value = value;
}
}
/>
{:else}
<Input
aria-invalid={!!input.error}
@@ -93,7 +101,12 @@
{id}
{placeholder}
{type}
bind:value={input.value}
bind:value={
() => input.value as string | number | undefined,
(value) => {
input.value = value;
}
}
{disabled}
oninput={(e) => onInput?.(e)}
{readonly}
@@ -4,6 +4,7 @@
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { m } from '$lib/paraglide/messages';
import WebAuthnService from '$lib/services/webauthn-service';
import unsavedChangesStoreSvelte from '$lib/stores/unsaved-changes-store.svelte';
import userStore from '$lib/stores/user-store';
import { cachedProfilePicture } from '$lib/utils/cached-image-util';
import { LucideLogOut, LucideUser } from '@lucide/svelte';
@@ -11,7 +12,12 @@
const webauthnService = new WebAuthnService();
async function logout() {
await webauthnService.logout();
if (!unsavedChangesStoreSvelte.hasChanges) {
await webauthnService.logout();
}
// Navigate regardless of unsaved changes status
// If there are unsaved changes, the save changes bar will shake and navigation will be canceled
goto('/login');
}
</script>
@@ -1,4 +1,6 @@
<script lang="ts" generics="T extends {id:string}">
import { replaceState } from '$app/navigation';
import { page as currentPage } from '$app/state';
import Checkbox from '$lib/components/ui/checkbox/checkbox.svelte';
import * as Pagination from '$lib/components/ui/pagination';
import * as Select from '$lib/components/ui/select';
@@ -151,7 +153,7 @@
function changePageState(page: number) {
const url = new URL(window.location.href);
url.searchParams.set(`${id}-page`, page.toString());
history.replaceState(history.state, '', url.toString());
replaceState(url, currentPage.state);
requestOptions.pagination!.page = page;
}
@@ -1,4 +1,5 @@
<script lang="ts">
import { replaceState } from '$app/navigation';
import { page } from '$app/state';
import { cn } from '$lib/utils/style.js';
import { Tabs as TabsPrimitive } from 'bits-ui';
@@ -21,8 +22,8 @@
});
function onTabChange(newValue: string) {
if (useHash && page.url.hash !== newValue) {
history.replaceState(history.state, '', location.pathname + location.search + `#${newValue}`);
if (useHash && page.url.hash.substring(1) !== newValue) {
replaceState(location.pathname + location.search + `#${newValue}`, page.state);
}
}
</script>
@@ -0,0 +1,247 @@
<script lang="ts">
import { beforeNavigate } from '$app/navigation';
import { Button } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages';
import appConfigStore from '$lib/stores/application-configuration-store';
import unsavedChanges from '$lib/stores/unsaved-changes-store.svelte';
import { cn } from '$lib/utils/style';
import { LucideCircleAlert } from '@lucide/svelte';
import { tick } from 'svelte';
import { cubicOut } from 'svelte/easing';
const ROW_HEIGHT = 44;
const BOTTOM_OFFSET = 20;
const VIEWPORT_MARGIN = 16;
const INSET = 18;
const INSET_BUTTONS = 8;
const MOTION =
'duration-[380ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none';
const LAYER = 'absolute top-0 flex items-center whitespace-nowrap';
type BloomTint = 'pending' | 'success' | 'error';
let shake = $state(false);
let bloom = $state<{ tint: BloomTint }>();
let pillWidth = $state<number>();
let compact = $state(false);
let frame = $state<HTMLElement>();
let pendingMessage = $state<HTMLElement>();
let statusMessage = $state<HTMLElement>();
let actions = $state<HTMLElement>();
const animate = $derived(!$appConfigStore?.disableAnimations);
const status = $derived(unsavedChanges.status);
const showingStatus = $derived(!!status);
const showingActions = $derived(unsavedChanges.hasChanges);
const barOpen = $derived(showingActions || showingStatus);
// Settled means in view; the other message waits a row height away
const pendingSettled = $derived(!showingStatus && !compact);
const statusSettled = $derived(showingStatus && !compact);
// A new object every time, so {#key} remounts the layer and replays the animation
function flashBloom(tint: BloomTint) {
bloom = { tint };
}
$effect(() => {
if (barOpen) flashBloom('pending');
});
$effect(() => {
if (status) flashBloom(status.type === 'error' ? 'error' : 'success');
});
function measure() {
const message = showingStatus ? statusMessage : pendingMessage;
if (!message || !actions || !frame) return;
// offsetWidth ignores the scale the buttons carry while fading, and rounding the text up keeps its last glyph
const end = showingActions ? actions.offsetWidth + INSET_BUTTONS : INSET;
const roomy = INSET + Math.ceil(message.getBoundingClientRect().width) + end;
// An unmeasured frame reports no width, which must not read as "nothing fits"
const available = frame.clientWidth ? frame.clientWidth - 2 * VIEWPORT_MARGIN : Infinity;
compact = showingActions && roomy > available;
pillWidth = Math.min(compact ? actions.offsetWidth + 2 * INSET_BUTTONS : roomy, available);
}
$effect(measure);
// Catches what no state change announces: a longer message, a font loading, the viewport resizing
$effect(() => {
const layers = [pendingMessage, statusMessage, actions, frame].filter((layer) => !!layer);
if (layers.length === 0) return;
const observer = new ResizeObserver(measure);
for (const layer of layers) observer.observe(layer);
return () => observer.disconnect();
});
function rise(_node: HTMLElement, { duration = 220 } = {}) {
return {
duration: animate ? duration : 0,
easing: cubicOut,
css: (t: number, u: number) =>
`opacity: ${t}; transform: translateY(${u * 8}px) scale(${0.98 + t * 0.02})`
};
}
beforeNavigate((nav) => {
if (!unsavedChanges.hasChanges) return;
// Cancelling a full page unload makes the browser show its own "leave site?" prompt.
nav.cancel();
if (nav.type === 'leave') return;
shake = true;
flashBloom('pending');
});
// A fresh edit supersedes any status message left over from a previous save.
$effect(() => {
if (unsavedChanges.hasChanges) unsavedChanges.clearStatus();
});
function handleSaveShortcut(event: KeyboardEvent) {
if (event.key.toLowerCase() !== 's' || !(event.metaKey || event.ctrlKey)) return;
if (!unsavedChanges.hasChanges || unsavedChanges.saving) return;
event.preventDefault();
void saveAll();
}
async function saveAll() {
if ((await unsavedChanges.saveAll()) === 'invalid') await revealFirstInvalidField();
}
// Brings the first field that failed validation into view, switching to the tab that contains
// it if necessary, since the bar is global while the field may sit on a hidden tab.
async function revealFirstInvalidField() {
await tick();
const field = document.querySelector<HTMLElement>(
'[aria-invalid="true"], [data-slot="field-error"]'
);
if (!field) return;
for (
let panel = field.closest<HTMLElement>('[role="tabpanel"]');
panel;
panel = panel.parentElement?.closest<HTMLElement>('[role="tabpanel"]') ?? null
) {
const value = panel.dataset.value;
if (value === undefined) continue;
panel
.closest('[data-slot="tabs"]')
?.querySelector<HTMLElement>(`[role="tab"][data-value="${CSS.escape(value)}"]`)
?.click();
}
await tick();
field.scrollIntoView({ block: 'center', behavior: 'smooth' });
field.focus({ preventScroll: true });
}
</script>
<svelte:window onkeydown={handleSaveShortcut} />
<!-- Belongs to the message, so it leaves with it and never counts towards the buttons' width -->
{#snippet divider()}
<span class="bg-border ml-2.5 mr-2 h-4.5 w-px shrink-0"></span>
{/snippet}
{#if barOpen}
<div
bind:this={frame}
class={cn('fixed inset-x-0 z-50 flex', compact ? 'justify-end' : 'justify-center')}
style:bottom="{BOTTOM_OFFSET}px"
style:padding-inline="{VIEWPORT_MARGIN}px"
transition:rise
class:animate-shake={shake}
onanimationend={() => (shake = false)}
>
<div
role="status"
aria-live="polite"
class={cn(
'bg-popover text-popover-foreground shadow-raised dark:bg-secondary relative isolate max-w-full overflow-hidden rounded-full border',
animate && `transition-[width] ${MOTION}`
)}
style:height="{ROW_HEIGHT}px"
style:width={pillWidth ? `${pillWidth}px` : undefined}
>
{#if animate && bloom}
{#key bloom}
<span
aria-hidden="true"
class="animate-bloom pointer-events-none absolute inset-0 -z-1 bloom-{bloom.tint}"
></span>
{/key}
{/if}
<span
bind:this={pendingMessage}
class={cn(LAYER, 'gap-2.5', animate && `transition-[translate] ${MOTION}`)}
style:height="{ROW_HEIGHT}px"
style:left="{INSET}px"
style:translate={pendingSettled ? '0' : `0 -${ROW_HEIGHT}px`}
inert={!pendingSettled}
>
{#if unsavedChanges.dirtyCount > 1}
<span
class="bg-primary/15 text-foreground flex size-5 items-center justify-center rounded-full font-bold tabular-nums"
>
{unsavedChanges.dirtyCount}
</span>
{/if}
<span class="text-sm font-semibold tracking-tight">{m.you_have_unsaved_changes()}</span>
{@render divider()}
</span>
<span
bind:this={statusMessage}
class={cn(LAYER, 'gap-2.5', animate && `transition-[translate] ${MOTION}`)}
style:height="{ROW_HEIGHT}px"
style:left="{INSET}px"
style:translate={statusSettled ? '0' : `0 ${ROW_HEIGHT}px`}
inert={!statusSettled}
>
{#if status?.type === 'error'}
<LucideCircleAlert class="text-destructive size-4 shrink-0" />
{/if}
<span class="text-sm font-semibold tracking-tight">{status?.message ?? ''}</span>
{#if showingActions}
{@render divider()}
{/if}
</span>
<span
bind:this={actions}
class={cn(
LAYER,
animate && `transition-[opacity,scale] ${MOTION}`,
!showingActions && 'scale-[0.96] opacity-0'
)}
style:height="{ROW_HEIGHT}px"
style:right="{INSET_BUTTONS}px"
inert={!showingActions}
>
<Button
variant="ghost"
size="sm"
class="hover:bg-foreground/10 dark:hover:bg-foreground/10"
disabled={unsavedChanges.saving}
onclick={() => unsavedChanges.discardAll()}
>
{m.discard()}
</Button>
<Button class="ml-1.5" size="sm" isLoading={unsavedChanges.saving} onclick={saveAll}>
{m.save()}
</Button>
</span>
</div>
</div>
{/if}
@@ -0,0 +1,105 @@
import { m } from '$lib/paraglide/messages';
import { getAxiosErrorMessage } from '$lib/utils/error-util';
import { isAxiosError } from 'axios';
import { SvelteSet } from 'svelte/reactivity';
export interface UnsavedSection {
readonly dirty: boolean;
validate?: () => boolean;
save: () => Promise<void>;
discard: () => void;
}
export type SaveResult = 'saved' | 'invalid' | 'failed';
const STATUS_DISPLAY_MS = 3000;
const sections = new SvelteSet<UnsavedSection>();
let saving = $state(false);
let status = $state<{ type: 'success' | 'error'; message: string } | null>(null);
let statusTimeout: ReturnType<typeof setTimeout> | undefined;
const dirtyCount = $derived([...sections].filter((section) => section.dirty).length);
const hasChanges = $derived(dirtyCount > 0);
function register(section: UnsavedSection) {
sections.add(section);
}
function unregister(section: UnsavedSection) {
sections.delete(section);
}
function clearStatus() {
clearTimeout(statusTimeout);
status = null;
}
function showStatus(type: 'success' | 'error', message: string) {
clearTimeout(statusTimeout);
status = { type, message };
statusTimeout = setTimeout(() => (status = null), STATUS_DISPLAY_MS);
}
function errorMessage(e: unknown) {
if (isAxiosError(e)) return getAxiosErrorMessage(e);
return e instanceof Error ? e.message : m.an_unknown_error_occurred();
}
async function saveAll(): Promise<SaveResult> {
const dirtySections = [...sections].filter((section) => section.dirty);
if (dirtySections.length === 0) return 'saved';
// Every section is validated before anything is persisted, so that a validation error in one section can't leave the page half-saved.
const allValid = dirtySections.map((section) => section.validate?.() ?? true).every(Boolean);
if (!allValid) {
showStatus('error', m.please_fix_the_errors_before_saving());
return 'invalid';
}
saving = true;
let failure: { error: unknown } | undefined;
// Saved one after another rather than in parallel: several sections of a page can target the same endpoint.
for (const section of dirtySections) {
try {
await section.save();
} catch (error) {
failure ??= { error };
}
}
saving = false;
if (failure) {
showStatus('error', errorMessage(failure.error));
return 'failed';
}
showStatus('success', m.changes_saved_successfully());
return 'saved';
}
function discardAll() {
clearStatus();
for (const section of sections) {
if (section.dirty) section.discard();
}
}
export default {
get hasChanges() {
return hasChanges;
},
get dirtyCount() {
return dirtyCount;
},
get saving() {
return saving;
},
get status() {
return status;
},
register,
unregister,
saveAll,
discardAll,
clearStatus
};
+107 -34
View File
@@ -1,3 +1,4 @@
import { reactiveState } from '$lib/utils/reactive-state.svelte';
import { get, writable } from 'svelte/store';
import { z } from 'zod/v4';
@@ -12,22 +13,28 @@ type FormInputs<T> = {
};
export function createForm<T extends z.ZodType<any, any>>(schema: T, initialValues: z.infer<T>) {
// Create a writable store for the inputs
const inputsStore = writable<FormInputs<z.infer<T>>>(initializeInputs(initialValues));
// The values the inputs are compared against and restored to. Kept as a private deep copy so
// that editing an object or array field can't change what it is supposed to be compared with.
let baseline: z.infer<T> = deepCopy(initialValues);
// The inputs are deeply reactive, so that field components can update them in place
// (`input.value = ...`) and anything derived from them, such as the unsaved-changes
// tracking, still re-evaluates.
const inputsStore = writable<FormInputs<z.infer<T>>>(reactiveState(initializeInputs()));
const errorsStore = writable<z.ZodError<any> | undefined>();
function initializeInputs(initialValues: z.infer<T>): FormInputs<z.infer<T>> {
function initializeInputs(): FormInputs<z.infer<T>> {
const inputs: FormInputs<z.infer<T>> = {} as FormInputs<z.infer<T>>;
const shape =
schema instanceof z.ZodObject ? (schema.shape as Record<string, z.ZodTypeAny>) : {};
for (const key in initialValues) {
if (Object.prototype.hasOwnProperty.call(initialValues, key)) {
for (const key in baseline) {
if (Object.prototype.hasOwnProperty.call(baseline, key)) {
const fieldSchema = shape[key];
inputs[key as keyof z.infer<T>] = {
value: initialValues[key as keyof z.infer<T>],
value: deepCopy(baseline[key as keyof z.infer<T>]),
error: null,
required: fieldSchema ? isRequired(fieldSchema) : false
};
@@ -37,40 +44,31 @@ export function createForm<T extends z.ZodType<any, any>>(schema: T, initialValu
}
function validate() {
let success = true;
inputsStore.update((inputs) => {
const values = Object.fromEntries(
Object.entries(inputs).map(([key, input]) => [key, input.value])
);
const inputs = get(inputsStore);
const values = Object.fromEntries(
Object.entries(inputs).map(([key, input]) => [key, trimValue(input.value)])
);
const result = schema.safeParse(values);
errorsStore.set(result.error);
const result = schema.safeParse(values);
errorsStore.set(result.error);
if (!result.success) {
success = false;
for (const input of Object.keys(inputs)) {
const error = result.error.issues.find((e) => e.path[0] === input);
if (error) {
inputs[input as keyof z.infer<T>].error = error.message;
} else {
inputs[input as keyof z.infer<T>].error = null;
}
}
} else {
for (const input of Object.keys(inputs)) {
inputs[input as keyof z.infer<T>].error = null;
}
}
// Set the error messages for each input based on the validation result
for (const input of Object.keys(inputs)) {
inputs[input as keyof z.infer<T>].error = result.success
? null
: (result.error.issues.find((e) => e.path[0] === input)?.message ?? null);
}
// If the validation succeeded, update the baseline with the new valid values
if (result.success) {
for (const key in result.data) {
if (Object.prototype.hasOwnProperty.call(inputs, key)) {
inputs[key as keyof z.infer<T>].value = result.data[key];
}
}
}
return inputs;
});
return success ? data() : null;
inputsStore.set(inputs);
return result.success ? result.data : null;
}
function data() {
@@ -86,13 +84,37 @@ export function createForm<T extends z.ZodType<any, any>>(schema: T, initialValu
return values;
}
function isDirty() {
const inputs = get(inputsStore);
return Object.keys(inputs).some(
(key) => !deepEqual(inputs[key as keyof z.infer<T>].value, baseline[key as keyof z.infer<T>])
);
}
// Moves the baseline forward, e.g. after a successful save whose data isn't reflected back
// into a reactive prop that would otherwise rebuild the form.
function commit(values: z.infer<T>) {
baseline = { ...baseline, ...deepCopy(values) };
// Notify subscribers, so that anything comparing the inputs against the baseline
// (e.g. the unsaved-changes tracking) is re-evaluated.
inputsStore.update((inputs) => inputs);
}
function clearErrors() {
errorsStore.set(undefined);
inputsStore.update((inputs) => {
for (const input of Object.values(inputs) as FormInput<unknown>[]) input.error = null;
return inputs;
});
}
function reset() {
inputsStore.update((inputs) => {
for (const input of Object.keys(inputs)) {
const current = inputs[input as keyof z.infer<T>];
inputs[input as keyof z.infer<T>] = {
...current,
value: initialValues[input as keyof z.infer<T>],
value: deepCopy(baseline[input as keyof z.infer<T>]),
error: null
};
}
@@ -154,6 +176,57 @@ export function createForm<T extends z.ZodType<any, any>>(schema: T, initialValu
data,
validate,
setValue,
reset
reset,
clearErrors,
isDirty,
commit
};
}
/**
* Picks the properties declared by `schema` out of a larger object, e.g. to build a form for one
* section of the application configuration.
*/
export function pickSchemaValues<T extends z.ZodObject>(schema: T, values: z.infer<T>): z.infer<T> {
return Object.fromEntries(
Object.keys(schema.shape).map((key) => [key, (values as Record<string, unknown>)[key]])
) as z.infer<T>;
}
/**
* Creates a deep copy of the given value, ensuring that nested objects and arrays are also copied.
*/
export function deepCopy<T>(value: T): T {
if (Array.isArray(value)) return value.map(deepCopy) as T;
if (value instanceof Date) return new Date(value) as T;
if (value !== null && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [key, deepCopy(item)])
) as T;
}
return value;
}
/**
* Performs a deep comparison between two values to determine if they are equivalent.
*/
export function deepEqual(a: unknown, b: unknown): boolean {
if (Object.is(a, b)) return true;
if (typeof a !== typeof b || a === null || b === null) return false;
if (Array.isArray(a) || Array.isArray(b)) {
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
return a.every((item, i) => deepEqual(item, b[i]));
}
if (typeof a === 'object' && typeof b === 'object') {
const aKeys = Object.keys(a as object);
const bKeys = Object.keys(b as object);
if (aKeys.length !== bKeys.length) return false;
return aKeys.every((key) =>
deepEqual((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key])
);
}
return false;
}
@@ -0,0 +1,9 @@
/**
* Wraps a value in deeply reactive state, so that reads of it inside `$derived` and `$effect`
* are tracked even when it is mutated in place afterwards. Lives in its own module because
* runes are only available in `.svelte.ts` files.
*/
export function reactiveState<T>(value: T): T {
const state = $state(value);
return state;
}
@@ -0,0 +1,112 @@
import { m } from '$lib/paraglide/messages';
import unsavedChanges, { type UnsavedSection } from '$lib/stores/unsaved-changes-store.svelte';
import { deepCopy, deepEqual } from '$lib/utils/form-util';
import { untrack } from 'svelte';
import { fromStore, type Readable } from 'svelte/store';
/**
* Registers a section of a page (e.g. a card) with the unsaved-changes bar.
* The section is unregistered when the calling component is destroyed.
*/
export function trackUnsavedSection(
dirty: () => boolean,
save: () => Promise<void>,
discard: () => void,
options: { validate?: () => boolean } = {}
) {
const isDirty = $derived.by(dirty);
const section: UnsavedSection = {
get dirty() {
return isDirty;
},
validate: options.validate,
save,
discard
};
$effect(() => {
unsavedChanges.register(section);
return () => unsavedChanges.unregister(section);
});
}
interface TrackedForm<T> {
inputs: Readable<unknown>;
isDirty: () => boolean;
validate: () => T | null;
commit: (data: T) => void;
reset: () => void;
clearErrors: () => void;
}
/**
* Convenience wrapper around `trackUnsavedSection` for forms built with `createForm`.
*/
export function trackFormChanges<T>(
getForm: () => TrackedForm<T>,
onSave: (data: T) => Promise<unknown>,
extra: { dirty?: () => boolean; discard?: () => void; enabled?: () => boolean } = {}
) {
const inputs = $derived(fromStore(getForm().inputs));
const isDirty = $derived.by(() => {
// The inputs are deeply reactive, so edits are tracked through the reads in `isDirty()`.
// Reading the store on top of that picks up `commit()`, which moves the baseline the
// inputs are compared against without touching the inputs themselves.
void inputs.current;
if (extra.enabled && !extra.enabled()) return false;
return getForm().isDirty() || (extra.dirty?.() ?? false);
});
// Validation errors are only meaningful while there is something to save: a field that is
// edited back to its saved value would otherwise keep showing the error of a failed save.
$effect(() => {
if (!isDirty) untrack(() => getForm().clearErrors());
});
trackUnsavedSection(
() => isDirty,
async () => {
const form = getForm();
const data = form.validate();
// The bar validates every section before saving, so this only happens if the inputs
// changed in between.
if (!data) throw new Error(m.please_fix_the_errors_before_saving());
await onSave(data);
form.commit(data);
},
() => {
getForm().reset();
extra.discard?.();
},
{ validate: () => getForm().validate() !== null }
);
}
/**
* Convenience wrapper around `trackUnsavedSection` for a value that is edited in place rather
* than through `createForm`, typically a list bound to a selection component.
*/
export function trackUnsavedValue<T>(
read: () => T,
write: (value: T) => void,
save: (value: T) => Promise<unknown>
) {
let saved = $state.raw(deepCopy(read()));
// Reads again rather than reusing the value that was saved, so that a caller that replaces
// the value with the server's response is taken into account.
const markSaved = () => {
saved = deepCopy(read());
};
trackUnsavedSection(
() => !deepEqual(read(), saved),
async () => {
await save(deepCopy(read()));
markSaved();
},
() => write(deepCopy(saved))
);
return { markSaved };
}