Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 98a58f6296 | |||
| 26b29e1d7e |
@@ -18,48 +18,12 @@ env:
|
||||
IMAGE: ghcr.io/vvzvlad/gitmost
|
||||
|
||||
jobs:
|
||||
# Run the reusable test suite. Together with the e2e jobs below it gates the
|
||||
# publish job (the image push), not the build itself — build runs in parallel.
|
||||
# Run the reusable test suite first so a failing test blocks the image build.
|
||||
test:
|
||||
uses: ./.github/workflows/test.yml
|
||||
|
||||
# Runs in parallel with the test/e2e jobs and only warms the buildx cache
|
||||
# (GHA cache, scope develop-amd64). No push happens here — the publish job
|
||||
# below is the only one that pushes the image.
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Resolve version
|
||||
id: version
|
||||
run: echo "value=$(git describe --tags --always)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build develop image (warm cache, no push)
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
build-args: |
|
||||
APP_VERSION=${{ steps.version.outputs.value }}
|
||||
AI_AGENT_ROLES_CATALOG_URL=https://raw.githubusercontent.com/vvzvlad/gitmost/develop/agent-roles-catalog
|
||||
push: false
|
||||
cache-from: type=gha,scope=develop-amd64
|
||||
cache-to: type=gha,scope=develop-amd64,mode=max,ignore-error=true
|
||||
|
||||
# The gate: rebuilds from the cache the build job just wrote (near-instant on
|
||||
# a cache hit; worst case — cache eviction — a full rebuild, which matches the
|
||||
# old sequential timing) and pushes :develop only when unit tests AND both
|
||||
# e2e suites AND the build are green.
|
||||
publish:
|
||||
needs: [test, e2e-server, e2e-mcp, build]
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
@@ -93,10 +57,13 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ env.IMAGE }}:develop
|
||||
cache-from: type=gha,scope=develop-amd64
|
||||
cache-to: type=gha,scope=develop-amd64,mode=max,ignore-error=true
|
||||
|
||||
# e2e jobs gate the publish (image push), not the build: the :develop image
|
||||
# is pushed only when unit tests AND both e2e suites pass (publish.needs
|
||||
# lists them all).
|
||||
# e2e jobs run on every develop push but DO NOT gate the build/publish above:
|
||||
# `build` stays `needs: test` only, so the :develop image still ships even if
|
||||
# e2e fails. A failing e2e job turns the run red and triggers GitHub's email
|
||||
# to the pusher — that red run + email is the intended notification, not a
|
||||
# deploy block.
|
||||
e2e-server:
|
||||
runs-on: ubuntu-latest
|
||||
# Hard cap: the full-AppModule e2e leaks open handles and hung jest to the 6h max.
|
||||
@@ -157,7 +124,9 @@ jobs:
|
||||
- name: Run server e2e
|
||||
run: pnpm --filter ./apps/server test:e2e
|
||||
|
||||
# Gates the publish too — see the comment above e2e-server.
|
||||
# Same rationale as e2e-server: this job is intentionally NOT in
|
||||
# `build.needs`. Deploy of the :develop image must not be blocked by e2e;
|
||||
# a red run plus GitHub's email to the pusher is the notification mechanism.
|
||||
e2e-mcp:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
+2
-16
@@ -5,13 +5,6 @@ RUN npm install -g pnpm@10.4.0
|
||||
|
||||
FROM base AS builder
|
||||
|
||||
# re2 (packages/mcp) always compiles from source under pnpm (the prebuilt-binary
|
||||
# download cannot identify the GitHub repo), so node-gyp needs python3/make/g++.
|
||||
# This stage is discarded, so the toolchain can stay installed.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends python3 make g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
@@ -64,17 +57,10 @@ COPY --from=builder /app/patches /app/patches
|
||||
|
||||
RUN chown -R node:node /app
|
||||
|
||||
# Toolchain is needed transiently to compile re2 during the prod install; install
|
||||
# and purge it in one layer to keep the final image slim. The install itself runs
|
||||
# as the node user via su to keep node_modules ownership without a costly chown layer.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends python3 make g++ \
|
||||
&& su node -c "pnpm install --frozen-lockfile --prod" \
|
||||
&& apt-get purge -y --auto-remove python3 make g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
USER node
|
||||
|
||||
RUN pnpm install --frozen-lockfile --prod
|
||||
|
||||
RUN mkdir -p /app/data/storage
|
||||
|
||||
VOLUME ["/app/data/storage"]
|
||||
|
||||
@@ -97,6 +97,7 @@
|
||||
"typescript": "5.9.3",
|
||||
"typescript-eslint": "8.57.1",
|
||||
"vite": "8.0.5",
|
||||
"vite-plugin-compression2": "2.5.3",
|
||||
"vitest": "4.1.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { extractPageSlugId } from "@/lib";
|
||||
import {
|
||||
AI_CHATS_RQ_KEY,
|
||||
@@ -219,9 +219,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);
|
||||
@@ -233,10 +231,8 @@ export default function AiChatWindow() {
|
||||
[roles],
|
||||
);
|
||||
|
||||
// Gated on windowOpen too: no message history is fetched while the window is
|
||||
// closed (it is loaded when the window opens with an active chat).
|
||||
const { data: messageRows, isLoading: messagesLoading } =
|
||||
useAiChatMessagesQuery(activeChatId ?? undefined, windowOpen);
|
||||
useAiChatMessagesQuery(activeChatId ?? undefined);
|
||||
|
||||
// The page the user is currently viewing. AiChatWindow lives in a pathless
|
||||
// parent layout route, so useParams() can't see :pageSlug. Match the full
|
||||
@@ -248,7 +244,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
|
||||
|
||||
@@ -52,12 +52,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 }) =>
|
||||
@@ -65,7 +61,6 @@ export function useAiChatsQuery(enabled: boolean = true) {
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.meta.hasNextPage ? (lastPage.meta.nextCursor ?? undefined) : undefined,
|
||||
enabled,
|
||||
});
|
||||
|
||||
const data = useMemo<IPagination<IAiChat> | undefined>(() => {
|
||||
@@ -88,10 +83,7 @@ export function useAiChatsQuery(enabled: boolean = true) {
|
||||
* Load all persisted messages of a chat (oldest first), flattening the
|
||||
* paginated server response. Used to seed `useChat` initial messages.
|
||||
*/
|
||||
export function useAiChatMessagesQuery(
|
||||
chatId: string | undefined,
|
||||
enabled: boolean = true,
|
||||
) {
|
||||
export function useAiChatMessagesQuery(chatId: string | undefined) {
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: AI_CHAT_MESSAGES_RQ_KEY(chatId ?? ""),
|
||||
queryFn: ({ pageParam }) =>
|
||||
@@ -99,7 +91,7 @@ export function useAiChatMessagesQuery(
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.meta.hasNextPage ? (lastPage.meta.nextCursor ?? undefined) : undefined,
|
||||
enabled: !!chatId && enabled,
|
||||
enabled: !!chatId,
|
||||
});
|
||||
|
||||
// useInfiniteQuery only fetches the first page on its own. The hook's contract
|
||||
|
||||
@@ -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,90 +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");
|
||||
});
|
||||
|
||||
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];
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { compression } from "vite-plugin-compression2";
|
||||
import * as path from "path";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
@@ -53,7 +54,25 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
APP_VERSION: JSON.stringify(resolveAppVersion(envPath)),
|
||||
},
|
||||
plugins: [react()],
|
||||
plugins: [
|
||||
react(),
|
||||
// Emit .br and .gz next to every built asset so the server can serve the
|
||||
// precompressed copy (see @fastify/static preCompressed in static.module.ts).
|
||||
compression({
|
||||
algorithms: ["brotliCompress", "gzip"],
|
||||
// vite-plugin-compression2's default `include` only covers text-ish
|
||||
// bundle output (js/mjs/json/css/html/svg/…). Extend it with the large
|
||||
// VAD binaries copied from public/vad (.wasm ~26MB, .onnx ~2.3MB) so
|
||||
// they are brotli/gzip'd once at build time and served via
|
||||
// @fastify/static preCompressed — otherwise @fastify/compress would
|
||||
// re-brotli them on EVERY request. The default types are repeated here
|
||||
// because setting `include` replaces (does not extend) the default.
|
||||
include: /\.(html|xml|css|json|js|mjs|svg|yaml|yml|toml|wasm|onnx)$/,
|
||||
// index.html is rewritten at server boot (window.CONFIG injection); a
|
||||
// precompressed copy would go stale — NEVER precompress it.
|
||||
exclude: [/index\.html$/],
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
rolldownOptions: {
|
||||
output: {
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"@clickhouse/client": "^1.18.2",
|
||||
"@docmost/mcp": "workspace:*",
|
||||
"@docmost/pdf-inspector": "1.9.6",
|
||||
"@fastify/compress": "^9.0.0",
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/multipart": "^10.0.0",
|
||||
"@fastify/static": "^9.1.3",
|
||||
|
||||
@@ -474,6 +474,19 @@ export class AttachmentController {
|
||||
const fileSize = Number(attachment.fileSize);
|
||||
const rangeHeader = req.headers.range;
|
||||
|
||||
// Opt this download route out of the global @fastify/compress hook.
|
||||
// Attachment bytes are final and mostly binary, so on-the-fly compression
|
||||
// only burns CPU — and on the 206/Range branch it is actively corrupting:
|
||||
// compress decides purely by Content-Type, so for a compressible mime
|
||||
// (application/octet-stream fallback, image/svg+xml, text/*) it would gzip
|
||||
// the byte slice and drop Content-Length while Content-Range still
|
||||
// describes the RAW offsets and the status stays 206. A resuming client
|
||||
// (`curl -C -`, download managers) then appends the encoded bytes as if
|
||||
// raw and ends up with a broken file. @fastify/compress skips whenever the
|
||||
// request carries `x-no-compression` (see its onSend hook), so setting it
|
||||
// here covers both the 200 (full file) and 206 (range) responses.
|
||||
req.headers['x-no-compression'] = 'true';
|
||||
|
||||
res.header('Accept-Ranges', 'bytes');
|
||||
res.header(
|
||||
'Content-Security-Policy',
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { resolveStaticAssetHeaders } from './static.module';
|
||||
|
||||
// Unit tests for the static-asset cache classifier extracted from the
|
||||
// @fastify/static setHeaders callback (precedent: sandbox.controller.spec.ts).
|
||||
describe('resolveStaticAssetHeaders', () => {
|
||||
it('marks a content-hashed /assets/ file immutable and sets Vary', () => {
|
||||
const headers = resolveStaticAssetHeaders(
|
||||
'/app/apps/client/dist/assets/index-a1b2c3.js',
|
||||
);
|
||||
expect(headers['cache-control']).toBe(
|
||||
'public, max-age=31536000, immutable',
|
||||
);
|
||||
expect(headers['vary']).toBe('Accept-Encoding');
|
||||
});
|
||||
|
||||
it('makes index.html always revalidate (never immutable)', () => {
|
||||
const headers = resolveStaticAssetHeaders(
|
||||
'/app/apps/client/dist/index.html',
|
||||
);
|
||||
expect(headers['cache-control']).toBe(
|
||||
'no-cache, no-store, must-revalidate',
|
||||
);
|
||||
expect(headers['vary']).toBe('Accept-Encoding');
|
||||
});
|
||||
|
||||
it('does NOT mark a non-hashed asset immutable but still sets Vary', () => {
|
||||
const headers = resolveStaticAssetHeaders(
|
||||
'/app/apps/client/dist/locales/en.json',
|
||||
);
|
||||
// No immutable cache-control — this path keeps @fastify/static's default
|
||||
// etag/last-modified revalidation.
|
||||
expect(headers['cache-control']).toBeUndefined();
|
||||
expect(headers['vary']).toBe('Accept-Encoding');
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,46 @@ import * as fs from 'node:fs';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
import { EnvironmentService } from '../environment/environment.service';
|
||||
|
||||
/**
|
||||
* Resolve the response headers for a statically served client asset.
|
||||
*
|
||||
* Extracted from the @fastify/static `setHeaders` callback so the cache
|
||||
* classification stays a pure, unit-testable function (see
|
||||
* static.module.spec.ts).
|
||||
*
|
||||
* `Vary: Accept-Encoding` is emitted for every static response because
|
||||
* @fastify/static negotiates a precompressed .br/.gz neighbour by the client's
|
||||
* Accept-Encoding but does NOT set Vary itself. Without it a shared/proxy cache
|
||||
* keyed on the URL alone could store the brotli variant and later serve it to a
|
||||
* client that only sent `Accept-Encoding: identity`/gzip → an undecodable body.
|
||||
* This matters most for the immutable /assets/ files, which proxies may keep
|
||||
* for a year.
|
||||
*/
|
||||
export function resolveStaticAssetHeaders(
|
||||
filePath: string,
|
||||
): Record<string, string> {
|
||||
const headers: Record<string, string> = { vary: 'Accept-Encoding' };
|
||||
|
||||
// Content-hashed files under /assets/ never change for a given URL, so they
|
||||
// can be cached forever and skip revalidation entirely.
|
||||
if (filePath.includes('/assets/')) {
|
||||
headers['cache-control'] = 'public, max-age=31536000, immutable';
|
||||
return headers;
|
||||
}
|
||||
|
||||
// index.html is rewritten at boot (window.CONFIG injection) and on every
|
||||
// deploy — it must be revalidated on every load.
|
||||
if (filePath.endsWith('index.html')) {
|
||||
headers['cache-control'] = 'no-cache, no-store, must-revalidate';
|
||||
return headers;
|
||||
}
|
||||
|
||||
// Everything else (locales, vad, icons, manifest) is NOT content-hashed and
|
||||
// changes between deploys, so it keeps @fastify/static's default
|
||||
// etag/last-modified revalidation — do NOT mark it immutable.
|
||||
return headers;
|
||||
}
|
||||
|
||||
@Module({})
|
||||
export class StaticModule implements OnModuleInit {
|
||||
constructor(
|
||||
@@ -68,6 +108,16 @@ export class StaticModule implements OnModuleInit {
|
||||
await app.register(fastifyStatic, {
|
||||
root: clientDistPath,
|
||||
wildcard: false,
|
||||
// Serve the build-time .br/.gz neighbour when the client accepts it
|
||||
// (see vite-plugin-compression2 in apps/client/vite.config.ts).
|
||||
preCompressed: true,
|
||||
setHeaders: (res, filePath) => {
|
||||
for (const [name, value] of Object.entries(
|
||||
resolveStaticAssetHeaders(filePath),
|
||||
)) {
|
||||
res.setHeader(name, value);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
app.get(RENDER_PATH, (req: any, res: any) => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { TransformHttpResponseInterceptor } from './common/interceptors/http-res
|
||||
import { WsRedisIoAdapter } from './ws/adapter/ws-redis.adapter';
|
||||
import fastifyMultipart from '@fastify/multipart';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import fastifyCompress from '@fastify/compress';
|
||||
import fastifyIp from 'fastify-ip';
|
||||
import { InternalLogFilter } from './common/logger/internal-log-filter';
|
||||
import { EnvironmentService } from './integrations/environment/environment.service';
|
||||
@@ -60,6 +61,17 @@ async function bootstrap() {
|
||||
await app.register(fastifyIp);
|
||||
await app.register(fastifyMultipart);
|
||||
await app.register(fastifyCookie);
|
||||
// Compress dynamic responses (API JSON, the rewritten share-SEO HTML) when the
|
||||
// client accepts br/gzip. @fastify/compress only compresses content-types that
|
||||
// mime-db flags `compressible` (application/json, text/html, …); `text/event-stream`
|
||||
// is not in mime-db, so SSE is never compressed by the allowlist. The AI-chat
|
||||
// stream additionally hijacks the raw socket (pipeUIMessageStreamToResponse ->
|
||||
// res.raw in ai-chat.service.ts), bypassing Fastify's reply/onSend lifecycle
|
||||
// entirely, so this hook can never buffer that stream.
|
||||
await app.register(fastifyCompress, {
|
||||
// Skip tiny payloads where compression overhead outweighs the savings.
|
||||
threshold: 1024,
|
||||
});
|
||||
|
||||
const environmentService = app.get(EnvironmentService);
|
||||
const frameHeader = resolveFrameHeader(
|
||||
|
||||
@@ -12,6 +12,11 @@ services:
|
||||
ports:
|
||||
- "3000:3000"
|
||||
restart: unless-stopped
|
||||
# The app already serves precompressed (brotli/gzip) static assets with
|
||||
# long-lived cache headers and gzips dynamic API responses. For the best
|
||||
# cold-load latency you can OPTIONALLY put a reverse proxy (caddy / nginx /
|
||||
# traefik) in front with HTTP/2 (or HTTP/3) and brotli enabled — none is
|
||||
# required for compression to work.
|
||||
volumes:
|
||||
- docmost:/app/data/storage
|
||||
|
||||
|
||||
@@ -450,7 +450,7 @@ async function main() {
|
||||
// 8. get_page markdown round-trip sanity (table separator present)
|
||||
const md = await client.getPage(pageId);
|
||||
check("get_page md: table separator emitted", md.data.content.includes("| --- |"), "");
|
||||
check("get_page md: callout exported as Obsidian '> [!info]'", md.data.content.includes("> [!info]"));
|
||||
check("get_page md: callout exported as :::", md.data.content.includes(":::info"));
|
||||
|
||||
// 9. comments: create / list / reply / update / check_new / delete
|
||||
const beforeComments = new Date(Date.now() - 1000).toISOString();
|
||||
|
||||
@@ -635,17 +635,13 @@ const Attachment = Node.create({
|
||||
},
|
||||
name: {
|
||||
default: null,
|
||||
// Empty-string-vs-absent idempotency (GS-EDIT-REVERT class): "" -> default.
|
||||
parseHTML: (el: HTMLElement) =>
|
||||
el.getAttribute("data-attachment-name") || null,
|
||||
parseHTML: (el: HTMLElement) => el.getAttribute("data-attachment-name"),
|
||||
renderHTML: (attrs: Record<string, any>) =>
|
||||
attrs.name ? { "data-attachment-name": attrs.name } : {},
|
||||
},
|
||||
mime: {
|
||||
default: null,
|
||||
// Empty-string-vs-absent idempotency (GS-EDIT-REVERT class): "" -> default.
|
||||
parseHTML: (el: HTMLElement) =>
|
||||
el.getAttribute("data-attachment-mime") || null,
|
||||
parseHTML: (el: HTMLElement) => el.getAttribute("data-attachment-mime"),
|
||||
renderHTML: (attrs: Record<string, any>) =>
|
||||
attrs.mime ? { "data-attachment-mime": attrs.mime } : {},
|
||||
},
|
||||
@@ -693,10 +689,7 @@ const Video = Node.create({
|
||||
},
|
||||
alt: {
|
||||
default: null,
|
||||
// Empty-string-vs-absent idempotency: coerce "" back to the default so a
|
||||
// stray empty `aria-label` never materializes `alt: ""` on a video stored
|
||||
// with no alt (same GS-EDIT-REVERT class as the image `alt` fix).
|
||||
parseHTML: (el: HTMLElement) => el.getAttribute("aria-label") || null,
|
||||
parseHTML: (el: HTMLElement) => el.getAttribute("aria-label"),
|
||||
renderHTML: (attrs: Record<string, any>) =>
|
||||
attrs.alt ? { "aria-label": attrs.alt } : {},
|
||||
},
|
||||
@@ -871,15 +864,13 @@ const diagramAttributes = () => ({
|
||||
},
|
||||
title: {
|
||||
default: null,
|
||||
// Empty-string-vs-absent idempotency (GS-EDIT-REVERT class): "" -> default.
|
||||
parseHTML: (el: HTMLElement) => el.getAttribute("data-title") || null,
|
||||
parseHTML: (el: HTMLElement) => el.getAttribute("data-title"),
|
||||
renderHTML: (attrs: Record<string, any>) =>
|
||||
attrs.title ? { "data-title": attrs.title } : {},
|
||||
},
|
||||
alt: {
|
||||
default: null,
|
||||
// Empty-string-vs-absent idempotency (GS-EDIT-REVERT class): "" -> default.
|
||||
parseHTML: (el: HTMLElement) => el.getAttribute("data-alt") || null,
|
||||
parseHTML: (el: HTMLElement) => el.getAttribute("data-alt"),
|
||||
renderHTML: (attrs: Record<string, any>) =>
|
||||
attrs.alt ? { "data-alt": attrs.alt } : {},
|
||||
},
|
||||
@@ -1115,8 +1106,7 @@ const Pdf = Node.create({
|
||||
},
|
||||
name: {
|
||||
default: null,
|
||||
// Empty-string-vs-absent idempotency (GS-EDIT-REVERT class): "" -> default.
|
||||
parseHTML: (el: HTMLElement) => el.getAttribute("data-name") || null,
|
||||
parseHTML: (el: HTMLElement) => el.getAttribute("data-name"),
|
||||
renderHTML: (attrs: Record<string, any>) =>
|
||||
attrs.name ? { "data-name": attrs.name } : {},
|
||||
},
|
||||
@@ -1501,29 +1491,6 @@ export const docmostExtensions = [
|
||||
...parent.height,
|
||||
parseHTML: (el: HTMLElement) => el.getAttribute("height"),
|
||||
},
|
||||
// Empty-string-vs-absent idempotency (GS-EDIT-REVERT class). `marked`
|
||||
// renders `` as `<img alt="">`, so the stock Image `alt`
|
||||
// parseHTML (`getAttribute("alt")`) materializes `alt: ""` on an image
|
||||
// that was stored with NO alt (attr absent). That is a false diff against
|
||||
// the editor-stored form (a no-alt image has alt ABSENT, not ""), so a
|
||||
// git-sync / ai-chat touch of a page with a plain image produced phantom
|
||||
// churn. Coerce an empty string back to the attr's default (null) so the
|
||||
// import is idempotent. A real alt survives verbatim (`|| undefined` keeps
|
||||
// the truthy value; the default fills the empty case). `title` is coerced
|
||||
// the same way for the whole class, even though `marked` does not
|
||||
// currently emit `title=""` — defence in depth against any path that does.
|
||||
// NOTE: this DIVERGES from editor-ext's literal image `alt` parseHTML
|
||||
// (`getAttribute("alt")`, which returns "" verbatim), but CONVERGES on
|
||||
// editor-ext's real STORED shape: an editor image inserted without alt
|
||||
// renders with no `alt` attribute and re-parses as absent, never "".
|
||||
alt: {
|
||||
...parent.alt,
|
||||
parseHTML: (el: HTMLElement) => el.getAttribute("alt") || null,
|
||||
},
|
||||
title: {
|
||||
...parent.title,
|
||||
parseHTML: (el: HTMLElement) => el.getAttribute("title") || null,
|
||||
},
|
||||
};
|
||||
},
|
||||
}).configure({ inline: false }),
|
||||
|
||||
@@ -1,443 +0,0 @@
|
||||
/**
|
||||
* Reusable round-trip-STABILITY matrix helper (fixtures-first).
|
||||
*
|
||||
* A single stored node authored WITHOUT a given string attribute (attr
|
||||
* absent / undefined) must not gain a phantom EMPTY-STRING value after a
|
||||
* markdown round-trip — the "empty-string-vs-absent" churn class. This helper,
|
||||
* given a node spec, drives a matrix of attribute combinations through the REAL
|
||||
* converter (`convertProseMirrorToMarkdown` -> `markdownToProseMirror`) and
|
||||
* asserts byte-stability on two contours:
|
||||
*
|
||||
* 1. RAW round-trip: for the node under test, every attribute the round-trip
|
||||
* materializes must equal what the INPUT authored — an authored attr keeps
|
||||
* its value, an ABSENT attr may only reappear at its SCHEMA DEFAULT. If an
|
||||
* absent attr comes back as a NON-default value (e.g. `alt: ""` where the
|
||||
* default is `null`), that is an instability and is reported precisely as
|
||||
* `type.attr: absent -> "<got>"`. This is the contour git-sync / stored
|
||||
* JSON diffs on, so masking it only in `canonicalize` would leave the noise.
|
||||
*
|
||||
* 2. CANONICAL round-trip: `canonicalizeContent(original)` must deep-equal
|
||||
* `canonicalizeContent(roundtrip)` (a second, semantic contour).
|
||||
*
|
||||
* The ONLY normalization the helper treats as allowed (not an instability) is
|
||||
* the DOCUMENTED numeric width/height/size/aspectRatio -> string coercion the
|
||||
* converter performs on purpose (a stored numeric `640` re-parses via
|
||||
* `getAttribute` as the string `"640"`). It is encoded here as an explicit
|
||||
* per-spec `numericStringAttrs` set applied to BOTH contours, NOT a silent skip.
|
||||
*
|
||||
* The helper is node-type agnostic: image and the whole media family share the
|
||||
* `align !== "center"` predicate + `<!--name {…}-->` comment machinery, so one
|
||||
* matrix guards the shared class.
|
||||
*/
|
||||
import { getSchema } from "@tiptap/core";
|
||||
import {
|
||||
convertProseMirrorToMarkdown,
|
||||
markdownToProseMirror,
|
||||
canonicalizeContent,
|
||||
docmostExtensions,
|
||||
} from "../src/lib/index.js";
|
||||
import { firstDivergence } from "./roundtrip-helpers.js";
|
||||
|
||||
/** One attribute's two probe values. */
|
||||
export interface AttrMatrixEntry {
|
||||
/** Attribute name on the node. */
|
||||
attr: string;
|
||||
/**
|
||||
* The "default" pick. `undefined` means the attribute is OMITTED entirely
|
||||
* (the absent case — the one that can materialize an empty string on import).
|
||||
* A concrete value is authored verbatim.
|
||||
*/
|
||||
default: unknown;
|
||||
/** A representative NON-default value to exercise (must survive verbatim). */
|
||||
nonDefault: unknown;
|
||||
/**
|
||||
* Marks the attr as a member of the EMPTY-STRING class the fix targets: a
|
||||
* string attr whose schema default is `null`/absent and whose parseHTML
|
||||
* coerces `"" -> default` (image/drawio `alt`+`title`, video `alt` via
|
||||
* aria-label, pdf/attachment `name`, attachment `mime`). Set true to also
|
||||
* drive the THIRD-STATE convergence case (see runConvergenceCase) for this
|
||||
* attr. Attrs whose default is NOT null (e.g. embed `provider`, default "")
|
||||
* or that are not `""`-coerced (control attrs) are left unset.
|
||||
*/
|
||||
emptyStringClass?: boolean;
|
||||
}
|
||||
|
||||
/** A node type + the attribute matrix to sweep for it. */
|
||||
export interface NodeStabilitySpec {
|
||||
/** Node type (e.g. "image", "video"). */
|
||||
type: string;
|
||||
/** Attributes always present on the node (e.g. `{ src: "/i.png" }`). */
|
||||
baseAttrs?: Record<string, unknown>;
|
||||
/** Attributes to sweep at default and non-default. */
|
||||
attrMatrix: AttrMatrixEntry[];
|
||||
/**
|
||||
* Attributes whose numeric -> string coercion on round-trip is DOCUMENTED and
|
||||
* intentional; compared modulo `String(x)` on both sides. Defaults to the
|
||||
* converter's known sizing set.
|
||||
*/
|
||||
numericStringAttrs?: string[];
|
||||
}
|
||||
|
||||
/** A single unstable finding, legible enough to tie a gate-lock to. */
|
||||
export interface Instability {
|
||||
type: string;
|
||||
attr: string;
|
||||
/** What the input authored: the literal value, or the ABSENT sentinel. */
|
||||
authored: unknown | typeof ABSENT;
|
||||
/** What the round-trip produced. */
|
||||
got: unknown;
|
||||
/** What a stable round-trip should have produced (authored value or default). */
|
||||
expected: unknown;
|
||||
}
|
||||
|
||||
/** One matrix cell's result. */
|
||||
export interface ComboResult {
|
||||
label: string;
|
||||
authored: Record<string, unknown>;
|
||||
/** RAW-contour instabilities on the node under test. */
|
||||
raw: Instability[];
|
||||
/** CANONICAL-contour divergence (path + values) or null when equal. */
|
||||
canonical: { path: string; a: unknown; b: unknown } | null;
|
||||
/** True when the node type failed to round-trip at all (structural loss). */
|
||||
missing: boolean;
|
||||
md: string;
|
||||
}
|
||||
|
||||
/** Whole-matrix report for one node spec. */
|
||||
export interface MatrixReport {
|
||||
type: string;
|
||||
combos: ComboResult[];
|
||||
}
|
||||
|
||||
/** Sentinel marking an attribute the input did NOT author. */
|
||||
export const ABSENT = Symbol("ABSENT");
|
||||
|
||||
const DEFAULT_NUMERIC_STRING_ATTRS = [
|
||||
"width",
|
||||
"height",
|
||||
"size",
|
||||
"aspectRatio",
|
||||
];
|
||||
|
||||
// The ProseMirror schema the converter targets — its attribute `default`s are
|
||||
// the authoritative "what an absent attr should re-materialize as" oracle.
|
||||
const schema = getSchema(docmostExtensions);
|
||||
|
||||
/** Read the schema default for every attribute of a node type. */
|
||||
function schemaDefaults(type: string): Record<string, unknown> {
|
||||
const specAttrs = (schema.nodes[type]?.spec?.attrs ?? {}) as Record<
|
||||
string,
|
||||
{ default: unknown }
|
||||
>;
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(specAttrs)) out[k] = v.default;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Find the first node of a given type anywhere in a PM doc tree. */
|
||||
function findFirst(node: any, type: string): any {
|
||||
if (node && node.type === type) return node;
|
||||
for (const child of node?.content ?? []) {
|
||||
const hit = findFirst(child, type);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Coerce a scalar for the documented numeric->string comparison. */
|
||||
const numStr = (x: unknown): unknown => (x == null ? x : String(x));
|
||||
|
||||
/**
|
||||
* Enumerate the cartesian product of the matrix: every attribute independently
|
||||
* at its default (index 0) or non-default (index 1) pick. The all-default
|
||||
* corner is included (the baseline). Small by construction (2^N over a handful
|
||||
* of at-risk string attrs).
|
||||
*/
|
||||
function enumerateCombos(matrix: AttrMatrixEntry[]): number[][] {
|
||||
let combos: number[][] = [[]];
|
||||
for (let i = 0; i < matrix.length; i++) {
|
||||
const next: number[][] = [];
|
||||
for (const c of combos) {
|
||||
next.push([...c, 0]);
|
||||
next.push([...c, 1]);
|
||||
}
|
||||
combos = next;
|
||||
}
|
||||
return combos;
|
||||
}
|
||||
|
||||
/** Build the authored attrs for one combo pick vector. */
|
||||
function authoredAttrs(
|
||||
spec: NodeStabilitySpec,
|
||||
picks: number[],
|
||||
): Record<string, unknown> {
|
||||
const attrs: Record<string, unknown> = { ...(spec.baseAttrs ?? {}) };
|
||||
spec.attrMatrix.forEach((entry, i) => {
|
||||
if (picks[i] === 1) {
|
||||
attrs[entry.attr] = entry.nonDefault;
|
||||
} else if (entry.default !== undefined) {
|
||||
attrs[entry.attr] = entry.default;
|
||||
}
|
||||
// default === undefined -> OMIT the attr entirely (the absent case).
|
||||
});
|
||||
return attrs;
|
||||
}
|
||||
|
||||
/** Human-readable label for a combo (which attrs are at non-default). */
|
||||
function comboLabel(spec: NodeStabilitySpec, picks: number[]): string {
|
||||
const on = spec.attrMatrix
|
||||
.filter((_, i) => picks[i] === 1)
|
||||
.map((e) => e.attr);
|
||||
return on.length === 0 ? "<all-default>" : on.join("+");
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the full stability matrix for one node spec and return a structured
|
||||
* report (does NOT throw — the caller asserts, so a failure can print the whole
|
||||
* report). Every combo runs the real export->import pipeline once.
|
||||
*/
|
||||
export async function runStabilityMatrix(
|
||||
spec: NodeStabilitySpec,
|
||||
): Promise<MatrixReport> {
|
||||
const numericStringAttrs = new Set(
|
||||
spec.numericStringAttrs ?? DEFAULT_NUMERIC_STRING_ATTRS,
|
||||
);
|
||||
const defaults = schemaDefaults(spec.type);
|
||||
const combos: ComboResult[] = [];
|
||||
|
||||
for (const picks of enumerateCombos(spec.attrMatrix)) {
|
||||
const authored = authoredAttrs(spec, picks);
|
||||
const doc = { type: "doc", content: [{ type: spec.type, attrs: authored }] };
|
||||
const md = convertProseMirrorToMarkdown(doc);
|
||||
const rt = await markdownToProseMirror(md);
|
||||
const node = findFirst(rt, spec.type);
|
||||
|
||||
const result: ComboResult = {
|
||||
label: comboLabel(spec, picks),
|
||||
authored,
|
||||
raw: [],
|
||||
canonical: null,
|
||||
missing: node == null,
|
||||
md,
|
||||
};
|
||||
|
||||
if (node != null) {
|
||||
// RAW contour: every materialized attr must equal the authored value, or
|
||||
// (for an absent attr) the schema default — modulo the documented numeric
|
||||
// string coercion.
|
||||
const rtAttrs = (node.attrs ?? {}) as Record<string, unknown>;
|
||||
for (const key of Object.keys(rtAttrs)) {
|
||||
const authoredHas = Object.prototype.hasOwnProperty.call(authored, key);
|
||||
const expected = authoredHas ? authored[key] : defaults[key];
|
||||
let got = rtAttrs[key];
|
||||
let exp = expected;
|
||||
if (numericStringAttrs.has(key)) {
|
||||
got = numStr(got);
|
||||
exp = numStr(exp);
|
||||
}
|
||||
if (firstDivergence(got, exp) !== null) {
|
||||
result.raw.push({
|
||||
type: spec.type,
|
||||
attr: key,
|
||||
authored: authoredHas ? authored[key] : ABSENT,
|
||||
got: rtAttrs[key],
|
||||
expected,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// CANONICAL contour: canonical forms deep-equal, modulo the same numeric
|
||||
// string coercion (applied to both trees so a documented coercion is not
|
||||
// counted as a divergence).
|
||||
const ca = normalizeNumeric(canonicalizeContent(doc), numericStringAttrs);
|
||||
const cb = normalizeNumeric(canonicalizeContent(rt), numericStringAttrs);
|
||||
result.canonical = firstDivergence(ca, cb);
|
||||
}
|
||||
|
||||
combos.push(result);
|
||||
}
|
||||
|
||||
return { type: spec.type, combos };
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-copy a canonical tree, coercing the documented numeric->string attrs to
|
||||
* their string form so an intentional `640 -> "640"` coercion is not reported
|
||||
* as a canonical divergence. Only touches the listed attribute keys.
|
||||
*/
|
||||
function normalizeNumeric(node: any, attrs: Set<string>): any {
|
||||
if (Array.isArray(node)) return node.map((n) => normalizeNumeric(n, attrs));
|
||||
if (node === null || typeof node !== "object") return node;
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(node)) {
|
||||
if (key === "attrs" && node.attrs && typeof node.attrs === "object") {
|
||||
const a: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(node.attrs)) {
|
||||
a[k] = attrs.has(k) ? numStr(v) : v;
|
||||
}
|
||||
out.attrs = a;
|
||||
} else {
|
||||
out[key] = normalizeNumeric(node[key], attrs);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Flatten a report to just its unstable combos (for a terse assertion). */
|
||||
export function unstableCombos(report: MatrixReport): ComboResult[] {
|
||||
return report.combos.filter(
|
||||
(c) => c.missing || c.raw.length > 0 || c.canonical !== null,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// THIRD STATE: an EXPLICITLY-STORED empty string on a string attr.
|
||||
//
|
||||
// The matrix above sweeps TWO states per string attr: absent/default and a
|
||||
// non-default value — and asserts FIRST-pass byte-stability for both. There is
|
||||
// a third, degenerate state the matrix does NOT cover: the attr stored as a
|
||||
// LITERAL `""`. This is DISTINCT from "the node never had the attr": a user
|
||||
// types an alt in the editor, then deletes it, and Tiptap's
|
||||
// `updateAttributes({ alt: "" })` persists a literal `alt: ""` in the stored
|
||||
// JSON. There is no absent-vs-"" distinction in the DOM once serialized, so the
|
||||
// fix's `getAttribute("alt") || null` coercion canonicalizes BOTH to the
|
||||
// default (`null`).
|
||||
//
|
||||
// Consequence — and this is CORRECT, not a bug: a doc carrying an explicit `""`
|
||||
// converges to the default on the FIRST round-trip (a ONE-TIME diff: `"" ->
|
||||
// null`), then is byte-stable from the SECOND round-trip on (idempotent). So
|
||||
// this state must be pinned with a DIFFERENT contract than the matrix's:
|
||||
// - do NOT assert first-pass byte-stability (the first pass legitimately
|
||||
// changes `""` -> default), and
|
||||
// - DO assert the first pass converges to the default AND the second pass is
|
||||
// idempotent (rt2 deep-equals rt1).
|
||||
//
|
||||
// A future sync/QA pass diffing stored pages will see this one-time `"" -> null`
|
||||
// normalization exactly once per affected node; it is the converter canon, not
|
||||
// corruption, and must not be flagged as data loss.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Result of the third-state ("explicit empty string") convergence probe. */
|
||||
export interface ConvergenceResult {
|
||||
type: string;
|
||||
attr: string;
|
||||
/** The schema default the attr must converge to on pass 1 (null / absent). */
|
||||
expectedDefault: unknown;
|
||||
/** rt1's materialized value for the attr — must equal `expectedDefault`. */
|
||||
firstPassValue: unknown;
|
||||
/** True when the node round-tripped AND rt1 converged the attr to default. */
|
||||
convergedToDefault: boolean;
|
||||
/** rt1-vs-rt2 divergence; MUST be null (idempotent from pass 2 on). */
|
||||
secondPassDivergence: { path: string; a: unknown; b: unknown } | null;
|
||||
/** True when the node type failed to round-trip at all (structural loss). */
|
||||
missing: boolean;
|
||||
}
|
||||
|
||||
/** Round-trip a full PM doc through the real converter once. */
|
||||
async function roundtripDoc(doc: any): Promise<any> {
|
||||
return markdownToProseMirror(convertProseMirrorToMarkdown(doc));
|
||||
}
|
||||
|
||||
/**
|
||||
* Third-state convergence probe for one string attr of the empty-string class.
|
||||
*
|
||||
* (a) builds a doc with the attr EXPLICITLY set to `""` (baseAttrs + `""`),
|
||||
* (b) rt1 = roundtrip(doc); asserts rt1's attr equals the schema default — the
|
||||
* documented ONE-TIME `"" -> default` normalization (NOT byte-stable vs the
|
||||
* `""` input, so first-pass stability is deliberately NOT asserted here),
|
||||
* (c) rt2 = roundtrip(rt1); asserts rt2 deep-equals rt1 — idempotent from the
|
||||
* second round-trip on.
|
||||
*
|
||||
* Returns a structured result (does NOT throw) so the caller can assert and
|
||||
* print. Reusable across the whole node family: drive it for every attr flagged
|
||||
* `emptyStringClass` on every spec (see convergenceCasesFor / the test driver).
|
||||
*/
|
||||
export async function runConvergenceCase(
|
||||
spec: NodeStabilitySpec,
|
||||
attr: string,
|
||||
): Promise<ConvergenceResult> {
|
||||
const expectedDefault = schemaDefaults(spec.type)[attr];
|
||||
|
||||
// (a) The degenerate third state: attr persisted as a LITERAL "".
|
||||
const authored = { ...(spec.baseAttrs ?? {}), [attr]: "" };
|
||||
const doc = { type: "doc", content: [{ type: spec.type, attrs: authored }] };
|
||||
|
||||
// (b) First round-trip: "" must normalize to the default (a one-time diff).
|
||||
const rt1 = await roundtripDoc(doc);
|
||||
const node1 = findFirst(rt1, spec.type);
|
||||
const firstPassValue = node1?.attrs?.[attr];
|
||||
const convergedToDefault =
|
||||
node1 != null && firstDivergence(firstPassValue, expectedDefault) === null;
|
||||
|
||||
// (c) Second round-trip: must be byte-stable (rt2 deep-equals rt1). We compare
|
||||
// the WHOLE docs — both are converter OUTPUTS already in the same materialized
|
||||
// form (numeric attrs are strings on both sides), so no numeric normalization
|
||||
// is needed here, unlike the raw/canonical contours above.
|
||||
const rt2 = node1 != null ? await roundtripDoc(rt1) : rt1;
|
||||
const secondPassDivergence =
|
||||
node1 != null ? firstDivergence(rt1, rt2) : null;
|
||||
|
||||
return {
|
||||
type: spec.type,
|
||||
attr,
|
||||
expectedDefault,
|
||||
firstPassValue,
|
||||
convergedToDefault,
|
||||
secondPassDivergence,
|
||||
missing: node1 == null,
|
||||
};
|
||||
}
|
||||
|
||||
/** The attrs of a spec flagged as members of the empty-string class. */
|
||||
export function convergenceCasesFor(spec: NodeStabilitySpec): string[] {
|
||||
return spec.attrMatrix
|
||||
.filter((e) => e.emptyStringClass)
|
||||
.map((e) => e.attr);
|
||||
}
|
||||
|
||||
/** True when a convergence result honours the "converges once, then stable" contract. */
|
||||
export function convergenceOk(r: ConvergenceResult): boolean {
|
||||
return !r.missing && r.convergedToDefault && r.secondPassDivergence === null;
|
||||
}
|
||||
|
||||
/** Render a convergence result as a legible one-liner for a failed assertion. */
|
||||
export function formatConvergence(r: ConvergenceResult): string {
|
||||
if (r.missing) return `${r.type}.${r.attr}: DID-NOT-ROUND-TRIP`;
|
||||
const parts: string[] = [];
|
||||
if (!r.convergedToDefault) {
|
||||
parts.push(
|
||||
`pass1 did NOT converge: got ${JSON.stringify(r.firstPassValue)} (expected default ${JSON.stringify(r.expectedDefault)})`,
|
||||
);
|
||||
}
|
||||
if (r.secondPassDivergence) {
|
||||
parts.push(
|
||||
`pass2 NOT idempotent @ ${r.secondPassDivergence.path}: ${JSON.stringify(r.secondPassDivergence.a)} vs ${JSON.stringify(r.secondPassDivergence.b)}`,
|
||||
);
|
||||
}
|
||||
const status = parts.length === 0 ? "converges-once-then-stable" : parts.join("; ");
|
||||
return `${r.type}.${r.attr}: ${status}`;
|
||||
}
|
||||
|
||||
/** Render a report as a legible multi-line string for a failed assertion. */
|
||||
export function formatReport(report: MatrixReport): string {
|
||||
const lines: string[] = [`node "${report.type}":`];
|
||||
for (const c of report.combos) {
|
||||
const flags: string[] = [];
|
||||
if (c.missing) flags.push("DID-NOT-ROUND-TRIP");
|
||||
for (const i of c.raw) {
|
||||
const authored =
|
||||
i.authored === ABSENT ? "absent" : JSON.stringify(i.authored);
|
||||
flags.push(
|
||||
`RAW ${i.type}.${i.attr}: ${authored} -> ${JSON.stringify(i.got)} (expected ${JSON.stringify(i.expected)})`,
|
||||
);
|
||||
}
|
||||
if (c.canonical) {
|
||||
flags.push(
|
||||
`CANON @ ${c.canonical.path}: ${JSON.stringify(c.canonical.a)} vs ${JSON.stringify(c.canonical.b)}`,
|
||||
);
|
||||
}
|
||||
const status = flags.length === 0 ? "stable" : flags.join("; ");
|
||||
lines.push(` [${c.label}] ${status}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
runStabilityMatrix,
|
||||
unstableCombos,
|
||||
formatReport,
|
||||
runConvergenceCase,
|
||||
convergenceCasesFor,
|
||||
convergenceOk,
|
||||
formatConvergence,
|
||||
type NodeStabilitySpec,
|
||||
} from "./roundtrip-stability.helper.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Round-trip STABILITY matrix for image + the media family.
|
||||
//
|
||||
// Guards the "empty-string-vs-absent" churn class (GS-EDIT-REVERT family): a
|
||||
// stored node authored WITHOUT a string attr (alt/title/caption/aria-label/...)
|
||||
// must not gain a phantom `attr: ""` after `markdownToProseMirror(convert…)`.
|
||||
// Each spec sweeps the at-risk string attrs at DEFAULT (absent) and at a real
|
||||
// NON-default value; the helper asserts both the RAW round-trip (attrs equal the
|
||||
// input's, modulo the documented numeric width/height/size/aspectRatio -> string
|
||||
// coercion) and the CANONICAL round-trip (canonical forms deep-equal).
|
||||
//
|
||||
// The image + media family share the `align !== "center"` predicate and the
|
||||
// `<!--name {…}-->` comment machinery, so one matrix guards the shared class.
|
||||
// align is NOT part of this class (it round-trips correctly) and is not swept.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SPECS: NodeStabilitySpec[] = [
|
||||
{
|
||||
// Image carries the most at-risk string attrs. `alt` is the one marked
|
||||
// materializes as `<img alt="">` on `` import (the real bug); title
|
||||
// and caption are covered as the same class. attachmentId is a string attr
|
||||
// that must stay absent when unset (control).
|
||||
type: "image",
|
||||
baseAttrs: { src: "/i.png" },
|
||||
attrMatrix: [
|
||||
{ attr: "alt", default: undefined, nonDefault: "a real alt text", emptyStringClass: true },
|
||||
{ attr: "title", default: undefined, nonDefault: "a real title", emptyStringClass: true },
|
||||
{ attr: "caption", default: undefined, nonDefault: "a real caption" },
|
||||
{ attr: "attachmentId", default: undefined, nonDefault: "att-42" },
|
||||
],
|
||||
},
|
||||
{
|
||||
// Video's `alt` rides the `aria-label` attribute (media aria-label at risk).
|
||||
type: "video",
|
||||
baseAttrs: { src: "/v.mp4" },
|
||||
attrMatrix: [
|
||||
{ attr: "alt", default: undefined, nonDefault: "a clip", emptyStringClass: true },
|
||||
{ attr: "attachmentId", default: undefined, nonDefault: "att-1" },
|
||||
],
|
||||
},
|
||||
{
|
||||
// Audio carries no alt/title; attachmentId is its only optional string attr.
|
||||
type: "audio",
|
||||
baseAttrs: { src: "/a.mp3" },
|
||||
attrMatrix: [
|
||||
{ attr: "attachmentId", default: undefined, nonDefault: "att-2" },
|
||||
],
|
||||
},
|
||||
{
|
||||
// pdf: link-form media. `name` (filename) is its at-risk string attr.
|
||||
type: "pdf",
|
||||
baseAttrs: { src: "/d.pdf" },
|
||||
attrMatrix: [
|
||||
{ attr: "name", default: undefined, nonDefault: "report.pdf", emptyStringClass: true },
|
||||
{ attr: "attachmentId", default: undefined, nonDefault: "att-3" },
|
||||
],
|
||||
},
|
||||
{
|
||||
// attachment: link-form media (file card). `name` + `mime` string attrs.
|
||||
type: "attachment",
|
||||
baseAttrs: { url: "/f.zip" },
|
||||
attrMatrix: [
|
||||
{ attr: "name", default: undefined, nonDefault: "bundle.zip", emptyStringClass: true },
|
||||
{ attr: "mime", default: undefined, nonDefault: "application/zip", emptyStringClass: true },
|
||||
{ attr: "attachmentId", default: undefined, nonDefault: "att-4" },
|
||||
],
|
||||
},
|
||||
{
|
||||
// embed: link-form media. `provider` is its at-risk string attr (schema
|
||||
// default ""). embed's numeric width/height defaults (800/600) are a SEPARATE,
|
||||
// documented limitation OUTSIDE the empty-string class: they are not in
|
||||
// canonicalize's KNOWN_DEFAULTS, so an ABSENT width/height re-imports as the
|
||||
// 800/600 default and diverges canonically (see the note in canonicalize.ts).
|
||||
// That is canonicalize-owned and out of scope here, so we author the
|
||||
// dimensions at their defaults (as real editor embeds carry them) to keep this
|
||||
// guard focused on the empty-string/provider class.
|
||||
// provider's schema default is "" (NOT null), so a re-imported "" is the
|
||||
// correct value, not a phantom — it is outside the null-default empty-string
|
||||
// class. We author it at its "" default (the default pick) so the sweep still
|
||||
// asserts a non-default provider ("youtube") round-trips, without tripping the
|
||||
// canonicalize KNOWN_DEFAULTS gap for embed's non-null defaults.
|
||||
type: "embed",
|
||||
baseAttrs: { src: "https://example.com/x", width: 800, height: 600 },
|
||||
attrMatrix: [
|
||||
{ attr: "provider", default: "", nonDefault: "youtube" },
|
||||
],
|
||||
},
|
||||
{
|
||||
// drawio: image-form diagram. `title` + `alt` string attrs (data-title/-alt).
|
||||
type: "drawio",
|
||||
baseAttrs: { src: "blob:drawio" },
|
||||
attrMatrix: [
|
||||
{ attr: "title", default: undefined, nonDefault: "flow chart", emptyStringClass: true },
|
||||
{ attr: "alt", default: undefined, nonDefault: "an alt", emptyStringClass: true },
|
||||
{ attr: "attachmentId", default: undefined, nonDefault: "att-5" },
|
||||
],
|
||||
},
|
||||
{
|
||||
// excalidraw: image-form diagram, same shared diagramAttributes set.
|
||||
type: "excalidraw",
|
||||
baseAttrs: { src: "blob:excalidraw" },
|
||||
attrMatrix: [
|
||||
{ attr: "title", default: undefined, nonDefault: "sketch", emptyStringClass: true },
|
||||
{ attr: "alt", default: undefined, nonDefault: "an alt", emptyStringClass: true },
|
||||
{ attr: "attachmentId", default: undefined, nonDefault: "att-6" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
describe("round-trip stability matrix (image + media family)", () => {
|
||||
for (const spec of SPECS) {
|
||||
it(`${spec.type}: no attr materializes an empty-string / phantom value`, async () => {
|
||||
const report = await runStabilityMatrix(spec);
|
||||
const unstable = unstableCombos(report);
|
||||
// On failure, print the WHOLE matrix so which (attr, value) combos are
|
||||
// unstable is legible.
|
||||
expect(unstable, `\n${formatReport(report)}\n`).toEqual([]);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// THIRD STATE: an attr EXPLICITLY stored as a literal "" (GS-EDIT-REVERT: a user
|
||||
// typed alt/title/name/... then deleted it, so Tiptap persisted `attr: ""` — a
|
||||
// value DISTINCT from "attr was never set"). Unlike the absent case above, this
|
||||
// state is NOT first-pass byte-stable: the fix's `"" -> default` coercion is a
|
||||
// deliberate ONE-TIME normalization on the FIRST sync round-trip, stable
|
||||
// thereafter. We therefore assert a DIFFERENT contract — "converges to default
|
||||
// on pass 1, then idempotent from pass 2 on" — for every empty-string-class attr
|
||||
// across the whole node family (image/video/pdf/attachment/drawio/excalidraw).
|
||||
//
|
||||
// IMPORTANT for a future sync/QA pass: the pass-1 `"" -> null` diff is the
|
||||
// converter canon, not corruption. It appears at most once per affected node and
|
||||
// must NOT be flagged as "the converter is losing/corrupting page data".
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("round-trip third state: explicit empty string converges once, then idempotent", () => {
|
||||
for (const spec of SPECS) {
|
||||
for (const attr of convergenceCasesFor(spec)) {
|
||||
it(`${spec.type}.${attr}: "" normalizes to default on pass 1, byte-stable from pass 2`, async () => {
|
||||
const r = await runConvergenceCase(spec, attr);
|
||||
// Pass 1 must converge "" -> the schema default (the one-time diff) and
|
||||
// pass 2 (roundtrip of pass-1 output) must be byte-stable. formatConvergence
|
||||
// prints exactly which half failed.
|
||||
expect(convergenceOk(r), `\n${formatConvergence(r)}\n`).toBe(true);
|
||||
// Spell the contract out explicitly so the intent is legible in the test:
|
||||
expect(r.convergedToDefault, `\n${formatConvergence(r)}\n`).toBe(true);
|
||||
expect(r.firstPassValue).toEqual(r.expectedDefault);
|
||||
expect(r.secondPassDivergence, `\n${formatConvergence(r)}\n`).toBeNull();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
Generated
+127
-3
@@ -501,6 +501,9 @@ importers:
|
||||
vite:
|
||||
specifier: 8.0.5
|
||||
version: 8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3)
|
||||
vite-plugin-compression2:
|
||||
specifier: 2.5.3
|
||||
version: 2.5.3
|
||||
vitest:
|
||||
specifier: 4.1.6
|
||||
version: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@22.19.1)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@25.0.0)(vite@8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3))
|
||||
@@ -540,6 +543,9 @@ importers:
|
||||
'@docmost/pdf-inspector':
|
||||
specifier: 1.9.6
|
||||
version: 1.9.6
|
||||
'@fastify/compress':
|
||||
specifier: ^9.0.0
|
||||
version: 9.0.0
|
||||
'@fastify/cookie':
|
||||
specifier: ^11.0.2
|
||||
version: 11.0.2
|
||||
@@ -2694,6 +2700,9 @@ packages:
|
||||
'@fastify/busboy@3.1.1':
|
||||
resolution: {integrity: sha512-5DGmA8FTdB2XbDeEwc/5ZXBl6UbBAyBOOLlPuBnZ/N1SwdH9Ii+cOX3tBROlDgcTXxjOYnLMVoKk9+FXAw0CJw==}
|
||||
|
||||
'@fastify/compress@9.0.0':
|
||||
resolution: {integrity: sha512-PZRg+ut5xd/ubsGPWfoPNryoCOtEdHboIWpDieTUHov1gKdLitF8mRmT3JbqNnRbelQXSNXUsIpakAEKR6AcTQ==}
|
||||
|
||||
'@fastify/cookie@11.0.2':
|
||||
resolution: {integrity: sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==}
|
||||
|
||||
@@ -4487,6 +4496,15 @@ packages:
|
||||
'@rolldown/pluginutils@1.0.0-rc.7':
|
||||
resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==}
|
||||
|
||||
'@rollup/pluginutils@5.4.0':
|
||||
resolution: {integrity: sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
peerDependencies:
|
||||
rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0
|
||||
peerDependenciesMeta:
|
||||
rollup:
|
||||
optional: true
|
||||
|
||||
'@selderee/plugin-htmlparser2@0.11.0':
|
||||
resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==}
|
||||
|
||||
@@ -5676,6 +5694,10 @@ packages:
|
||||
resolution: {integrity: sha512-/XrFJgzQQQHpti1raDJC6m4ws6aNktmjBlhk8Fdlk7LwCEuDoieEJJY9OFHjfiFJFFRM2tK+Ky/IsfbbmlMu1w==}
|
||||
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
|
||||
|
||||
abort-controller@3.0.0:
|
||||
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
|
||||
engines: {node: '>=6.5'}
|
||||
|
||||
abstract-logging@2.0.1:
|
||||
resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
|
||||
|
||||
@@ -6055,6 +6077,9 @@ packages:
|
||||
buffer@5.7.1:
|
||||
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
|
||||
|
||||
buffer@6.0.3:
|
||||
resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==}
|
||||
|
||||
bullmq@5.76.10:
|
||||
resolution: {integrity: sha512-LWve7SpQjYSpCP2GEsWmoyzTz2H37L8HRmSTu3YihYsTOr5kJxrfEX6aEV7m6eskEMWXSHZYTMZepX6qNaH6CQ==}
|
||||
engines: {node: '>=12.22.0'}
|
||||
@@ -6810,6 +6835,9 @@ packages:
|
||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
duplexify@3.7.1:
|
||||
resolution: {integrity: sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==}
|
||||
|
||||
ecdsa-sig-formatter@1.0.11:
|
||||
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
|
||||
|
||||
@@ -7053,6 +7081,9 @@ packages:
|
||||
resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
|
||||
engines: {node: '>=4.0'}
|
||||
|
||||
estree-walker@2.0.2:
|
||||
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
|
||||
|
||||
estree-walker@3.0.3:
|
||||
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
||||
|
||||
@@ -7064,6 +7095,10 @@ packages:
|
||||
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
event-target-shim@5.0.1:
|
||||
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
eventemitter2@6.4.9:
|
||||
resolution: {integrity: sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==}
|
||||
|
||||
@@ -9073,6 +9108,9 @@ packages:
|
||||
peberminta@0.9.0:
|
||||
resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==}
|
||||
|
||||
peek-stream@1.1.3:
|
||||
resolution: {integrity: sha512-FhJ+YbOSBb9/rIl2ZeE/QHEsWn7PqNYt8ARAY3kIgNGOk13g9FGyIY6JIl/xB/3TFRVoTv5as0l11weORrTekA==}
|
||||
|
||||
pend@1.2.0:
|
||||
resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
|
||||
|
||||
@@ -9318,6 +9356,10 @@ packages:
|
||||
process-warning@5.0.0:
|
||||
resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==}
|
||||
|
||||
process@0.11.10:
|
||||
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
|
||||
engines: {node: '>= 0.6.0'}
|
||||
|
||||
prompts@2.4.2:
|
||||
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
|
||||
engines: {node: '>= 6'}
|
||||
@@ -9609,6 +9651,10 @@ packages:
|
||||
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
|
||||
engines: {node: '>= 6'}
|
||||
|
||||
readable-stream@4.7.0:
|
||||
resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==}
|
||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||
|
||||
readdirp@3.6.0:
|
||||
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
|
||||
engines: {node: '>=8.10.0'}
|
||||
@@ -9997,6 +10043,9 @@ packages:
|
||||
stream-browserify@3.0.0:
|
||||
resolution: {integrity: sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==}
|
||||
|
||||
stream-shift@1.0.3:
|
||||
resolution: {integrity: sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==}
|
||||
|
||||
strict-event-emitter-types@2.0.0:
|
||||
resolution: {integrity: sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==}
|
||||
|
||||
@@ -10137,6 +10186,9 @@ packages:
|
||||
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
tar-mini@0.2.0:
|
||||
resolution: {integrity: sha512-+qfUHz700DWnRutdUsxRRVZ38G1Qr27OetwaMYTdg8hcPxf46U0S1Zf76dQMWRBmusOt2ZCK5kbIaiLkoGO7WQ==}
|
||||
|
||||
tar-stream@2.2.0:
|
||||
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -10177,6 +10229,9 @@ packages:
|
||||
resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
through2@2.0.5:
|
||||
resolution: {integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==}
|
||||
|
||||
tiny-invariant@1.3.3:
|
||||
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
|
||||
|
||||
@@ -10582,6 +10637,9 @@ packages:
|
||||
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
vite-plugin-compression2@2.5.3:
|
||||
resolution: {integrity: sha512-ItPgqQWkcnBbVw7is9OKwiZ8v6+ju9rYROl5Lp6QfQDEx/d55AwJQb/KLpsQqsU9HoigYBsZ8tK6I02UwJNvEw==}
|
||||
|
||||
vite@8.0.5:
|
||||
resolution: {integrity: sha512-nmu43Qvq9UopTRfMx2jOYW5l16pb3iDC1JH6yMuPkpVbzK0k+L7dfsEDH4jRgYFmsg0sTAqkojoZgzLMlwHsCQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@@ -12921,6 +12979,15 @@ snapshots:
|
||||
|
||||
'@fastify/busboy@3.1.1': {}
|
||||
|
||||
'@fastify/compress@9.0.0':
|
||||
dependencies:
|
||||
'@fastify/accept-negotiator': 2.0.1
|
||||
fastify-plugin: 5.1.0
|
||||
mime-db: 1.54.0
|
||||
minipass: 7.1.3
|
||||
peek-stream: 1.1.3
|
||||
readable-stream: 4.7.0
|
||||
|
||||
'@fastify/cookie@11.0.2':
|
||||
dependencies:
|
||||
cookie: 1.1.1
|
||||
@@ -14936,6 +15003,12 @@ snapshots:
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-rc.7': {}
|
||||
|
||||
'@rollup/pluginutils@5.4.0':
|
||||
dependencies:
|
||||
'@types/estree': 1.0.9
|
||||
estree-walker: 2.0.2
|
||||
picomatch: 4.0.4
|
||||
|
||||
'@selderee/plugin-htmlparser2@0.11.0':
|
||||
dependencies:
|
||||
domhandler: 5.0.3
|
||||
@@ -16153,7 +16226,7 @@ snapshots:
|
||||
obug: 2.1.1
|
||||
std-env: 4.1.0
|
||||
tinyrainbow: 3.1.0
|
||||
vitest: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@27.4.0(@noble/hashes@2.0.1))(vite@8.0.5(@types/node@25.5.0)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3))
|
||||
vitest: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@22.19.1)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@25.0.0)(vite@8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3))
|
||||
|
||||
'@vitest/expect@4.1.6':
|
||||
dependencies:
|
||||
@@ -16309,6 +16382,10 @@ snapshots:
|
||||
|
||||
abbrev@5.0.0: {}
|
||||
|
||||
abort-controller@3.0.0:
|
||||
dependencies:
|
||||
event-target-shim: 5.0.1
|
||||
|
||||
abstract-logging@2.0.1: {}
|
||||
|
||||
accepts@1.3.8:
|
||||
@@ -16755,6 +16832,11 @@ snapshots:
|
||||
base64-js: 1.5.1
|
||||
ieee754: 1.2.1
|
||||
|
||||
buffer@6.0.3:
|
||||
dependencies:
|
||||
base64-js: 1.5.1
|
||||
ieee754: 1.2.1
|
||||
|
||||
bullmq@5.76.10:
|
||||
dependencies:
|
||||
cron-parser: 4.9.0
|
||||
@@ -17508,6 +17590,13 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
gopd: 1.2.0
|
||||
|
||||
duplexify@3.7.1:
|
||||
dependencies:
|
||||
end-of-stream: 1.4.4
|
||||
inherits: 2.0.4
|
||||
readable-stream: 2.3.8
|
||||
stream-shift: 1.0.3
|
||||
|
||||
ecdsa-sig-formatter@1.0.11:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
@@ -17934,6 +18023,8 @@ snapshots:
|
||||
|
||||
estraverse@5.3.0: {}
|
||||
|
||||
estree-walker@2.0.2: {}
|
||||
|
||||
estree-walker@3.0.3:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.9
|
||||
@@ -17942,6 +18033,8 @@ snapshots:
|
||||
|
||||
etag@1.8.1: {}
|
||||
|
||||
event-target-shim@5.0.1: {}
|
||||
|
||||
eventemitter2@6.4.9: {}
|
||||
|
||||
eventemitter3@4.0.7: {}
|
||||
@@ -20205,6 +20298,12 @@ snapshots:
|
||||
|
||||
peberminta@0.9.0: {}
|
||||
|
||||
peek-stream@1.1.3:
|
||||
dependencies:
|
||||
buffer-from: 1.1.2
|
||||
duplexify: 3.7.1
|
||||
through2: 2.0.5
|
||||
|
||||
pend@1.2.0: {}
|
||||
|
||||
perfect-freehand@1.2.0: {}
|
||||
@@ -20476,6 +20575,8 @@ snapshots:
|
||||
|
||||
process-warning@5.0.0: {}
|
||||
|
||||
process@0.11.10: {}
|
||||
|
||||
prompts@2.4.2:
|
||||
dependencies:
|
||||
kleur: 3.0.3
|
||||
@@ -20892,6 +20993,14 @@ snapshots:
|
||||
string_decoder: 1.3.0
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
readable-stream@4.7.0:
|
||||
dependencies:
|
||||
abort-controller: 3.0.0
|
||||
buffer: 6.0.3
|
||||
events: 3.3.0
|
||||
process: 0.11.10
|
||||
string_decoder: 1.3.0
|
||||
|
||||
readdirp@3.6.0:
|
||||
dependencies:
|
||||
picomatch: 2.3.2
|
||||
@@ -21345,6 +21454,8 @@ snapshots:
|
||||
inherits: 2.0.4
|
||||
readable-stream: 3.6.2
|
||||
|
||||
stream-shift@1.0.3: {}
|
||||
|
||||
strict-event-emitter-types@2.0.0: {}
|
||||
|
||||
string-length@4.0.2:
|
||||
@@ -21505,6 +21616,8 @@ snapshots:
|
||||
|
||||
tapable@2.3.0: {}
|
||||
|
||||
tar-mini@0.2.0: {}
|
||||
|
||||
tar-stream@2.2.0:
|
||||
dependencies:
|
||||
bl: 4.1.0
|
||||
@@ -21550,6 +21663,11 @@ snapshots:
|
||||
|
||||
throttleit@2.1.0: {}
|
||||
|
||||
through2@2.0.5:
|
||||
dependencies:
|
||||
readable-stream: 2.3.8
|
||||
xtend: 4.0.2
|
||||
|
||||
tiny-invariant@1.3.3: {}
|
||||
|
||||
tinybench@2.9.0: {}
|
||||
@@ -21954,6 +22072,13 @@ snapshots:
|
||||
|
||||
vary@1.1.2: {}
|
||||
|
||||
vite-plugin-compression2@2.5.3:
|
||||
dependencies:
|
||||
'@rollup/pluginutils': 5.4.0
|
||||
tar-mini: 0.2.0
|
||||
transitivePeerDependencies:
|
||||
- rollup
|
||||
|
||||
vite@8.0.5(@types/node@20.19.43)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3):
|
||||
dependencies:
|
||||
lightningcss: 1.32.0
|
||||
@@ -22334,8 +22459,7 @@ snapshots:
|
||||
|
||||
xpath@0.0.34: {}
|
||||
|
||||
xtend@4.0.2:
|
||||
optional: true
|
||||
xtend@4.0.2: {}
|
||||
|
||||
y-indexeddb@9.0.12(yjs@13.6.30(patch_hash=1ceeb66dba1f86545c98a3ff7f5152aff9b35caf409091cef9caedb5e65c8810)):
|
||||
dependencies:
|
||||
|
||||
Reference in New Issue
Block a user