A canvas editor save conflict happens when someone else saved the same document after you opened it. Without a check, the last save silently wins and the other person's work is gone. The engine uses a revision number to catch this.
Try it
Draw in tab A and save. Then draw in tab B and save. Tab B gets a conflict, and you choose what to keep.
Loading the live demo. Its code is below.
"use client";
import {
useDocumentEngine,
useDocumentState,
} from "fabricjs-document-engine/react";
import { createMemoryStorage } from "fabricjs-document-engine/storage";
import {
PencilIcon,
RotateCcwIcon,
SaveIcon,
ShieldCheckIcon,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { DemoCanvas, newShape, Status, ToolButton } from "./demo-ui";
import { useFabricCanvas } from "./use-fabric-canvas";
// Both editors save to the same storage, like two tabs of one app.
const storage = createMemoryStorage();
const DOCUMENT_ID = "conflict-demo";
const TONE = {
saved: "ok",
saving: "busy",
unsaved: "idle",
conflict: "error",
error: "error",
} as const;
function Tab({ name, color }: { name: string; color: string }) {
const t = useTranslations("demos");
const { elementRef, canvas } = useFabricCanvas();
const engine = useDocumentEngine(canvas, {
storage,
document: { id: DOCUMENT_ID },
});
const state = useDocumentState(engine);
const status = state?.saveStatus ?? "saved";
const conflict = status === "conflict";
return (
<div className="flex min-w-0 flex-col gap-2">
<p className="text-[13px] font-medium">{t("conflict.tab", { name })}</p>
<DemoCanvas elementRef={elementRef} label={t("conflict.tab", { name })} />
{/* The conflict actions are always there, so the layout never jumps. */}
<div className="grid grid-cols-2 gap-1.5">
<ToolButton
icon={PencilIcon}
label={t("conflict.draw")}
onClick={() => canvas?.add(newShape("square", color))}
/>
<ToolButton
icon={SaveIcon}
label={t("conflict.save")}
primary
disabled={!state?.isDirty || conflict}
onClick={() => void engine?.save().catch(() => undefined)}
/>
<ToolButton
icon={RotateCcwIcon}
label={t("conflict.reload")}
disabled={!conflict}
onClick={() =>
void engine?.load(DOCUMENT_ID, { discardUnsavedChanges: true })
}
/>
<ToolButton
icon={ShieldCheckIcon}
label={t("conflict.keepMine")}
disabled={!conflict}
onClick={() => void engine?.save({ overwrite: true })}
/>
</div>
<Status tone={TONE[status]}>
{t(`conflict.status.${status}`, { revision: state?.revision ?? 0 })}
</Status>
</div>
);
}
export default function SaveConflictDemo() {
return (
<div className="grid w-full max-w-md grid-cols-2 gap-3 text-left">
<Tab name="A" color="#f2836f" />
<Tab name="B" color="#6f00ff" />
</div>
);
}How revisions work
Every saved document has a revision number. When the engine saves, it tells your storage adapter the revision it last saw, in expectedRevision. If the stored revision is different, someone saved in between, and the adapter refuses with SAVE_CONFLICT.
The built-in adapters already do this. For your own backend, see custom backend.
Handle a conflict
The save fails with SAVE_CONFLICT, and status becomes conflict. Conflicts are never retried. Ask the user what to do:
engine.on("save:error", async ({ error }) => {
if (error.code !== "SAVE_CONFLICT") return;
const keepMine = await askUser("Someone else saved this document.", [
"Keep my version",
"Load their version",
]);
if (keepMine) {
await engine.save({ overwrite: true });
} else {
const { id } = engine.getDocumentInfo();
await engine.load(id, { discardUnsavedChanges: true });
}
});save({ overwrite: true })skips the check. Your storage receivesexpectedRevision: null.load(id, { discardUnsavedChanges: true })throws your changes away and opens the saved version.
Multiple tabs of one user
Most conflicts come from multiple tabs of the same person, not from two people. The same check covers both. If you see a canvas editor save conflict inside a single tab, your adapter is not returning the new revision. Return { revision } from saveDocument, or store document.revision.
After a crash
A document restored from recovery keeps the revision it was based on. If the server moved on in the meantime, the next save reports a conflict instead of overwriting newer work.