mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-02 01:45:52 +02:00
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:
@@ -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 ");
|
||||
|
||||
// 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 ");
|
||||
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 ",
|
||||
);
|
||||
});
|
||||
|
||||
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 ");
|
||||
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 ");
|
||||
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 ");
|
||||
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 ");
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user