Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d84e5ddbad | |||
| 6bf8361936 | |||
| f46d89eafb |
@@ -67,20 +67,14 @@ export default function GlobalAppShell({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Attach the global mousemove/mouseup only WHILE resizing (started on the
|
||||
// handle's mousedown via startResizing → isResizing=true) and detach on
|
||||
// mouseup (stopResizing → isResizing=false). Previously these listeners were
|
||||
// attached for the whole app lifetime, so every mouse move over the app ran
|
||||
// the resize handler.
|
||||
// https://codesandbox.io/p/sandbox/kz9de
|
||||
if (!isResizing) return;
|
||||
//https://codesandbox.io/p/sandbox/kz9de
|
||||
window.addEventListener("mousemove", resize);
|
||||
window.addEventListener("mouseup", stopResizing);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", resize);
|
||||
window.removeEventListener("mouseup", stopResizing);
|
||||
};
|
||||
}, [isResizing, resize, stopResizing]);
|
||||
}, [resize, stopResizing]);
|
||||
|
||||
const location = useLocation();
|
||||
const isSettingsRoute = location.pathname.startsWith("/settings");
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
Button,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
import { useClickOutside, useDisclosure } from "@mantine/hooks";
|
||||
import { useClickOutside, useDisclosure, useWindowEvent } from "@mantine/hooks";
|
||||
import { Suspense } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -57,22 +57,14 @@ function EmojiPicker({
|
||||
[dropdown, target],
|
||||
);
|
||||
|
||||
// We need this because the default Mantine popover closeOnEscape does not work.
|
||||
// Attach the global keydown ONLY while the picker is open (every tree row
|
||||
// renders an EmojiPicker, so an always-on window listener meant ~20-30 idle
|
||||
// keydown handlers firing on each keystroke).
|
||||
useEffect(() => {
|
||||
if (!opened) return;
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
handlers.close();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeydown);
|
||||
return () => window.removeEventListener("keydown", handleKeydown);
|
||||
}, [opened, handlers]);
|
||||
// We need this because the default Mantine popover closeOnEscape does not work
|
||||
useWindowEvent("keydown", (event) => {
|
||||
if (opened && event.key === "Escape") {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
handlers.close();
|
||||
}
|
||||
});
|
||||
|
||||
// emoji-mart's built-in autoFocus calls .focus() without preventScroll, which
|
||||
// makes the browser scroll every scrollable ancestor of the search input to
|
||||
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
desktopSidebarAtom,
|
||||
mobileSidebarAtom,
|
||||
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
import { usePageMetaQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import {
|
||||
pageEditorAtom,
|
||||
readOnlyEditorAtom,
|
||||
@@ -245,9 +245,7 @@ export default function AiChatWindow() {
|
||||
// left partly off-screen).
|
||||
const [geom, setGeom] = useAtom(aiChatWindowGeomAtom);
|
||||
|
||||
// Gated on windowOpen: the chat list is only needed once the window is open,
|
||||
// so a closed window issues no chat-list request/refetch on navigation.
|
||||
const { data: chats } = useAiChatsQuery(windowOpen);
|
||||
const { data: chats } = useAiChatsQuery();
|
||||
// Roles for the new-chat picker (any member may list them). Only fetched while
|
||||
// the window is open.
|
||||
const { data: roles } = useAiRolesQuery(windowOpen);
|
||||
@@ -293,10 +291,6 @@ export default function AiChatWindow() {
|
||||
Date.now() - lastActivityAtRef.current < DEGRADED_POLL_IDLE_MAX_MS
|
||||
? 2500
|
||||
: false,
|
||||
// #344: gate on windowOpen too — no message history is fetched (and no
|
||||
// degraded poll runs) while the window is closed; it loads when the window
|
||||
// opens with an active chat.
|
||||
windowOpen,
|
||||
);
|
||||
|
||||
// #430: re-stamp the activity clock whenever the polled rows change while the
|
||||
@@ -342,7 +336,7 @@ export default function AiChatWindow() {
|
||||
// reads/writes via its CASL-enforced page tools using the id.
|
||||
const pageRouteMatch = useMatch("/s/:spaceSlug/p/:pageSlug");
|
||||
const pageSlug = pageRouteMatch?.params?.pageSlug;
|
||||
const { data: openPageData } = usePageMetaQuery({
|
||||
const { data: openPageData } = usePageQuery({
|
||||
pageId: extractPageSlugId(pageSlug),
|
||||
});
|
||||
const openPage = openPageData
|
||||
|
||||
@@ -53,12 +53,8 @@ export const AI_CHAT_MESSAGES_RQ_KEY = (chatId: string) => [
|
||||
chatId,
|
||||
];
|
||||
|
||||
/**
|
||||
* Paginated list of the current user's chats (auto-loads further pages).
|
||||
* `enabled` (default true) lets the AI chat window skip fetching while it is
|
||||
* closed — the list is only needed once the window is open.
|
||||
*/
|
||||
export function useAiChatsQuery(enabled: boolean = true) {
|
||||
/** Paginated list of the current user's chats (auto-loads further pages). */
|
||||
export function useAiChatsQuery() {
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: AI_CHATS_RQ_KEY,
|
||||
queryFn: ({ pageParam }) => getAiChats({ cursor: pageParam, limit: 50 }),
|
||||
@@ -67,7 +63,6 @@ export function useAiChatsQuery(enabled: boolean = true) {
|
||||
lastPage.meta.hasNextPage
|
||||
? (lastPage.meta.nextCursor ?? undefined)
|
||||
: undefined,
|
||||
enabled,
|
||||
});
|
||||
|
||||
const data = useMemo<IPagination<IAiChat> | undefined>(() => {
|
||||
@@ -98,9 +93,6 @@ export function useAiChatMessagesQuery(
|
||||
// follow the detached run to settle. The callback form lives in AiChatWindow;
|
||||
// threaded here verbatim so this query owns the polling. Undefined => no poll.
|
||||
refetchInterval?: number | false | (() => number | false),
|
||||
// #344: gate the query so a backgrounded/hidden window stops issuing refetches
|
||||
// and duplicating work. Defaults to enabled to preserve existing call-sites.
|
||||
enabled: boolean = true,
|
||||
) {
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: AI_CHAT_MESSAGES_RQ_KEY(chatId ?? ""),
|
||||
@@ -111,7 +103,7 @@ export function useAiChatMessagesQuery(
|
||||
lastPage.meta.hasNextPage
|
||||
? (lastPage.meta.nextCursor ?? undefined)
|
||||
: undefined,
|
||||
enabled: !!chatId && enabled,
|
||||
enabled: !!chatId,
|
||||
refetchInterval,
|
||||
});
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ vi.mock("@/features/comment/components/comment-editor", () => ({
|
||||
// case renders in isolation.
|
||||
vi.mock("@/features/page/queries/page-query.ts", () => ({
|
||||
usePageQuery: () => ({ data: undefined, isLoading: false, isError: false }),
|
||||
usePageMetaQuery: () => ({ data: undefined, isLoading: false, isError: false }),
|
||||
}));
|
||||
vi.mock("@/features/share/queries/share-query.ts", () => ({
|
||||
useSharePageQuery: () => ({ data: undefined }),
|
||||
|
||||
@@ -22,7 +22,7 @@ import CommentEditor from "@/features/comment/components/comment-editor";
|
||||
import CommentActions from "@/features/comment/components/comment-actions";
|
||||
import { useFocusWithin } from "@mantine/hooks";
|
||||
import { IComment } from "@/features/comment/types/comment.types.ts";
|
||||
import { usePageMetaQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||
@@ -56,7 +56,7 @@ export function buildChildrenByParent(
|
||||
function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
||||
const { t } = useTranslation();
|
||||
const { pageSlug } = useParams();
|
||||
const { data: page } = usePageMetaQuery({ pageId: extractPageSlugId(pageSlug) });
|
||||
const { data: page } = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
|
||||
const {
|
||||
data: comments,
|
||||
isLoading: isCommentsLoading,
|
||||
|
||||
@@ -24,7 +24,7 @@ import classes from "./link.module.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { INTERNAL_LINK_REGEX } from "@/lib/constants";
|
||||
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
|
||||
import { usePageMetaQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { useSharePageQuery } from "@/features/share/queries/share-query.ts";
|
||||
import { buildSharedPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
@@ -83,7 +83,7 @@ export default function LinkView(props: MarkViewProps) {
|
||||
const isPopoverVisible = popoverState !== "closed";
|
||||
const activeView = isPopoverVisible ? popoverState : lastOpenState.current;
|
||||
|
||||
const { data: linkedPage } = usePageMetaQuery({
|
||||
const { data: linkedPage } = usePageQuery({
|
||||
pageId: isPopoverVisible && slugId && !isShareRoute ? slugId : null,
|
||||
});
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ import { IconFileDescription, IconPlus } from "@tabler/icons-react";
|
||||
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { v7 as uuid7 } from "uuid";
|
||||
import { useAtom, useSetAtom, useStore } from "jotai";
|
||||
import { useAtom } from "jotai";
|
||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import {
|
||||
MentionListProps,
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
import { IPage } from "@/features/page/types/page.types";
|
||||
import {
|
||||
useCreatePageMutation,
|
||||
usePageMetaQuery,
|
||||
usePageQuery,
|
||||
} from "@/features/page/queries/page-query";
|
||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom";
|
||||
import { treeModel } from "@/features/page/tree/model/tree-model";
|
||||
@@ -50,16 +50,12 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
const [countAnnouncement, setCountAnnouncement] = useState("");
|
||||
const [selectionAnnouncement, setSelectionAnnouncement] = useState("");
|
||||
const { pageSlug, spaceSlug } = useParams();
|
||||
const { data: page } = usePageMetaQuery({ pageId: extractPageSlugId(pageSlug) });
|
||||
const { data: page } = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
|
||||
const { data: space } = useSpaceQuery(spaceSlug);
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
const [renderItems, setRenderItems] = useState<MentionSuggestionItem[]>([]);
|
||||
const { t } = useTranslation();
|
||||
// Setter-only: the tree value is read only imperatively inside createPage
|
||||
// (via `store` below), never at render, so useSetAtom avoids re-rendering the
|
||||
// mention popup on any tree event.
|
||||
const setData = useSetAtom(treeDataAtom);
|
||||
const store = useStore();
|
||||
const [data, setData] = useAtom(treeDataAtom);
|
||||
const createPageMutation = useCreatePageMutation();
|
||||
const emit = useQueryEmit();
|
||||
const isInCommentContext = props.isInCommentContext ?? false;
|
||||
@@ -276,11 +272,9 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
children: [],
|
||||
};
|
||||
|
||||
// Read the live tree imperatively at call time.
|
||||
const currentTree = store.get(treeDataAtom);
|
||||
const lastIndex = currentTree.length;
|
||||
const lastIndex = data.length;
|
||||
|
||||
setData(treeModel.insert(currentTree, parentId, newNode, lastIndex));
|
||||
setData(treeModel.insert(data, parentId, newNode, lastIndex));
|
||||
|
||||
props.command({
|
||||
id: uuid7(),
|
||||
|
||||
@@ -2,7 +2,7 @@ import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import { ActionIcon, Anchor, Text } from "@mantine/core";
|
||||
import { IconFileDescription } from "@tabler/icons-react";
|
||||
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
import { usePageMetaQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { useSharePageQuery } from "@/features/share/queries/share-query.ts";
|
||||
import {
|
||||
buildPageUrl,
|
||||
@@ -36,7 +36,7 @@ export function MentionContent({ attrs }: { attrs: MentionAttrs }) {
|
||||
data: page,
|
||||
isLoading,
|
||||
isError,
|
||||
} = usePageMetaQuery({ pageId: isPageMention && !isShareRoute ? slugId : null });
|
||||
} = usePageQuery({ pageId: isPageMention && !isShareRoute ? slugId : null });
|
||||
|
||||
const { data: sharedPage } = useSharePageQuery({
|
||||
pageId: isPageMention && isShareRoute ? slugId : undefined,
|
||||
|
||||
@@ -24,6 +24,7 @@ export function useFavoritesQuery(type?: FavoriteType, spaceId?: string) {
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,
|
||||
refetchOnMount: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -31,6 +32,7 @@ export function useFavoriteIds(type: FavoriteType, spaceId?: string): Set<string
|
||||
const { data } = useQuery({
|
||||
queryKey: ["favorite-ids", type, spaceId],
|
||||
queryFn: () => getFavoriteIds(type, spaceId),
|
||||
refetchOnMount: true,
|
||||
});
|
||||
|
||||
const items = data?.items;
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useAtomValue } from "jotai";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import { usePageMetaQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
||||
import { useBacklinksCountQuery } from "@/features/page-details/queries/backlinks-query.ts";
|
||||
import { BacklinksModal } from "./backlinks-modal";
|
||||
@@ -23,7 +23,7 @@ import { LabelsSection } from "@/features/label/components/labels-section.tsx";
|
||||
|
||||
export function PageDetailsAside() {
|
||||
const { pageSlug } = useParams();
|
||||
const { data: page } = usePageMetaQuery({
|
||||
const { data: page } = usePageQuery({
|
||||
pageId: extractPageSlugId(pageSlug),
|
||||
});
|
||||
const pageEditor = useAtomValue(pageEditorAtom);
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types";
|
||||
|
||||
// breadcrumb.tsx transitively imports @/main.tsx (via usePageMetaQuery ->
|
||||
// queryClient), whose module body calls ReactDOM.createRoot on a null root in
|
||||
// jsdom. Stub it so importing the pure helper under test doesn't run that
|
||||
// (breadcrumbPathEqual does not use queryClient, so a dummy is enough).
|
||||
vi.mock("@/main.tsx", () => ({ queryClient: {} }));
|
||||
|
||||
import { breadcrumbPathEqual } from "./breadcrumb";
|
||||
|
||||
// breadcrumbPathEqual is the ONLY point where a false-positive equality would
|
||||
// leave a stale/incorrect breadcrumb trail on screen: it decides whether the
|
||||
// selectAtom hands back the same reference (no re-render) for the ancestor chain.
|
||||
// Pin both directions — a too-loose equality goes stale on a rename; a too-tight
|
||||
// one loses the perf win.
|
||||
const node = (over: Partial<SpaceTreeNode>): SpaceTreeNode =>
|
||||
({ id: "a", slugId: "sa", name: "A", icon: "📄", ...over }) as SpaceTreeNode;
|
||||
|
||||
describe("breadcrumbPathEqual", () => {
|
||||
it("both null → true", () => {
|
||||
expect(breadcrumbPathEqual(null, null)).toBe(true);
|
||||
});
|
||||
|
||||
it("same reference → true", () => {
|
||||
const p = [node({})];
|
||||
expect(breadcrumbPathEqual(p, p)).toBe(true);
|
||||
});
|
||||
|
||||
it("equal by id/slugId/name/icon (different arrays) → true", () => {
|
||||
expect(breadcrumbPathEqual([node({})], [node({})])).toBe(true);
|
||||
});
|
||||
|
||||
it("one side null → false", () => {
|
||||
expect(breadcrumbPathEqual([node({})], null)).toBe(false);
|
||||
expect(breadcrumbPathEqual(null, [node({})])).toBe(false);
|
||||
});
|
||||
|
||||
it("different length → false", () => {
|
||||
expect(
|
||||
breadcrumbPathEqual([node({})], [node({}), node({ id: "b" })]),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["name", "icon", "slugId", "id"] as const)(
|
||||
"a changed %s → false (breadcrumb must re-render)",
|
||||
(field) => {
|
||||
expect(
|
||||
breadcrumbPathEqual([node({})], [node({ [field]: "CHANGED" })]),
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -1,9 +1,7 @@
|
||||
import { useAtomValue } from "jotai";
|
||||
import { selectAtom } from "jotai/utils";
|
||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { computeBreadcrumbState } from "./breadcrumb.utils";
|
||||
import { findBreadcrumbPath } from "@/features/page/tree/utils";
|
||||
import {
|
||||
Button,
|
||||
Anchor,
|
||||
@@ -20,7 +18,7 @@ import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
import { IPage } from "@/features/page/types/page.types.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import {
|
||||
usePageMetaQuery,
|
||||
usePageQuery,
|
||||
usePageBreadcrumbsQuery,
|
||||
} from "@/features/page/queries/page-query.ts";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
@@ -34,84 +32,39 @@ function getTitle(name: string, icon: string) {
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Equality over a breadcrumb chain by the only fields the breadcrumb renders
|
||||
* (id, slugId, name, icon). Lets the selectAtom below hand back the SAME
|
||||
* reference when an unrelated tree mutation leaves THIS page's ancestor chain
|
||||
* visually unchanged, so the breadcrumb no longer re-renders on every tree
|
||||
* event (it previously subscribed to the whole treeDataAtom).
|
||||
*/
|
||||
export function breadcrumbPathEqual(
|
||||
a: SpaceTreeNode[] | null,
|
||||
b: SpaceTreeNode[] | null,
|
||||
): boolean {
|
||||
if (a === b) return true;
|
||||
if (!a || !b || a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (
|
||||
a[i].id !== b[i].id ||
|
||||
a[i].slugId !== b[i].slugId ||
|
||||
a[i].name !== b[i].name ||
|
||||
a[i].icon !== b[i].icon
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export default function Breadcrumb() {
|
||||
const { t } = useTranslation();
|
||||
const treeData = useAtomValue(treeDataAtom);
|
||||
const [breadcrumbNodes, setBreadcrumbNodes] = useState<
|
||||
SpaceTreeNode[] | null
|
||||
>(null);
|
||||
const { pageSlug, spaceSlug } = useParams();
|
||||
const { data: currentPage } = usePageMetaQuery({
|
||||
const { data: currentPage } = usePageQuery({
|
||||
pageId: extractPageSlugId(pageSlug),
|
||||
});
|
||||
const currentPageId = currentPage?.id;
|
||||
// The page's own ancestor chain, fetched independently of the lazily-built
|
||||
// sidebar tree so a deep page doesn't render a blank breadcrumb for seconds
|
||||
// while the tree backfills (#218).
|
||||
const { data: ancestors } = usePageBreadcrumbsQuery(currentPageId);
|
||||
const { data: ancestors } = usePageBreadcrumbsQuery(currentPage?.id);
|
||||
const isMobile = useMediaQuery("(max-width: 48em)");
|
||||
|
||||
// Narrowed subscription: instead of subscribing to the whole treeDataAtom and
|
||||
// recomputing on every tree event, derive ONLY the current page's ancestor
|
||||
// chain. The custom equality returns the previous reference when that chain is
|
||||
// visually unchanged, so an unrelated tree mutation no longer re-renders this
|
||||
// component. Mirrors computeBreadcrumbState's tree-hit branch
|
||||
// (findBreadcrumbPath); the tree-miss/ancestors fallback is applied below.
|
||||
const treePathAtom = useMemo(
|
||||
() =>
|
||||
selectAtom(
|
||||
treeDataAtom,
|
||||
(tree): SpaceTreeNode[] | null =>
|
||||
currentPageId ? findBreadcrumbPath(tree, currentPageId) : null,
|
||||
breadcrumbPathEqual,
|
||||
),
|
||||
[currentPageId],
|
||||
);
|
||||
const treePath = useAtomValue(treePathAtom);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentPage) return;
|
||||
|
||||
// Selection/mapping + stale-clearing live in a pure, unit-tested helper
|
||||
// (#218). The tree-hit chain (treePath) always wins when present; otherwise
|
||||
// fall back to the page's own ancestors and the stale-clearing logic — this
|
||||
// reproduces computeBreadcrumbState(fullTree, ancestors, …) exactly, since
|
||||
// its tree-hit branch is precisely findBreadcrumbPath(fullTree, pageId).
|
||||
// (#218). It resolves the correct chain when possible and, on a transient
|
||||
// miss, clears a chain left over from a previously-viewed page instead of
|
||||
// showing the wrong trail — while keeping a chain already resolved for THIS
|
||||
// page to avoid a blank flash.
|
||||
setBreadcrumbNodes((previous) =>
|
||||
treePath ??
|
||||
computeBreadcrumbState(
|
||||
null,
|
||||
treeData,
|
||||
ancestors as IPage[] | undefined,
|
||||
currentPage.id,
|
||||
previous,
|
||||
),
|
||||
);
|
||||
}, [currentPage?.id, treePath, ancestors]);
|
||||
}, [currentPage?.id, treeData, ancestors]);
|
||||
|
||||
const HiddenNodesTooltipContent = () =>
|
||||
breadcrumbNodes?.slice(1, -1).map((node) => (
|
||||
|
||||
@@ -24,7 +24,7 @@ import { historyAtoms } from "@/features/page-history/atoms/history-atoms.ts";
|
||||
import { useDisclosure, useHotkeys } from "@mantine/hooks";
|
||||
import { useClipboard } from "@/hooks/use-clipboard";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { usePageMetaQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import {
|
||||
useToggleTemporaryMutation,
|
||||
syncTemporaryExpiresInCache,
|
||||
@@ -67,7 +67,7 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
||||
const commentsTriggerProps = useAsideTriggerProps("comments");
|
||||
const tocTriggerProps = useAsideTriggerProps("toc");
|
||||
const { pageSlug } = useParams();
|
||||
const { data: page } = usePageMetaQuery({
|
||||
const { data: page } = usePageQuery({
|
||||
pageId: extractPageSlugId(pageSlug),
|
||||
});
|
||||
const isDeleted = !!page?.deletedAt;
|
||||
@@ -146,7 +146,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
||||
const [, setHistoryModalOpen] = useAtom(historyAtoms);
|
||||
const clipboard = useClipboard({ timeout: 500 });
|
||||
const { pageSlug, spaceSlug } = useParams();
|
||||
const { data: page, isLoading } = usePageMetaQuery({
|
||||
const { data: page, isLoading } = usePageQuery({
|
||||
pageId: extractPageSlugId(pageSlug),
|
||||
});
|
||||
const { handleDelete } = useTreeMutation(page?.spaceId ?? "");
|
||||
|
||||
@@ -10,7 +10,7 @@ import { IconClockHour4, IconTrash } from "@tabler/icons-react";
|
||||
import { useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
||||
import { usePageMetaQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
|
||||
import {
|
||||
useToggleTemporaryMutation,
|
||||
@@ -35,7 +35,7 @@ type TemporaryNoteBannerProps = {
|
||||
*/
|
||||
export function TemporaryNoteBanner({ slugId }: TemporaryNoteBannerProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: page } = usePageMetaQuery({ pageId: slugId });
|
||||
const { data: page } = usePageQuery({ pageId: slugId });
|
||||
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
||||
const spaceAbility = useSpaceAbility(space?.membership?.permissions);
|
||||
const expiresTimeAgo = useTimeAgo(page?.temporaryExpiresAt);
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import type { IPage } from "@/features/page/types/page.types";
|
||||
|
||||
// A fresh QueryClient stands in for the app singleton (importing the real
|
||||
// @/main.tsx would run ReactDOM.createRoot, which has no DOM root in jsdom). The
|
||||
// factory constructs it (QueryClient can't be referenced in vi.hoisted — that
|
||||
// runs before imports resolve); we import the SAME mocked instance back to seed
|
||||
// and assert on it.
|
||||
vi.mock("@/main.tsx", async () => {
|
||||
const { QueryClient } = await import("@tanstack/react-query");
|
||||
return { queryClient: new QueryClient() };
|
||||
});
|
||||
|
||||
import { queryClient as h_qc } from "@/main.tsx";
|
||||
import { invalidateOnUpdatePage } from "./page-query";
|
||||
|
||||
const h = { qc: h_qc };
|
||||
|
||||
// invalidateOnUpdatePage is the field-only (title/icon) tree path: instead of a
|
||||
// blanket invalidate it patches the affected node IN PLACE in every cached embed
|
||||
// subtree. The undefined-guard is LOAD-BEARING: a title-only socket event carries
|
||||
// icon:undefined, and without the guard `{...p, icon: undefined}` would WIPE the
|
||||
// icon in every cached subtree.
|
||||
const page = (over: Partial<IPage>): IPage =>
|
||||
({ id: "p1", title: "Old", icon: "📄", spaceId: "s1" }) as IPage &
|
||||
typeof over as IPage;
|
||||
|
||||
describe("invalidateOnUpdatePage — pointwise embed-cache patch", () => {
|
||||
beforeEach(() => {
|
||||
h.qc.clear();
|
||||
});
|
||||
|
||||
it("title-only event updates title but PRESERVES the icon (undefined-guard)", () => {
|
||||
const key = ["page-tree", "parent-1"];
|
||||
h.qc.setQueryData<IPage[]>(key, [
|
||||
{ id: "p1", title: "Old", icon: "📄", spaceId: "s1" } as IPage,
|
||||
{ id: "p2", title: "Other", icon: "📁", spaceId: "s1" } as IPage,
|
||||
]);
|
||||
|
||||
// icon passed as undefined (a title-only update)
|
||||
invalidateOnUpdatePage(
|
||||
"s1",
|
||||
"parent-1",
|
||||
"p1",
|
||||
"New Title",
|
||||
undefined as unknown as string,
|
||||
);
|
||||
|
||||
const patched = h.qc.getQueryData<IPage[]>(key)!;
|
||||
const p1 = patched.find((p) => p.id === "p1")!;
|
||||
const p2 = patched.find((p) => p.id === "p2")!;
|
||||
expect(p1.title).toBe("New Title");
|
||||
expect(p1.icon).toBe("📄"); // preserved, not wiped
|
||||
// Sibling node untouched.
|
||||
expect(p2.title).toBe("Other");
|
||||
expect(p2.icon).toBe("📁");
|
||||
});
|
||||
|
||||
it("icon-only event updates icon but preserves the title", () => {
|
||||
const key = ["page-tree", "parent-1"];
|
||||
h.qc.setQueryData<IPage[]>(key, [
|
||||
{ id: "p1", title: "Keep", icon: "📄", spaceId: "s1" } as IPage,
|
||||
]);
|
||||
|
||||
invalidateOnUpdatePage(
|
||||
"s1",
|
||||
"parent-1",
|
||||
"p1",
|
||||
undefined as unknown as string,
|
||||
"🚀",
|
||||
);
|
||||
|
||||
const p1 = h.qc.getQueryData<IPage[]>(key)!.find((p) => p.id === "p1")!;
|
||||
expect(p1.icon).toBe("🚀");
|
||||
expect(p1.title).toBe("Keep");
|
||||
});
|
||||
|
||||
// The sidebar-pages cache (InfiniteData) is patched on the same event. It must
|
||||
// carry the SAME undefined-guard as the embed path above — otherwise a
|
||||
// title-only event's icon:undefined would wipe the sidebar entry's icon.
|
||||
const sidebarKey = ["sidebar-pages", { pageId: "parent-1", spaceId: "s1" }];
|
||||
const seedSidebar = () =>
|
||||
h.qc.setQueryData(sidebarKey, {
|
||||
pageParams: [undefined],
|
||||
pages: [
|
||||
{
|
||||
items: [
|
||||
{ id: "p1", title: "Old", icon: "📄", spaceId: "s1" } as IPage,
|
||||
{ id: "p2", title: "Other", icon: "📁", spaceId: "s1" } as IPage,
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const sidebarItem = (id: string) => {
|
||||
const data = h.qc.getQueryData(sidebarKey) as {
|
||||
pages: { items: IPage[] }[];
|
||||
};
|
||||
return data.pages[0].items.find((p) => p.id === id)!;
|
||||
};
|
||||
|
||||
it("sidebar cache: title-only event updates title but PRESERVES the icon", () => {
|
||||
seedSidebar();
|
||||
|
||||
invalidateOnUpdatePage(
|
||||
"s1",
|
||||
"parent-1",
|
||||
"p1",
|
||||
"New Title",
|
||||
undefined as unknown as string,
|
||||
);
|
||||
|
||||
const p1 = sidebarItem("p1");
|
||||
expect(p1.title).toBe("New Title");
|
||||
expect(p1.icon).toBe("📄"); // preserved, not wiped
|
||||
// Sibling untouched.
|
||||
const p2 = sidebarItem("p2");
|
||||
expect(p2.title).toBe("Other");
|
||||
expect(p2.icon).toBe("📁");
|
||||
});
|
||||
|
||||
it("sidebar cache: icon-only event updates icon but PRESERVES the title", () => {
|
||||
seedSidebar();
|
||||
|
||||
invalidateOnUpdatePage(
|
||||
"s1",
|
||||
"parent-1",
|
||||
"p1",
|
||||
undefined as unknown as string,
|
||||
"🚀",
|
||||
);
|
||||
|
||||
const p1 = sidebarItem("p1");
|
||||
expect(p1.icon).toBe("🚀");
|
||||
expect(p1.title).toBe("Old"); // preserved, not wiped
|
||||
});
|
||||
|
||||
it("does not touch a subtree that lacks the updated node", () => {
|
||||
const otherKey = ["page-tree", "unrelated"];
|
||||
const before = [
|
||||
{ id: "x1", title: "X", icon: "❌", spaceId: "s1" } as IPage,
|
||||
];
|
||||
h.qc.setQueryData<IPage[]>(otherKey, before);
|
||||
|
||||
invalidateOnUpdatePage("s1", "parent-1", "p1", "New", "🚀");
|
||||
|
||||
// Same reference back — the subtree without p1 is left as-is.
|
||||
expect(h.qc.getQueryData<IPage[]>(otherKey)).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -51,10 +51,6 @@ export function usePageQuery(
|
||||
queryFn: () => getPageById(pageInput),
|
||||
enabled: !!pageInput.pageId,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
// Keep the previously-loaded page visible while navigating to a new one
|
||||
// instead of flashing a blank/skeleton frame (the new page's content
|
||||
// streams in when ready). isLoading stays true only for the very first load.
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -70,61 +66,6 @@ export function usePageQuery(
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
* A page view that omits the large, frequently-changing `content` field. Every
|
||||
* other field is preserved, so consumers that read only metadata (title, icon,
|
||||
* permissions, id, creator, timestamps, …) keep working unchanged.
|
||||
*/
|
||||
export type IPageMeta = Omit<IPage, "content">;
|
||||
|
||||
function selectPageMeta(page: IPage): IPageMeta {
|
||||
// Drop `content`; react-query's structural sharing (replaceEqualDeep) then
|
||||
// returns the SAME reference whenever the remaining fields are unchanged, so a
|
||||
// pure content churn (typing / debouncedUpdateContent, collab `page.updated`)
|
||||
// no longer changes this slice's identity and its ~13 subscribers don't
|
||||
// re-render on every keystroke wave.
|
||||
const { content: _content, ...meta } = page;
|
||||
return meta as IPageMeta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata-only variant of {@link usePageQuery}. Shares the SAME query cache
|
||||
* entry (`["pages", pageId]`, full object incl. content), but this hook returns
|
||||
* a stable content-less slice so peripheral subscribers stop re-rendering on
|
||||
* every content update. Use it anywhere the full `content` is not read.
|
||||
*/
|
||||
export function usePageMetaQuery(
|
||||
pageInput: Partial<IPageInput>,
|
||||
): UseQueryResult<IPageMeta, Error> {
|
||||
const query = useQuery({
|
||||
queryKey: ["pages", pageInput.pageId],
|
||||
queryFn: () => getPageById(pageInput),
|
||||
enabled: !!pageInput.pageId,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
select: selectPageMeta,
|
||||
// Match usePageQuery: keep the previous page's metadata visible while
|
||||
// navigating so the periphery (header, breadcrumb, …) doesn't flash blank.
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
// Mirror usePageQuery's cross-key alias write so a page fetched by one
|
||||
// identifier is also cached under the other. The cache stores the FULL page
|
||||
// (select only narrows what THIS hook returns), so read the full object back
|
||||
// from the cache and alias THAT — never the content-less slice.
|
||||
useEffect(() => {
|
||||
if (!query.data) return;
|
||||
const full = queryClient.getQueryData<IPage>(["pages", pageInput.pageId]);
|
||||
if (!full) return;
|
||||
if (isValidUuid(pageInput.pageId)) {
|
||||
queryClient.setQueryData(["pages", full.slugId], full);
|
||||
} else {
|
||||
queryClient.setQueryData(["pages", full.id], full);
|
||||
}
|
||||
}, [query.data]);
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
export function useCreatePageMutation() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation<IPage, Error, Partial<IPageInput>>({
|
||||
@@ -410,12 +351,6 @@ export function useRecentChangesQuery(spaceId?: string) {
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,
|
||||
// KEEP refetchOnMount:true (against the global default false): recent-changes
|
||||
// IS invalidated on page create/update/move/delete, but invalidateQueries only
|
||||
// marks an UNMOUNTED query stale — it doesn't refetch it. The widget isn't
|
||||
// always mounted, so an event that lands while it's unmounted leaves it stale,
|
||||
// and the global refetchOnMount:false would not re-fetch on remount. The mount
|
||||
// refetch closes that gap.
|
||||
refetchOnMount: true,
|
||||
});
|
||||
}
|
||||
@@ -432,9 +367,6 @@ export function useCreatedByQuery(params?: {
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,
|
||||
// KEEP refetchOnMount:true: the "created-by" key is never invalidated (no
|
||||
// socket/mutation path), so the mount refetch is its ONLY freshness mechanism
|
||||
// — without it the list shows stale cache on navigation.
|
||||
refetchOnMount: true,
|
||||
});
|
||||
}
|
||||
@@ -448,14 +380,8 @@ export function useDeletedPagesQuery(
|
||||
queryFn: () => getDeletedPages(spaceId, params),
|
||||
enabled: !!spaceId,
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 0,
|
||||
// KEEP refetchOnMount:true: ["trash-list"] IS invalidated by the
|
||||
// move-to-trash / delete / restore mutations, but invalidateQueries only marks
|
||||
// an unmounted query stale — it doesn't refetch it. The trash panel isn't
|
||||
// usually mounted when a page is trashed, so on opening it the global
|
||||
// refetchOnMount:false would show a stale list; the mount refetch closes that.
|
||||
// (Do NOT remove the three trash-list invalidations — they are not dead code.)
|
||||
refetchOnMount: true,
|
||||
staleTime: 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -590,35 +516,7 @@ export function invalidateOnUpdatePage(
|
||||
title: string,
|
||||
icon: string,
|
||||
) {
|
||||
// Scoped page-tree refresh (was a blanket `invalidatePageTree()`): this is the
|
||||
// FIELD-only update path (title/icon — no structural change), and the sidebar
|
||||
// tree is already updated pointwise (applyUpdateOne / optimistic setData) plus
|
||||
// via the sidebar-pages cache below. Invalidating ALL ["page-tree"] queries
|
||||
// here refetched every open recursive subpages-embed block on each
|
||||
// rename/icon-change — pure duplicate work. Instead patch just the affected
|
||||
// node IN PLACE in every cached embed subtree: same visible result, no network
|
||||
// churn, no full embed-tree rebuild. Structural events (create/move/delete)
|
||||
// keep the blanket invalidate in their own helpers.
|
||||
const pageTreeMatches = queryClient.getQueriesData<IPage[]>({
|
||||
queryKey: ["page-tree"],
|
||||
});
|
||||
pageTreeMatches.forEach(([key, items]) => {
|
||||
if (!items || !items.some((p) => p.id === id)) return;
|
||||
queryClient.setQueryData<IPage[]>(key, (old) =>
|
||||
old?.map((p) =>
|
||||
p.id === id
|
||||
? {
|
||||
...p,
|
||||
// Guard undefined so a title-only event can't wipe the icon (and
|
||||
// vice versa) in the embed cache.
|
||||
...(title !== undefined ? { title } : {}),
|
||||
...(icon !== undefined ? { icon } : {}),
|
||||
}
|
||||
: p,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
invalidatePageTree();
|
||||
let queryKey: QueryKey = null;
|
||||
if (parentPageId === null) {
|
||||
queryKey = ["root-sidebar-pages", spaceId];
|
||||
@@ -636,14 +534,7 @@ export function invalidateOnUpdatePage(
|
||||
...page,
|
||||
items: page.items.map((sidebarPage: IPage) =>
|
||||
sidebarPage.id === id
|
||||
? {
|
||||
...sidebarPage,
|
||||
// Guard undefined so a title-only event can't wipe the icon
|
||||
// (and vice versa) in the sidebar-pages cache — mirrors the
|
||||
// embed-cache patch above.
|
||||
...(title !== undefined ? { title } : {}),
|
||||
...(icon !== undefined ? { icon } : {}),
|
||||
}
|
||||
? { ...sidebarPage, title: title, icon: icon }
|
||||
: sidebarPage,
|
||||
),
|
||||
})),
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useRestorePageModal } from "@/features/page/hooks/use-restore-page-moda
|
||||
import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx";
|
||||
import {
|
||||
useDeletePageMutation,
|
||||
usePageMetaQuery,
|
||||
usePageQuery,
|
||||
useRestorePageMutation,
|
||||
} from "@/features/page/queries/page-query.ts";
|
||||
import { getSpaceUrl } from "@/lib/config.ts";
|
||||
@@ -25,7 +25,7 @@ type DeletedPageBannerProps = {
|
||||
export function DeletedPageBanner({ slugId }: DeletedPageBannerProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { data: page } = usePageMetaQuery({ pageId: slugId });
|
||||
const { data: page } = usePageQuery({ pageId: slugId });
|
||||
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
||||
const spaceAbility = useSpaceAbility(space?.membership?.permissions);
|
||||
const deletedTimeAgo = useTimeAgo(page?.deletedAt);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useSetAtom, useStore } from "jotai";
|
||||
import { useAtom } from "jotai";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { ActionIcon, Menu, rem } from "@mantine/core";
|
||||
@@ -52,11 +52,7 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
|
||||
const clipboard = useClipboard({ timeout: 500 });
|
||||
const { spaceSlug } = useParams();
|
||||
const { handleDelete } = useTreeMutation(node.spaceId);
|
||||
// Setter-only: the tree value is read only imperatively inside the duplicate
|
||||
// handler (via `store` below), never at render, so useSetAtom avoids
|
||||
// re-rendering every row's NodeMenu on any tree event.
|
||||
const setData = useSetAtom(treeDataAtom);
|
||||
const store = useStore();
|
||||
const [data, setData] = useAtom(treeDataAtom);
|
||||
const emit = useQueryEmit();
|
||||
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
||||
useDisclosure(false);
|
||||
@@ -129,8 +125,8 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
|
||||
try {
|
||||
const duplicatedPage = await duplicatePage({ pageId: node.id });
|
||||
|
||||
// figure out parent + insertion index (read the live tree imperatively)
|
||||
const siblings = treeModel.siblingsOf(store.get(treeDataAtom), node.id);
|
||||
// figure out parent + insertion index
|
||||
const siblings = treeModel.siblingsOf(data, node.id);
|
||||
const parentId = siblings?.parentId ?? null;
|
||||
const currentIndex = siblings?.index ?? 0;
|
||||
const newIndex = currentIndex + 1;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useRef } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import { useAtom } from "jotai";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActionIcon, rem, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
@@ -51,11 +51,7 @@ export function SpaceTreeRow({
|
||||
const { t } = useTranslation();
|
||||
const { spaceSlug } = useParams();
|
||||
const updatePageMutation = useUpdatePageMutation();
|
||||
// Setter-only: subscribing to the whole treeDataAtom (via useAtom) re-rendered
|
||||
// every virtualized row on any tree event, bypassing the DocTreeRow memo. This
|
||||
// row never reads the tree value, only writes it, so useSetAtom avoids the
|
||||
// value subscription.
|
||||
const setTreeData = useSetAtom(treeDataAtom);
|
||||
const [, setTreeData] = useAtom(treeDataAtom);
|
||||
const emit = useQueryEmit();
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
|
||||
|
||||
@@ -35,7 +35,6 @@ vi.mock("@/features/page/queries/page-query.ts", () => ({
|
||||
isFetching: false,
|
||||
}),
|
||||
usePageQuery: () => ({ data: undefined }),
|
||||
usePageMetaQuery: () => ({ data: undefined }),
|
||||
fetchAllAncestorChildren: (...args: unknown[]) =>
|
||||
fetchAllAncestorChildrenMock(...args),
|
||||
}));
|
||||
|
||||
@@ -26,7 +26,6 @@ vi.mock("@/features/page/queries/page-query.ts", () => ({
|
||||
isFetching: false,
|
||||
}),
|
||||
usePageQuery: () => ({ data: undefined }),
|
||||
usePageMetaQuery: () => ({ data: undefined }),
|
||||
fetchAllAncestorChildren: vi.fn(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { notifications } from "@mantine/notifications";
|
||||
import {
|
||||
fetchAllAncestorChildren,
|
||||
useGetRootSidebarPagesQuery,
|
||||
usePageMetaQuery,
|
||||
usePageQuery,
|
||||
} from "@/features/page/queries/page-query.ts";
|
||||
import classes from "@/features/page/tree/styles/tree.module.css";
|
||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||
@@ -76,7 +76,7 @@ const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(function SpaceTree(
|
||||
const [isDataLoaded, setIsDataLoaded] = useState(false);
|
||||
const spaceIdRef = useRef(spaceId);
|
||||
spaceIdRef.current = spaceId;
|
||||
const { data: currentPage } = usePageMetaQuery({
|
||||
const { data: currentPage } = usePageQuery({
|
||||
pageId: extractPageSlugId(pageSlug),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback } from "react";
|
||||
import { useSetAtom, useStore } from "jotai";
|
||||
import { useAtom, useSetAtom, useStore } from "jotai";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
@@ -34,10 +34,7 @@ export type UseTreeMutation = {
|
||||
|
||||
export function useTreeMutation(spaceId: string): UseTreeMutation {
|
||||
const { t } = useTranslation();
|
||||
// Setter-only: this hook never reads the tree reactively (handlers read the
|
||||
// live value imperatively via `store` below), so useSetAtom avoids
|
||||
// re-rendering SpaceSidebar on every tree event.
|
||||
const setData = useSetAtom(treeDataAtom);
|
||||
const [, setData] = useAtom(treeDataAtom);
|
||||
// `store` reads the *current* treeDataAtom imperatively in handlers — avoids
|
||||
// stale-closure issues when the caller updates the tree (e.g. lazy-load
|
||||
// children) and then immediately invokes a handler.
|
||||
|
||||
@@ -28,7 +28,6 @@ vi.mock("@/features/share/queries/share-query.ts", () => ({
|
||||
|
||||
vi.mock("@/features/page/queries/page-query.ts", () => ({
|
||||
usePageQuery: () => ({ data: { id: "page-1", title: "Doc" } }),
|
||||
usePageMetaQuery: () => ({ data: { id: "page-1", title: "Doc" } }),
|
||||
}));
|
||||
|
||||
vi.mock("@/features/space/queries/space-query.ts", () => ({
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { extractPageSlugId, getPageIcon } from "@/lib";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePageMetaQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import CopyTextButton from "@/components/common/copy.tsx";
|
||||
import { getAppUrl } from "@/lib/config.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
@@ -37,7 +37,7 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { pageSlug } = useParams();
|
||||
const pageSlugId = extractPageSlugId(pageSlug);
|
||||
const { data: page } = usePageMetaQuery({ pageId: pageSlugId });
|
||||
const { data: page } = usePageQuery({ pageId: pageSlugId });
|
||||
const pageId = page?.id;
|
||||
const { data: share } = useShareForPageQuery(pageId);
|
||||
const { spaceSlug } = useParams();
|
||||
|
||||
@@ -38,11 +38,6 @@ export function useGetSpacesQuery(
|
||||
queryKey: ["spaces", params],
|
||||
queryFn: () => getSpaces(params),
|
||||
placeholderData: keepPreviousData,
|
||||
// KEEP refetchOnMount:true (against the global default false): the ["spaces"]
|
||||
// key is invalidated only by same-tab mutations (no socket path), so a
|
||||
// cross-actor change — an admin adding/removing THIS user from a space — has
|
||||
// no local mutation or socket event and would leave the space list stale until
|
||||
// a hard reload. The mount refetch is its only cross-actor freshness path.
|
||||
refetchOnMount: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ export function useWatchedSpaceIds(): Set<string> {
|
||||
const { data } = useQuery({
|
||||
queryKey: [WATCHED_SPACE_IDS_KEY],
|
||||
queryFn: () => getWatchedSpaceIds(),
|
||||
refetchOnMount: true,
|
||||
});
|
||||
|
||||
const items = data?.items;
|
||||
|
||||
@@ -19,11 +19,7 @@ export const useQuerySubscription = () => {
|
||||
const [socket] = useAtom(socketAtom);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!socket) return;
|
||||
// Named handler + off() cleanup (mirrors use-notification-socket). Without
|
||||
// cleanup, every socket recreation / effect re-run stacked another listener,
|
||||
// so a single broadcast fired duplicated invalidateQueries / setQueryData.
|
||||
const handleMessage = (event) => {
|
||||
socket?.on("message", (event) => {
|
||||
const data: WebSocketEvent = event;
|
||||
|
||||
let entity = null;
|
||||
@@ -167,11 +163,6 @@ export const useQuerySubscription = () => {
|
||||
});
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
socket.on("message", handleMessage);
|
||||
return () => {
|
||||
socket.off("message", handleMessage);
|
||||
};
|
||||
});
|
||||
}, [queryClient, socket]);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect } from "react";
|
||||
import { socketAtom } from "@/features/websocket/atoms/socket-atom.ts";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import { useAtom } from "jotai";
|
||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||
import { WebSocketEvent } from "@/features/websocket/types";
|
||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||
@@ -16,10 +16,7 @@ import localEmitter from "@/lib/local-emitter.ts";
|
||||
|
||||
export const useTreeSocket = () => {
|
||||
const [socket] = useAtom(socketAtom);
|
||||
// Setter-only: this hook writes the tree from socket events but never reads it
|
||||
// reactively, so useSetAtom avoids re-rendering UserProvider (its host) on
|
||||
// every tree event.
|
||||
const setTreeData = useSetAtom(treeDataAtom);
|
||||
const [, setTreeData] = useAtom(treeDataAtom);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -40,11 +37,7 @@ export const useTreeSocket = () => {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
// Named handler + off() cleanup (mirrors use-notification-socket). Without
|
||||
// cleanup, every socket recreation / effect re-run stacked another listener,
|
||||
// so a single broadcast fired duplicated tree walks after each reconnect.
|
||||
const handleMessage = (event: WebSocketEvent) => {
|
||||
socket?.on("message", (event: WebSocketEvent) => {
|
||||
switch (event.operation) {
|
||||
case "updateOne":
|
||||
if (event.entity[0] === "pages") {
|
||||
@@ -71,11 +64,6 @@ export const useTreeSocket = () => {
|
||||
});
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
socket.on("message", handleMessage);
|
||||
return () => {
|
||||
socket.off("message", handleMessage);
|
||||
};
|
||||
}, [socket, queryClient, setTreeData]);
|
||||
});
|
||||
}, [socket]);
|
||||
};
|
||||
|
||||
@@ -243,5 +243,6 @@ export function useAppVersion(
|
||||
queryFn: () => getAppVersion(),
|
||||
staleTime: 60 * 60 * 1000, // 1 hr
|
||||
enabled: isEnabled,
|
||||
refetchOnMount: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useEffect } from "react";
|
||||
import { usePageMetaQuery } from "@/features/page/queries/page-query";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import { Error404 } from "@/components/ui/error-404.tsx";
|
||||
@@ -11,7 +11,7 @@ export default function PageRedirect() {
|
||||
data: page,
|
||||
isLoading: pageIsLoading,
|
||||
isError,
|
||||
} = usePageMetaQuery({ pageId: extractPageSlugId(pageSlug) });
|
||||
} = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useTranslation } from "react-i18next";
|
||||
import React from "react";
|
||||
import { EmptyState } from "@/components/ui/empty-state.tsx";
|
||||
import { IconAlertTriangle, IconFileOff } from "@tabler/icons-react";
|
||||
import { Button, Skeleton } from "@mantine/core";
|
||||
import { Button } from "@mantine/core";
|
||||
import { Link } from "react-router-dom";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
const MemoizedFullEditor = React.memo(FullEditor);
|
||||
@@ -58,7 +58,7 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
|
||||
(space?.settings?.comments?.allowViewerComments === true);
|
||||
|
||||
if (isLoading) {
|
||||
return <PageSkeleton />;
|
||||
return <></>;
|
||||
}
|
||||
|
||||
if (isError || !page) {
|
||||
@@ -87,7 +87,7 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
|
||||
}
|
||||
|
||||
if (!space) {
|
||||
return <PageSkeleton />;
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -116,18 +116,3 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Lightweight loading placeholder shown instead of a blank fragment while the
|
||||
// page (or its space) is loading, so navigation into a not-yet-cached page no
|
||||
// longer flashes empty. Approximates the title + first content lines.
|
||||
function PageSkeleton() {
|
||||
return (
|
||||
<div>
|
||||
<Skeleton height={34} width="45%" mt="xl" radius="sm" />
|
||||
<Skeleton height={16} mt="xl" radius="sm" />
|
||||
<Skeleton height={16} mt="sm" radius="sm" />
|
||||
<Skeleton height={16} mt="sm" width="85%" radius="sm" />
|
||||
<Skeleton height={16} mt="sm" width="70%" radius="sm" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -729,6 +729,35 @@ export class AiChatToolsService {
|
||||
}),
|
||||
),
|
||||
|
||||
// Schema + description live in @docmost/mcp's SHARED_TOOL_SPECS (#423).
|
||||
// meta.hash in the result is the baseHash drawioUpdate requires.
|
||||
drawioGet: sharedTool(
|
||||
sharedToolSpecs.drawioGet,
|
||||
async ({ pageId, node, format }) =>
|
||||
await client.drawioGet(pageId, node, format ?? 'xml'),
|
||||
),
|
||||
|
||||
// Schema + description live in @docmost/mcp's SHARED_TOOL_SPECS (#423).
|
||||
// The flat schema fields are regrouped into the client's `where` object.
|
||||
drawioCreate: sharedTool(
|
||||
sharedToolSpecs.drawioCreate,
|
||||
async ({ pageId, xml, position, anchorNodeId, anchorText, title }) =>
|
||||
await client.drawioCreate(
|
||||
pageId,
|
||||
{ position, anchorNodeId, anchorText },
|
||||
xml,
|
||||
title,
|
||||
),
|
||||
),
|
||||
|
||||
// Schema + description live in @docmost/mcp's SHARED_TOOL_SPECS (#423).
|
||||
// baseHash is the optimistic lock: mismatch => structured conflict error.
|
||||
drawioUpdate: sharedTool(
|
||||
sharedToolSpecs.drawioUpdate,
|
||||
async ({ pageId, node, xml, baseHash }) =>
|
||||
await client.drawioUpdate(pageId, node, xml, baseHash),
|
||||
),
|
||||
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||
// The table reference parameter was unified to `table` (was `tableRef`).
|
||||
tableInsertRow: sharedTool(
|
||||
|
||||
@@ -168,6 +168,32 @@ export interface DocmostClientLike {
|
||||
url: string,
|
||||
opts?: { align?: 'left' | 'center' | 'right'; alt?: string },
|
||||
): Promise<Record<string, unknown>>;
|
||||
// --- draw.io diagrams (#423, stage 1) ---
|
||||
// Read a diagram as decoded mxGraph XML (default) or the raw .drawio.svg.
|
||||
// meta.hash is the optimistic-lock key drawioUpdate expects as baseHash.
|
||||
drawioGet(
|
||||
pageId: string,
|
||||
node: string,
|
||||
format?: 'xml' | 'svg',
|
||||
): Promise<Record<string, unknown>>;
|
||||
// Lint mxGraph XML, build the .drawio.svg attachment and insert a drawio node.
|
||||
drawioCreate(
|
||||
pageId: string,
|
||||
where: {
|
||||
position: 'before' | 'after' | 'append';
|
||||
anchorNodeId?: string;
|
||||
anchorText?: string;
|
||||
},
|
||||
xml: string,
|
||||
title?: string,
|
||||
): Promise<Record<string, unknown>>;
|
||||
// Optimistic-locked full replacement of a diagram (baseHash from drawioGet).
|
||||
drawioUpdate(
|
||||
pageId: string,
|
||||
node: string,
|
||||
xml: string,
|
||||
baseHash: string,
|
||||
): Promise<Record<string, unknown>>;
|
||||
tableInsertRow(
|
||||
pageId: string,
|
||||
tableRef: string,
|
||||
|
||||
@@ -5,6 +5,16 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { CommentService } from './comment.service';
|
||||
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
|
||||
import { QueueJob } from '../../integrations/queue/constants';
|
||||
|
||||
// #399: the resolve/unresolve flip and the ephemeral anchor removal are enqueued
|
||||
// as COMMENT_MARK_UPDATE jobs (off the HTTP path), NOT awaited against the collab
|
||||
// gateway. applyCommentSuggestion (the document TEXT edit) is untouched — it
|
||||
// still runs synchronously via the gateway.
|
||||
const markJob = (generalQueue: any, action: string) =>
|
||||
generalQueue.add.mock.calls.find(
|
||||
(c: any[]) => c[0] === QueueJob.COMMENT_MARK_UPDATE && c[1]?.action === action,
|
||||
);
|
||||
|
||||
/**
|
||||
* Focused coverage for CommentService.applySuggestion (comment.service.ts).
|
||||
@@ -59,6 +69,7 @@ describe('CommentService — applySuggestion', () => {
|
||||
commentRepo,
|
||||
wsService,
|
||||
collaborationGateway,
|
||||
generalQueue,
|
||||
auditService,
|
||||
};
|
||||
}
|
||||
@@ -86,9 +97,15 @@ describe('CommentService — applySuggestion', () => {
|
||||
|
||||
// --- no replies → ephemeral delete branch -------------------------------
|
||||
|
||||
it('applied=true, no replies → replaces text, hard-deletes, strips the anchor mark, audits APPLIED, outcome=deleted', async () => {
|
||||
const { service, commentRepo, wsService, collaborationGateway, auditService } =
|
||||
makeService({ applied: true, currentText: 'new text' });
|
||||
it('applied=true, no replies → replaces text, hard-deletes, enqueues the anchor-mark removal, audits APPLIED, outcome=deleted', async () => {
|
||||
const {
|
||||
service,
|
||||
commentRepo,
|
||||
wsService,
|
||||
collaborationGateway,
|
||||
generalQueue,
|
||||
auditService,
|
||||
} = makeService({ applied: true, currentText: 'new text' });
|
||||
|
||||
const result = await service.applySuggestion(suggestionComment(), user());
|
||||
|
||||
@@ -105,12 +122,20 @@ describe('CommentService — applySuggestion', () => {
|
||||
);
|
||||
|
||||
// Ephemeral: the redundant comment is hard-deleted (atomic-conditional) and
|
||||
// its inline anchor mark removed via the deleteCommentMark collab event.
|
||||
// its inline anchor mark removal is ENQUEUED (#399), no longer a sync gateway
|
||||
// call. The gateway was only touched for the applyCommentSuggestion text edit.
|
||||
expect(commentRepo.deleteCommentIfChildless).toHaveBeenCalledWith('c-1');
|
||||
expect(collaborationGateway.handleYjsEvent).toHaveBeenCalledWith(
|
||||
const del = markJob(generalQueue, 'delete');
|
||||
expect(del).toBeDefined();
|
||||
expect(del[1]).toMatchObject({
|
||||
documentName: 'page.page-1',
|
||||
commentId: 'c-1',
|
||||
userId: 'user-1',
|
||||
});
|
||||
expect(collaborationGateway.handleYjsEvent).not.toHaveBeenCalledWith(
|
||||
'deleteCommentMark',
|
||||
'page.page-1',
|
||||
expect.objectContaining({ commentId: 'c-1', user: expect.any(Object) }),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
// No applied stamps are written for a row about to be deleted.
|
||||
expect(appliedPatch(commentRepo)).toBeUndefined();
|
||||
@@ -258,7 +283,7 @@ describe('CommentService — applySuggestion', () => {
|
||||
// The suggested text is already applied to the document, but between the
|
||||
// hasChildren read and the atomic delete a reply landed. The parent must NOT
|
||||
// be hard-deleted (cascade would destroy the reply); resolve the thread.
|
||||
const { service, commentRepo, wsService, collaborationGateway } =
|
||||
const { service, commentRepo, wsService, generalQueue } =
|
||||
makeService({ applied: true, currentText: 'new text' }, false, 0);
|
||||
|
||||
const result = await service.applySuggestion(suggestionComment(), user());
|
||||
@@ -275,11 +300,8 @@ describe('CommentService — applySuggestion', () => {
|
||||
.map((c: any[]) => c[0])
|
||||
.find((p: any) => 'resolvedAt' in p);
|
||||
expect(resolvePatch.resolvedAt).toBeInstanceOf(Date);
|
||||
expect(collaborationGateway.handleYjsEvent).toHaveBeenCalledWith(
|
||||
'resolveCommentMark',
|
||||
'page.page-1',
|
||||
expect.objectContaining({ commentId: 'c-1', resolved: true }),
|
||||
);
|
||||
// The resolve mark is enqueued (#399), not a sync gateway call.
|
||||
expect(markJob(generalQueue, 'resolve')).toBeDefined();
|
||||
expect(result.outcome).toBe('resolved');
|
||||
});
|
||||
|
||||
|
||||
@@ -313,11 +313,15 @@ describe('CommentService — behavior', () => {
|
||||
});
|
||||
|
||||
const [patch] = commentRepo.updateComment.mock.calls[0];
|
||||
expect(patch).toEqual({
|
||||
// #399: resolve/unresolve now also stamps updatedAt (the async mark
|
||||
// worker's race-guard reads it to order out-of-order events). The
|
||||
// resolve-state fields are still cleared to null on unresolve.
|
||||
expect(patch).toMatchObject({
|
||||
resolvedAt: null,
|
||||
resolvedById: null,
|
||||
resolvedSource: null,
|
||||
});
|
||||
expect(patch.updatedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("notifies the author when SOMEONE ELSE resolves their comment", async () => {
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { CommentService } from './comment.service';
|
||||
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
|
||||
import { QueueJob } from '../../integrations/queue/constants';
|
||||
|
||||
// #399: the inline comment-mark op (resolve flip / ephemeral-suggestion anchor
|
||||
// removal) is now enqueued as a COMMENT_MARK_UPDATE job instead of being awaited
|
||||
// against the collab gateway on the HTTP path. Find that job by action.
|
||||
const markJob = (generalQueue: any, action: string) =>
|
||||
generalQueue.add.mock.calls.find(
|
||||
(c: any[]) => c[0] === QueueJob.COMMENT_MARK_UPDATE && c[1]?.action === action,
|
||||
);
|
||||
|
||||
/**
|
||||
* Coverage for CommentService.dismissSuggestion (#329). Dismiss ("Не применять")
|
||||
@@ -44,7 +53,14 @@ describe('CommentService — dismissSuggestion', () => {
|
||||
auditService,
|
||||
);
|
||||
|
||||
return { service, commentRepo, wsService, collaborationGateway, auditService };
|
||||
return {
|
||||
service,
|
||||
commentRepo,
|
||||
wsService,
|
||||
collaborationGateway,
|
||||
generalQueue,
|
||||
auditService,
|
||||
};
|
||||
}
|
||||
|
||||
const suggestionComment = (over?: Partial<any>): any => ({
|
||||
@@ -62,25 +78,30 @@ describe('CommentService — dismissSuggestion', () => {
|
||||
});
|
||||
const user = (over?: Partial<any>): any => ({ id: 'user-1', ...over });
|
||||
|
||||
it('no replies → hard-deletes, strips the anchor mark, does NOT touch page text, audits DISMISSED, outcome=deleted', async () => {
|
||||
const { service, commentRepo, wsService, collaborationGateway, auditService } =
|
||||
makeService(false);
|
||||
it('no replies → hard-deletes, enqueues the anchor-mark removal, does NOT touch page text, audits DISMISSED, outcome=deleted', async () => {
|
||||
const {
|
||||
service,
|
||||
commentRepo,
|
||||
wsService,
|
||||
collaborationGateway,
|
||||
generalQueue,
|
||||
auditService,
|
||||
} = makeService(false);
|
||||
|
||||
const result = await service.dismissSuggestion(suggestionComment(), user());
|
||||
|
||||
// Never applies the suggestion to the document.
|
||||
expect(collaborationGateway.handleYjsEvent).not.toHaveBeenCalledWith(
|
||||
'applyCommentSuggestion',
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
// Hard-delete (atomic-conditional) + strip mark.
|
||||
// Never applies the suggestion to the document (no sync gateway call at all
|
||||
// now — the mark op is off the HTTP path, #399).
|
||||
expect(collaborationGateway.handleYjsEvent).not.toHaveBeenCalled();
|
||||
// Hard-delete (atomic-conditional) + enqueue the anchor-mark strip.
|
||||
expect(commentRepo.deleteCommentIfChildless).toHaveBeenCalledWith('c-1');
|
||||
expect(collaborationGateway.handleYjsEvent).toHaveBeenCalledWith(
|
||||
'deleteCommentMark',
|
||||
'page.page-1',
|
||||
expect.objectContaining({ commentId: 'c-1', user: expect.any(Object) }),
|
||||
);
|
||||
const del = markJob(generalQueue, 'delete');
|
||||
expect(del).toBeDefined();
|
||||
expect(del[1]).toMatchObject({
|
||||
documentName: 'page.page-1',
|
||||
commentId: 'c-1',
|
||||
userId: 'user-1',
|
||||
});
|
||||
expect(wsService.emitCommentEvent).toHaveBeenCalledWith(
|
||||
'space-1',
|
||||
'page-1',
|
||||
@@ -96,20 +117,20 @@ describe('CommentService — dismissSuggestion', () => {
|
||||
expect(result.outcome).toBe('deleted');
|
||||
});
|
||||
|
||||
it('no replies → if the anchor-mark removal FAILS, the row is NOT deleted and the error propagates (#329: no orphan anchor)', async () => {
|
||||
const { service, commentRepo, wsService, collaborationGateway } =
|
||||
makeService(false);
|
||||
// Mark removal is FATAL and runs BEFORE the irreversible row delete: a collab
|
||||
// failure (e.g. COLLAB_DISABLE_REDIS "no live instance") must abort the whole
|
||||
// operation, leaving row + mark consistent — never a deleted row with an
|
||||
// orphan anchor left in the document reporting success.
|
||||
collaborationGateway.handleYjsEvent = jest.fn(async () => {
|
||||
throw new Error('requires a live collaboration instance');
|
||||
it('no replies → if the anchor-mark ENQUEUE FAILS, the row is NOT deleted and the error propagates (#329/#399: no orphan anchor)', async () => {
|
||||
const { service, commentRepo, wsService, generalQueue } = makeService(false);
|
||||
// #399: the mark removal now runs async in a worker, but the ENQUEUE is
|
||||
// awaited BEFORE the irreversible row delete — so the anchor-removal job is
|
||||
// durably scheduled before the row can vanish. If even the enqueue fails
|
||||
// (e.g. Redis down), the whole operation aborts, leaving row + mark
|
||||
// consistent — never a deleted row with an orphan anchor reporting success.
|
||||
generalQueue.add = jest.fn(async () => {
|
||||
throw new Error('queue add failed: no redis');
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.dismissSuggestion(suggestionComment(), user()),
|
||||
).rejects.toThrow(/live collaboration/);
|
||||
).rejects.toThrow(/queue add failed/);
|
||||
|
||||
expect(commentRepo.deleteCommentIfChildless).not.toHaveBeenCalled();
|
||||
expect(wsService.emitCommentEvent).not.toHaveBeenCalledWith(
|
||||
@@ -120,23 +141,29 @@ describe('CommentService — dismissSuggestion', () => {
|
||||
});
|
||||
|
||||
it('WITH replies → resolves (not delete), does NOT apply, audits DISMISSED, outcome=resolved', async () => {
|
||||
const { service, commentRepo, wsService, collaborationGateway, auditService } =
|
||||
makeService(true);
|
||||
const {
|
||||
service,
|
||||
commentRepo,
|
||||
collaborationGateway,
|
||||
generalQueue,
|
||||
auditService,
|
||||
} = makeService(true);
|
||||
|
||||
const result = await service.dismissSuggestion(suggestionComment(), user());
|
||||
|
||||
// Resolved via resolveComment (resolve patch + resolve mark), NOT deleted.
|
||||
// Resolved via resolveComment (resolve patch + enqueued resolve mark), NOT
|
||||
// deleted.
|
||||
const resolvePatch = commentRepo.updateComment.mock.calls
|
||||
.map((c: any[]) => c[0])
|
||||
.find((p: any) => 'resolvedAt' in p);
|
||||
expect(resolvePatch.resolvedAt).toBeInstanceOf(Date);
|
||||
expect(resolvePatch.resolvedById).toBe('user-1');
|
||||
expect(commentRepo.deleteComment).not.toHaveBeenCalled();
|
||||
expect(collaborationGateway.handleYjsEvent).toHaveBeenCalledWith(
|
||||
'resolveCommentMark',
|
||||
'page.page-1',
|
||||
expect.objectContaining({ commentId: 'c-1', resolved: true }),
|
||||
);
|
||||
// No sync gateway call; the resolve mark is enqueued (#399).
|
||||
expect(collaborationGateway.handleYjsEvent).not.toHaveBeenCalled();
|
||||
const res = markJob(generalQueue, 'resolve');
|
||||
expect(res).toBeDefined();
|
||||
expect(res[1]).toMatchObject({ documentName: 'page.page-1', commentId: 'c-1' });
|
||||
// No applied stamp — dismiss does not apply the edit.
|
||||
const appliedPatch = commentRepo.updateComment.mock.calls
|
||||
.map((c: any[]) => c[0])
|
||||
@@ -156,8 +183,7 @@ describe('CommentService — dismissSuggestion', () => {
|
||||
// but the atomic delete matches 0 rows because a reply landed in the window
|
||||
// between that read and the delete. The parent must NOT be hard-deleted
|
||||
// (a cascade would destroy the just-added reply); the thread is resolved.
|
||||
const { service, commentRepo, wsService, collaborationGateway } =
|
||||
makeService(false, 0);
|
||||
const { service, commentRepo, wsService, generalQueue } = makeService(false, 0);
|
||||
|
||||
const result = await service.dismissSuggestion(suggestionComment(), user());
|
||||
|
||||
@@ -175,11 +201,9 @@ describe('CommentService — dismissSuggestion', () => {
|
||||
.find((p: any) => 'resolvedAt' in p);
|
||||
expect(resolvePatch.resolvedAt).toBeInstanceOf(Date);
|
||||
expect(resolvePatch.resolvedById).toBe('user-1');
|
||||
expect(collaborationGateway.handleYjsEvent).toHaveBeenCalledWith(
|
||||
'resolveCommentMark',
|
||||
'page.page-1',
|
||||
expect.objectContaining({ commentId: 'c-1', resolved: true }),
|
||||
);
|
||||
// A resolve mark job is enqueued (the anchor was already delete-marked; the
|
||||
// resolve mirror is idempotent — #399).
|
||||
expect(markJob(generalQueue, 'resolve')).toBeDefined();
|
||||
expect(result.outcome).toBe('resolved');
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { CommentService } from './comment.service';
|
||||
import { QueueJob } from '../../integrations/queue/constants';
|
||||
|
||||
// Flush pending microtasks so a fire-and-forget `.catch(...)` runs before we assert.
|
||||
const flushMicrotasks = () => new Promise((r) => setImmediate(r));
|
||||
|
||||
/**
|
||||
* #399: the comment inline-mark update is moved OFF the HTTP critical path.
|
||||
* resolveComment / unresolve / the ephemeral-suggestion delete must NO LONGER
|
||||
* await CollaborationGateway.handleYjsEvent (which loaded the whole Y.Doc and
|
||||
* ran the store pipeline synchronously, ~4.5s p95). Instead they enqueue an
|
||||
* idempotent COMMENT_MARK_UPDATE job onto the GENERAL_QUEUE with the payload the
|
||||
* worker replays.
|
||||
*
|
||||
* The service is constructed directly with jest mocks (the @InjectQueue tokens
|
||||
* cannot be resolved by Test.createTestingModule — see comment.service.spec.ts).
|
||||
*/
|
||||
describe('CommentService — async comment mark (#399)', () => {
|
||||
function makeService() {
|
||||
const commentRepo: any = {
|
||||
findById: jest.fn(async (id: string) => ({
|
||||
id,
|
||||
content: {},
|
||||
spaceId: 'space-1',
|
||||
pageId: 'page-1',
|
||||
})),
|
||||
updateComment: jest.fn(async () => undefined),
|
||||
hasChildren: jest.fn(async () => false),
|
||||
deleteCommentIfChildless: jest.fn(async () => 1),
|
||||
};
|
||||
const pageRepo: any = {};
|
||||
const wsService: any = { emitCommentEvent: jest.fn() };
|
||||
// The gateway MUST NOT be touched on the HTTP path anymore.
|
||||
const collaborationGateway: any = {
|
||||
handleYjsEvent: jest.fn(async () => undefined),
|
||||
};
|
||||
const generalQueue: any = { add: jest.fn(() => Promise.resolve()) };
|
||||
const notificationQueue: any = { add: jest.fn(async () => undefined) };
|
||||
const auditService: any = { log: jest.fn() };
|
||||
|
||||
const service = new CommentService(
|
||||
commentRepo,
|
||||
pageRepo,
|
||||
wsService,
|
||||
collaborationGateway,
|
||||
generalQueue,
|
||||
notificationQueue,
|
||||
auditService,
|
||||
);
|
||||
return {
|
||||
service,
|
||||
commentRepo,
|
||||
collaborationGateway,
|
||||
generalQueue,
|
||||
auditService,
|
||||
};
|
||||
}
|
||||
|
||||
const comment = (over?: Partial<any>): any => ({
|
||||
id: 'c-1',
|
||||
creatorId: 'user-1',
|
||||
pageId: 'page-1',
|
||||
spaceId: 'space-1',
|
||||
workspaceId: 'ws-1',
|
||||
...over,
|
||||
});
|
||||
const user = (over?: Partial<any>): any => ({ id: 'user-1', ...over });
|
||||
|
||||
const markJob = (generalQueue: any) =>
|
||||
generalQueue.add.mock.calls.find(
|
||||
(c: any[]) => c[0] === QueueJob.COMMENT_MARK_UPDATE,
|
||||
);
|
||||
|
||||
it('resolveComment does NOT call the gateway synchronously, and enqueues a resolve mark job', async () => {
|
||||
const { service, collaborationGateway, generalQueue } = makeService();
|
||||
|
||||
await service.resolveComment(comment(), true, user());
|
||||
|
||||
// The whole point of #399: the Y.Doc mark op is off the HTTP path.
|
||||
expect(collaborationGateway.handleYjsEvent).not.toHaveBeenCalled();
|
||||
|
||||
const job = markJob(generalQueue);
|
||||
expect(job).toBeDefined();
|
||||
expect(job[0]).toBe(QueueJob.COMMENT_MARK_UPDATE);
|
||||
expect(job[1]).toMatchObject({
|
||||
documentName: 'page.page-1',
|
||||
commentId: 'c-1',
|
||||
action: 'resolve',
|
||||
userId: 'user-1',
|
||||
});
|
||||
expect(typeof job[1].ts).toBe('number');
|
||||
// ts equals the resolvedAt stamp written to the row (shared timestamp).
|
||||
const [patch] = (service as any).commentRepo.updateComment.mock.calls[0];
|
||||
expect(job[1].ts).toBe((patch.resolvedAt as Date).getTime());
|
||||
expect(job[1].ts).toBe((patch.updatedAt as Date).getTime());
|
||||
});
|
||||
|
||||
it('unresolve enqueues an unresolve mark job (action mapped from resolved=false)', async () => {
|
||||
const { service, collaborationGateway, generalQueue } = makeService();
|
||||
|
||||
await service.resolveComment(comment(), false, user());
|
||||
|
||||
expect(collaborationGateway.handleYjsEvent).not.toHaveBeenCalled();
|
||||
const job = markJob(generalQueue);
|
||||
expect(job[1]).toMatchObject({
|
||||
documentName: 'page.page-1',
|
||||
commentId: 'c-1',
|
||||
action: 'unresolve',
|
||||
userId: 'user-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('dismissing a childless ephemeral suggestion enqueues a delete mark job (not a sync gateway call)', async () => {
|
||||
const { service, collaborationGateway, generalQueue } = makeService();
|
||||
|
||||
await service.dismissSuggestion(
|
||||
comment({ suggestedText: 'new text', selection: 'old', resolvedAt: null }),
|
||||
user(),
|
||||
);
|
||||
|
||||
// The anchor removal is queued, not awaited against the gateway.
|
||||
expect(collaborationGateway.handleYjsEvent).not.toHaveBeenCalled();
|
||||
const job = markJob(generalQueue);
|
||||
expect(job).toBeDefined();
|
||||
expect(job[1]).toMatchObject({
|
||||
documentName: 'page.page-1',
|
||||
commentId: 'c-1',
|
||||
action: 'delete',
|
||||
userId: 'user-1',
|
||||
});
|
||||
expect(typeof job[1].ts).toBe('number');
|
||||
});
|
||||
|
||||
it('awaits the delete ENQUEUE before the irreversible row hard-delete (ordering preserved)', async () => {
|
||||
const { service, generalQueue, commentRepo } = makeService();
|
||||
const order: string[] = [];
|
||||
generalQueue.add.mockImplementation(async (name: string) => {
|
||||
order.push(`enqueue:${name}`);
|
||||
});
|
||||
commentRepo.deleteCommentIfChildless.mockImplementation(async () => {
|
||||
order.push('delete-row');
|
||||
return 1;
|
||||
});
|
||||
|
||||
await service.dismissSuggestion(
|
||||
comment({ suggestedText: 'new text', selection: 'old', resolvedAt: null }),
|
||||
user(),
|
||||
);
|
||||
|
||||
// The mark-removal job must be durably queued BEFORE the row disappears.
|
||||
expect(order).toEqual([
|
||||
`enqueue:${QueueJob.COMMENT_MARK_UPDATE}`,
|
||||
'delete-row',
|
||||
]);
|
||||
});
|
||||
|
||||
it('resolve is fire-and-forget: a queue-add rejection does NOT fail the HTTP call (best-effort warn)', async () => {
|
||||
const { service, generalQueue } = makeService();
|
||||
// The queue is unavailable — the whole point of #399 is that this must NOT
|
||||
// propagate out of resolveComment onto the HTTP request.
|
||||
const queueErr = new Error('queue is down');
|
||||
generalQueue.add.mockRejectedValue(queueErr);
|
||||
const warnSpy = jest
|
||||
.spyOn(Logger.prototype, 'warn')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
// Must resolve, never throw, even though the enqueue rejects.
|
||||
await expect(service.resolveComment(comment(), true, user())).resolves.not.toThrow();
|
||||
|
||||
// The rejection is swallowed on a microtask AFTER the method returns; flush it.
|
||||
await flushMicrotasks();
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Failed to enqueue comment mark update for comment c-1'),
|
||||
queueErr,
|
||||
);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,7 @@ import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination
|
||||
import { QueueJob, QueueName } from '../../integrations/queue/constants';
|
||||
import { extractUserMentionIdsFromJson } from '../../common/helpers/prosemirror/utils';
|
||||
import {
|
||||
ICommentMarkUpdateJob,
|
||||
ICommentNotificationJob,
|
||||
ICommentResolvedNotificationJob,
|
||||
} from '../../integrations/queue/constants/queue.interface';
|
||||
@@ -298,7 +299,11 @@ export class CommentService {
|
||||
// source is cleared alongside resolvedAt/resolvedById.
|
||||
provenance?: AuthProvenanceData,
|
||||
): Promise<Comment> {
|
||||
const resolvedAt = resolved ? new Date() : null;
|
||||
// One shared timestamp: it stamps resolvedAt AND updatedAt on the row and is
|
||||
// carried as the mark job's `ts`, so the worker's race-guard can order this
|
||||
// event against the row's authoritative resolve-state mutation time (#399).
|
||||
const now = new Date();
|
||||
const resolvedAt = resolved ? now : null;
|
||||
const resolvedById = resolved ? authUser.id : null;
|
||||
const isAgent = provenance?.actor === 'agent';
|
||||
// Set the agent marker only when resolving; on unresolve clear it back to
|
||||
@@ -307,25 +312,33 @@ export class CommentService {
|
||||
const resolvedSource = resolved && isAgent ? 'agent' : null;
|
||||
|
||||
await this.commentRepo.updateComment(
|
||||
{ resolvedAt, resolvedById, resolvedSource },
|
||||
// Bump updatedAt (not editedAt — that drives the "edited" badge) so the
|
||||
// row records WHEN the resolve state last changed; the async mark worker
|
||||
// compares its job ts against this to skip a superseded out-of-order event.
|
||||
{ resolvedAt, resolvedById, resolvedSource, updatedAt: now },
|
||||
comment.id,
|
||||
);
|
||||
|
||||
// Reflect the resolved state on the inline comment mark in the
|
||||
// collaborative document so all connected clients stay in sync.
|
||||
// #399: mirror the resolved state onto the inline comment mark OFF the HTTP
|
||||
// critical path. The DB row above is the source of truth (updated in ms); the
|
||||
// mark is an eventual mirror for connected clients, and its failure was
|
||||
// ALREADY swallowed (best-effort warn) — so instead of awaiting the whole
|
||||
// Y.Doc load + immediate store pipeline (~4.5s p95), enqueue an idempotent,
|
||||
// retryable COMMENT_MARK_UPDATE job. (Store-pipeline cost itself is #348's
|
||||
// scope, not duplicated here.)
|
||||
const documentName = `page.${comment.pageId}`;
|
||||
try {
|
||||
await this.collaborationGateway.handleYjsEvent(
|
||||
'resolveCommentMark',
|
||||
documentName,
|
||||
{ commentId: comment.id, resolved, user: authUser },
|
||||
);
|
||||
} catch (error) {
|
||||
void this.enqueueCommentMarkUpdate(
|
||||
documentName,
|
||||
comment.id,
|
||||
resolved ? 'resolve' : 'unresolve',
|
||||
now.getTime(),
|
||||
authUser.id,
|
||||
).catch((error) =>
|
||||
this.logger.warn(
|
||||
`Failed to update comment mark for comment ${comment.id}`,
|
||||
`Failed to enqueue comment mark update for comment ${comment.id}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
),
|
||||
);
|
||||
|
||||
// Notify the comment author when someone else resolves their comment.
|
||||
if (resolved && comment.creatorId !== authUser.id) {
|
||||
@@ -671,23 +684,54 @@ export class CommentService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the inline `comment` mark for a comment from the collaborative
|
||||
* document. FATAL, NOT best-effort: unlike resolveComment (which keeps the row,
|
||||
* so a failed mark update is recoverable), this is used before an irreversible
|
||||
* hard-delete, so the mark removal MUST succeed or throw. Under
|
||||
* COLLAB_DISABLE_REDIS the gateway invokes the deleteCommentMark handler
|
||||
* directly (never a silent no-op) and a missing live instance surfaces as a
|
||||
* thrown error, which we let propagate so the caller aborts before deleting.
|
||||
* Schedule removal of the inline `comment` anchor mark from the collaborative
|
||||
* document (ephemeral suggestion #329), OFF the HTTP critical path (#399).
|
||||
*
|
||||
* ORDERING PRESERVED: we `await` the ENQUEUE (a fast Redis add), not the mark
|
||||
* op, and the caller only proceeds to the irreversible row hard-delete after
|
||||
* this resolves. So the anchor-removal job is DURABLY queued before the row
|
||||
* vanishes — a queue-add failure throws here and aborts the delete (row + mark
|
||||
* stay consistent), preserving the invariant the old FATAL sync call gave. The
|
||||
* mark op itself now runs async in the worker: it is idempotent and retried
|
||||
* (3 attempts), so a transient collab failure self-heals; only an exhausted-
|
||||
* retries job leaves a DB↔mark divergence, now VISIBLE via BullMQ failed-job
|
||||
* metrics (was a hard 5xx before). Delete carries no state guard — the row is
|
||||
* being removed, and stripping an absent mark is a no-op.
|
||||
*/
|
||||
private async deleteCommentMark(comment: Comment, user: User): Promise<void> {
|
||||
const documentName = `page.${comment.pageId}`;
|
||||
await this.collaborationGateway.handleYjsEvent(
|
||||
'deleteCommentMark',
|
||||
await this.enqueueCommentMarkUpdate(
|
||||
documentName,
|
||||
{ commentId: comment.id, user },
|
||||
comment.id,
|
||||
'delete',
|
||||
Date.now(),
|
||||
user.id,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enqueue an idempotent COMMENT_MARK_UPDATE job (#399) — the single path that
|
||||
* mirrors a comment's inline-mark state into the collab Y.Doc off the HTTP
|
||||
* response. The worker (GeneralQueueProcessor) runs the SAME handleYjsEvent
|
||||
* the sync code used, so the mark op is byte-identical.
|
||||
*/
|
||||
private enqueueCommentMarkUpdate(
|
||||
documentName: string,
|
||||
commentId: string,
|
||||
action: 'resolve' | 'unresolve' | 'delete',
|
||||
ts: number,
|
||||
userId: string,
|
||||
): Promise<unknown> {
|
||||
const jobData: ICommentMarkUpdateJob = {
|
||||
documentName,
|
||||
commentId,
|
||||
action,
|
||||
ts,
|
||||
userId,
|
||||
};
|
||||
return this.generalQueue.add(QueueJob.COMMENT_MARK_UPDATE, jobData);
|
||||
}
|
||||
|
||||
private async queueCommentNotification(
|
||||
content: any,
|
||||
oldMentionIds: string[],
|
||||
|
||||
@@ -61,6 +61,9 @@ export enum QueueJob {
|
||||
|
||||
COMMENT_NOTIFICATION = 'comment-notification',
|
||||
COMMENT_RESOLVED_NOTIFICATION = 'comment-resolved-notification',
|
||||
// #399: off-critical-path mirror of a comment's inline mark into the collab
|
||||
// Y.Doc (resolve/unresolve flip, or ephemeral-suggestion anchor removal).
|
||||
COMMENT_MARK_UPDATE = 'comment-mark-update',
|
||||
PAGE_MENTION_NOTIFICATION = 'page-mention-notification',
|
||||
PAGE_PERMISSION_GRANTED = 'page-permission-granted',
|
||||
PAGE_UPDATE_DIGEST = 'page-update-digest',
|
||||
|
||||
@@ -63,6 +63,33 @@ export interface ICommentNotificationJob {
|
||||
notifyWatchers: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* GENERAL_QUEUE payload for the off-critical-path comment inline-mark mirror
|
||||
* (#399). The comment DB row is the source of truth and is already updated
|
||||
* synchronously (ms); this job flips/removes the inline `comment` mark in the
|
||||
* collaborative Y.Doc for connected clients, OFF the HTTP response path, so
|
||||
* `POST /api/comments/resolve` no longer waits the whole Y.Doc load + store
|
||||
* pipeline (was ~4.5s p95). The mark op is idempotent, so BullMQ retries are
|
||||
* safe.
|
||||
*
|
||||
* `action`:
|
||||
* - 'resolve' / 'unresolve' → flip the mark's `resolved` attribute (exactly
|
||||
* what the synchronous resolveCommentMark path did);
|
||||
* - 'delete' → strip the anchor mark entirely (ephemeral suggestion #329).
|
||||
* `ts` is the DB-mutation timestamp (ms). The worker's race-guard uses it (with
|
||||
* the row's authoritative resolved state) to skip a resolve/unresolve event
|
||||
* that a newer, opposite event has already superseded (out-of-order drain).
|
||||
* `userId` supplies the connection-context user the store pipeline attributes
|
||||
* the change to (persistence.extension reads context.user.id).
|
||||
*/
|
||||
export interface ICommentMarkUpdateJob {
|
||||
documentName: string;
|
||||
commentId: string;
|
||||
action: 'resolve' | 'unresolve' | 'delete';
|
||||
ts: number;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface ICommentResolvedNotificationJob {
|
||||
commentId: string;
|
||||
commentCreatorId: string;
|
||||
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
import { Job } from 'bullmq';
|
||||
import { GeneralQueueProcessor } from './general-queue.processor';
|
||||
import { QueueJob } from '../constants';
|
||||
import { ICommentMarkUpdateJob } from '../constants/queue.interface';
|
||||
|
||||
/**
|
||||
* #399: the GENERAL_QUEUE worker replays the comment inline-mark op that used to
|
||||
* run synchronously on the HTTP path. It must call the SAME gateway handler with
|
||||
* the SAME semantics (resolve/unresolve → flip the `resolved` attribute; delete
|
||||
* → strip the anchor), and its timestamp race-guard must skip an event a newer,
|
||||
* opposite event already superseded.
|
||||
*/
|
||||
describe('GeneralQueueProcessor — COMMENT_MARK_UPDATE (#399)', () => {
|
||||
function makeProc() {
|
||||
const collaborationGateway: any = {
|
||||
handleYjsEvent: jest.fn(async () => undefined),
|
||||
};
|
||||
const commentRepo: any = { findById: jest.fn() };
|
||||
// #399: the processor resolves CollaborationGateway lazily via ModuleRef
|
||||
// (strict:false) to avoid a DI cycle; the fake returns our gateway spy.
|
||||
const moduleRef: any = { get: jest.fn(() => collaborationGateway) };
|
||||
const proc = new GeneralQueueProcessor(
|
||||
{} as any, // db
|
||||
{} as any, // backlinkRepo
|
||||
{} as any, // watcherRepo
|
||||
commentRepo,
|
||||
moduleRef,
|
||||
);
|
||||
return { proc, collaborationGateway, commentRepo };
|
||||
}
|
||||
|
||||
const job = (data: ICommentMarkUpdateJob): Job =>
|
||||
({ name: QueueJob.COMMENT_MARK_UPDATE, data }) as unknown as Job;
|
||||
|
||||
const base = {
|
||||
documentName: 'page.page-1',
|
||||
commentId: 'c-1',
|
||||
userId: 'user-1',
|
||||
};
|
||||
|
||||
it('resolve → resolveCommentMark with resolved:true and the same-shape args', async () => {
|
||||
const { proc, collaborationGateway, commentRepo } = makeProc();
|
||||
const ts = 1000;
|
||||
// Row reflects the resolve (source of truth), stamped at the same ts.
|
||||
commentRepo.findById.mockResolvedValue({
|
||||
id: 'c-1',
|
||||
resolvedAt: new Date(ts),
|
||||
updatedAt: new Date(ts),
|
||||
});
|
||||
|
||||
await proc.process(job({ ...base, action: 'resolve', ts }));
|
||||
|
||||
expect(collaborationGateway.handleYjsEvent).toHaveBeenCalledTimes(1);
|
||||
expect(collaborationGateway.handleYjsEvent).toHaveBeenCalledWith(
|
||||
'resolveCommentMark',
|
||||
'page.page-1',
|
||||
{ commentId: 'c-1', resolved: true, user: { id: 'user-1' } },
|
||||
);
|
||||
});
|
||||
|
||||
it('unresolve → resolveCommentMark with resolved:false', async () => {
|
||||
const { proc, collaborationGateway, commentRepo } = makeProc();
|
||||
const ts = 2000;
|
||||
commentRepo.findById.mockResolvedValue({
|
||||
id: 'c-1',
|
||||
resolvedAt: null,
|
||||
updatedAt: new Date(ts),
|
||||
});
|
||||
|
||||
await proc.process(job({ ...base, action: 'unresolve', ts }));
|
||||
|
||||
expect(collaborationGateway.handleYjsEvent).toHaveBeenCalledWith(
|
||||
'resolveCommentMark',
|
||||
'page.page-1',
|
||||
{ commentId: 'c-1', resolved: false, user: { id: 'user-1' } },
|
||||
);
|
||||
});
|
||||
|
||||
it('delete → deleteCommentMark (strip the anchor), no row lookup / no state guard', async () => {
|
||||
const { proc, collaborationGateway, commentRepo } = makeProc();
|
||||
|
||||
await proc.process(job({ ...base, action: 'delete', ts: 123 }));
|
||||
|
||||
expect(collaborationGateway.handleYjsEvent).toHaveBeenCalledWith(
|
||||
'deleteCommentMark',
|
||||
'page.page-1',
|
||||
{ commentId: 'c-1', user: { id: 'user-1' } },
|
||||
);
|
||||
// Delete carries no state guard — the row is (being) removed.
|
||||
expect(commentRepo.findById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('SKIPS a stale resolve superseded by a newer unresolve (row unresolved, job ts older)', async () => {
|
||||
const { proc, collaborationGateway, commentRepo } = makeProc();
|
||||
// A later unresolve already set the row: resolvedAt null, updatedAt = 5000.
|
||||
commentRepo.findById.mockResolvedValue({
|
||||
id: 'c-1',
|
||||
resolvedAt: null,
|
||||
updatedAt: new Date(5000),
|
||||
});
|
||||
|
||||
// Stale resolve job enqueued at ts=1000 (< 5000), intends resolved=true,
|
||||
// but the row's authoritative state is unresolved → skip.
|
||||
await proc.process(job({ ...base, action: 'resolve', ts: 1000 }));
|
||||
|
||||
expect(collaborationGateway.handleYjsEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('SKIPS a stale unresolve superseded by a newer resolve', async () => {
|
||||
const { proc, collaborationGateway, commentRepo } = makeProc();
|
||||
commentRepo.findById.mockResolvedValue({
|
||||
id: 'c-1',
|
||||
resolvedAt: new Date(5000),
|
||||
updatedAt: new Date(5000),
|
||||
});
|
||||
|
||||
await proc.process(job({ ...base, action: 'unresolve', ts: 1000 }));
|
||||
|
||||
expect(collaborationGateway.handleYjsEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies when the row state agrees even if ts is older (idempotent, not a stale flip)', async () => {
|
||||
const { proc, collaborationGateway, commentRepo } = makeProc();
|
||||
// Row is resolved and its updatedAt is newer than the job ts, but the state
|
||||
// AGREES with the job → this is a harmless idempotent replay, not a stale
|
||||
// opposite event, so it must still apply.
|
||||
commentRepo.findById.mockResolvedValue({
|
||||
id: 'c-1',
|
||||
resolvedAt: new Date(9000),
|
||||
updatedAt: new Date(9000),
|
||||
});
|
||||
|
||||
await proc.process(job({ ...base, action: 'resolve', ts: 1000 }));
|
||||
|
||||
expect(collaborationGateway.handleYjsEvent).toHaveBeenCalledWith(
|
||||
'resolveCommentMark',
|
||||
'page.page-1',
|
||||
{ commentId: 'c-1', resolved: true, user: { id: 'user-1' } },
|
||||
);
|
||||
});
|
||||
|
||||
it('skips (no throw) when the comment row has vanished', async () => {
|
||||
const { proc, collaborationGateway, commentRepo } = makeProc();
|
||||
commentRepo.findById.mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
proc.process(job({ ...base, action: 'resolve', ts: 1000 })),
|
||||
).resolves.toBeUndefined();
|
||||
expect(collaborationGateway.handleYjsEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { Job } from 'bullmq';
|
||||
import { QueueJob, QueueName } from '../constants';
|
||||
import {
|
||||
IAddPageWatchersJob,
|
||||
ICommentMarkUpdateJob,
|
||||
IPageBacklinkJob,
|
||||
} from '../constants/queue.interface';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
@@ -13,8 +14,11 @@ import {
|
||||
WatcherRepo,
|
||||
WatcherType,
|
||||
} from '@docmost/db/repos/watcher/watcher.repo';
|
||||
import { InsertableWatcher } from '@docmost/db/types/entity.types';
|
||||
import { InsertableWatcher, User } from '@docmost/db/types/entity.types';
|
||||
import { processBacklinks } from '../tasks/backlinks.task';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { CollaborationGateway } from '../../../collaboration/collaboration.gateway';
|
||||
import { CommentRepo } from '@docmost/db/repos/comment/comment.repo';
|
||||
|
||||
@Processor(QueueName.GENERAL_QUEUE)
|
||||
export class GeneralQueueProcessor
|
||||
@@ -22,14 +26,32 @@ export class GeneralQueueProcessor
|
||||
implements OnModuleDestroy
|
||||
{
|
||||
private readonly logger = new Logger(GeneralQueueProcessor.name);
|
||||
// #399: CollaborationGateway lives in CollaborationModule. We resolve it lazily
|
||||
// via ModuleRef instead of importing that module into the @Global QueueModule —
|
||||
// CollaborationModule's own HistoryProcessor injects this module's global
|
||||
// GENERAL_QUEUE token, so a static import edge here would form a DI cycle. A
|
||||
// lazy strict:false lookup (cached) sidesteps it; the gateway is a singleton in
|
||||
// both the API-server and collab processes that run this worker.
|
||||
private collaborationGateway?: CollaborationGateway;
|
||||
constructor(
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
private readonly backlinkRepo: BacklinkRepo,
|
||||
private readonly watcherRepo: WatcherRepo,
|
||||
private readonly commentRepo: CommentRepo,
|
||||
private readonly moduleRef: ModuleRef,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
private getCollaborationGateway(): CollaborationGateway {
|
||||
if (!this.collaborationGateway) {
|
||||
this.collaborationGateway = this.moduleRef.get(CollaborationGateway, {
|
||||
strict: false,
|
||||
});
|
||||
}
|
||||
return this.collaborationGateway;
|
||||
}
|
||||
|
||||
async process(job: Job): Promise<void> {
|
||||
try {
|
||||
switch (job.name) {
|
||||
@@ -56,12 +78,87 @@ export class GeneralQueueProcessor
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case QueueJob.COMMENT_MARK_UPDATE: {
|
||||
await this.processCommentMarkUpdate(
|
||||
job.data as ICommentMarkUpdateJob,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #399: apply a comment's inline-mark mirror in the collab Y.Doc, off the HTTP
|
||||
* critical path. Runs the SAME gateway path the synchronous comment.service
|
||||
* code used (byte-identical mark op):
|
||||
* - resolve / unresolve → resolveCommentMark (flip the `resolved` attribute);
|
||||
* - delete → deleteCommentMark (strip the ephemeral-suggestion anchor #329).
|
||||
* The op is idempotent, so a BullMQ retry is safe. Throwing propagates to
|
||||
* WorkerHost → the job is retried and, on exhaustion, surfaces in failed-job
|
||||
* metrics (the divergence is now visible rather than a silently-swallowed warn).
|
||||
*/
|
||||
private async processCommentMarkUpdate(
|
||||
data: ICommentMarkUpdateJob,
|
||||
): Promise<void> {
|
||||
const { documentName, commentId, action, ts, userId } = data;
|
||||
// Minimal connection-context user: the store pipeline reads context.user.id
|
||||
// to attribute the change (persistence.extension). The mark mutation itself
|
||||
// does not depend on the user, so the op stays byte-identical. Deliberate
|
||||
// trade-off: the store pipeline's transient `page.updated` broadcast carries
|
||||
// only { id } here, so its live "who edited" badge loses name/avatarUrl for
|
||||
// this async mark replay. lastUpdatedById is still set correctly; the diff is
|
||||
// cosmetic and self-heals on the next real edit — worth it to stay off the
|
||||
// HTTP path and avoid re-loading the users row.
|
||||
const user = { id: userId } as User;
|
||||
|
||||
if (action === 'delete') {
|
||||
await this.getCollaborationGateway().handleYjsEvent(
|
||||
'deleteCommentMark',
|
||||
documentName,
|
||||
{ commentId, user },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// resolve / unresolve. The comment row is written SYNCHRONOUSLY before this
|
||||
// job is enqueued, so it is the source of truth for the final resolved state
|
||||
// and its updatedAt records when that state last changed. Race-guard: if a
|
||||
// newer, OPPOSITE event has already superseded this one (its ts is older than
|
||||
// the row's last resolve-state mutation AND the row's current resolved state
|
||||
// disagrees with what this job intends — e.g. an unresolve that drained ahead
|
||||
// of this resolve), skip it rather than flip the mark to a stale state.
|
||||
const comment = await this.commentRepo.findById(commentId);
|
||||
if (!comment) {
|
||||
// The comment vanished (e.g. hard-deleted) → nothing left to mirror.
|
||||
return;
|
||||
}
|
||||
const wantResolved = action === 'resolve';
|
||||
const rowResolved = comment.resolvedAt != null;
|
||||
const rowMutatedAt = new Date(comment.updatedAt).getTime();
|
||||
// `<=`, not `<`: on a sub-millisecond tie (two opposite toggles stamped in
|
||||
// the same ms) skip the disagreeing job rather than let queue order decide.
|
||||
// The consistent job (whose intent matches the row) short-circuits on the
|
||||
// first condition, so a real update is never dropped; only a mark that both
|
||||
// disagrees with the row AND is no newer than it is discarded.
|
||||
if (rowResolved !== wantResolved && ts <= rowMutatedAt) {
|
||||
this.logger.debug(
|
||||
`Skipping stale comment mark '${action}' for ${commentId} ` +
|
||||
`(job ts ${ts} < row ${rowMutatedAt}, row resolved=${rowResolved})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.getCollaborationGateway().handleYjsEvent(
|
||||
'resolveCommentMark',
|
||||
documentName,
|
||||
{ commentId, resolved: wantResolved, user },
|
||||
);
|
||||
}
|
||||
|
||||
@OnWorkerEvent('active')
|
||||
onActive(job: Job) {
|
||||
this.logger.debug(`Processing ${job.name} job`);
|
||||
|
||||
@@ -81,6 +81,10 @@ const HOST_CONTRACT_METHODS = [
|
||||
"insertImage",
|
||||
"replaceImage",
|
||||
"insertFootnote",
|
||||
// draw.io diagrams (#423, stage 1) — read + create + optimistic-locked update
|
||||
"drawioGet",
|
||||
"drawioCreate",
|
||||
"drawioUpdate",
|
||||
// write (comment)
|
||||
"createComment",
|
||||
"resolveComment",
|
||||
|
||||
Reference in New Issue
Block a user