mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-31 17:10:43 +02:00
* Reapply "perf(issues): virtualize inbox/list/board/swimlane (MUL-4474, 方案2) (#…" (#5395)
This reverts commit c10bfa8f56.
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): seed virtualized lists so route-return doesn't flash blank (MUL-4750)
The relanded MUL-4474 virtualization flashed an empty card area (group /
column headers present, rows blank) when returning to /issues or crossing
inbox<->issues. Two stacked blank windows caused it:
1. The scroll element reaches Virtuoso via a callback ref that lands in
state, so the first render after a remount has customScrollParent === null
and the code rendered nothing.
2. Even once mounted, Virtuoso renders 0 rows until its post-paint
ResizeObserver measures the viewport.
Fix both, four surfaces (list / board / swimlane / inbox):
- New shared <VirtuosoSeed> renders a bounded slice of the real rows while the
scroll parent is still null, reusing each caller's own itemContent /
computeItemKey so a seeded row is identical to its virtualized counterpart.
- Pass initialItemCount={Math.min(len, SEED)} so the measurement frame keeps
those rows instead of collapsing to empty.
SEED is capped at 30 and floored by Math.min, so small workspaces
(hasMore=false) and short columns never over-mount — the path that crashed on
real Desktop before. restoreStateFrom (tab-switch Activity restore) is
intentionally out of scope for this round.
Verified: @multica/views tsc --noEmit, eslint, and full vitest (1936 tests)
pass. Real-Desktop route-return / DnD / keyboard / scroll-position regression
pass still owed on device.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* perf(issues): defer per-card popup mounting, share one context menu per surface
Tab-switching to a board froze the main thread for seconds: every card
eagerly mounted ~6 popup roots (context menu, pickers, hover cards) plus
per-card query subscriptions, multiplied by seed x columns x remount.
- DeferredPopup: pickers render a pixel-identical static trigger and mount
the real popover on first pointerenter/keydown (Base UI opens on click,
so the warm mount always wins the race)
- AssigneePicker/PriorityPicker/DateOnlyPicker defer when uncontrolled;
AssigneePicker's members/agents/squads/frequency subscriptions now only
start on interaction
- IssueActionsContextMenu: one controlled ContextMenu per surface anchored
at the cursor via a virtual anchor; items delegate (issue, position) up.
Known debt: iOS Safari long-press no longer opens it
- ActorAvatar hover cards warm-mount on pointerenter with a manual
first-dwell timer matching Base UI's OPEN_DELAY
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(issues): stabilize board column scrollbar across mounts
Column scrollbars redrew visibly on every surface mount (route switches,
first open): the seed frame's scroll height covered only the seeded cards,
then Virtuoso spaced out the full count.
- VirtuosoSeed: optional estimatedItemHeight renders a trailing spacer so
the seed frame's scroll height already approximates the full list
- Board columns: seed capped at 10 (one column viewport of ~110px cards,
not the 36px-row-sized generic 30) and the same estimate feeds Virtuoso's
defaultItemHeight so both phases agree until real measurements land
- Columns at <=30 cards skip virtualization entirely and render plainly
(same itemContent), making their scroll height browser-measured truth in
every scenario -- the per-column split Linear ships
(data-virtual-cluster=false for small clusters)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* perf(editor): reduce issue detail mount cost
Parse long Markdown in smaller chunks without the duplicate initial sync, defer title and empty composer editors until intent, and keep the description editor eager to avoid layout shifts.
* chore(desktop): add navigation boundary lint rule (MUL-4741 Phase 2 prereq)
The tab Coordinator protocol requires that application code never
navigates directly (invariant 1: a Router location change without a
Coordinator token is a protocol error). Enforce it statically:
- renderer app code may not import useNavigate/Navigate from
react-router-dom nor call router.navigate; src/platform is exempt
- the five known legacy sites (the RFC §8.1 migration checklist,
cross-validated: the rule fires on exactly those and nothing else)
carry inline eslint-disable directives tagged MUL-4741 — the Phase 2
migration removes them one by one, and this rule holding with zero
disables is the machine check that the migration is complete
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(views): open deferred pickers on click, align triggerRender types
Pointerenter warm-mounting swapped the trigger element mid-gesture: real
browsers re-hit-test so the click lands on the new trigger, but synthetic
pointer sequences (tests, assistive tech) keep dispatching on the detached
node and the first click dies. Upgrade now happens on click/Enter/Space
only — the same timing as Base UI's own trigger — with the in-flight click
stopped so the popup's just-mounted outside-press dismissal doesn't close
it in the same breath.
Also widen triggerRender to ReactElement<Record<string, unknown>> (React
19 defaults ReactElement props to unknown) and mount the
IssueContextMenuProvider in the swimlane test harness like IssueSurface
does in production.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(desktop): single-router tab sessions with Coordinator protocol (MUL-4741 Phase 2)
Replace the per-tab-router + <Activity> keep-alive model with the approved
single-router session architecture:
- TabSession: tabs are pure serializable state (url, resourceKey, virtual
history stack, scroll memento). Persist v4 does the one-time legacy
view-state import from v3; mountGeneration is deliberately unpersisted.
- Coordinator (platform/tab-coordinator.ts) is the only router writer: it
reconciles THE app router to the active session URL with navigation
tokens; a location change without a token is a protocol error handled by
bounded recovery (invariant 1). The router history is never used — every
reconcile is a replace; back/forward are session-stack operations.
- ActiveTabHost mounts exactly one tab, keyed on tabId:mountGeneration.
reload() = generation bump + active-scope query invalidation (never
router.revalidate, never a global cache invalidation). Warm switches
restore scroll pre-paint; cold restores pre-size containers from the
memento's saved scrollHeight and settle when data lands.
- resourceKey dedup (pathname only) replaces exact-path dedup: opening
/slug/issues?filter=b focuses the existing issues tab (RFC §8.2,
deliberate semantic change).
- All five §8.1 legacy navigation sites migrated (index <Navigate>, error
page recovery, workspace-layout login bounce, overlay parking, shell
back/forward); their MUL-4741 ratchet eslint-disables are removed, so the
navigation boundary rule now holds with zero exemptions outside
src/platform.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(issues): register board columns and list scroller for scroll mementos
Per-container scroll registration for the MUL-4741 tab session memento:
board columns key by group id (each column's offset restores
independently, the per-column split Linear ships), the list view keys as
"list". Chat and issue-detail already carry the marker.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(tabs): pull-based scroll restoration fed into virtualized lists (MUL-4741)
Rebuild the restore side of the memento protocol on first principles (the
model Linear ships): a saved offset is an INPUT to the mounting view, not a
post-hoc DOM mutation from outside.
- ScrollRestorationProvider (views/platform): views pull their saved offset
while mounting. Virtualized lists feed it into Virtuoso's initialScrollTop
so the first render already materializes the rows around it — this
replaces the pushed spacer+scrollTop hook, whose foreign spacer deadlocked
against Virtuoso's own height model (restore landed the viewport in
phantom space, Virtuoso rendered nothing, and the spacer's removal
condition could never be met → blank issue detail). Plain containers
assign the offset at ref-attach, pre-paint. Web has no provider and
behaves as before.
- Memento keys gain a route dimension (`${pathname}::${containerKey}`) and
capture now also fires before in-tab navigation, so back/forward restores
each route's own offsets and same-named containers on different routes
no longer collide.
- commitScrollMemento uses REPLACE-per-route semantics: a container
scrolled back to 0 clears its stale offset instead of resurrecting the
old position on the next visit.
- List view gets the same estimate alignment as the board (36px rows into
the seed spacer and defaultItemHeight), which keeps the shared scroller's
height truthful from the first frame so the restored offset sticks.
Known gap: chat's bottom-anchored list captures offsets but has no restore
consumer — intentional, it re-anchors to bottom on mount.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* perf(editor): make unlabelled code fences plaintext instead of auto-detected
Follow-up to the issue-detail mount work: lowlight's highlightAuto runs
every registered grammar over the full block for code fences without a
language, which dominated mount cost on code-heavy comments. Extract a
shared syntax-highlight module whose auto fallback deterministically
renders plaintext; explicitly labelled languages highlight as before.
Also ignore .gstack/.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* perf(issues): lazy-mount column-header popups, right-size swimlane seed
Trace analysis of tab/view switching (30s session): a swimlane mount spent
its largest slice on eagerly-mounted header machinery — ~170 tooltip roots
(one per lane x status cell add-button), 13 column dropdown-menus, and up
to 30 fully-materialized lanes from the generic seed count.
- DeferredTooltip (views/common): renders only the trigger until first
hover, then mounts a controlled Tooltip anchored to the SAME element
(no trigger swap, so mid-gesture events never land on a detached node);
ui TooltipContent grows an `anchor` passthrough for it.
- Board/list/swimlane header add-buttons and hide-column dropdowns now
defer via DeferredTooltip / DeferredPopup (which gains an ariaHasPopup
option for menu triggers).
- Swimlane lane seed drops 30 -> 6 (a lane row is ~300px+; a viewport fits
~3) on both the pre-scroll seed and Virtuoso's initialItemCount.
- openTab gains an `activate` option so "open and focus" paths (pinned-tab
redirect, explicit open-in-new-tab) are ONE store write instead of
openTab + setActiveTab back-to-back — one full subscriber pass per user
action instead of two.
- Dev-only breadcrumb logs IssueSurfaceContent's remount key: the trace
showed the surface mounting twice inside one task, and the my-issues
relation toggle is one confirmed key-flip source; the log ties the next
trace's mounts to exact key transitions.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* perf(issues): stop double full-tree render passes on surface interactions
Trace forensics on view switching showed every interaction paying TWO full
surface render passes (React's own Cascading Update marker sits between
them): entering swimlane flips controller-level loading state (loadProjects
enables the projects query), and any such flag flip re-rendered the entire
unmemoized view tree (~600-1000ms dev per pass).
- Memoize BoardView / ListView / SwimLaneView: controller/data outputs are
already useMemo/useCallback-stable, so a controller flag flip now
re-renders the header, not the whole board. The one unstable prop —
BoardView's inline assigneeGroups.flatMap — moves into a useMemo.
- Selection reset on mount swapped the initial empty Set for a NEW empty
Set, buying a guaranteed extra full pass per surface mount; functional
bail keeps the reference when nothing was selected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* perf(ui): drive sidebar resize by direct DOM writes, drop motion/react
Trace analysis showed sidebar motion.div mounts costing ~1s across a
session. Width previews during drag now write straight to the two layout
shells (CSS disables their transitions while data-sidebar-resizing is set);
React only sees the single committed width on pointer-up, and framer-motion
leaves the sidebar entirely.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(issues): wire swimlane's outer scroller into tab scroll restoration
Review blocker on #5403: board/list/issue-detail register their scroll
containers with the tab session memento protocol, but the swimlane outer
scroller did not — under the single-router architecture an inactive tab
unmounts, so a deep-scrolled swimlane returned at top after a tab switch
or reload.
Same wiring as the other surfaces: data-tab-scroll-root="swimlane" for
capture, useRestoredScrollRef in the scroller's attach callback for the
pre-paint assignment, and the saved offset into the lane Virtuoso's
initialScrollTop. Regression test asserts both the capture marker and the
restored offset.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: multica-agent <github@multica.ai>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
627 lines
23 KiB
TypeScript
627 lines
23 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { act, fireEvent, render, screen } from "@testing-library/react";
|
|
import { createRef } from "react";
|
|
import type { Attachment } from "@multica/core/types";
|
|
import type { UploadResult } from "@multica/core/hooks/use-file-upload";
|
|
|
|
const mockFocus = vi.hoisted(() => vi.fn());
|
|
const mockSetContent = vi.hoisted(() => vi.fn());
|
|
const mockSetTextSelection = vi.hoisted(() => vi.fn());
|
|
const mockDispatch = vi.hoisted(() => vi.fn());
|
|
const emptyTr = vi.hoisted(() => ({ __emptyTransaction: true }));
|
|
// Captures the options ContentEditor hands to createEditorExtensions on its
|
|
// most recent render — lets the placeholder tests assert the getter it wires
|
|
// into the Placeholder extension reads the live value.
|
|
const capturedExtOptions = vi.hoisted<{
|
|
current: { placeholder?: string | (() => string) } | undefined;
|
|
}>(() => ({ current: undefined }));
|
|
const editorState = vi.hoisted(() => ({
|
|
isFocused: false,
|
|
isDestroyed: false,
|
|
markdown: "",
|
|
// Nodes the mocked doc reports via `descendants`. The content-sync effect
|
|
// walks these to detect in-flight uploads; default empty = nothing uploading.
|
|
uploadingNodes: [] as Array<{ attrs: { uploading?: boolean } }>,
|
|
}));
|
|
|
|
// Records the attachments[] prop the provider received on its most recent
|
|
// render. Content-editor merges its `attachments` prop with in-session
|
|
// upload results before passing them down — these tests assert that merged
|
|
// shape lands here.
|
|
const providerProps = vi.hoisted<{ attachments: Attachment[] | undefined }>(
|
|
() => ({ attachments: undefined }),
|
|
);
|
|
|
|
const uploadAndInsertFileMock = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("@tanstack/react-query", () => ({
|
|
useQueryClient: () => ({}),
|
|
}));
|
|
|
|
vi.mock("./extensions", () => ({
|
|
createEditorExtensions: (options: {
|
|
placeholder?: string | (() => string);
|
|
}) => {
|
|
capturedExtOptions.current = options;
|
|
return [];
|
|
},
|
|
}));
|
|
|
|
vi.mock("./extensions/file-upload", () => ({
|
|
uploadAndInsertFile: uploadAndInsertFileMock,
|
|
}));
|
|
|
|
vi.mock("./utils/preprocess", () => ({
|
|
preprocessMarkdown: (value: string) => value,
|
|
}));
|
|
|
|
// Empty-list repair needs a live ProseMirror doc (covered by
|
|
// repair-list-items.test.ts against the real editor). Here it is a no-op so the
|
|
// mocked editor's sync path exercises the normal (non-repair) branch.
|
|
vi.mock("./utils/repair-list-items", () => ({
|
|
repairEmptyListItems: vi.fn(() => false),
|
|
}));
|
|
|
|
vi.mock("./bubble-menu", () => ({
|
|
EditorBubbleMenu: () => null,
|
|
}));
|
|
|
|
vi.mock("./attachment-download-context", () => ({
|
|
AttachmentDownloadProvider: ({
|
|
attachments,
|
|
children,
|
|
}: {
|
|
attachments?: Attachment[];
|
|
children: React.ReactNode;
|
|
}) => {
|
|
providerProps.attachments = attachments;
|
|
return <>{children}</>;
|
|
},
|
|
}));
|
|
|
|
const editorRef = vi.hoisted<{ current: unknown }>(() => ({ current: null }));
|
|
const onCreateFired = vi.hoisted(() => ({ value: false }));
|
|
const latestEditorOptions = vi.hoisted<{
|
|
current?: { onUpdate?: (args: { editor: unknown }) => void };
|
|
}>(() => ({}));
|
|
|
|
vi.mock("@tiptap/react", () => ({
|
|
useEditor: (options: {
|
|
onCreate?: (args: { editor: unknown }) => void;
|
|
onUpdate?: (args: { editor: unknown }) => void;
|
|
}) => {
|
|
latestEditorOptions.current = options;
|
|
if (!editorRef.current) {
|
|
editorRef.current = {
|
|
get isFocused() {
|
|
return editorState.isFocused;
|
|
},
|
|
get isDestroyed() {
|
|
return editorState.isDestroyed;
|
|
},
|
|
commands: {
|
|
focus: mockFocus,
|
|
clearContent: vi.fn(),
|
|
setContent: mockSetContent,
|
|
setTextSelection: mockSetTextSelection,
|
|
},
|
|
getMarkdown: () => editorState.markdown,
|
|
view: { dispatch: mockDispatch },
|
|
state: {
|
|
get tr() {
|
|
return emptyTr;
|
|
},
|
|
doc: {
|
|
content: { size: 0 },
|
|
descendants: (cb: (node: { attrs: { uploading?: boolean } }) => boolean | void) => {
|
|
for (const node of editorState.uploadingNodes) {
|
|
if (cb(node) === false) break;
|
|
}
|
|
},
|
|
},
|
|
selection: { empty: true, from: 0, to: 0 },
|
|
},
|
|
};
|
|
}
|
|
if (!onCreateFired.value) {
|
|
onCreateFired.value = true;
|
|
options?.onCreate?.({ editor: editorRef.current });
|
|
}
|
|
return editorRef.current;
|
|
},
|
|
EditorContent: ({ className }: { className?: string }) => (
|
|
<div className={className} data-testid="editor-content">
|
|
<div className="ProseMirror rich-text-editor" data-testid="prosemirror" />
|
|
</div>
|
|
),
|
|
}));
|
|
|
|
import { ContentEditor, type ContentEditorRef } from "./content-editor";
|
|
|
|
describe("ContentEditor", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
editorState.isFocused = false;
|
|
editorState.isDestroyed = false;
|
|
editorState.markdown = "";
|
|
editorState.uploadingNodes = [];
|
|
editorRef.current = null;
|
|
onCreateFired.value = false;
|
|
latestEditorOptions.current = undefined;
|
|
providerProps.attachments = undefined;
|
|
capturedExtOptions.current = undefined;
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("focuses the editor when clicking the empty container area", () => {
|
|
render(<ContentEditor placeholder="Add description..." />);
|
|
|
|
const shell = screen.getByTestId("editor-content").parentElement;
|
|
expect(shell).not.toBeNull();
|
|
|
|
fireEvent.mouseDown(shell!);
|
|
|
|
expect(mockFocus).toHaveBeenCalledWith("end");
|
|
});
|
|
|
|
it("does not hijack clicks that land inside the ProseMirror node", () => {
|
|
render(<ContentEditor placeholder="Add description..." />);
|
|
|
|
fireEvent.mouseDown(screen.getByTestId("prosemirror"));
|
|
|
|
expect(mockFocus).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("syncs editor content when defaultValue changes externally and editor is unfocused", () => {
|
|
editorState.markdown = "old content";
|
|
const { rerender } = render(<ContentEditor defaultValue="old content" />);
|
|
|
|
expect(mockSetContent).not.toHaveBeenCalled();
|
|
|
|
// Editor still holds the old, in-sync content; external value changes.
|
|
editorState.markdown = "old content";
|
|
rerender(<ContentEditor defaultValue="new content from server" />);
|
|
|
|
expect(mockSetContent).toHaveBeenCalledTimes(1);
|
|
expect(mockSetContent).toHaveBeenCalledWith(
|
|
"new content from server",
|
|
expect.objectContaining({ emitUpdate: false, contentType: "markdown" }),
|
|
);
|
|
});
|
|
|
|
it("does not parse the initial defaultValue twice when markdown round-trip canonicalizes it", () => {
|
|
// useEditor already parsed defaultValue through its `content` option. The
|
|
// editor's canonical Markdown can legitimately differ from the source, so
|
|
// that difference alone must not trigger an immediate second setContent.
|
|
editorState.markdown = "- [ ] canonical task";
|
|
|
|
render(
|
|
<ContentEditor defaultValue={"- [ ] source task\n\ncontinuation"} />,
|
|
);
|
|
|
|
expect(mockSetContent).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not sync while a file upload is in flight (in-flight upload node must survive external defaultValue changes)", () => {
|
|
editorState.markdown = "old content";
|
|
const { rerender } = render(<ContentEditor defaultValue="old content" />);
|
|
|
|
// A file is uploading: the doc holds a node with attrs.uploading. An
|
|
// external defaultValue change (e.g. chat lazy-creating a session mid-upload
|
|
// flips the draft key → defaultValue) must NOT setContent over it, or the
|
|
// uploading node is wiped and the upload's finalize can't find it.
|
|
editorState.uploadingNodes = [{ attrs: { uploading: true } }];
|
|
rerender(<ContentEditor defaultValue="" />);
|
|
|
|
expect(mockSetContent).not.toHaveBeenCalled();
|
|
|
|
// Once the upload settles (no uploading node), a later external change syncs.
|
|
editorState.uploadingNodes = [];
|
|
rerender(<ContentEditor defaultValue="new content from server" />);
|
|
expect(mockSetContent).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("does not sync when editor is focused and has unsaved local edits", () => {
|
|
editorState.markdown = "old content";
|
|
const { rerender } = render(<ContentEditor defaultValue="old content" />);
|
|
|
|
// User is typing — focused AND dirty (markdown diverges from
|
|
// lastEmittedRef, which was seeded with "old content" by onCreate).
|
|
editorState.isFocused = true;
|
|
editorState.markdown = "user-typed-content";
|
|
|
|
rerender(<ContentEditor defaultValue="incoming external change" />);
|
|
|
|
expect(mockSetContent).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("syncs even when editor is focused, as long as it is clean (focused-but-clean must not be permanently dropped)", () => {
|
|
// This case is the regression test for the focused-but-clean hole:
|
|
// user clicks into the editor (focused = true) but types nothing
|
|
// (markdown still equals lastEmittedRef). An external update arrives.
|
|
// With an unconditional `if (isFocused) return`, this sync would be lost
|
|
// forever because onBlur has no replay path.
|
|
editorState.markdown = "old content";
|
|
const { rerender } = render(<ContentEditor defaultValue="old content" />);
|
|
|
|
editorState.isFocused = true;
|
|
editorState.markdown = "old content"; // clean — no typing happened
|
|
|
|
rerender(<ContentEditor defaultValue="new content from server" />);
|
|
|
|
expect(mockSetContent).toHaveBeenCalledTimes(1);
|
|
expect(mockSetContent).toHaveBeenCalledWith(
|
|
"new content from server",
|
|
expect.objectContaining({ emitUpdate: false, contentType: "markdown" }),
|
|
);
|
|
});
|
|
|
|
it("does not sync when editor is unfocused but has unsaved local edits (blur-before-debounce window)", () => {
|
|
editorState.markdown = "old content";
|
|
const { rerender } = render(
|
|
<ContentEditor defaultValue="old content" onUpdate={() => {}} />,
|
|
);
|
|
|
|
// User typed locally, then blurred. Debounce hasn't flushed yet so
|
|
// lastEmittedRef inside the component still reflects "old content".
|
|
editorState.isFocused = false;
|
|
editorState.markdown = "user typed but unsaved";
|
|
|
|
rerender(
|
|
<ContentEditor
|
|
defaultValue="external update from another agent"
|
|
onUpdate={() => {}}
|
|
/>,
|
|
);
|
|
|
|
expect(mockSetContent).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not sync when defaultValue normalizes to the current editor markdown", () => {
|
|
editorState.markdown = "same content";
|
|
const { rerender } = render(<ContentEditor defaultValue="same content" />);
|
|
|
|
// Different `defaultValue` string forces the effect to re-run (the dep
|
|
// array sees a new value), but the trailing whitespace normalises away
|
|
// via `trimEnd()`, so `setContent` must still short-circuit.
|
|
rerender(<ContentEditor defaultValue={"same content\n"} />);
|
|
|
|
expect(mockSetContent).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("refactor safety net: imperative getMarkdown() stays untrimmed, keeping its exact current return value", () => {
|
|
// The imperative `getMarkdown()` is deliberately NOT routed through
|
|
// `normalizeMarkdown` (which would `trimEnd()`). This pins down that the
|
|
// F2a/F3 dedupe refactor preserved the method's exact return value —
|
|
// trailing blank lines included — instead of folding it into the trimming
|
|
// helper. `stripBlobUrls` (unmocked here) only strips blob image markdown,
|
|
// so the trailing newlines survive untouched.
|
|
editorState.markdown = "kept body\n\n";
|
|
|
|
const ref = createRef<ContentEditorRef>();
|
|
render(<ContentEditor ref={ref} />);
|
|
|
|
expect(ref.current).not.toBeNull();
|
|
expect(ref.current?.getMarkdown()).toBe("kept body\n\n");
|
|
});
|
|
|
|
it("flushes a pending debounced update on unmount when flushPendingOnUnmount is set", () => {
|
|
vi.useFakeTimers();
|
|
const onUpdate = vi.fn();
|
|
editorState.markdown = "old content";
|
|
const { unmount } = render(
|
|
<ContentEditor
|
|
defaultValue="old content"
|
|
onUpdate={onUpdate}
|
|
debounceMs={1500}
|
|
flushPendingOnUnmount
|
|
/>,
|
|
);
|
|
|
|
editorState.markdown = "old content\n\n";
|
|
act(() => {
|
|
latestEditorOptions.current?.onUpdate?.({ editor: editorRef.current });
|
|
});
|
|
|
|
expect(onUpdate).not.toHaveBeenCalled();
|
|
|
|
// The flush must emit the copy cached at onUpdate time — by cleanup time
|
|
// Tiptap may already have torn the instance down, so reading the editor
|
|
// during unmount is not an option.
|
|
editorState.isDestroyed = true;
|
|
editorState.markdown = "";
|
|
|
|
unmount();
|
|
|
|
expect(onUpdate).toHaveBeenCalledTimes(1);
|
|
expect(onUpdate).toHaveBeenCalledWith(
|
|
"old content\n\n",
|
|
);
|
|
act(() => {
|
|
vi.advanceTimersByTime(1500);
|
|
});
|
|
expect(onUpdate).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("drops a pending debounced update on unmount by default", () => {
|
|
// Regression guard for draft resurrection: composers like comment edit
|
|
// cancel `clearDraft()` and then unmount this editor. A default unmount
|
|
// flush would re-emit the discarded markdown into onUpdate, which writes
|
|
// it straight back into the draft store.
|
|
vi.useFakeTimers();
|
|
const onUpdate = vi.fn();
|
|
editorState.markdown = "edit draft the user cancelled";
|
|
const { unmount } = render(
|
|
<ContentEditor
|
|
defaultValue=""
|
|
onUpdate={onUpdate}
|
|
debounceMs={300}
|
|
/>,
|
|
);
|
|
|
|
act(() => {
|
|
latestEditorOptions.current?.onUpdate?.({ editor: editorRef.current });
|
|
});
|
|
expect(onUpdate).not.toHaveBeenCalled();
|
|
|
|
unmount();
|
|
|
|
expect(onUpdate).not.toHaveBeenCalled();
|
|
act(() => {
|
|
vi.advanceTimersByTime(300);
|
|
});
|
|
expect(onUpdate).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not re-emit on unmount when the debounce already fired", () => {
|
|
vi.useFakeTimers();
|
|
const onUpdate = vi.fn();
|
|
const { unmount } = render(
|
|
<ContentEditor
|
|
defaultValue=""
|
|
onUpdate={onUpdate}
|
|
debounceMs={1500}
|
|
flushPendingOnUnmount
|
|
/>,
|
|
);
|
|
|
|
editorState.markdown = "typed content";
|
|
act(() => {
|
|
latestEditorOptions.current?.onUpdate?.({ editor: editorRef.current });
|
|
vi.advanceTimersByTime(1500);
|
|
});
|
|
expect(onUpdate).toHaveBeenCalledTimes(1);
|
|
|
|
unmount();
|
|
|
|
expect(onUpdate).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("refreshes the live placeholder getter and repaints when the placeholder prop changes", () => {
|
|
// Repro for MUL-4276: Tiptap's Placeholder snapshots a *string* option at
|
|
// mount, so switching between an archived and an active chat session under
|
|
// the SAME agent (no editor remount) left the input frozen on the archived
|
|
// copy. The fix wires a *getter* over a live ref into the extension and, on
|
|
// change, dispatches an empty transaction to force a decoration recompute.
|
|
const { rerender } = render(
|
|
<ContentEditor placeholder="This session is archived" />,
|
|
);
|
|
// What reaches the Placeholder extension is a getter, not a static string.
|
|
const getter = capturedExtOptions.current?.placeholder;
|
|
expect(typeof getter).toBe("function");
|
|
expect((getter as () => string)()).toBe("This session is archived");
|
|
|
|
mockDispatch.mockClear();
|
|
|
|
rerender(<ContentEditor placeholder="Message agent…" />);
|
|
|
|
// Same getter identity now returns the new text (reads the live ref)…
|
|
expect((getter as () => string)()).toBe("Message agent…");
|
|
// …and a repaint was nudged so the mounted decoration re-reads it.
|
|
expect(mockDispatch).toHaveBeenCalledTimes(1);
|
|
expect(mockDispatch).toHaveBeenCalledWith(emptyTr);
|
|
});
|
|
|
|
it("does not repaint when the placeholder prop is unchanged", () => {
|
|
const { rerender } = render(<ContentEditor placeholder="Message agent…" />);
|
|
|
|
mockDispatch.mockClear();
|
|
|
|
rerender(<ContentEditor placeholder="Message agent…" />);
|
|
|
|
expect(mockDispatch).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
function makeAttachment(id: string, overrides: Partial<Attachment> = {}): Attachment {
|
|
return {
|
|
id,
|
|
workspace_id: "ws-1",
|
|
issue_id: null,
|
|
comment_id: null,
|
|
chat_session_id: null,
|
|
chat_message_id: null,
|
|
uploader_type: "member",
|
|
uploader_id: "u-1",
|
|
filename: `${id}.png`,
|
|
url: `/uploads/${id}.png`,
|
|
download_url: `/api/attachments/${id}/download`,
|
|
markdown_url: `https://api.multica.test/api/attachments/${id}/download`,
|
|
content_type: "image/png",
|
|
size_bytes: 1,
|
|
created_at: "2026-06-10T00:00:00Z",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function asUploadResult(att: Attachment): UploadResult {
|
|
return { ...att, link: att.url, markdownLink: `/api/attachments/${att.id}/download` };
|
|
}
|
|
|
|
// MUL-3192 — surfaces like the quick-create modal upload images through the
|
|
// editor without a server-supplied `attachments` prop. Without in-session
|
|
// tracking, the AttachmentDownloadProvider had nothing to resolve the
|
|
// freshly-inserted /api/attachments/<id>/download URL against, so
|
|
// Attachment.normalize() couldn't swap it for a freshly-loadable URL — the
|
|
// <img> rendered broken on Desktop where the renderer's origin doesn't
|
|
// proxy /api to the API host. ContentEditor now wraps onUploadFile so the
|
|
// successful UploadResult lands in the provider as a tracked record.
|
|
describe("ContentEditor — in-session attachment tracking (MUL-3192)", () => {
|
|
it("seeds the AttachmentDownloadProvider with the caller-supplied attachments prop", () => {
|
|
const att = makeAttachment("seed-1");
|
|
render(<ContentEditor attachments={[att]} />);
|
|
expect(providerProps.attachments).toEqual([att]);
|
|
});
|
|
|
|
it("appends a successful upload result to the provider's attachments list", async () => {
|
|
const onUploadFile = vi.fn(async (_file: File) =>
|
|
asUploadResult(makeAttachment("uploaded-1")),
|
|
);
|
|
// Capture the wrapped uploader the editor hands to uploadAndInsertFile,
|
|
// then invoke it the same way the file-upload extension would.
|
|
let capturedHandler:
|
|
| ((file: File) => Promise<UploadResult | null>)
|
|
| undefined;
|
|
uploadAndInsertFileMock.mockImplementation(
|
|
async (_editor: unknown, file: File, handler: typeof capturedHandler) => {
|
|
capturedHandler = handler;
|
|
await handler?.(file);
|
|
},
|
|
);
|
|
|
|
let imperativeRef: { uploadFile: (file: File) => void } | null = null;
|
|
render(
|
|
<ContentEditor
|
|
onUploadFile={onUploadFile}
|
|
ref={(r) => {
|
|
imperativeRef = r;
|
|
}}
|
|
/>,
|
|
);
|
|
|
|
expect(providerProps.attachments).toBeUndefined();
|
|
|
|
await act(async () => {
|
|
imperativeRef?.uploadFile(new File(["payload"], "shot.png", { type: "image/png" }));
|
|
});
|
|
|
|
// The wrapper (not the raw caller-supplied uploader) is what reaches
|
|
// the file-upload extension — that's the layer that captures successful
|
|
// results into the provider.
|
|
expect(capturedHandler).toBeTypeOf("function");
|
|
expect(capturedHandler).not.toBe(onUploadFile);
|
|
expect(onUploadFile).toHaveBeenCalledTimes(1);
|
|
|
|
expect(providerProps.attachments).toHaveLength(1);
|
|
expect(providerProps.attachments?.[0]?.id).toBe("uploaded-1");
|
|
});
|
|
|
|
it("merges in-session uploads with the caller's attachments prop, preferring the prop on id collision", async () => {
|
|
// The pre-loaded record carries a freshly-signed download_url; the
|
|
// upload result for the same id has an older download_url. After merge
|
|
// the provider should still expose the prop's record so the editor's
|
|
// resolveAttachment lookup hands back the freshest data.
|
|
const seeded = makeAttachment("shared-1", {
|
|
download_url: "https://cdn.example/freshly-signed.png?Signature=fresh",
|
|
});
|
|
const collision = makeAttachment("shared-1", {
|
|
download_url: "https://cdn.example/freshly-signed.png?Signature=stale",
|
|
});
|
|
const onUploadFile = vi.fn(async () => asUploadResult(collision));
|
|
uploadAndInsertFileMock.mockImplementation(
|
|
async (_e: unknown, file: File, handler: (f: File) => Promise<unknown>) => {
|
|
await handler(file);
|
|
},
|
|
);
|
|
|
|
let imperativeRef: { uploadFile: (file: File) => void } | null = null;
|
|
render(
|
|
<ContentEditor
|
|
attachments={[seeded]}
|
|
onUploadFile={onUploadFile}
|
|
ref={(r) => {
|
|
imperativeRef = r;
|
|
}}
|
|
/>,
|
|
);
|
|
|
|
await act(async () => {
|
|
imperativeRef?.uploadFile(new File(["x"], "shared.png", { type: "image/png" }));
|
|
});
|
|
|
|
expect(providerProps.attachments).toHaveLength(1);
|
|
expect(providerProps.attachments?.[0]?.download_url).toContain("Signature=fresh");
|
|
});
|
|
|
|
it("backfills an empty caller download_url from the session upload on id collision", async () => {
|
|
// The create-issue draft persists attachment records with download_url
|
|
// stripped (the signed URL is response-scoped). While the upload session
|
|
// is still alive, the provider should hand back the signed URL so the
|
|
// just-pasted image first-paints from it instead of detouring through
|
|
// markdown_url.
|
|
const draftRecord = makeAttachment("draft-1", { download_url: "" });
|
|
const uploaded = makeAttachment("draft-1", {
|
|
download_url: "https://cdn.example/draft-1.png?Signature=fresh",
|
|
});
|
|
const onUploadFile = vi.fn(async () => asUploadResult(uploaded));
|
|
uploadAndInsertFileMock.mockImplementation(
|
|
async (_e: unknown, file: File, handler: (f: File) => Promise<unknown>) => {
|
|
await handler(file);
|
|
},
|
|
);
|
|
|
|
let imperativeRef: { uploadFile: (file: File) => void } | null = null;
|
|
render(
|
|
<ContentEditor
|
|
attachments={[draftRecord]}
|
|
onUploadFile={onUploadFile}
|
|
ref={(r) => {
|
|
imperativeRef = r;
|
|
}}
|
|
/>,
|
|
);
|
|
|
|
await act(async () => {
|
|
imperativeRef?.uploadFile(new File(["x"], "draft-1.png", { type: "image/png" }));
|
|
});
|
|
|
|
expect(providerProps.attachments).toHaveLength(1);
|
|
expect(providerProps.attachments?.[0]?.download_url).toContain("Signature=fresh");
|
|
// Everything except the backfilled field still comes from the caller copy.
|
|
expect(providerProps.attachments?.[0]?.filename).toBe(draftRecord.filename);
|
|
});
|
|
|
|
it("does not append a duplicate when the same upload result returns twice (paste-then-drop the same blob)", async () => {
|
|
const result = asUploadResult(makeAttachment("dedup-1"));
|
|
const onUploadFile = vi.fn(async () => result);
|
|
uploadAndInsertFileMock.mockImplementation(
|
|
async (_e: unknown, file: File, handler: (f: File) => Promise<unknown>) => {
|
|
await handler(file);
|
|
},
|
|
);
|
|
|
|
let imperativeRef: { uploadFile: (file: File) => void } | null = null;
|
|
render(
|
|
<ContentEditor
|
|
onUploadFile={onUploadFile}
|
|
ref={(r) => {
|
|
imperativeRef = r;
|
|
}}
|
|
/>,
|
|
);
|
|
|
|
await act(async () => {
|
|
imperativeRef?.uploadFile(new File(["a"], "a.png", { type: "image/png" }));
|
|
});
|
|
await act(async () => {
|
|
imperativeRef?.uploadFile(new File(["b"], "b.png", { type: "image/png" }));
|
|
});
|
|
|
|
expect(providerProps.attachments).toHaveLength(1);
|
|
expect(providerProps.attachments?.[0]?.id).toBe("dedup-1");
|
|
});
|
|
});
|