Compare commits

..

2 Commits

Author SHA1 Message Date
agent_coder 574267de06 perf(mcp): кэшировать collab-токен в клиенте — чтобы кэш CollabSession (#400) реально попадал (#435)
CollabSession-реестр (#400/#431) ключуется на `wsUrl + " " + pageId + " " +
token`. Клиент чеканил СВЕЖИЙ collab-токен на КАЖДУЮ мутацию (in-app —
пере-подписывает JWT с новым iat/exp раз в секунду; внешний MCP — POST
/auth/collab-token на каждый вызов), поэтому token-компонент ключа менялся
каждый раз и сессия почти никогда не переиспользовалась (connect-штормы, 25s
таймауты, зомби-сессии).

- новое пер-инстансное поле collabTokenCache {token, mintedAt};
- getCollabTokenWithReauth(forceRefresh=false) сначала смотрит кэш (гейт
  !forceRefresh && ttl>0 && свежесть), оборачивает ОБЕ ветки чеканки
  (provider-путь in-app агента И REST POST /auth/collab-token), обе через
  rememberCollabToken (пустой токен не кэшируется);
- readCollabTokenTtlMs() читает env MCP_COLLAB_TOKEN_TTL_MS свежо; дефолт 5
  мин (сильно ниже 24h жизни токена и <= max-age сессии, окно устаревания
  прав не расширяется сверх #431); ЯВНЫЙ 0/отрицательное отключают кэш
  (rollback-knob = точный fetch-per-call), unset/непарсибельное -> дефолт;
- reauth-ретрай (401/403) в обеих ветках рекурсит forceRefresh=true (обход
  кэша -> свежий токен), гард !forceRefresh ограничивает ровно одним
  повтором;
- кэш сбрасывается на КАЖДОЙ смене идентичности: в login() и в
  response-интерцепторе (где this.token зануляется перед пере-логином) —
  инвариант-4 (изоляция идентичностей) сохранён, кэш пер-клиентный.

Внутренний цикл: 1 проход внутреннего ревью (APPROVE WITH SUGGESTIONS);
правка по ревью — уточнён docstring readCollabTokenTtlMs (расхождение с
поведением: непарсибельное значение даёт дефолт 5мин с ВКЛючённым кэшем, а
не отключает; отключает только явный 0/отрицательное).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 05:21:45 +03:00
agent_vscode f46d89eafb fix(ai-chat): wire drawio CRUD tools in-app to restore SHARED_TOOL_SPECS parity
PR #434 (drawio stage 1) added drawioGet/drawioCreate/drawioUpdate to the
shared tool-spec registry with in-app metadata (inAppKey, deferred tier,
catalogLine) but wired them only in the standalone MCP server, breaking the
contract-parity and phantom-catalog unit tests on develop CI.

- expose drawioGet/drawioCreate/drawioUpdate in forUser() via sharedTool(),
  mirroring the MCP transport's argument mapping (format ?? 'xml' default,
  flat schema regrouped into the client's `where` object, positional
  baseHash pass-through)
- extend the DocmostClientLike hand-mirror with the three client methods
- append the three names to the HOST_CONTRACT_METHODS drift-guard whitelist

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 04:51:24 +03:00
38 changed files with 536 additions and 556 deletions
@@ -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");
+9 -17
View File
@@ -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,
});
}
+2 -2
View File
@@ -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(() => {
+3 -18
View File
@@ -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,
+115 -32
View File
@@ -167,6 +167,35 @@ function isUuid(value: string): boolean {
return typeof value === "string" && UUID_RE.test(value);
}
/**
* Collab-token cache TTL in milliseconds (issue #435). Read fresh from the
* environment on every mint like collab-session.ts readConfig so tests and a
* live rollback can change it without reloading the module.
*
* Why a cache at all: the live CollabSession registry (#400/#431) keys sessions
* on (wsUrl, pageId, collabToken) for identity isolation (invariant 4). But BOTH
* collab-token sources mint a FRESH token per mutation the in-app provider
* re-signs a JWT whose iat/exp (seconds) changes every second, and the external
* MCP POSTs /auth/collab-token each call so the token in the key changed on
* every op and the session was almost never reused (connect-storms, 25s
* timeouts, zombie sessions). Caching the token per-client keeps the key stable
* across a burst of mutations so ONE session is reused.
*
* Default 5 min: well under the 24h collab-token lifetime AND <= the collab
* session max-age (10 min, MCP_COLLAB_SESSION_MAX_AGE_MS), so the
* permission-staleness window is not widened beyond what #431 already accepted.
* The rollback knob is an EXPLICIT 0 (or a negative number): that DISABLES the
* cache an exact fetch-per-call legacy path, mirroring how idleMs<=0 disables
* the session cache. Unset OR unparseable (e.g. a typo like "5min", "abc") falls
* back to the 5-min default with the cache ON parseInt yields NaN, which is
* treated as "not configured", not as "disabled". So to turn the cache off you
* must set the value to exactly 0, not to garbage.
*/
function readCollabTokenTtlMs(): number {
const raw = parseInt(process.env.MCP_COLLAB_TOKEN_TTL_MS ?? "", 10);
return Number.isFinite(raw) ? Math.max(0, raw) : 5 * 60 * 1000;
}
export class DocmostClient {
private client: AxiosInstance;
private token: string | null = null;
@@ -205,6 +234,15 @@ export class DocmostClient {
// resolvePageId), so only slugId->uuid entries are stored/read here.
private pageIdCache = new Map<string, string>();
// Collab-token cache (issue #435): the last minted collab token plus the
// wall-clock time it was minted, so a burst of content mutations reuses ONE
// token and therefore ONE live CollabSession (whose registry key includes the
// token — #400 invariant 4). Per-instance: a DocmostClient is built per
// user/per chat request, so a cached token can never leak across identities.
// Reset whenever the client's identity changes (login() / this.token cleared);
// bypassed on a forced refresh (the 401/403 reauth path). null = no token yet.
private collabTokenCache: { token: string; mintedAt: number } | null = null;
// Two construction forms:
// - new DocmostClient(config) // discriminated union (current)
// - new DocmostClient(baseURL, email, password) // legacy positional creds
@@ -273,8 +311,11 @@ export class DocmostClient {
if (config && isAuthError && !config._retry && !isLoginRequest) {
config._retry = true;
// Drop the stale token + Authorization header before re-login.
// Drop the stale token + Authorization header before re-login. Also
// clear the collab-token cache (#435): a new identity/login must not
// keep serving a collab token minted under the old one.
this.token = null;
this.collabTokenCache = null;
delete this.client.defaults.headers.common["Authorization"];
try {
await this.login();
@@ -323,6 +364,9 @@ export class DocmostClient {
throw new Error("getToken returned an empty token");
}
this.token = token;
// Identity (re)established: drop any collab token minted under a
// previous identity so the #435 cache can never outlive it.
this.collabTokenCache = null;
this.client.defaults.headers.common["Authorization"] =
`Bearer ${token}`;
})
@@ -345,8 +389,34 @@ export class DocmostClient {
* by this.client's response interceptor; this helper replicates that
* behaviour for collab-token requests: ensure a token, try once, and on an
* expired-token auth error perform a fresh login and retry exactly once.
*
* Collab-token cache (issue #435): both sources the getCollabToken provider
* (in-app agent) AND the REST /auth/collab-token endpoint (external MCP) mint
* a FRESH token per call, whose string therefore changes every op. Since the
* live CollabSession registry keys on the token string (#400/#431 invariant 4),
* that churned the key and defeated session reuse. So we cache the last minted
* token per-client for readCollabTokenTtlMs() and hand it back for a burst of
* mutations, keeping the session key stable. `forceRefresh` bypasses the cache
* (the 401/403 reauth retry uses it, so the retry cannot be handed the same
* stale token that just failed otherwise reauth would be a no-op). TTL 0
* disables the cache: exact fetch-per-call legacy behaviour.
*/
private async getCollabTokenWithReauth(): Promise<string> {
private async getCollabTokenWithReauth(
forceRefresh = false,
): Promise<string> {
const ttl = readCollabTokenTtlMs();
// Serve the cached collab token while it is still fresh (identity isolation
// is preserved: the cache is a per-instance field on a client built per
// user/per chat request, and it is cleared on every identity change).
if (
!forceRefresh &&
ttl > 0 &&
this.collabTokenCache &&
Date.now() - this.collabTokenCache.mintedAt < ttl
) {
return this.collabTokenCache.token;
}
// Collab-token PROVIDER path: when a getCollabToken provider was supplied
// (the internal agent's provenance collab token), use it instead of the
// REST /auth/collab-token endpoint. Re-invoke it once on a 401/403 (e.g. the
@@ -357,23 +427,13 @@ export class DocmostClient {
if (typeof token !== "string" || token.length === 0) {
throw new Error("getCollabToken returned an empty token");
}
return token;
return this.rememberCollabToken(token, ttl);
} catch (e) {
const axiosStatus = axios.isAxiosError(e)
? e.response?.status
: undefined;
const attachedStatus = (e as any)?.status;
const isAuthError =
axiosStatus === 401 ||
axiosStatus === 403 ||
attachedStatus === 401 ||
attachedStatus === 403;
if (isAuthError) {
const token = await this.getCollabTokenFn();
if (typeof token !== "string" || token.length === 0) {
throw new Error("getCollabToken returned an empty token");
}
return token;
// On an auth error retry EXACTLY once, forcing a refresh so the retry
// re-invokes the provider (bypassing the cache) for a genuinely fresh
// token. `!forceRefresh` bounds it to a single retry (no loop).
if (this.isCollabAuthError(e) && !forceRefresh) {
return this.getCollabTokenWithReauth(true);
}
throw e;
}
@@ -381,28 +441,51 @@ export class DocmostClient {
await this.ensureAuthenticated();
try {
return await getCollabToken(this.apiUrl, this.token!);
const token = await getCollabToken(this.apiUrl, this.token!);
return this.rememberCollabToken(token, ttl);
} catch (e) {
// getCollabToken wraps the AxiosError in a plain Error but attaches the
// HTTP status as `.status`, so detect an auth failure via either the raw
// AxiosError shape OR the attached status.
const axiosStatus = axios.isAxiosError(e)
? e.response?.status
: undefined;
const attachedStatus = (e as any)?.status;
const isAuthError =
axiosStatus === 401 ||
axiosStatus === 403 ||
attachedStatus === 401 ||
attachedStatus === 403;
if (isAuthError) {
// HTTP status as `.status`, so isCollabAuthError detects an auth failure
// via either the raw AxiosError shape OR the attached status.
if (this.isCollabAuthError(e) && !forceRefresh) {
// Fresh login (which clears this.token AND the collab-token cache), then
// retry exactly once with the cache bypassed via forceRefresh.
await this.login();
return await getCollabToken(this.apiUrl, this.token!);
return this.getCollabTokenWithReauth(true);
}
throw e;
}
}
/**
* Store a freshly minted collab token in the per-client cache (issue #435) and
* return it unchanged. No-op write when the cache is disabled (ttl<=0) or the
* token is empty, so a disabled cache is exact fetch-per-call legacy behaviour
* and a bad token is never cached.
*/
private rememberCollabToken(token: string, ttl: number): string {
if (ttl > 0 && typeof token === "string" && token.length > 0) {
this.collabTokenCache = { token, mintedAt: Date.now() };
}
return token;
}
/**
* True when an error carries a 401/403 either as a raw AxiosError
* (`error.response.status`) or as the plain-Error `.status` that
* lib/auth-utils.getCollabToken attaches after wrapping the AxiosError.
*/
private isCollabAuthError(e: unknown): boolean {
const axiosStatus = axios.isAxiosError(e) ? e.response?.status : undefined;
const attachedStatus = (e as any)?.status;
return (
axiosStatus === 401 ||
axiosStatus === 403 ||
attachedStatus === 401 ||
attachedStatus === 403
);
}
/**
* Connect to the collaboration websocket, read the live doc, apply
* `transform`, write the result, and wait for the server to persist it
@@ -0,0 +1,282 @@
// Unit tests for the collab-token cache (issue #435). The live CollabSession
// registry (#400/#431) keys sessions on (wsUrl, pageId, collabToken), so a token
// string that changes every op defeats reuse. This cache holds the last minted
// token per DocmostClient for MCP_COLLAB_TOKEN_TTL_MS so a burst of mutations
// reuses ONE token -> ONE session. These tests exercise both mint sources:
// - the getCollabToken PROVIDER path (in-app agent), via a counting provider fn;
// - the REST /auth/collab-token path (external MCP), via a mock http server.
// getCollabTokenWithReauth is private in TS but a plain method on the compiled
// build, so the tests call it directly (same convention as reauth.test.mjs).
import { test, afterEach, after } from "node:test";
import assert from "node:assert/strict";
import http from "node:http";
import { DocmostClient } from "../../build/client.js";
// Restore the env knob after each test so cases do not leak into one another.
const ENV_KEY = "MCP_COLLAB_TOKEN_TTL_MS";
afterEach(() => {
delete process.env[ENV_KEY];
});
// ---------------------------------------------------------------------------
// Small mock server for the REST /auth/collab-token path. Counts collab-token
// mints and can be told to 401 the first N of them (to drive the reauth retry).
// ---------------------------------------------------------------------------
function readBody(req) {
return new Promise((resolve) => {
let raw = "";
req.on("data", (c) => (raw += c));
req.on("end", () => resolve(raw));
});
}
function sendJson(res, status, obj, extra = {}) {
res.writeHead(status, { "Content-Type": "application/json", ...extra });
res.end(JSON.stringify(obj));
}
const openServers = [];
after(async () => {
await Promise.all(
openServers.map((s) => new Promise((r) => s.close(r))),
);
});
// state: { collabCalls, loginCalls, unauthorizedCollabHits }
function spawnCollabServer(state, { collabAuthFailsFor = 0 } = {}) {
return new Promise((resolve) => {
const server = http.createServer(async (req, res) => {
await readBody(req);
if (req.url === "/api/auth/login") {
state.loginCalls++;
// A fresh authToken per login so an identity change is observable.
sendJson(res, 200, { success: true }, {
"Set-Cookie": `authToken=login-${state.loginCalls}; Path=/; HttpOnly`,
});
return;
}
if (req.url === "/api/auth/collab-token") {
state.collabCalls++;
if (state.collabCalls <= collabAuthFailsFor) {
sendJson(res, 401, { message: "Unauthorized" });
return;
}
// Unique token per mint so a stale cached value is distinguishable.
sendJson(res, 200, { data: { token: `collab-${state.collabCalls}` } });
return;
}
sendJson(res, 404, { message: "not found" });
});
server.listen(0, "127.0.0.1", () => {
openServers.push(server);
resolve(`http://127.0.0.1:${server.address().port}/api`);
});
});
}
// ===========================================================================
// PROVIDER path (in-app agent getCollabToken fn)
// ===========================================================================
// A counting provider that returns a distinct token each call so a cached
// (reused) token is visibly the SAME string while a fresh mint is different.
function countingProvider() {
let n = 0;
const fn = async () => {
n++;
return `provider-token-${n}`;
};
return {
fn,
get calls() {
return n;
},
};
}
test("within TTL, repeated calls return the SAME token and mint ONCE (provider path)", async () => {
process.env[ENV_KEY] = "300000"; // 5 min
const p = countingProvider();
const client = new DocmostClient({
apiUrl: "http://127.0.0.1:1/api",
getToken: async () => "access",
getCollabToken: p.fn,
});
const a = await client.getCollabTokenWithReauth();
const b = await client.getCollabTokenWithReauth();
const c = await client.getCollabTokenWithReauth();
assert.equal(a, "provider-token-1");
assert.equal(b, a, "second call reuses the cached token");
assert.equal(c, a, "third call reuses the cached token");
assert.equal(p.calls, 1, "the provider is invoked exactly once within the TTL");
});
test("after TTL expiry a new token is minted (provider path)", async () => {
process.env[ENV_KEY] = "20"; // 20ms TTL
const p = countingProvider();
const client = new DocmostClient({
apiUrl: "http://127.0.0.1:1/api",
getToken: async () => "access",
getCollabToken: p.fn,
});
const a = await client.getCollabTokenWithReauth();
await new Promise((r) => setTimeout(r, 40)); // let the TTL lapse
const b = await client.getCollabTokenWithReauth();
assert.equal(a, "provider-token-1");
assert.equal(b, "provider-token-2", "a fresh token is minted after expiry");
assert.equal(p.calls, 2);
});
test("MCP_COLLAB_TOKEN_TTL_MS=0 disables the cache: mint on EVERY call (provider path)", async () => {
process.env[ENV_KEY] = "0";
const p = countingProvider();
const client = new DocmostClient({
apiUrl: "http://127.0.0.1:1/api",
getToken: async () => "access",
getCollabToken: p.fn,
});
await client.getCollabTokenWithReauth();
await client.getCollabTokenWithReauth();
await client.getCollabTokenWithReauth();
assert.equal(p.calls, 3, "cache disabled -> exact fetch-per-call legacy path");
});
test("a 401 triggers the internal reauth retry, which bypasses the cache and mints fresh (provider path)", async () => {
process.env[ENV_KEY] = "300000";
let n = 0;
const provider = async () => {
n++;
if (n === 1) {
// The FIRST mint fails with an auth error; the internal reauth retry must
// re-invoke the provider (bypassing the empty cache) for a fresh token.
const err = new Error("collab token expired");
err.status = 401;
throw err;
}
return `provider-token-${n}`;
};
const client = new DocmostClient({
apiUrl: "http://127.0.0.1:1/api",
getToken: async () => "access",
getCollabToken: provider,
});
// Cache is empty: mint #1 401s -> the reauth retry mints #2 and caches it.
const tok = await client.getCollabTokenWithReauth();
assert.equal(tok, "provider-token-2", "the post-401 retry token wins");
assert.equal(n, 2, "exactly one failed mint + one retry, no loop");
// The retried token is what got cached (no extra mint on a cache hit).
const cached = await client.getCollabTokenWithReauth();
assert.equal(cached, "provider-token-2");
assert.equal(n, 2, "served from cache, provider not re-invoked");
});
test("forceRefresh=true bypasses a warm cache and mints a fresh token (provider path)", async () => {
process.env[ENV_KEY] = "300000";
const p = countingProvider();
const client = new DocmostClient({
apiUrl: "http://127.0.0.1:1/api",
getToken: async () => "access",
getCollabToken: p.fn,
});
const first = await client.getCollabTokenWithReauth(); // caches token-1
assert.equal(first, "provider-token-1");
// A forced refresh (what the reauth path passes) must NOT return the cached
// token-1; it mints a fresh token-2 and replaces the cache.
const forced = await client.getCollabTokenWithReauth(true);
assert.equal(forced, "provider-token-2", "cache bypassed on forceRefresh");
assert.equal(p.calls, 2);
const cached = await client.getCollabTokenWithReauth();
assert.equal(cached, "provider-token-2", "the fresh token replaced the cache");
assert.equal(p.calls, 2);
});
test("two consecutive mutations keep the SAME token, so the session key is stable (provider path)", async () => {
// The whole point of #435: acquireCollabSession keys on the token, so two
// acquire calls in a burst must be handed the identical token string.
process.env[ENV_KEY] = "300000";
const p = countingProvider();
const client = new DocmostClient({
apiUrl: "http://127.0.0.1:1/api",
getToken: async () => "access",
getCollabToken: p.fn,
});
const t1 = await client.getCollabTokenWithReauth();
const t2 = await client.getCollabTokenWithReauth();
assert.equal(t1, t2, "identical token across two mutations -> one session key");
assert.equal(p.calls, 1);
});
// ===========================================================================
// REST /auth/collab-token path (external MCP)
// ===========================================================================
test("within TTL, the REST /auth/collab-token endpoint is hit ONCE", async () => {
process.env[ENV_KEY] = "300000";
const state = { collabCalls: 0, loginCalls: 0 };
const baseURL = await spawnCollabServer(state);
const client = new DocmostClient(baseURL, "user@example.com", "pw");
const a = await client.getCollabTokenWithReauth();
const b = await client.getCollabTokenWithReauth();
assert.equal(a, "collab-1");
assert.equal(b, a, "cached token reused");
assert.equal(state.collabCalls, 1, "POST /auth/collab-token called once");
});
test("TTL=0 hits the REST endpoint on every call", async () => {
process.env[ENV_KEY] = "0";
const state = { collabCalls: 0, loginCalls: 0 };
const baseURL = await spawnCollabServer(state);
const client = new DocmostClient(baseURL, "user@example.com", "pw");
await client.getCollabTokenWithReauth();
await client.getCollabTokenWithReauth();
assert.equal(state.collabCalls, 2, "cache disabled -> fetch each call");
});
test("401 on REST collab-token re-logs-in and refetches (cache bypassed)", async () => {
process.env[ENV_KEY] = "300000";
const state = { collabCalls: 0, loginCalls: 0 };
// The first collab-token mint 401s; the reauth path logs in and retries.
const baseURL = await spawnCollabServer(state, { collabAuthFailsFor: 1 });
const client = new DocmostClient(baseURL, "user@example.com", "pw");
// Pre-seed a token so the initial call does not perform an initial login.
client.token = "seed";
client.client.defaults.headers.common["Authorization"] = "Bearer seed";
const tok = await client.getCollabTokenWithReauth();
assert.equal(tok, "collab-2", "the post-reauth mint wins, not the failed one");
assert.equal(state.loginCalls, 1, "re-login happened exactly once");
assert.equal(state.collabCalls, 2, "one failed mint + one successful retry");
});
test("a fresh login clears the cache so a collab token cannot outlive the identity", async () => {
process.env[ENV_KEY] = "300000";
const state = { collabCalls: 0, loginCalls: 0 };
const baseURL = await spawnCollabServer(state);
const client = new DocmostClient(baseURL, "user@example.com", "pw");
const before = await client.getCollabTokenWithReauth();
assert.equal(before, "collab-1");
// Simulate an identity change (the 401 interceptor / re-login path calls
// login(), which must drop the cached collab token).
await client.login();
const after = await client.getCollabTokenWithReauth();
assert.equal(after, "collab-2", "cache was invalidated by login(); refetched");
assert.equal(state.collabCalls, 2);
});
@@ -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",