Concurrent Record Edit
Occupancy Banner plus stale-version save — tell operators who else is editing, keep fields editable, reject silent overwrite.
CRM-style concurrent edit (B2): warn that someone else is editing the record, do not lock fields, and fail the save when version is stale. Occupancy is optional UX. Record version is mandatory correctness.
This page is the composition rule. Install chrome from Occupancy Banner and Stale Save Dialog.
When To Use
- Two operators may open the same ticket / order / application.
- You can send If-Match / body
versionon PUT. - You do not have an exclusive-draft runtime (Fiori Locked + take-over). That is a different product.
- You do not need Google Docs cursors on structured fields.
The Rule
Three signals, three homes:
| Signal | Meaning | Blocks edit? | Surface |
|---|---|---|---|
| Occupancy | Someone else is editing (viewers optional) | No | Occupancy Banner |
| Exclusive lock | Someone else owns a draft | Yes | Out of scope here |
| Stale version | Someone else already saved | Edit allowed; save rejected | Stale Save Dialog + Stale Version Banner after Keep editing |
Do not paint field padlocks for occupancy. Do not refuse PUT because occupancy exists. Do not last-write-wins without version.
Stack Order
When all three are present under the page header:
- Stale Version Banner (behind after Keep editing)
- Occupancy Banner
- Form Error Summary
Occupancy drop (heartbeat / TTL miss) hides the Occupancy Banner — it never locks fields.
Three Layers
| Layer | Owns | Does not own |
|---|---|---|
| Kit | Banner, dialog, Stale Version Banner, isStaleVersionProblem | Heartbeat, SSE / WebSocket, ETag minting, FormPage.onFinish intercept, EventSource |
| Host | Occupancy fetch/heartbeat; remember version; PUT with If-Match; map 412 → dialog; Keep editing → Banner; Reload = GET + setValues | Pink Alert; per-field locks for occupancy |
| Backend | version on every mutable record; optional occupancy sessions; RFC 9457 Problem | Reject PUT because a tab is open |
Transport Ladder
Short host contract — the kit stays transport-agnostic:
| Tier | What the host does |
|---|---|
| Floor | version only — no occupancy channel |
| Default occupancy | REST heartbeat + TTL |
| Upgrade | SSE (or similar) push for occupants plus REST heartbeat |
Version is always on PUT. The kit never opens EventSource. Occupancy failure only hides the Banner; it never locks the form. A PUT without version is a data incident.
Occupancy (Optional)
Info Banner under the header, above the form (and below Stale Version Banner when that is showing). Default: other editors only. Pass includeViewers for ServiceNow-style viewers. Not dismissible; empty list → render nothing. Copy is awareness only — conflict copy lives on Stale Save Dialog / Stale Version Banner.
Host payload: { id, kind: "view" | "edit", displayName? }[]. Display pages send view; FormPage edit sends edit.
Ada Chen is editing this record.
"use client";
import { OccupancyBanner } from "@/components/f-ui/occupancy-banner/occupancy-banner";
export function OccupancyBannerDemo() {
return (
<OccupancyBanner
currentUserId="you"
occupants={[
{ id: "ada", kind: "edit", displayName: "Ada Chen" },
{ id: "you", kind: "edit", displayName: "You" },
]}
/>
);
}Stale Save (Required)
PUT with If-Match: "<version>" (or body version). Zero-row update → 412 + Problem code: "object.stale_version". Optional modifiedBy, currentVersion.
Dialog actions: Reload (primary) · Keep editing (secondary). Optional draftText → Copy my values; optional changedFields list — no per-field merge. No kit Overwrite.
After Keep editing, the host mounts Stale Version Banner until Reload. Hide the Banner while the dialog is open.
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>
);
}Try: edit Ada’s notes and Save, then Save on Ben without reloading. Keep editing shows the Banner.
Backend Contract (Host Service)
Version (required)
- GET returns
versionandETag: "<version>". - PUT
UPDATE … WHERE id = ? AND version = ?. - Conflict: 412 +
{ "title": "…", "status": 412, "code": "object.stale_version" }. 409 is accepted only with that code.
Occupancy (optional)
- Session row:
objectId,actorId,sessionId(per tab),kind,expiresAt. - Heartbeat 20–30s; TTL 45–90s. Demo TTL is 8s so ghosts are easy to see.
- Occupancy must not gate PUT.
version lives in host state, not a Formily business field.
Checklist
- Occupancy never sets
readOnly/pattern. - Stack: Stale Version Banner → Occupancy Banner → Form Error Summary.
- Validation 422 stays on Form Error Summary; 412 stays on Stale Save Dialog.
- No toast, no notification drawer, no list “Locked by” column in this kit train.
- No default Overwrite; no kit SSE /
EventSource.
Related
Object Messaging And Table Chrome
Reusable decisions for Message Popover, validation surfaces, cell value state, and region table toolbars — distilled from enterprise patterns.
Approval And Case Patterns
Compose multi-party approvals and complaint case workbenches from f-ui page recipes — including the Role Surface Formula for different viewers on the same instance.