Fabric.js Document Engine

Search documentation

Find a page or section

中文

Save conflicts

View as Markdown

What happens when two tabs edit the same document, and how the user decides.

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.

save-conflict-demo.tsx
"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 receives expectedRevision: 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.