Stale Save Dialog
Dialog after a 412 stale-version save, plus isStaleVersionProblem and Stale Version Banner. Reload or keep editing; no default overwrite.
Stale Save Dialog is the save-collision surface when another user already persisted the record. It wraps Confirm. Form values stay on screen until Reload. There is no default Overwrite. Optional draftText offers Copy my values; optional changedFields lists what differs — no per-field merge. After Keep editing, the host mounts Stale Version Banner until Reload. Pair with Occupancy Banner for awareness. Policy: Concurrent Record Edit.
When To Use
- Open the dialog only after PUT/PATCH returns 412 (or Problem
codeobject.stale_version). - Do not use it for validation 422 — that is Form Error Summary.
- Do not intercept
FormPage.onFinishinside the kit. The hostcatches and callsisStaleVersionProblem. - Keep local values until the user chooses Reload. Reload is GET +
form.setValues+ newversion. - After Keep editing, mount Stale Version Banner while the session is still behind; hide it while the dialog is open; clear it on Reload.
- Overwrite is host
extraonly, never a kit button.
Features
| Area | Behavior |
|---|---|
| Trigger | Host opens after isStaleVersionProblem(err) |
| Reload | Primary — host refetches; dialog shows loading on the confirm button |
| Keep editing | Secondary — close, keep dirty values and the stale version; host may show Stale Version Banner |
| Draft | Optional draftText → Copy my values in the dialog body (clipboard only) |
| Diff list | Optional changedFields — labels + local/server strings; no merge UI |
| Detector | isStaleVersionProblem — 412, or object.stale_version on code / nested body · problem · data. Bare 409 is not enough |
| Chrome | Confirm dialog, not page Result, not toast |
Installing
pnpm dlx shadcn@latest add https://ui.isaacfei.com/r/stale-save-dialog.jsonnpx shadcn@latest add https://ui.isaacfei.com/r/stale-save-dialog.jsonyarn dlx shadcn@latest add https://ui.isaacfei.com/r/stale-save-dialog.jsonbun x shadcn@latest add https://ui.isaacfei.com/r/stale-save-dialog.jsonWith a namespace: npx shadcn@latest add @f-ui/stale-save-dialog.
registryDependencies: confirm, fui-i18n, button, copy-affordance. Runtime: lucide-react.
Usage
import { isStaleVersionProblem } from "@/components/f-ui/stale-save-dialog/is-stale-version-problem";
import { StaleSaveDialog } from "@/components/f-ui/stale-save-dialog/stale-save-dialog";
import { StaleVersionBanner } from "@/components/f-ui/stale-save-dialog/stale-version-banner";
try {
await putRecord({ ...values, version });
} catch (err) {
if (isStaleVersionProblem(err)) setStaleOpen(true);
else throw err;
}
{behind && !staleOpen ? (
<StaleVersionBanner
modifiedBy={err.modifiedBy}
onReload={reloadFromServer}
/>
) : null}
<StaleSaveDialog
open={staleOpen}
onOpenChange={setStaleOpen}
modifiedBy={err.modifiedBy}
draftText={values.notes}
changedFields={[
{ label: "Notes", local: values.notes, server: serverNotes },
]}
onReload={reloadFromServer}
onKeepEditing={() => {
setStaleOpen(false);
setBehind(true);
}}
/>Examples
After A Failed Save
Host opens the dialog when the detector returns true.
"use client";
import { useState } from "react";
import { Button } from "@/components/f-ui/button/button";
import { StaleSaveDialog } from "@/components/f-ui/stale-save-dialog/stale-save-dialog";
export function StaleSaveDialogDemo() {
const [open, setOpen] = useState(false);
return (
<div className="flex flex-col gap-3">
<Button type="button" onClick={() => setOpen(true)}>
Simulate stale save
</Button>
<StaleSaveDialog
open={open}
onOpenChange={setOpen}
modifiedBy={{ displayName: "Ada Chen" }}
onReload={() => {
setOpen(false);
}}
onKeepEditing={() => {
setOpen(false);
}}
/>
</div>
);
}Two Sessions (Fake Backend)
Ada saves; Ben still holds version 1. Ben’s Save must open this dialog. Keep editing mounts Stale Version Banner until Reload copies the server notes.
Ada Chen
Loaded version 1
Ben Park
Loaded version 1
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@/components/f-ui/button/button";
import { OccupancyBanner } from "@/components/f-ui/occupancy-banner/occupancy-banner";
import type { Occupant } from "@/components/f-ui/occupancy-banner/occupancy-message";
import { isStaleVersionProblem } from "@/components/f-ui/stale-save-dialog/is-stale-version-problem";
import { StaleSaveDialog } from "@/components/f-ui/stale-save-dialog/stale-save-dialog";
import { StaleVersionBanner } from "@/components/f-ui/stale-save-dialog/stale-version-banner";
import { Textarea } from "@/components/ui/textarea";
type Ticket = {
notes: string;
version: string;
modifiedBy?: { displayName: string };
};
type Presence = Occupant & { expiresAt: number };
const TTL_MS = 8000;
function bumpVersion(version: string): string {
const n = Number.parseInt(version, 10);
return String(Number.isFinite(n) ? n + 1 : 1);
}
function saveTicket(ticket: Ticket, clientVersion: string, notes: string, actor: string): Ticket {
if (clientVersion !== ticket.version) {
throw {
status: 412,
code: "object.stale_version",
modifiedBy: ticket.modifiedBy,
};
}
return {
notes,
version: bumpVersion(ticket.version),
modifiedBy: { displayName: actor },
};
}
function SessionPane({
actorId,
actorName,
ticket,
presence,
onHeartbeat,
onSave,
onReload,
}: {
actorId: string;
actorName: string;
ticket: Ticket;
presence: Presence[];
onHeartbeat: (occupant: Occupant) => void;
onSave: (clientVersion: string, notes: string) => Ticket;
onReload: () => Ticket;
}) {
const [notes, setNotes] = useState(ticket.notes);
const [version, setVersion] = useState(ticket.version);
const [open, setOpen] = useState(false);
const [behind, setBehind] = useState(false);
const [modifiedBy, setModifiedBy] = useState<{ displayName?: string }>();
const [error, setError] = useState<string | null>(null);
useEffect(() => {
onHeartbeat({ id: actorId, kind: "edit", displayName: actorName });
const id = window.setInterval(() => {
onHeartbeat({ id: actorId, kind: "edit", displayName: actorName });
}, 2000);
return () => window.clearInterval(id);
}, [actorId, actorName, onHeartbeat]);
const occupants = useMemo(
() => presence.filter((row) => row.id !== actorId),
[presence, actorId],
);
const handleReload = () => {
const next = onReload();
setNotes(next.notes);
setVersion(next.version);
setOpen(false);
setBehind(false);
};
return (
<div className="flex min-w-0 flex-1 flex-col gap-3 rounded-xl border bg-card p-4">
<p className="text-sm font-medium">{actorName}</p>
{behind && !open ? (
<StaleVersionBanner modifiedBy={modifiedBy} onReload={handleReload} />
) : null}
<OccupancyBanner currentUserId={actorId} occupants={occupants} />
<label className="flex flex-col gap-1.5 text-sm">
Notes
<Textarea value={notes} onChange={(e) => setNotes(e.target.value)} rows={4} />
</label>
<p className="text-muted-foreground text-xs">Loaded version {version}</p>
{error ? <p className="text-destructive text-sm">{error}</p> : null}
<Button
type="button"
onClick={() => {
setError(null);
try {
const next = onSave(version, notes);
setVersion(next.version);
setBehind(false);
} catch (err) {
if (isStaleVersionProblem(err)) {
const problem = err as { modifiedBy?: { displayName?: string } };
setModifiedBy(problem.modifiedBy);
setOpen(true);
return;
}
setError("Could not save.");
}
}}
>
Save
</Button>
<StaleSaveDialog
open={open}
onOpenChange={setOpen}
modifiedBy={modifiedBy}
draftText={notes}
changedFields={[{ label: "Notes", local: notes, server: ticket.notes }]}
onReload={handleReload}
onKeepEditing={() => {
setOpen(false);
setBehind(true);
}}
/>
</div>
);
}
export function StaleSaveDialogTwoSessionDemo() {
const [ticket, setTicket] = useState<Ticket>({
notes: "Kickoff notes for the Q3 order.",
version: "1",
});
const ticketRef = useRef(ticket);
ticketRef.current = ticket;
const [presence, setPresence] = useState<Presence[]>([]);
const [, setTick] = useState(0);
useEffect(() => {
const id = window.setInterval(() => setTick((n) => n + 1), 1000);
return () => window.clearInterval(id);
}, []);
const onHeartbeat = useCallback((occupant: Occupant) => {
setPresence((rows) => {
const next = rows.filter((row) => row.id !== occupant.id);
next.push({ ...occupant, expiresAt: Date.now() + TTL_MS });
return next;
});
}, []);
const livePresence = presence.filter((row) => row.expiresAt > Date.now());
return (
<div className="flex flex-col gap-3 sm:flex-row">
<SessionPane
actorId="ada"
actorName="Ada Chen"
ticket={ticket}
presence={livePresence}
onHeartbeat={onHeartbeat}
onSave={(clientVersion, notes) => {
const next = saveTicket(ticketRef.current, clientVersion, notes, "Ada Chen");
setTicket(next);
return next;
}}
onReload={() => ticketRef.current}
/>
<SessionPane
actorId="ben"
actorName="Ben Park"
ticket={ticket}
presence={livePresence}
onHeartbeat={onHeartbeat}
onSave={(clientVersion, notes) => {
const next = saveTicket(ticketRef.current, clientVersion, notes, "Ben Park");
setTicket(next);
return next;
}}
onReload={() => ticketRef.current}
/>
</div>
);
}Stale Version Banner
Host-owned object chrome after Keep editing. Not dismissible — only Reload clears it. Hide while the dialog is open. Place it above Occupancy Banner and Form Error Summary. Warning mark (text-warning), same Primer card family — not a pink Alert.
import { StaleVersionBanner } from "@/components/f-ui/stale-save-dialog/stale-version-banner";API Reference
StaleSaveDialog Props
| Prop | Type | Default | Notes |
|---|---|---|---|
open | boolean | — | Controlled |
onOpenChange | (open: boolean) => void | — | |
modifiedBy | { displayName?: string } | — | Optional name in the title |
onReload | () => void | Promise<void> | — | Host GET + setValues |
onKeepEditing | () => void | — | Dismiss without reload |
draftText | string | — | When set, shows Copy my values in the body |
changedFields | StaleChangedField[] | — | Optional list; display only — no per-field merge |
extra | ReactNode | — | Host Overwrite (not shipped) |
StaleChangedField: { label: string; local?: string; server?: string }.
StaleVersionBanner Props
| Prop | Type | Default | Notes |
|---|---|---|---|
modifiedBy | { displayName?: string } | — | Optional name in the message |
onReload | () => void | Promise<void> | — | Same host Reload as the dialog |
className | string | — | Root |
isStaleVersionProblem(err)
Returns true when status === 412 or code === "object.stale_version" (including nested body / problem / data). Validation 422 is false.
Composition
StaleSaveDialog
└─ ConfirmDialog (Reload / Keep editing)
└─ body: changedFields? · Copy my values? · extra?
StaleVersionBanner (host-mounted)
└─ role="status" Banner (Warning mark + Reload)isStaleVersionProblem is a pure helper — call it from the host onFinish catch, not from FormPage internals.