diff --git a/apps/client/src/features/comment/components/comment-content-view.test.tsx b/apps/client/src/features/comment/components/comment-content-view.test.tsx
new file mode 100644
index 00000000..96e01961
--- /dev/null
+++ b/apps/client/src/features/comment/components/comment-content-view.test.tsx
@@ -0,0 +1,250 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { MantineProvider } from "@mantine/core";
+import { MemoryRouter } from "react-router-dom";
+
+// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
+
+// The fallback path renders the full TipTap editor; stub it so we can assert the
+// safety valve fired without pulling in the editor stack.
+vi.mock("@/features/comment/components/comment-editor", () => ({
+ default: () =>
+
+ );
+ }
+
+ try {
+ const pmDoc = doc as PMNode;
+ if (!pmDoc || typeof pmDoc !== "object" || pmDoc.type !== "doc") {
+ throw new UnknownNodeError("Not a ProseMirror doc");
+ }
+ return {renderChildren(pmDoc.content, "n")};
+ } catch (err) {
+ if (err instanceof UnknownNodeError) {
+ return fallback();
+ }
+ throw err;
+ }
+}
+
+export default CommentContentView;
diff --git a/apps/client/src/features/comment/components/comment-list-item.test.tsx b/apps/client/src/features/comment/components/comment-list-item.test.tsx
index 0a343bf7..5b217520 100644
--- a/apps/client/src/features/comment/components/comment-list-item.test.tsx
+++ b/apps/client/src/features/comment/components/comment-list-item.test.tsx
@@ -1,5 +1,5 @@
-import { describe, it, expect, vi } from "vitest";
-import { render, screen, fireEvent } from "@testing-library/react";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { IComment } from "@/features/comment/types/comment.types";
@@ -9,10 +9,11 @@ import { IComment } from "@/features/comment/types/comment.types";
// component renders in isolation. We only assert the AI-badge rendering branch.
const applyMutateAsync = vi.fn();
const dismissMutateAsync = vi.fn();
+const updateMutateAsync = vi.fn();
vi.mock("@/features/comment/queries/comment-query", () => ({
useDeleteCommentMutation: () => ({ mutateAsync: vi.fn() }),
useResolveCommentMutation: () => ({ mutateAsync: vi.fn() }),
- useUpdateCommentMutation: () => ({ mutateAsync: vi.fn() }),
+ useUpdateCommentMutation: () => ({ mutateAsync: updateMutateAsync }),
useApplySuggestionMutation: () => ({
mutateAsync: applyMutateAsync,
isPending: false,
@@ -23,9 +24,51 @@ vi.mock("@/features/comment/queries/comment-query", () => ({
}),
}));
+// The document the mocked editor emits via onUpdate when the edit form is open.
+// Duplicated inside the mock factory (below) to keep the factory self-contained.
+const EDITED_DOC = {
+ type: "doc",
+ content: [
+ { type: "paragraph", content: [{ type: "text", text: "edited via editor" }] },
+ ],
+};
+
// CommentEditor pulls in the full TipTap editor stack; replace it with a stub.
-vi.mock("@/features/comment/components/comment-editor", () => ({
- default: () => ,
+// In edit mode the stub exposes buttons that fire the real onUpdate/onSave props
+// so the edit->save/cancel flow can be driven without a live editor.
+vi.mock("@/features/comment/components/comment-editor", () => {
+ const doc = {
+ type: "doc",
+ content: [
+ { type: "paragraph", content: [{ type: "text", text: "edited via editor" }] },
+ ],
+ };
+ return {
+ default: ({ onUpdate, onSave }: any) => (
+
+
+ ),
+ };
+});
+
+// CommentContentView (used for the read-only body) imports the mention view,
+// which pulls page-query -> main.tsx (createRoot). Stub the queries so the item
+// renders in isolation without the app entry side-effect.
+vi.mock("@/features/page/queries/page-query.ts", () => ({
+ usePageQuery: () => ({ data: undefined, isLoading: false, isError: false }),
+}));
+vi.mock("@/features/share/queries/share-query.ts", () => ({
+ useSharePageQuery: () => ({ data: undefined }),
}));
import CommentListItem from "./comment-list-item";
@@ -286,3 +329,132 @@ describe("canShowDismiss predicate", () => {
expect(canShowDismiss(c({ parentCommentId: "p" }), true, true)).toBe(false);
});
});
+
+describe("CommentListItem — edit -> save/cancel flow (#340 F3)", () => {
+ const body = (t: string) =>
+ JSON.stringify({
+ type: "doc",
+ content: [{ type: "paragraph", content: [{ type: "text", text: t }] }],
+ });
+
+ // The edit menu item is gated on the viewer owning the comment
+ // (currentUser.id === creatorId). currentUserAtom is atomWithStorage-backed,
+ // so seed localStorage to make the viewer the owner (creatorId "user-1").
+ beforeEach(() => {
+ updateMutateAsync.mockClear();
+ localStorage.setItem(
+ "currentUser",
+ JSON.stringify({ user: { id: "user-1", name: "Owner" } }),
+ );
+ });
+ afterEach(() => {
+ localStorage.clear();
+ });
+
+ async function openEditor() {
+ // Open the comment menu, then click "Edit comment" to toggle into edit mode.
+ fireEvent.click(screen.getByLabelText("Comment menu"));
+ fireEvent.click(await screen.findByText("Edit comment"));
+ // Edit form (mocked editor + actions) is now mounted.
+ await screen.findByTestId("comment-editor");
+ }
+
+ it("saves the edited content and, on cache update, shows the new body", async () => {
+ const { rerender } = renderItem(
+ baseComment({ content: body("original body") }),
+ );
+ // Static body first.
+ expect(screen.getByText("original body")).toBeDefined();
+
+ await openEditor();
+
+ // Editor emits an update (populates editContentRef), then Save is clicked.
+ fireEvent.click(screen.getByTestId("editor-emit-update"));
+ fireEvent.click(screen.getByRole("button", { name: "Save" }));
+
+ // mutateAsync is called with the stringified edited doc.
+ expect(updateMutateAsync).toHaveBeenCalledWith({
+ commentId: "c-1",
+ content: JSON.stringify(EDITED_DOC),
+ });
+
+ // On success the form closes (isEditing -> false); the static body renders
+ // from the comment.content prop again.
+ await waitFor(() =>
+ expect(screen.queryByTestId("comment-editor")).toBeNull(),
+ );
+
+ // Simulate the cache invalidation swapping in a new comment object with the
+ // updated content — the static body reflects it.
+ rerender(
+
+
+ ,
+ );
+ expect(screen.getByText("updated body after save")).toBeDefined();
+ expect(screen.queryByText("original body")).toBeNull();
+ });
+
+ it("cancel restores the static body and does not call the update mutation", async () => {
+ renderItem(baseComment({ content: body("original body") }));
+ await openEditor();
+
+ // Type something (editContentRef set), then cancel.
+ fireEvent.click(screen.getByTestId("editor-emit-update"));
+ fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
+
+ // Editor unmounts, static body restored, no save happened.
+ await waitFor(() =>
+ expect(screen.queryByTestId("comment-editor")).toBeNull(),
+ );
+ expect(screen.getByText("original body")).toBeDefined();
+ expect(updateMutateAsync).not.toHaveBeenCalled();
+ });
+
+ it("saving without editing sends the existing content (editContentRef cleared after cancel)", async () => {
+ renderItem(baseComment({ content: body("original body") }));
+
+ // Cancel path clears editContentRef...
+ await openEditor();
+ fireEvent.click(screen.getByTestId("editor-emit-update"));
+ fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
+ await waitFor(() =>
+ expect(screen.queryByTestId("comment-editor")).toBeNull(),
+ );
+
+ // ...so re-opening and saving WITHOUT an update falls back to comment.content.
+ await openEditor();
+ fireEvent.click(screen.getByRole("button", { name: "Save" }));
+ expect(updateMutateAsync).toHaveBeenCalledWith({
+ commentId: "c-1",
+ content: JSON.stringify(body("original body")),
+ });
+ });
+});
+
+describe("CommentListItem — read-only body renders statically", () => {
+ it("renders the comment body as static text without a TipTap editor", () => {
+ renderItem(
+ baseComment({
+ content: JSON.stringify({
+ type: "doc",
+ content: [
+ {
+ type: "paragraph",
+ content: [{ type: "text", text: "Hello static world" }],
+ },
+ ],
+ }),
+ }),
+ );
+ // Body text is present...
+ expect(screen.getByText("Hello static world")).toBeDefined();
+ // ...and it did NOT go through the (mocked) CommentEditor instance.
+ expect(screen.queryByTestId("comment-editor")).toBeNull();
+ });
+});
diff --git a/apps/client/src/features/comment/components/comment-list-item.tsx b/apps/client/src/features/comment/components/comment-list-item.tsx
index aa9b253a..543ed1a1 100644
--- a/apps/client/src/features/comment/components/comment-list-item.tsx
+++ b/apps/client/src/features/comment/components/comment-list-item.tsx
@@ -1,10 +1,11 @@
import { Group, Text, Box, Badge, Button } from "@mantine/core";
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
-import React, { useEffect, useMemo, useRef, useState } from "react";
+import React, { useMemo, useRef, useState } from "react";
import classes from "./comment.module.css";
import { useAtom, useAtomValue } from "jotai";
import { useTimeAgo } from "@/hooks/use-time-ago";
import CommentEditor from "@/features/comment/components/comment-editor";
+import CommentContentView from "@/features/comment/components/comment-content-view";
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
import CommentActions from "@/features/comment/components/comment-actions";
import CommentMenu from "@/features/comment/components/comment-menu";
@@ -50,7 +51,6 @@ function CommentListItem({
const [isEditing, setIsEditing] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const editor = useAtomValue(pageEditorAtom);
- const [content, setContent] = useState(comment.content);
const editContentRef = useRef(null);
const updateCommentMutation = useUpdateCommentMutation();
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
@@ -78,22 +78,16 @@ function CommentListItem({
const isOwnerOrAdmin =
currentUser?.user?.id === comment.creatorId || userSpaceRole === "admin";
- useEffect(() => {
- setContent(comment.content);
- }, [comment]);
async function handleUpdateComment() {
try {
setIsLoading(true);
const commentToUpdate = {
commentId: comment.id,
- content: JSON.stringify(editContentRef.current ?? content),
+ content: JSON.stringify(editContentRef.current ?? comment.content),
};
await updateCommentMutation.mutateAsync(commentToUpdate);
- if (editContentRef.current) {
- setContent(editContentRef.current);
- editContentRef.current = null;
- }
+ editContentRef.current = null;
setIsEditing(false);
} catch (error) {
console.error("Failed to update comment:", error);
@@ -350,11 +344,11 @@ function CommentListItem({
)}
{!isEditing ? (
-
+
) : (
<>
{ editContentRef.current = newContent; }}
onSave={handleUpdateComment}
@@ -374,4 +368,6 @@ function CommentListItem({
);
}
-export default CommentListItem;
+// Memoized so a resolve/apply/reply cache update (which only replaces the touched
+// comment's object identity) re-renders that one thread, not all ~356 items.
+export default React.memo(CommentListItem);
diff --git a/apps/client/src/features/comment/components/comment-list-with-tabs.test.tsx b/apps/client/src/features/comment/components/comment-list-with-tabs.test.tsx
new file mode 100644
index 00000000..cdc3bcb4
--- /dev/null
+++ b/apps/client/src/features/comment/components/comment-list-with-tabs.test.tsx
@@ -0,0 +1,108 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen, fireEvent } from "@testing-library/react";
+import { MantineProvider } from "@mantine/core";
+import { IComment } from "@/features/comment/types/comment.types";
+
+// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
+
+// CommentEditor pulls in the full TipTap editor stack; replace it with a stub so
+// the lazy reply editor's mount transition can be observed without the editor.
+vi.mock("@/features/comment/components/comment-editor", () => ({
+ default: () => ,
+}));
+
+// page-query -> main.tsx (createRoot) is a module side effect; stub the queries
+// pulled in transitively so importing the module is side-effect free.
+vi.mock("@/features/page/queries/page-query.ts", () => ({
+ usePageQuery: () => ({ data: undefined, isLoading: false, isError: false }),
+}));
+vi.mock("@/features/share/queries/share-query.ts", () => ({
+ useSharePageQuery: () => ({ data: undefined }),
+}));
+// space-query -> main.tsx (createRoot) is another module side effect; stub it.
+vi.mock("@/features/space/queries/space-query.ts", () => ({
+ useGetSpaceBySlugQuery: () => ({ data: undefined }),
+}));
+
+import {
+ buildChildrenByParent,
+ CommentEditorWithActions,
+} from "./comment-list-with-tabs";
+
+const c = (id: string, parentCommentId: string | null = null): IComment =>
+ ({ id, parentCommentId }) as IComment;
+
+describe("buildChildrenByParent (childrenByParent grouping)", () => {
+ it("returns an empty map for undefined or empty input", () => {
+ expect(buildChildrenByParent(undefined).size).toBe(0);
+ expect(buildChildrenByParent([]).size).toBe(0);
+ });
+
+ it("does not index a top-level comment (parentCommentId null)", () => {
+ const map = buildChildrenByParent([c("p1", null)]);
+ expect(map.size).toBe(0);
+ expect(map.has("p1")).toBe(false);
+ });
+
+ it("groups replies under the correct parent, including reply-to-reply nesting", () => {
+ const p1 = c("p1", null);
+ const r1 = c("r1", "p1");
+ const r2 = c("r2", "r1"); // a reply to a reply
+ const map = buildChildrenByParent([p1, r1, r2]);
+ expect(map.get("p1")).toEqual([r1]);
+ expect(map.get("r1")).toEqual([r2]);
+ // The top-level comment itself is never a key.
+ expect(map.has("p1") && map.get("p1")?.length).toBe(1);
+ });
+
+ it("still groups a reply whose parent is not present in items", () => {
+ const orphan = c("o1", "missing-parent");
+ const map = buildChildrenByParent([orphan]);
+ expect(map.get("missing-parent")).toEqual([orphan]);
+ });
+
+ it("preserves insertion order among sibling replies", () => {
+ const map = buildChildrenByParent([
+ c("a", "p1"),
+ c("b", "p1"),
+ c("d", "p1"),
+ ]);
+ expect(map.get("p1")?.map((x) => x.id)).toEqual(["a", "b", "d"]);
+ });
+});
+
+function renderReplyEditor() {
+ return render(
+
+
+ ,
+ );
+}
+
+describe("CommentEditorWithActions — lazy reply editor activation", () => {
+ it("shows only the stub initially (no editor instance mounted)", () => {
+ renderReplyEditor();
+ expect(screen.getByRole("button")).toBeDefined();
+ expect(screen.queryByTestId("comment-editor")).toBeNull();
+ });
+
+ it("mounts the real editor when the stub is clicked and keeps it mounted", () => {
+ renderReplyEditor();
+ fireEvent.click(screen.getByRole("button"));
+ expect(screen.getByTestId("comment-editor")).toBeDefined();
+ // The stub button is replaced by the editor subtree.
+ expect(screen.queryByRole("button")).toBeNull();
+ });
+
+ it("mounts the editor when the stub receives focus", () => {
+ renderReplyEditor();
+ fireEvent.focus(screen.getByRole("button"));
+ expect(screen.getByTestId("comment-editor")).toBeDefined();
+ });
+
+ it("mounts the editor on Enter keydown of the stub", () => {
+ renderReplyEditor();
+ fireEvent.keyDown(screen.getByRole("button"), { key: "Enter" });
+ expect(screen.getByTestId("comment-editor")).toBeDefined();
+ });
+});
diff --git a/apps/client/src/features/comment/components/comment-list-with-tabs.tsx b/apps/client/src/features/comment/components/comment-list-with-tabs.tsx
index af9d0783..b5e62e8e 100644
--- a/apps/client/src/features/comment/components/comment-list-with-tabs.tsx
+++ b/apps/client/src/features/comment/components/comment-list-with-tabs.tsx
@@ -23,7 +23,6 @@ import CommentActions from "@/features/comment/components/comment-actions";
import { useFocusWithin } from "@mantine/hooks";
import { IComment } from "@/features/comment/types/comment.types.ts";
import { usePageQuery } from "@/features/page/queries/page-query.ts";
-import { IPagination } from "@/lib/types.ts";
import { extractPageSlugId } from "@/lib";
import { useTranslation } from "react-i18next";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
@@ -36,6 +35,24 @@ interface CommentListWithTabsProps {
onClose?: () => void;
}
+// Index replies by their parent id once (O(n)), instead of an O(n^2) filter per
+// thread. Replies whose parent is not in `items` are still grouped under their
+// parentCommentId (they simply won't be reached by the top-level walk).
+// Exported for unit testing.
+export function buildChildrenByParent(
+ items: IComment[] | undefined,
+): Map {
+ const m = new Map();
+ for (const c of items ?? []) {
+ if (c.parentCommentId) {
+ const arr = m.get(c.parentCommentId);
+ if (arr) arr.push(c);
+ else m.set(c.parentCommentId, [c]);
+ }
+ }
+ return m;
+}
+
function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
const { t } = useTranslation();
const { pageSlug } = useParams();
@@ -46,7 +63,9 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
isError,
} = useCommentsQuery({ pageId: page?.id });
const createCommentMutation = useCreateCommentMutation();
- const [isLoading, setIsLoading] = useState(false);
+ // mutateAsync is a stable reference across renders; depend on it (not the
+ // mutation object) so the reply/comment callbacks stay stable.
+ const createCommentAsync = createCommentMutation.mutateAsync;
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
const canEdit = page?.permissions?.canEdit ?? false;
@@ -75,13 +94,21 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
return { activeComments: active, resolvedComments: resolved };
}, [comments]);
+ // Index replies by their parent once, instead of an O(n^2) filter per thread.
+ // The map ref changes on any comments update, so MemoizedChildComments re-runs
+ // (cheap) and re-looks-up, while memoized CommentListItems skip unchanged items.
+ const childrenByParent = useMemo(
+ () => buildChildrenByParent(comments?.items),
+ [comments?.items],
+ );
+
const [isPageCommentLoading, setIsPageCommentLoading] = useState(false);
const handleAddPageComment = useCallback(
async (_commentId: string, content: string) => {
try {
setIsPageCommentLoading(true);
- const createdComment = await createCommentMutation.mutateAsync({
+ const createdComment = await createCommentAsync({
pageId: page?.id,
content: JSON.stringify(content),
});
@@ -100,27 +127,26 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
setIsPageCommentLoading(false);
}
},
- [createCommentMutation, page?.id],
+ [createCommentAsync, page?.id],
);
const handleAddReply = useCallback(
async (commentId: string, content: string) => {
+ // Pending state lives inside CommentEditorWithActions so sending a reply
+ // does not churn renderComments and re-render the whole list.
try {
- setIsLoading(true);
const commentData = {
pageId: page?.id,
parentCommentId: commentId,
content: JSON.stringify(content),
};
- await createCommentMutation.mutateAsync(commentData);
+ await createCommentAsync(commentData);
} catch (error) {
console.error("Failed to post comment:", error);
- } finally {
- setIsLoading(false);
}
},
- [createCommentMutation, page?.id],
+ [createCommentAsync, page?.id],
);
const renderComments = useCallback(
@@ -143,7 +169,7 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
userSpaceRole={space?.membership?.role}
/>
>
)}
),
[
- comments,
+ childrenByParent,
handleAddReply,
- isLoading,
+ page?.id,
space?.membership?.role,
canComment,
canEdit,
@@ -203,6 +228,11 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
Resolved -> Open switch.
+ keepMounted={false}
style={{
flex: "1 1 auto",
display: "flex",
@@ -261,7 +291,10 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
type="scroll"
>
-
+ {/* keepMounted keeps the Open panel alive even while Resolved is
+ active, so a lazily-mounted reply editor's draft (and an
+ in-progress edit) is not discarded on tab switch. */}
+
{activeComments.length === 0 ? (