fix(chat): keep an in-flight upload bound to the draft it started in (MUL-4864)

Second review blocker. An upload PINS the editor to its source document:
Guard 0 refuses to setContent over an `uploading` node, because wiping it
strands the upload's finalize and the file silently vanishes. So while an
upload runs, the instance still holds A's document even though the user
has navigated to B — and the upload's own completion dispatch fires
onUpdate on that instance, which resolves to the latest render's closure.
Result: A's body and its attachment URL were written into B's draft, B's
own draft was destroyed, and A lost the upload it was waiting for.

Nothing stopped this: useUploadGate gates submit, not session/agent
navigation. Reproduced as a test before fixing — B's "B's own words" was
replaced by A's body + A's CDN URL.

Root cause is the same shape as the debounce bug, one level up: the
editor's writes were keyed off what is SELECTED, when they belong to what
is LOADED. Those are the same key except while an upload pins the
document.

- chat-input models that explicitly with `editorDraftKeyRef` — the draft
  whose document the instance holds. Every editor-driven write (onUpdate,
  the upload's attachment binding) uses it, not `draftKey`.
- The draft switch defers while `hasActiveUploads()`, leaving both the
  document and its writes on the source key, then completes when the gate
  clears. Blocking navigation instead would make the user wait on a
  network round-trip to change tabs.
- The deferred case must force the adopt: ContentEditor's sync effect
  CONSUMED the defaultValue change while Guard 0 was up
  (lastDefaultValueRef advances before the guard), so it never re-runs and
  B's draft would never load. New `adoptContent()` ref method lands it;
  its body is the sync effect's own apply path, extracted so both agree.
- handleSend refuses while loaded !== selected. The upload gate covers
  most of that window but not the sliver between the upload's final
  dispatch and the adopt re-render — React flushes that on a scheduler
  task, so Mod+Enter can land first.

Tests (real ContentEditor, real Guard 0, real debounce, real upload
lifecycle incl. the transaction that publishes the queue flip):
completing upload's markdown stays in the source draft and never reaches
the target; an attachment dropped while pinned binds to the source; the
target draft loads once the guard clears; send is refused mid-divergence.
Each verified to fail without its fix — the send guard initially passed
vacuously (the disabled SubmitButton swallowed the click) and now drives
the real Mod+Enter path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Naiyuan Qing
2026-07-16 15:27:48 +08:00
parent a9e9269dc7
commit 94e375ee20
4 changed files with 379 additions and 86 deletions

View File

@@ -1,6 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { act, render } from "@testing-library/react";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { I18nProvider } from "@multica/core/i18n/react";
import type { UploadResult } from "@multica/core/hooks/use-file-upload";
import enCommon from "../../locales/en/common.json";
import enChat from "../../locales/en/chat.json";
@@ -39,11 +40,32 @@ const mockSetContent = vi.hoisted(() => vi.fn());
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({}),
}));
// Captures what ContentEditor wires into its extensions. `onSubmitRef` is the
// Mod+Enter path — it bypasses the SubmitButton entirely, which is why the
// send guards live inside the handler and not only in the button's disabled
// state.
const capturedExtOptions = vi.hoisted<{
current?: { onSubmitRef?: { current?: () => void } };
}>(() => ({}));
vi.mock("../../editor/extensions", () => ({
createEditorExtensions: () => [],
createEditorExtensions: (options: { onSubmitRef?: { current?: () => void } }) => {
capturedExtOptions.current = options;
return [];
},
}));
// Mirrors the real contract that matters here: `handler` is captured at CALL
// time (content-editor passes `onUploadFileRef.current` when the upload
// starts), and on completion the real implementation dispatches into the SAME
// editor instance — which is what fires onUpdate. Tests drive that dispatch
// explicitly via `finishUpload()`.
vi.mock("../../editor/extensions/file-upload", () => ({
uploadAndInsertFile: vi.fn(),
uploadAndInsertFile: async (
_editor: unknown,
file: File,
handler: (f: File) => Promise<unknown>,
) => {
await handler(file);
},
}));
vi.mock("../../editor/utils/preprocess", () => ({
preprocessMarkdown: (value: string) => value,
@@ -144,6 +166,29 @@ import { useChatStore } from "@multica/core/chat";
const TEST_RESOURCES = { en: { common: enCommon, chat: enChat } };
function makeUpload(id: string, filename: string): UploadResult {
const link = `/api/attachments/${id}/download`;
return {
id,
filename,
workspace_id: "ws-1",
issue_id: null,
comment_id: null,
chat_session_id: null,
chat_message_id: null,
uploader_type: "member",
uploader_id: "user-1",
url: link,
download_url: link,
markdown_url: link,
content_type: "image/png",
size_bytes: 1,
created_at: new Date(0).toISOString(),
markdownLink: link,
link,
};
}
function store() {
return useChatStore.getState() as unknown as {
activeSessionId: string | null;
@@ -153,14 +198,43 @@ function store() {
};
}
function element() {
function element(props: Partial<React.ComponentProps<typeof ChatInput>> = {}) {
return (
<I18nProvider locale="en" resources={TEST_RESOURCES}>
<ChatInput onSend={vi.fn()} agentName="Multica" />
<ChatInput onSend={vi.fn()} agentName="Multica" {...props} />
</I18nProvider>
);
}
/** ContentEditor publishes upload-queue flips off `editor.on("transaction")`,
* not off onUpdate — every insert/dispatch below is a transaction in the real
* editor, so the tests emit one too. */
function emitTransaction() {
act(() => {
for (const listener of [...transactionListeners.current]) listener();
});
}
/** Put the editor in the state a live upload leaves it in: file-upload.ts
* inserts a blob placeholder node (attrs.uploading = true) via a transaction,
* so `hasUploadingNode` — and therefore Guard 0, `hasActiveUploads`, and the
* host's upload gate — all report an upload in flight. */
function beginUpload(markdown: string) {
editorState.uploadingNodes = [{ attrs: { uploading: true } }];
type(markdown);
emitTransaction();
}
/** What file-upload.ts does on completion: swap the blob for the CDN URL and
* clear the uploading flag, then dispatch into the SAME editor instance. That
* dispatch both fires onUpdate and, as a transaction, is how ContentEditor
* publishes the flip that un-gates the host. */
function finishUpload(markdown: string) {
editorState.uploadingNodes = [];
type(markdown);
emitTransaction();
}
/** Simulate real typing: move the document, then fire the editor's own
* (debounced) onUpdate exactly as Tiptap would. */
function type(markdown: string) {
@@ -249,6 +323,129 @@ describe("ChatInput draft isolation across a composer switch (real debounce)", (
expect(store().inputDrafts).toEqual({ __new__: "half a thought" });
});
// An in-flight upload PINS the editor to the source document: Guard 0 in
// ContentEditor refuses to setContent over an `uploading` node (doing so
// strands the upload's finalize and the file vanishes). So while the upload
// runs, the instance still holds A's document even though the user is on B —
// and the upload's own completion dispatch fires onUpdate on that instance.
// Every write it produces belongs to A, not to wherever the user navigated.
//
// `useUploadGate` does NOT protect this: it gates submit, not session/agent
// navigation (use-chat-controller.ts handleSelectSession has no upload check).
describe("with an upload in flight", () => {
it("keeps the completing upload's markdown in the source draft, never the one switched to", () => {
store().activeSessionId = "session-a";
store().inputDrafts["session-b"] = "B's own words";
const { rerender } = render(element());
beginUpload("look at this ![](blob:local-preview)");
// User clicks session B while the upload is still running.
store().activeSessionId = "session-b";
rerender(element());
// Upload lands: blob → CDN URL, uploading flag cleared, dispatched into
// the same instance.
finishUpload("look at this ![](/api/attachments/att-1/download)");
act(() => {
vi.advanceTimersByTime(1000);
});
// B must never receive A's body or its attachment URL.
expect(store().inputDrafts["session-b"]).toBe("B's own words");
// The upload result belongs to the draft it was started in.
expect(store().inputDrafts["session-a"]).toBe(
"look at this ![](/api/attachments/att-1/download)",
);
});
it("binds an attachment dropped while the editor is still pinned to the source draft", async () => {
store().activeSessionId = "session-a";
const onUploadFile = vi.fn(async (_file: File) => makeUpload("att-2", "second.png"));
const { rerender } = render(element({ onUploadFile }));
// An upload is already in flight, so Guard 0 pins the editor to A's
// document.
beginUpload("first ![](blob:one)");
store().activeSessionId = "session-b";
rerender(element({ onUploadFile }));
// The composer still shows A's document, so a file dropped now lands in
// A's body — its attachment row has to bind to A, or the body and the
// staged attachment end up in different drafts.
await act(async () => {
fireEvent.drop(screen.getByTestId("editor-content"), {
dataTransfer: { files: [new File(["x"], "second.png", { type: "image/png" })] },
});
await Promise.resolve();
});
const addAttachment = (
useChatStore.getState() as unknown as {
addInputDraftAttachment: ReturnType<typeof vi.fn>;
}
).addInputDraftAttachment;
expect(addAttachment).toHaveBeenCalledWith(
"session-a",
expect.objectContaining({ id: "att-2" }),
);
expect(addAttachment).not.toHaveBeenCalledWith("session-b", expect.anything());
});
it("refuses to send while the composer still holds the source draft's document", () => {
// The upload gate covers most of the pinned window, but not the sliver
// between the upload's final dispatch (uploading node gone, so
// `hasActiveUploads()` is already false) and the re-render that adopts —
// React flushes that update on a scheduler task, so a click can land
// first. Sending then would post A's text into B's session.
store().activeSessionId = "session-a";
const onSend = vi.fn();
const { rerender } = render(element({ onSend }));
beginUpload("A's words ![](blob:one)");
store().activeSessionId = "session-b";
rerender(element({ onSend }));
// Upload's node is gone, but no transaction has re-rendered us yet, so
// the adopt has not run: the editor still holds A.
editorState.uploadingNodes = [];
act(() => {
latestEditorOptions.current?.onUpdate?.({ editor: editorInstance.current });
});
// Mod+Enter, which skips the SubmitButton (and its disabled state)
// entirely — the only way to actually reach handleSend in this window.
act(() => {
capturedExtOptions.current?.onSubmitRef?.current?.();
});
expect(onSend).not.toHaveBeenCalled();
});
it("loads the target draft once the upload guard clears", () => {
store().activeSessionId = "session-a";
store().inputDrafts["session-b"] = "B's own words";
const { rerender } = render(element());
beginUpload("uploading ![](blob:local-preview)");
store().activeSessionId = "session-b";
rerender(element());
// Guard 0 blocks the sync while the upload runs — and it CONSUMES the
// defaultValue change (lastDefaultValueRef advances before the guard), so
// nothing re-applies it later on its own.
mockSetContent.mockClear();
finishUpload("uploaded ![](/api/attachments/att-1/download)");
act(() => {
vi.advanceTimersByTime(1000);
});
// The composer must end up showing B's draft, not A's document.
expect(mockSetContent).toHaveBeenCalled();
});
});
it("does not strand the last keystrokes when a lazy session create re-keys the draft mid-compose", () => {
// First send / first upload in a New Chat flips activeSessionId null → uuid
// under a live editor. Those bytes were typed in the New Chat slot, so they

View File

@@ -119,6 +119,11 @@ vi.mock("../../editor", async () => ({
// the draft-switch flush that depends on it) is covered against the real
// ContentEditor in chat-input-draft-isolation.test.tsx.
flushPendingUpdate: () => null,
// Same file: the upload-pinned adopt path needs the real Guard 0, which
// this mock has no concept of. Kept so the ref honours the full contract.
adoptContent: (markdown: string) => {
valueRef.current = markdown;
},
}));
return (
<textarea

View File

@@ -184,14 +184,32 @@ export function ChatInput({
const appliedRestoreIdRef = useRef<string | null>(null);
const editorKey = editorKeyOverride ?? CHAT_COMPOSER_EDITOR_KEY;
// The draft whose document the editor instance is currently HOLDING.
//
// Normally identical to `draftKey`. The two diverge only while an in-flight
// upload pins the instance to the source document: ContentEditor's Guard 0
// refuses to `setContent` over an `uploading` node, because wiping that node
// strands the upload's finalize and the file silently disappears. Until the
// upload settles the composer still shows — and still edits — the source
// draft, so every byte the instance produces belongs to THIS key, not to
// wherever the user has since navigated.
//
// `draftKey` answers "what is selected"; this answers "what is loaded". Every
// write the editor drives must use the latter.
const editorDraftKeyRef = useRef(draftKey);
// Write a document into a draft slot: text, plus a prune of the attachments
// its body no longer references (deleting an image's markdown drops the
// staged upload with it). Shared by the live `onUpdate` and the draft-switch
// flush below, so a document can never be filed under one rule by one path
// and a different rule by the other.
// and a different rule by the other. Attachments are read live for `key`
// rather than passed in — during a divergence the rendered `draftAttachments`
// belongs to the selected draft, not the loaded one.
const commitDraft = useCallback(
(key: string, markdown: string, attachments: Attachment[]) => {
(key: string, markdown: string) => {
setInputDraft(key, markdown);
const attachments =
useChatStore.getState().inputDraftAttachments[key] ?? EMPTY_ATTACHMENTS;
if (attachments.length === 0) return;
const referenced = attachments.filter((attachment) =>
isAttachmentReferenced(markdown, attachment),
@@ -202,41 +220,6 @@ export function ChatInput({
},
[setInputDraft, setInputDraftAttachments],
);
// Re-point the composer at a different draft slot WITHOUT letting the
// outgoing slot's unflushed keystrokes follow it.
//
// One editor instance serves every chat draft (see the editorKey note), and
// its `onUpdate` is debounced. A debounce armed while the user was on draft A
// fires up to `debounceMs` later, by which time `onUpdate` resolves to the
// latest render's closure — so it would write A's document into B's slot,
// breaking draft isolation and handing A's context to B's agent on send.
// Meanwhile ContentEditor's dirty guard suppresses B's incoming sync while
// those unflushed bytes are live, so B's own draft never even loads.
//
// Flushing takes the pending document back and files it under the key it was
// typed in, and leaves the editor clean so B's draft can sync in.
//
// useLayoutEffect, not useEffect, for two ordering reasons: passive effects
// run child-first, so a passive flush here would land AFTER ContentEditor's
// sync effect had already skipped on a dirty editor; and a layout effect is
// part of the commit, so no pending timer can fire ahead of it.
const draftKeyRef = useRef(draftKey);
useLayoutEffect(() => {
const previousKey = draftKeyRef.current;
if (previousKey === draftKey) return;
draftKeyRef.current = draftKey;
const pending = editorRef.current?.flushPendingUpdate() ?? null;
if (pending === null) return;
logger.debug("input.draft flush on key change", { from: previousKey, to: draftKey });
// Read the source slot's attachments live: this render's `draftAttachments`
// already belongs to the INCOMING key.
commitDraft(
previousKey,
pending,
useChatStore.getState().inputDraftAttachments[previousKey] ?? EMPTY_ATTACHMENTS,
);
}, [draftKey, commitDraft]);
// Submit gate. `uploading` disables the SubmitButton the instant an upload
// starts; `isBlocked()` is re-read inside handleSend for the paths that skip
// the button entirely (Mod+Enter mid-paste, drag-drop racing the keyboard).
@@ -245,6 +228,56 @@ export function ChatInput({
// image would leave stuck (MUL-4808).
const uploadGate = useUploadGate(editorRef);
// Move the editor from the draft it holds to the draft that is selected.
//
// Two hazards make this more than "let the defaultValue sync handle it":
//
// 1. Unflushed keystrokes. `onUpdate` is debounced, and by the time a
// debounce armed under draft A fires, `onUpdate` resolves to the latest
// render's closure — it would file A's document under B. Flushing takes
// those bytes back and commits them to the key they were typed in.
// 2. An in-flight upload. Guard 0 pins the document (see editorDraftKeyRef),
// so the switch cannot happen yet at all: we leave BOTH the document and
// its writes on the source key and retry when the gate clears
// (`uploadGate.uploading` is a dep). Blocking navigation instead would be
// a worse trade — the user waits on a network round-trip to change tabs.
//
// Case 2 also has to force the adopt afterwards: ContentEditor's sync effect
// CONSUMED the defaultValue change while Guard 0 was up (lastDefaultValueRef
// advances before the guard), so it will never re-run for that value and the
// target draft would never load on its own.
//
// useLayoutEffect, not useEffect, for two ordering reasons: passive effects
// run child-first, so a passive flush here would land AFTER ContentEditor's
// sync effect had already skipped on a dirty editor; and a layout effect is
// part of the commit, so no pending debounce timer can fire ahead of it.
const deferredAdoptRef = useRef(false);
useLayoutEffect(() => {
const loadedKey = editorDraftKeyRef.current;
if (loadedKey === draftKey) return;
if (editorRef.current?.hasActiveUploads() === true) {
// Pinned. Stay bound to the source draft until the upload settles.
deferredAdoptRef.current = true;
logger.debug("input.draft switch deferred by in-flight upload", {
from: loadedKey,
to: draftKey,
});
return;
}
const pending = editorRef.current?.flushPendingUpdate() ?? null;
if (pending !== null) {
logger.debug("input.draft flush on key change", { from: loadedKey, to: draftKey });
commitDraft(loadedKey, pending);
}
editorDraftKeyRef.current = draftKey;
if (!deferredAdoptRef.current) return;
deferredAdoptRef.current = false;
const incoming = useChatStore.getState().inputDrafts[draftKey] ?? "";
logger.debug("input.draft adopting after upload settled", { key: draftKey });
editorRef.current?.adoptContent(incoming);
setIsEmpty(!incoming.trim());
}, [draftKey, uploadGate.uploading, commitDraft]);
// Maps "URL inserted into the editor" → "attachment row id" so that
// on send we can ask the server to bind only the attachments still
// referenced in the message body. Cleared after every send. Mirrors
@@ -317,11 +350,16 @@ export function ChatInput({
if (result) {
const persistedURL = result.markdownLink || result.link;
uploadMapRef.current.set(persistedURL, result.id);
if (result.id) addInputDraftAttachment(draftKey, result);
// Bind to the draft this file is landing IN. The editor inserts the
// node into whatever document it currently holds, so while an earlier
// upload pins it to the source draft, that — not the selected draft —
// is where the body lives. Filing the row anywhere else splits a
// message from its own attachment.
if (result.id) addInputDraftAttachment(editorDraftKeyRef.current, result);
}
return result;
},
[addInputDraftAttachment, draftKey, onUploadFile],
[addInputDraftAttachment, onUploadFile],
);
// Drop zone wraps the rounded card so a drop anywhere on the input
@@ -354,6 +392,20 @@ export function ChatInput({
logger.debug("input.send skipped: uploads in flight");
return;
}
// The editor is still holding a DIFFERENT draft's document than the one
// selected — an upload pinned it and the adopt below has not run yet. The
// upload gate above covers most of that window, but not the sliver between
// the upload's final dispatch (node gone) and the re-render that adopts:
// React flushes that state update on a scheduler task, so a click can land
// first. Sending here would post the source draft's text into the selected
// session and then clear the wrong draft.
if (editorDraftKeyRef.current !== draftKey) {
logger.debug("input.send skipped: composer still holds another draft", {
loaded: editorDraftKeyRef.current,
selected: draftKey,
});
return;
}
// Only send attachment IDs for uploads still present in the content.
// Edits / deletions that remove the markdown URL also drop the binding.
const activeIds: string[] = [];
@@ -472,7 +524,10 @@ export function ChatInput({
placeholder={placeholder}
onUpdate={(md) => {
setIsEmpty(!md.trim());
commitDraft(draftKey, md, draftAttachments);
// The LOADED key, not the selected one: while an upload pins the
// document this fires for the source draft's body — including the
// upload's own completion dispatch.
commitDraft(editorDraftKeyRef.current, md);
}}
onSubmit={handleSend}
onUploadFile={uploadEnabled ? handleUpload : undefined}

View File

@@ -32,6 +32,7 @@
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
@@ -236,6 +237,22 @@ interface ContentEditorRef {
* targets, so it reads the current document rather than a cached copy.
*/
flushPendingUpdate: () => string | null;
/**
* Force `markdown` into the document, bypassing the `defaultValue` sync
* guards.
*
* Those guards SKIP permanently rather than defer: `lastDefaultValueRef`
* advances before they run, so a `defaultValue` they refuse is never
* re-applied. That is correct for their usual case (the cache will send
* another value), but not for a host that re-points ONE instance at a
* different document and must land it exactly once — chat's draft switch,
* where an in-flight upload makes Guard 0 refuse the swap.
*
* The caller owns the safety the guards normally provide: only call once the
* reason for the block is gone (upload settled) and any pending edits have
* been flushed, or this destroys them.
*/
adoptContent: (markdown: string) => void;
}
// ---------------------------------------------------------------------------
@@ -570,6 +587,58 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
};
}, []);
// Replace the live document with external markdown. Shared by the
// `defaultValue` sync effect below and the imperative `adoptContent`, so
// both land content identically — chunked parse for large docs, no
// onUpdate echo, caret preserved. Callers own the decision to apply;
// the guards live in the effect, not here.
const applyExternalContent = useCallback(
(markdown: string) => {
if (!editor || editor.isDestroyed) return;
const incoming = markdown ? preprocessMarkdown(markdown) : "";
const incomingNormalized = normalizeMarkdown(incoming);
// Normalized-equal short-circuit. Avoids a no-op transaction when the
// cache reflects a write this same editor just emitted.
if (incomingNormalized === normalizeEditorMarkdown(editor)) return;
// `emitUpdate: false`. Tiptap v3's setContent defaults to
// `emitUpdate: true`; without this we would re-trigger onUpdate →
// server save → self-write loop.
const { from, to } = editor.state.selection;
// Same chunked path on WS-driven re-parse of a large description.
const manager =
incoming.length > MARKDOWN_CHUNK_THRESHOLD
? (editor.storage as { markdown?: { manager?: MarkdownManagerLike } })
.markdown?.manager
: undefined;
if (manager) {
editor.commands.setContent(parseMarkdownChunked(manager, incoming), {
emitUpdate: false,
});
} else {
editor.commands.setContent(incoming, {
emitUpdate: false,
contentType: "markdown",
});
}
// An empty list item in the incoming markdown parses into a caretless,
// schema-invalid node; repair it and let it own the caret. Otherwise clamp
// the prior selection to the new doc size so the caret doesn't snap to
// position 0 after ProseMirror replaces the document.
if (!repairEmptyListItems(editor, { from, to })) {
const docSize = editor.state.doc.content.size;
editor.commands.setTextSelection({
from: Math.min(from, docSize),
to: Math.min(to, docSize),
});
}
lastEmittedRef.current = normalizeEditorMarkdown(editor);
},
[editor],
);
// Sync external `defaultValue` changes into the editor.
// Tiptap v3 `useEditor` reads `content` only at mount (ueberdosis/tiptap#5831);
// without this effect, a WS-driven description update keeps the editor
@@ -595,6 +664,11 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
// finalize can no longer find it (the file vanishes, leaving an empty
// `!file[name]()`). Like the dirty guards below, an uploading node is
// local state that an external sync must not overwrite.
//
// NOTE: this (like every guard here) SKIPS the sync permanently for this
// value — `lastDefaultValueRef` has already advanced, so the effect will
// not re-run for it. A host that must still land this content once the
// block clears has to say so explicitly, via `adoptContent`.
if (hasUploadingNode(editor)) return;
const current = normalizeEditorMarkdown(editor);
@@ -617,47 +691,8 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
// here avoids overwriting unsaved local edits.
if (isDirty) return;
const incoming = defaultValue ? preprocessMarkdown(defaultValue) : "";
const incomingNormalized = normalizeMarkdown(incoming);
// Guard 3: normalized-equal short-circuit. Avoids a no-op transaction
// when the cache reflects a write this same editor just emitted.
if (incomingNormalized === current) return;
// Guard 4: `emitUpdate: false`. Tiptap v3's setContent defaults to
// `emitUpdate: true`; without this we would re-trigger onUpdate →
// server save → self-write loop.
const { from, to } = editor.state.selection;
// Same chunked path on WS-driven re-parse of a large description.
const manager =
incoming.length > MARKDOWN_CHUNK_THRESHOLD
? (editor.storage as { markdown?: { manager?: MarkdownManagerLike } })
.markdown?.manager
: undefined;
if (manager) {
editor.commands.setContent(parseMarkdownChunked(manager, incoming), {
emitUpdate: false,
});
} else {
editor.commands.setContent(incoming, {
emitUpdate: false,
contentType: "markdown",
});
}
// An empty list item in the incoming markdown parses into a caretless,
// schema-invalid node; repair it and let it own the caret. Otherwise clamp
// the prior selection to the new doc size so the caret doesn't snap to
// position 0 after ProseMirror replaces the document.
if (!repairEmptyListItems(editor, { from, to })) {
const docSize = editor.state.doc.content.size;
editor.commands.setTextSelection({
from: Math.min(from, docSize),
to: Math.min(to, docSize),
});
}
lastEmittedRef.current = normalizeEditorMarkdown(editor);
}, [defaultValue, editor]);
applyExternalContent(defaultValue);
}, [defaultValue, editor, applyExternalContent]);
// Sync external `placeholder` changes into the mounted editor.
// The Placeholder extension is configured with a getter over `placeholderRef`
@@ -734,6 +769,7 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
lastEmittedRef.current = md;
return md;
},
adoptContent: (markdown: string) => applyExternalContent(markdown),
}));
// Link hover card — disabled when BubbleMenu is active (has selection)