refactor(editor): modularize formatting and simplify editor state

Behavior-preserving restructure of the MemoEditor subsystem for simplicity
and extensibility:

- Add a data-driven command catalog (editorCommands.ts) as the single source
  for formatting verbs; the toolbar, active-state hook, and WYSIWYG handle all
  derive from it, so adding a verb is a one-file change.
- Add a createSuggestionExtension factory; TagSuggestion consumes it, so new
  `/` or `@` triggers reuse the shared popup in ~10 lines.
- Collapse FormattingController into a single EditorController with an optional
  `formatting` capability; PlainEditor is an honest textarea fallback (no faked
  formatting); replace stringly-typed isActive with typed getActiveFormats.
- Move audio-recorder state out of the reducer into useAudioRecorder, keeping a
  single recorderBusy flag in the store.
- Convert the editor context to an external store (useSyncExternalStore) with
  per-slice subscriptions, so typing no longer re-renders the toolbar, insert
  menu, or metadata.
- Extract the shared #tag lexing grammar (tag-grammar.ts) used by both the
  editor tokenizer and the remark renderer.
- Remove dead reducer actions/cases and fix a preview blob-URL leak on the
  upload path.

Add tests for the command catalog, suggestion factory, and autosave.
This commit is contained in:
boojack
2026-06-22 23:51:59 +08:00
parent 26f4b73cb9
commit 1e3ec38fa1
39 changed files with 1031 additions and 802 deletions
+65
View File
@@ -0,0 +1,65 @@
import { Editor } from "@tiptap/core";
import { describe, expect, it } from "vitest";
import { EDITOR_COMMANDS, EDITOR_COMMANDS_BY_ID, getActiveFormats } from "@/components/MemoEditor/Editor/editorCommands";
import { buildExtensions } from "@/components/MemoEditor/Editor/extensions";
function makeEditor(content = "") {
return new Editor({ extensions: buildExtensions(), content, contentType: "markdown" });
}
describe("editor command catalog", () => {
it("exposes every command keyed by id", () => {
for (const command of EDITOR_COMMANDS) {
expect(EDITOR_COMMANDS_BY_ID[command.id]).toBe(command);
}
});
it("bold command toggles the bold mark, reflected by getActiveFormats", () => {
const editor = makeEditor("hello");
try {
editor.commands.selectAll();
expect(getActiveFormats(editor).bold).toBe(false);
EDITOR_COMMANDS_BY_ID.bold.run(editor);
expect(getActiveFormats(editor).bold).toBe(true);
} finally {
editor.destroy();
}
});
it("heading2 command sets the level, reported by getActiveFormats.headingLevel", () => {
const editor = makeEditor("title");
try {
expect(getActiveFormats(editor).headingLevel).toBeNull();
EDITOR_COMMANDS_BY_ID.heading2.run(editor);
expect(getActiveFormats(editor).headingLevel).toBe(2);
EDITOR_COMMANDS_BY_ID.paragraph.run(editor);
expect(getActiveFormats(editor).headingLevel).toBeNull();
} finally {
editor.destroy();
}
});
it("taskList command toggles a task list", () => {
const editor = makeEditor("buy milk");
try {
EDITOR_COMMANDS_BY_ID.taskList.run(editor);
expect(getActiveFormats(editor).taskList).toBe(true);
} finally {
editor.destroy();
}
});
it("link command applies a link from ctx.url and clears it when already active", () => {
const editor = makeEditor("memos");
try {
editor.commands.selectAll();
EDITOR_COMMANDS_BY_ID.link.run(editor, { url: "https://usememos.com" });
expect(getActiveFormats(editor).link).toBe(true);
editor.commands.selectAll();
EDITOR_COMMANDS_BY_ID.link.run(editor); // active → unset (ignores missing url)
expect(getActiveFormats(editor).link).toBe(false);
} finally {
editor.destroy();
}
});
});
+4 -4
View File
@@ -71,16 +71,16 @@ describe("Editor EditorController", () => {
expect(onContentChange).toHaveBeenCalledWith(expect.stringContaining("hello"));
});
it("toggleBold bolds the selected text", () => {
it("formatting.run('bold') bolds the selected text", () => {
const { ref } = setup("bold me");
act(() => ref.current?.selectAll());
act(() => ref.current?.toggleBold());
act(() => ref.current?.formatting?.run("bold"));
expect(ref.current?.getMarkdown()).toBe("**bold me**");
});
it("toggleTaskList converts the current block", () => {
it("formatting.run('taskList') converts the current block", () => {
const { ref } = setup("buy milk");
act(() => ref.current?.toggleTaskList());
act(() => ref.current?.formatting?.run("taskList"));
expect(ref.current?.getMarkdown()).toBe("- [ ] buy milk");
});
});
+3 -3
View File
@@ -2,7 +2,7 @@ import { create } from "@bufbuild/protobuf";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { useMemoInit } from "@/components/MemoEditor/hooks";
import { EditorProvider, useEditorContext } from "@/components/MemoEditor/state";
import { EditorProvider, useEditorSelector } from "@/components/MemoEditor/state";
import { MemoSchema } from "@/types/proto/api/v1/memo_service_pb";
const toastMock = vi.hoisted(() => vi.fn());
@@ -21,8 +21,8 @@ vi.mock("@/utils/i18n", () => ({ useTranslate: () => (key: string) => key }));
function Harness({ content }: { content: string }) {
const memo = create(MemoSchema, { name: "memos/1", content });
useMemoInit({ editorRef: { current: null }, memo, username: "users/test" });
const { state } = useEditorContext();
return <span data-testid="mode">{state.ui.editorMode}</span>;
const editorMode = useEditorSelector((s) => s.ui.editorMode);
return <span data-testid="mode">{editorMode}</span>;
}
function renderGuard(content: string) {
+5 -4
View File
@@ -1,7 +1,7 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { EditorContent } from "@/components/MemoEditor/components/EditorContent";
import { EditorProvider, useEditorContext } from "@/components/MemoEditor/state";
import { EditorProvider, useEditorContext, useEditorSelector } from "@/components/MemoEditor/state";
vi.mock("@/hooks/useUserQueries", () => ({
useTagCounts: () => ({ data: {} }),
@@ -11,15 +11,16 @@ vi.mock("@/hooks/useUserQueries", () => ({
vi.mock("@/utils/i18n", () => ({ useTranslate: () => (key: string) => key }));
function ModeProbe() {
const { state, actions, dispatch } = useEditorContext();
const { actions, dispatch } = useEditorContext();
const editorMode = useEditorSelector((s) => s.ui.editorMode);
return (
<>
<button
type="button"
data-testid="probe-toggle"
onClick={() => dispatch(actions.setEditorMode(state.ui.editorMode === "wysiwyg" ? "raw" : "wysiwyg"))}
onClick={() => dispatch(actions.setEditorMode(editorMode === "wysiwyg" ? "raw" : "wysiwyg"))}
>
{state.ui.editorMode}
{editorMode}
</button>
<button type="button" data-testid="probe-set-content" onClick={() => dispatch(actions.updateContent("from **wysiwyg**"))}>
set content
+30 -28
View File
@@ -2,77 +2,79 @@ import { act, render } from "@testing-library/react";
import { createRef } from "react";
import { describe, expect, it, vi } from "vitest";
import Editor from "@/components/MemoEditor/Editor";
import type { EditorController, FormattingController } from "@/components/MemoEditor/types/editorController";
import type { EditorController } from "@/components/MemoEditor/types/editorController";
vi.mock("@/hooks/useUserQueries", async (importOriginal) => ({
...(await importOriginal<object>()),
useTagCounts: () => ({ data: {} }),
}));
type Handle = EditorController & FormattingController;
function setup(initialContent = "") {
const ref = createRef<Handle>();
const ref = createRef<EditorController>();
render(<Editor ref={ref} initialContent={initialContent} placeholder="memo" onContentChange={vi.fn()} onPaste={vi.fn()} />);
return ref;
}
describe("FormattingController (WYSIWYG)", () => {
it("toggleCode wraps the selection in inline code", () => {
describe("WYSIWYG formatting capability", () => {
it("the WYSIWYG editor exposes a formatting capability", () => {
const ref = setup("x");
expect(ref.current?.formatting).toBeDefined();
});
it("run('code') wraps the selection in inline code", () => {
const ref = setup("code me");
act(() => ref.current?.selectAll());
act(() => ref.current?.toggleCode());
act(() => ref.current?.formatting?.run("code"));
expect(ref.current?.getMarkdown()).toBe("`code me`");
});
it("toggleBulletList converts the current block", () => {
it("run('bulletList') converts the current block", () => {
const ref = setup("item");
act(() => ref.current?.toggleBulletList());
act(() => ref.current?.formatting?.run("bulletList"));
expect(ref.current?.getMarkdown()).toBe("- item");
});
it("toggleOrderedList converts the current block", () => {
it("run('orderedList') converts the current block", () => {
const ref = setup("item");
act(() => ref.current?.toggleOrderedList());
act(() => ref.current?.formatting?.run("orderedList"));
expect(ref.current?.getMarkdown()).toBe("1. item");
});
it("setHeading makes the block a heading and isActive reflects the level", () => {
it("run('heading2') makes the block a heading and getActiveFormats reflects the level", () => {
const ref = setup("title");
act(() => ref.current?.setHeading(2));
act(() => ref.current?.formatting?.run("heading2"));
expect(ref.current?.getMarkdown()).toBe("## title");
expect(ref.current?.isActive("heading", { level: 2 })).toBe(true);
expect(ref.current?.isActive("heading", { level: 1 })).toBe(false);
expect(ref.current?.formatting?.getActiveFormats().headingLevel).toBe(2);
});
it("setParagraph reverts a heading", () => {
it("run('paragraph') reverts a heading", () => {
const ref = setup("# title");
act(() => ref.current?.setParagraph());
act(() => ref.current?.formatting?.run("paragraph"));
expect(ref.current?.getMarkdown()).toBe("title");
});
it("isActive('bold') tracks the bold mark", () => {
it("getActiveFormats().bold tracks the bold mark", () => {
const ref = setup("x");
expect(ref.current?.isActive("bold")).toBe(false);
expect(ref.current?.formatting?.getActiveFormats().bold).toBe(false);
act(() => ref.current?.selectAll());
act(() => ref.current?.toggleBold());
expect(ref.current?.isActive("bold")).toBe(true);
act(() => ref.current?.formatting?.run("bold"));
expect(ref.current?.formatting?.getActiveFormats().bold).toBe(true);
});
it("toggleLink applies a link over the selection, and removes it when active", () => {
it("run('link') applies a link over the selection, and removes it when active", () => {
const ref = setup("memos");
act(() => ref.current?.selectAll());
act(() => ref.current?.toggleLink("https://usememos.com"));
act(() => ref.current?.formatting?.run("link", { url: "https://usememos.com" }));
expect(ref.current?.getMarkdown()).toBe("[memos](https://usememos.com)");
act(() => ref.current?.selectAll());
act(() => ref.current?.toggleLink());
act(() => ref.current?.formatting?.run("link"));
expect(ref.current?.getMarkdown()).toBe("memos");
});
it("getSelectedText returns the current selection text", () => {
const ref = setup("hello world");
act(() => ref.current?.selectAll());
expect(ref.current?.getSelectedText()).toBe("hello world");
expect(ref.current?.formatting?.getSelectedText()).toBe("hello world");
});
it("subscribe fires on transactions and unsubscribe stops it", () => {
@@ -80,14 +82,14 @@ describe("FormattingController (WYSIWYG)", () => {
const listener = vi.fn();
let unsubscribe = () => {};
act(() => {
unsubscribe = ref.current!.subscribe(listener);
unsubscribe = ref.current!.formatting!.subscribe(listener);
});
act(() => ref.current?.selectAll());
act(() => ref.current?.toggleBold());
act(() => ref.current?.formatting?.run("bold"));
const callsWhileSubscribed = listener.mock.calls.length;
expect(callsWhileSubscribed).toBeGreaterThan(0);
act(() => unsubscribe());
act(() => ref.current?.toggleItalic());
act(() => ref.current?.formatting?.run("italic"));
expect(listener.mock.calls.length).toBe(callsWhileSubscribed);
});
});
+32 -38
View File
@@ -2,7 +2,8 @@ import { fireEvent, render, screen } from "@testing-library/react";
import { createRef } from "react";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { FormattingToolbar } from "@/components/MemoEditor/components/FormattingToolbar";
import type { EditorController, FormattingController } from "@/components/MemoEditor/types/editorController";
import { type ActiveFormatState, EMPTY_ACTIVE_FORMATS } from "@/components/MemoEditor/Editor/editorCommands";
import type { EditorController } from "@/components/MemoEditor/types/editorController";
// Match the repo convention: t echoes the i18n key (no i18next backend in tests),
// so accessible names below are the keys themselves.
@@ -16,87 +17,80 @@ beforeAll(() => {
Element.prototype.releasePointerCapture = vi.fn();
});
type Handle = EditorController & FormattingController;
function makeController(overrides: Partial<Handle> = {}): Handle {
const noop = () => {};
return {
focus: noop,
function makeController(opts: { active?: Partial<ActiveFormatState>; getSelectedText?: () => string } = {}) {
const run = vi.fn();
const activeFormats: ActiveFormatState = { ...EMPTY_ACTIVE_FORMATS, ...opts.active };
const controller: EditorController = {
focus: () => {},
hasFocus: () => false,
isEmpty: () => true,
getMarkdown: () => "",
setMarkdown: noop,
insertMarkdown: noop,
scrollToCursor: noop,
selectAll: noop,
toggleBold: vi.fn(),
toggleItalic: vi.fn(),
toggleTaskList: vi.fn(),
toggleCode: vi.fn(),
toggleBulletList: vi.fn(),
toggleOrderedList: vi.fn(),
setHeading: vi.fn(),
setParagraph: vi.fn(),
toggleLink: vi.fn(),
getSelectedText: () => "",
isActive: () => false,
subscribe: () => () => {},
...overrides,
setMarkdown: () => {},
insertMarkdown: vi.fn(),
scrollToCursor: () => {},
selectAll: () => {},
formatting: {
run,
getActiveFormats: () => activeFormats,
getSelectedText: opts.getSelectedText ?? (() => ""),
subscribe: () => () => {},
},
};
return { controller, run };
}
function renderToolbar(controller: Handle, onExit = vi.fn()) {
const ref = createRef<Handle>();
function renderToolbar(controller: EditorController, onExit = vi.fn()) {
const ref = createRef<EditorController>();
ref.current = controller;
render(<FormattingToolbar controllerRef={ref} onExit={onExit} />);
return { onExit };
}
describe("FormattingToolbar", () => {
it("invokes toggleBold when the bold button is clicked", () => {
const controller = makeController();
it("runs the bold command when the bold button is clicked", () => {
const { controller, run } = makeController();
renderToolbar(controller);
fireEvent.click(screen.getByRole("button", { name: "editor.format.bold" }));
expect(controller.toggleBold).toHaveBeenCalledTimes(1);
expect(run).toHaveBeenCalledWith("bold");
});
it("invokes setHeading when a heading level is chosen", () => {
const controller = makeController();
it("runs the heading command when a heading level is chosen", () => {
const { controller, run } = makeController();
renderToolbar(controller);
// Keyboard open is the most reliable path for Radix menus in jsdom.
fireEvent.keyDown(screen.getByRole("button", { name: "editor.format.heading" }), { key: "Enter" });
fireEvent.click(screen.getByRole("menuitem", { name: "editor.format.heading-2" }));
expect(controller.setHeading).toHaveBeenCalledWith(2);
expect(run).toHaveBeenCalledWith("heading2");
});
it("reflects active marks via aria-pressed", () => {
const controller = makeController({ isActive: (name) => name === "bold" });
const { controller } = makeController({ active: { bold: true } });
renderToolbar(controller);
expect(screen.getByRole("button", { name: "editor.format.bold" })).toHaveAttribute("aria-pressed", "true");
expect(screen.getByRole("button", { name: "editor.format.italic" })).toHaveAttribute("aria-pressed", "false");
});
it("prompts for a URL and links the selection when adding a link", () => {
const controller = makeController({ getSelectedText: () => "memos" });
const { controller, run } = makeController({ getSelectedText: () => "memos" });
const promptSpy = vi.spyOn(window, "prompt").mockReturnValue("https://usememos.com");
renderToolbar(controller);
fireEvent.click(screen.getByRole("button", { name: "editor.format.link" }));
expect(controller.toggleLink).toHaveBeenCalledWith("https://usememos.com");
expect(run).toHaveBeenCalledWith("link", { url: "https://usememos.com" });
promptSpy.mockRestore();
});
it("removes an active link without prompting", () => {
const controller = makeController({ isActive: (name) => name === "link" });
const { controller, run } = makeController({ active: { link: true } });
const promptSpy = vi.spyOn(window, "prompt");
renderToolbar(controller);
fireEvent.click(screen.getByRole("button", { name: "editor.format.link" }));
expect(promptSpy).not.toHaveBeenCalled();
expect(controller.toggleLink).toHaveBeenCalledWith();
expect(run).toHaveBeenCalledWith("link");
promptSpy.mockRestore();
});
it("calls onExit when the exit button is clicked", () => {
const controller = makeController();
const { controller } = makeController();
const { onExit } = renderToolbar(controller);
fireEvent.click(screen.getByRole("button", { name: "editor.exit-focus-mode" }));
expect(onExit).toHaveBeenCalledTimes(1);
+3 -3
View File
@@ -1,7 +1,7 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { EditorProvider, useEditorContext } from "@/components/MemoEditor/state";
import { EditorProvider, useEditorSelector } from "@/components/MemoEditor/state";
import InsertMenu from "@/components/MemoEditor/Toolbar/InsertMenu";
// useTranslate returns the i18n key directly (no i18next backend in tests).
@@ -20,8 +20,8 @@ beforeAll(() => {
});
function ModeProbe() {
const { state } = useEditorContext();
return <span data-testid="mode">{state.ui.editorMode}</span>;
const editorMode = useEditorSelector((s) => s.ui.editorMode);
return <span data-testid="mode">{editorMode}</span>;
}
function renderInsertMenu() {
+5 -30
View File
@@ -34,36 +34,11 @@ describe("PlainEditor EditorController", () => {
expect(textarea.value).toBe("first line\n\ntranscribed");
});
it("toggleBold wraps the selection in **", () => {
const { controller, textarea } = setup("read the docs");
textarea.setSelectionRange(9, 13);
controller.toggleBold();
expect(textarea.value).toBe("read the **docs**");
});
it("toggleTaskList prefixes and unprefixes the current line", () => {
const { controller, textarea } = setup("buy milk");
textarea.setSelectionRange(4, 4);
controller.toggleTaskList();
expect(textarea.value).toBe("- [ ] buy milk");
controller.toggleTaskList();
expect(textarea.value).toBe("buy milk");
});
it("toggleTaskList unprefixes a checked task line", () => {
const { controller, textarea } = setup("- [x] done thing");
textarea.setSelectionRange(8, 8);
controller.toggleTaskList();
expect(textarea.value).toBe("done thing");
});
it("toggleTaskList preserves indentation", () => {
const { controller, textarea } = setup(" nested item");
textarea.setSelectionRange(4, 4);
controller.toggleTaskList();
expect(textarea.value).toBe(" - [ ] nested item");
controller.toggleTaskList();
expect(textarea.value).toBe(" nested item");
// The raw textarea is an honest fallback with no rich-formatting capability
// (controller.formatting is undefined); the focus-mode toolbar is WYSIWYG-only.
it("exposes no formatting capability", () => {
const { controller } = setup("x");
expect(controller.formatting).toBeUndefined();
});
it("insertMarkdown with an empty string is a no-op", () => {
+77
View File
@@ -0,0 +1,77 @@
import { Editor } from "@tiptap/core";
import { describe, expect, it } from "vitest";
import { buildExtensions } from "@/components/MemoEditor/Editor/extensions";
import { createSuggestionExtension } from "@/components/MemoEditor/Editor/suggestionExtension";
describe("createSuggestionExtension", () => {
it("creates a Tiptap extension with the configured name", () => {
const ext = createSuggestionExtension<string>({
name: "fooSuggestion",
char: "@",
items: () => [],
command: () => {},
renderItem: (item) => item,
getItemKey: (item) => item,
});
expect(ext.name).toBe("fooSuggestion");
});
it("contributes its suggestion plugin to the editor", () => {
const base = new Editor({ extensions: buildExtensions(), content: "", contentType: "markdown" });
const baseline = base.state.plugins.length;
base.destroy();
const withSuggestion = new Editor({
extensions: [
...buildExtensions(),
createSuggestionExtension<string>({
name: "pluginProbe",
char: "@",
items: () => [],
command: () => {},
renderItem: (item) => item,
getItemKey: (item) => item,
}),
],
content: "",
contentType: "markdown",
});
try {
expect(withSuggestion.state.plugins.length).toBeGreaterThan(baseline);
} finally {
withSuggestion.destroy();
}
});
it("lets multiple triggers coexist in one editor without plugin-key collision", () => {
const tagLike = createSuggestionExtension<string>({
name: "tagLike",
char: "#",
items: () => ["alpha"],
command: () => {},
renderItem: (item) => item,
getItemKey: (item) => item,
});
const slashLike = createSuggestionExtension<string>({
name: "slashLike",
char: "/",
items: () => ["heading"],
command: () => {},
renderItem: (item) => item,
getItemKey: (item) => item,
});
const editor = new Editor({
extensions: [...buildExtensions(), tagLike, slashLike],
content: "",
contentType: "markdown",
});
try {
const names = editor.extensionManager.extensions.map((e) => e.name);
expect(names).toContain("tagLike");
expect(names).toContain("slashLike");
} finally {
editor.destroy();
}
});
});
+85
View File
@@ -0,0 +1,85 @@
import { act, render } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useAutoSave } from "@/components/MemoEditor/hooks/useAutoSave";
import { cacheService } from "@/components/MemoEditor/services/cacheService";
import { EditorProvider, useEditorContext } from "@/components/MemoEditor/state";
// Probe surfaces the store's dispatch/actions plus the autosave API so tests can
// drive content changes the way the editor does and assert on cache writes.
let api: {
dispatch: ReturnType<typeof useEditorContext>["dispatch"];
actions: ReturnType<typeof useEditorContext>["actions"];
discardDraft: () => void;
};
function Probe({ username, cacheKey, enabled }: { username: string; cacheKey?: string; enabled?: boolean }) {
const { dispatch, actions } = useEditorContext();
const { discardDraft } = useAutoSave(username, cacheKey, enabled);
api = { dispatch, actions, discardDraft };
return null;
}
describe("useAutoSave (store-subscribed)", () => {
let saveSpy: ReturnType<typeof vi.spyOn>;
let saveNowSpy: ReturnType<typeof vi.spyOn>;
let clearSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
saveSpy = vi.spyOn(cacheService, "save").mockImplementation(() => {});
saveNowSpy = vi.spyOn(cacheService, "saveNow").mockImplementation(() => {});
clearSpy = vi.spyOn(cacheService, "clear").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it("persists content to the draft cache when content changes", () => {
render(
<EditorProvider>
<Probe username="users/steven" cacheKey="k" enabled />
</EditorProvider>,
);
const key = cacheService.key("users/steven", "k");
saveSpy.mockClear(); // ignore the mount-time persist of the initial empty content
act(() => {
api.dispatch(api.actions.updateContent("hello world"));
});
expect(saveSpy).toHaveBeenCalledWith(key, "hello world");
});
it("does not persist when disabled", () => {
render(
<EditorProvider>
<Probe username="users/steven" cacheKey="k" enabled={false} />
</EditorProvider>,
);
saveSpy.mockClear();
act(() => {
api.dispatch(api.actions.updateContent("ignored"));
});
expect(saveSpy).not.toHaveBeenCalled();
});
it("discardDraft clears the cache and suppresses the unmount flush", () => {
const { unmount } = render(
<EditorProvider>
<Probe username="users/steven" cacheKey="k2" enabled />
</EditorProvider>,
);
const key = cacheService.key("users/steven", "k2");
act(() => {
api.dispatch(api.actions.updateContent("draft body"));
});
act(() => {
api.discardDraft();
});
expect(clearSpy).toHaveBeenCalledWith(key);
saveNowSpy.mockClear();
unmount();
// The just-discarded content equals the latest content, so the unmount
// flush must NOT re-persist it as a stale draft.
expect(saveNowSpy).not.toHaveBeenCalled();
});
});