Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d1b033fe8 | |||
| fcbe840c74 | |||
| 5336f06d10 | |||
| 4bd579f7f6 | |||
| 7bf1c91a95 | |||
| 6c82c54470 | |||
| 382e5196da | |||
| 76e0c08cec |
@@ -18,12 +18,48 @@ env:
|
||||
IMAGE: ghcr.io/vvzvlad/gitmost
|
||||
|
||||
jobs:
|
||||
# Run the reusable test suite first so a failing test blocks the image build.
|
||||
# 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.
|
||||
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:
|
||||
needs: test
|
||||
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]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
@@ -57,13 +93,10 @@ 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 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 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-server:
|
||||
runs-on: ubuntu-latest
|
||||
# Hard cap: the full-AppModule e2e leaks open handles and hung jest to the 6h max.
|
||||
@@ -124,9 +157,7 @@ jobs:
|
||||
- name: Run server e2e
|
||||
run: pnpm --filter ./apps/server test:e2e
|
||||
|
||||
# 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.
|
||||
# Gates the publish too — see the comment above e2e-server.
|
||||
e2e-mcp:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
+16
-2
@@ -5,6 +5,13 @@ 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 . .
|
||||
@@ -57,9 +64,16 @@ COPY --from=builder /app/patches /app/patches
|
||||
|
||||
RUN chown -R node:node /app
|
||||
|
||||
USER node
|
||||
# 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/*
|
||||
|
||||
RUN pnpm install --frozen-lockfile --prod
|
||||
USER node
|
||||
|
||||
RUN mkdir -p /app/data/storage
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/react": "^3.0.208",
|
||||
"@braintree/sanitize-url": "7.1.2",
|
||||
"@atlaskit/pragmatic-drag-and-drop": "1.8.1",
|
||||
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "2.1.5",
|
||||
"@atlaskit/pragmatic-drag-and-drop-flourish": "2.0.15",
|
||||
|
||||
+24
-58
@@ -1,72 +1,38 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { Center, Loader } from "@mantine/core";
|
||||
import { Error404 } from "@/components/ui/error-404.tsx";
|
||||
import Layout from "@/components/layouts/global/layout.tsx";
|
||||
import { useTrackOrigin } from "@/hooks/use-track-origin";
|
||||
|
||||
// ShareLayout is route-split: its ShareShell chrome pulls in the table of
|
||||
// contents (and thus TipTap), so keeping it out of the eager graph removes the
|
||||
// editor engine from startup for authenticated users too.
|
||||
const ShareLayout = lazy(
|
||||
() => import("@/features/share/components/share-layout.tsx"),
|
||||
);
|
||||
|
||||
// Auth / entry pages stay eager: they are the first paint for an unauthenticated
|
||||
// visitor (e.g. /login) and are already small, so code-splitting them would only
|
||||
// add a cold-chunk round trip to the most common cold-start path.
|
||||
import SetupWorkspace from "@/pages/auth/setup-workspace.tsx";
|
||||
import LoginPage from "@/pages/auth/login";
|
||||
import Home from "@/pages/dashboard/home";
|
||||
import Page from "@/pages/page/page";
|
||||
import AccountSettings from "@/pages/settings/account/account-settings";
|
||||
import WorkspaceMembers from "@/pages/settings/workspace/workspace-members";
|
||||
import WorkspaceSettings from "@/pages/settings/workspace/workspace-settings";
|
||||
import AiSettings from "@/pages/settings/workspace/ai-settings";
|
||||
import Groups from "@/pages/settings/group/groups";
|
||||
import GroupInfo from "./pages/settings/group/group-info";
|
||||
import Spaces from "@/pages/settings/space/spaces.tsx";
|
||||
import { Error404 } from "@/components/ui/error-404.tsx";
|
||||
import AccountPreferences from "@/pages/settings/account/account-preferences.tsx";
|
||||
import SpaceHome from "@/pages/space/space-home.tsx";
|
||||
import PageRedirect from "@/pages/page/page-redirect.tsx";
|
||||
import Layout from "@/components/layouts/global/layout.tsx";
|
||||
import InviteSignup from "@/pages/auth/invite-signup.tsx";
|
||||
import ForgotPassword from "@/pages/auth/forgot-password.tsx";
|
||||
import PasswordReset from "./pages/auth/password-reset";
|
||||
import PageRedirect from "@/pages/page/page-redirect.tsx";
|
||||
import SharedPage from "@/pages/share/shared-page.tsx";
|
||||
import Shares from "@/pages/settings/shares/shares.tsx";
|
||||
import ShareLayout from "@/features/share/components/share-layout.tsx";
|
||||
import ShareRedirect from "@/pages/share/share-redirect.tsx";
|
||||
|
||||
// Heavy / leaf pages are route-split with React.lazy so their code (most
|
||||
// importantly the whole TipTap editor + KaTeX + lowlight grammars + drawio that
|
||||
// the page editor and the readonly share editor pull in) is fetched only when
|
||||
// the matching route is actually visited. The <Suspense> boundaries live inside
|
||||
// each Layout (around its <Outlet/>), so the app shell stays mounted while a
|
||||
// route chunk loads.
|
||||
const Home = lazy(() => import("@/pages/dashboard/home"));
|
||||
const Page = lazy(() => import("@/pages/page/page"));
|
||||
const SpaceHome = lazy(() => import("@/pages/space/space-home.tsx"));
|
||||
const SpaceTrash = lazy(() => import("@/pages/space/space-trash.tsx"));
|
||||
const SpacesPage = lazy(() => import("@/pages/spaces/spaces.tsx"));
|
||||
const FavoritesPage = lazy(() => import("@/pages/favorites/favorites-page"));
|
||||
const LabelPage = lazy(() => import("@/pages/label/label-page"));
|
||||
const SharedPage = lazy(() => import("@/pages/share/shared-page.tsx"));
|
||||
|
||||
const AccountSettings = lazy(
|
||||
() => import("@/pages/settings/account/account-settings"),
|
||||
);
|
||||
const AccountPreferences = lazy(
|
||||
() => import("@/pages/settings/account/account-preferences.tsx"),
|
||||
);
|
||||
const WorkspaceSettings = lazy(
|
||||
() => import("@/pages/settings/workspace/workspace-settings"),
|
||||
);
|
||||
const AiSettings = lazy(() => import("@/pages/settings/workspace/ai-settings"));
|
||||
const WorkspaceMembers = lazy(
|
||||
() => import("@/pages/settings/workspace/workspace-members"),
|
||||
);
|
||||
const Groups = lazy(() => import("@/pages/settings/group/groups"));
|
||||
const GroupInfo = lazy(() => import("./pages/settings/group/group-info"));
|
||||
const Spaces = lazy(() => import("@/pages/settings/space/spaces.tsx"));
|
||||
const Shares = lazy(() => import("@/pages/settings/shares/shares.tsx"));
|
||||
import { useTrackOrigin } from "@/hooks/use-track-origin";
|
||||
import SpacesPage from "@/pages/spaces/spaces.tsx";
|
||||
import SpaceTrash from "@/pages/space/space-trash.tsx";
|
||||
import FavoritesPage from "@/pages/favorites/favorites-page";
|
||||
import LabelPage from "@/pages/label/label-page";
|
||||
|
||||
export default function App() {
|
||||
useTrackOrigin();
|
||||
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<Center h="100vh">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
}
|
||||
>
|
||||
<>
|
||||
<Routes>
|
||||
<Route index element={<Navigate to="/home" />} />
|
||||
<Route path={"/login"} element={<LoginPage />} />
|
||||
@@ -117,6 +83,6 @@ export default function App() {
|
||||
|
||||
<Route path="*" element={<Error404 />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { isChunkLoadError } from "./chunk-load-error-boundary";
|
||||
|
||||
// The detector decides whether a caught render error is a stale-deploy chunk-404
|
||||
// (→ auto-reload to fetch the new manifest) vs a genuine app error (→ generic
|
||||
// recovery UI, no reload). A false negative on a real chunk failure re-blanks the
|
||||
// app; a false positive would auto-reload on an ordinary error. Pin both sides.
|
||||
describe("isChunkLoadError", () => {
|
||||
it("detects the ChunkLoadError name", () => {
|
||||
expect(isChunkLoadError({ name: "ChunkLoadError", message: "x" })).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"Failed to fetch dynamically imported module: https://x/assets/index-abc.js",
|
||||
"error loading dynamically imported module",
|
||||
"Importing a module script failed.",
|
||||
])("detects the dynamic-import failure message %#", (message) => {
|
||||
expect(isChunkLoadError({ name: "TypeError", message })).toBe(true);
|
||||
});
|
||||
|
||||
it("is case-insensitive on the message", () => {
|
||||
expect(
|
||||
isChunkLoadError({ message: "FAILED TO FETCH DYNAMICALLY IMPORTED MODULE" }),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
null,
|
||||
undefined,
|
||||
{},
|
||||
{ name: "TypeError", message: "Cannot read properties of undefined" },
|
||||
{ message: "Network request failed" },
|
||||
new Error("some ordinary render error"),
|
||||
])("returns false for a non-chunk error %#", (err) => {
|
||||
expect(isChunkLoadError(err)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
import { ReactNode } from "react";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
import { Button, Center, Stack, Text } from "@mantine/core";
|
||||
|
||||
const RELOAD_FLAG = "chunk-reload-attempted";
|
||||
|
||||
// Heuristic detection of a failed dynamic import. Since the code-splitting work,
|
||||
// every route (plus Aside / AiChatWindow) is React.lazy: when a new deploy
|
||||
// replaces the hashed chunks, a tab left open on the old index.html requests a
|
||||
// chunk URL that now 404s, and React.lazy rejects. Browsers / Vite surface these
|
||||
// with a ChunkLoadError name or one of these messages.
|
||||
export function isChunkLoadError(error: unknown): boolean {
|
||||
if (!error) return false;
|
||||
const name = (error as { name?: string }).name ?? "";
|
||||
const message = (error as { message?: string }).message ?? "";
|
||||
return (
|
||||
name === "ChunkLoadError" ||
|
||||
/Failed to fetch dynamically imported module/i.test(message) ||
|
||||
/error loading dynamically imported module/i.test(message) ||
|
||||
/Importing a module script failed/i.test(message)
|
||||
);
|
||||
}
|
||||
|
||||
function handleError(error: unknown) {
|
||||
if (!isChunkLoadError(error)) return;
|
||||
// A stale-chunk 404 is cured by a full reload that re-fetches index.html and
|
||||
// the new chunk manifest. Auto-reload once, guarding against a reload loop
|
||||
// (e.g. a genuinely missing chunk) with a one-shot sessionStorage flag. If the
|
||||
// flag is already set we fall through to the manual recovery UI below.
|
||||
try {
|
||||
if (sessionStorage.getItem(RELOAD_FLAG)) return;
|
||||
sessionStorage.setItem(RELOAD_FLAG, "1");
|
||||
} catch {
|
||||
// sessionStorage unavailable (private mode / disabled): skip the automatic
|
||||
// reload rather than risk an unguarded loop; the fallback UI still recovers.
|
||||
return;
|
||||
}
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
// Root-level boundary that sits ABOVE every route-level Suspense boundary so a
|
||||
// lazy route/component chunk failure is caught here instead of unmounting the
|
||||
// whole tree into a blank white screen. Per-feature ErrorBoundaries (page.tsx,
|
||||
// transclusion, page-embed) remain in place underneath for their local errors.
|
||||
export function ChunkLoadErrorBoundary({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ErrorBoundary
|
||||
onError={handleError}
|
||||
fallbackRender={({ error }) => {
|
||||
const chunk = isChunkLoadError(error);
|
||||
return (
|
||||
<Center h="100vh" p="md">
|
||||
<Stack align="center" gap="sm" maw={420}>
|
||||
<Text fw={600}>
|
||||
{chunk ? "A new version is available" : "Something went wrong"}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{chunk
|
||||
? "Please reload the page to load the latest version."
|
||||
: "An unexpected error occurred. Reloading the page may help."}
|
||||
</Text>
|
||||
<Button onClick={() => window.location.reload()}>Reload</Button>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import { AppShell, Container } from "@mantine/core";
|
||||
import React, { Suspense, useEffect, useRef, useState } from "react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import SettingsSidebar from "@/components/settings/settings-sidebar.tsx";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { aiChatWindowOpenAtom } from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
||||
import { useAtom } from "jotai";
|
||||
import {
|
||||
APP_NAVBAR_ID,
|
||||
NAVBAR_COLLAPSE_BREAKPOINT,
|
||||
@@ -15,6 +14,8 @@ import {
|
||||
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.tsx";
|
||||
import { AppHeader } from "@/components/layouts/global/app-header.tsx";
|
||||
import Aside from "@/components/layouts/global/aside.tsx";
|
||||
import AiChatWindow from "@/features/ai-chat/components/ai-chat-window.tsx";
|
||||
import GitmostGlobalBridge from "@/features/editor/gitmost/gitmost-global-bridge.tsx";
|
||||
import classes from "./app-shell.module.css";
|
||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||
@@ -22,21 +23,6 @@ import GlobalSidebar from "@/components/layouts/global/global-sidebar.tsx";
|
||||
import { ASIDE_PANEL_ID } from "@/hooks/use-toggle-aside.tsx";
|
||||
import { MAIN_CONTENT_ID, SkipToMain } from "@/components/ui/skip-to-main.tsx";
|
||||
|
||||
// Lazily load the AI chat window so the AI SDK runtime it pulls in is fetched
|
||||
// only after the user first opens the chat, instead of for every authenticated
|
||||
// user on load. The window itself renders null while closed, so there is no
|
||||
// behavior difference — it simply is not mounted until first opened.
|
||||
const AiChatWindow = React.lazy(
|
||||
() => import("@/features/ai-chat/components/ai-chat-window.tsx"),
|
||||
);
|
||||
|
||||
// The right aside hosts the comment panel and table of contents, both of which
|
||||
// pull in TipTap. It only ever renders on page routes, so lazy-loading it keeps
|
||||
// the whole editor engine out of the eager global-shell startup graph.
|
||||
const Aside = React.lazy(
|
||||
() => import("@/components/layouts/global/aside.tsx"),
|
||||
);
|
||||
|
||||
export default function GlobalAppShell({
|
||||
children,
|
||||
}: {
|
||||
@@ -51,15 +37,6 @@ export default function GlobalAppShell({
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const sidebarRef = useRef(null);
|
||||
|
||||
// Latch: once the AI chat window has been opened, keep it mounted so an
|
||||
// in-flight stream is never torn down. Before the first open the AI chat chunk
|
||||
// is never fetched.
|
||||
const aiChatOpen = useAtomValue(aiChatWindowOpenAtom);
|
||||
const [aiChatEverOpened, setAiChatEverOpened] = useState(false);
|
||||
useEffect(() => {
|
||||
if (aiChatOpen) setAiChatEverOpened(true);
|
||||
}, [aiChatOpen]);
|
||||
|
||||
const startResizing = React.useCallback((mouseDownEvent) => {
|
||||
mouseDownEvent.preventDefault();
|
||||
setIsResizing(true);
|
||||
@@ -90,14 +67,20 @@ export default function GlobalAppShell({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
//https://codesandbox.io/p/sandbox/kz9de
|
||||
// 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;
|
||||
window.addEventListener("mousemove", resize);
|
||||
window.addEventListener("mouseup", stopResizing);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", resize);
|
||||
window.removeEventListener("mouseup", stopResizing);
|
||||
};
|
||||
}, [resize, stopResizing]);
|
||||
}, [isResizing, resize, stopResizing]);
|
||||
|
||||
const location = useLocation();
|
||||
const isSettingsRoute = location.pathname.startsWith("/settings");
|
||||
@@ -183,21 +166,13 @@ export default function GlobalAppShell({
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Suspense fallback={null}>
|
||||
<Aside />
|
||||
</Suspense>
|
||||
<Aside />
|
||||
</AppShell.Aside>
|
||||
)}
|
||||
</AppShell>
|
||||
{/* Floating AI chat window. Mounted once globally on first open; it is
|
||||
position: fixed and self-hides when closed, so its place in the tree is
|
||||
not critical. Kept mounted after the first open so a live stream is not
|
||||
aborted. */}
|
||||
{aiChatEverOpened && (
|
||||
<Suspense fallback={null}>
|
||||
<AiChatWindow />
|
||||
</Suspense>
|
||||
)}
|
||||
{/* Floating AI chat window. Mounted once globally; it is position: fixed
|
||||
and self-hides when closed, so its place in the tree is not critical. */}
|
||||
<AiChatWindow />
|
||||
{/* Global gitmost native bridge: registers listSpaces / listPages /
|
||||
createPageWithRecording on window.gitmost so the native host can
|
||||
create a page with a recording even when no page editor is open. */}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { UserProvider } from "@/features/user/user-provider.tsx";
|
||||
import { Outlet, useParams } from "react-router-dom";
|
||||
import { Center, Loader } from "@mantine/core";
|
||||
import GlobalAppShell from "@/components/layouts/global/global-app-shell.tsx";
|
||||
import { SearchSpotlight } from "@/features/search/components/search-spotlight.tsx";
|
||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||
@@ -10,39 +8,10 @@ export default function Layout() {
|
||||
const { spaceSlug } = useParams();
|
||||
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
|
||||
|
||||
// Warm the (now route-split) editor chunk during idle time on authenticated
|
||||
// routes, so the first navigation to a page renders from cache instead of a
|
||||
// cold chunk fetch. Best-effort: gated on requestIdleCallback and never blocks
|
||||
// startup — the dynamic import mirrors the App.tsx route lazy loader so both
|
||||
// resolve to the same chunk.
|
||||
useEffect(() => {
|
||||
const ric =
|
||||
typeof window !== "undefined" && (window as any).requestIdleCallback;
|
||||
const warm = () => {
|
||||
// Best-effort prefetch: a failed warm-up (offline, stale 404) is harmless
|
||||
// and must not surface as an unhandledrejection.
|
||||
void import("@/pages/page/page").catch(() => {});
|
||||
};
|
||||
if (ric) {
|
||||
const id = ric(warm);
|
||||
return () => (window as any).cancelIdleCallback?.(id);
|
||||
}
|
||||
const timer = setTimeout(warm, 2000);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<UserProvider>
|
||||
<GlobalAppShell>
|
||||
<Suspense
|
||||
fallback={
|
||||
<Center h="60vh">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
}
|
||||
>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
<Outlet />
|
||||
</GlobalAppShell>
|
||||
<SearchSpotlight spaceId={space?.id} />
|
||||
</UserProvider>
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
Button,
|
||||
useMantineColorScheme,
|
||||
} from "@mantine/core";
|
||||
import { useClickOutside, useDisclosure, useWindowEvent } from "@mantine/hooks";
|
||||
import { useClickOutside, useDisclosure } from "@mantine/hooks";
|
||||
import { Suspense } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -57,14 +57,22 @@ function EmojiPicker({
|
||||
[dropdown, target],
|
||||
);
|
||||
|
||||
// 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();
|
||||
}
|
||||
});
|
||||
// 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]);
|
||||
|
||||
// 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 { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { usePageMetaQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import {
|
||||
AI_CHATS_RQ_KEY,
|
||||
@@ -219,7 +219,9 @@ export default function AiChatWindow() {
|
||||
// left partly off-screen).
|
||||
const [geom, setGeom] = useAtom(aiChatWindowGeomAtom);
|
||||
|
||||
const { data: chats } = useAiChatsQuery();
|
||||
// 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);
|
||||
// Roles for the new-chat picker (any member may list them). Only fetched while
|
||||
// the window is open.
|
||||
const { data: roles } = useAiRolesQuery(windowOpen);
|
||||
@@ -231,8 +233,10 @@ 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);
|
||||
useAiChatMessagesQuery(activeChatId ?? undefined, windowOpen);
|
||||
|
||||
// The page the user is currently viewing. AiChatWindow lives in a pathless
|
||||
// parent layout route, so useParams() can't see :pageSlug. Match the full
|
||||
@@ -244,7 +248,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 } = usePageQuery({
|
||||
const { data: openPageData } = usePageMetaQuery({
|
||||
pageId: extractPageSlugId(pageSlug),
|
||||
});
|
||||
const openPage = openPageData
|
||||
|
||||
@@ -52,8 +52,12 @@ export const AI_CHAT_MESSAGES_RQ_KEY = (chatId: string) => [
|
||||
chatId,
|
||||
];
|
||||
|
||||
/** Paginated list of the current user's chats (auto-loads further pages). */
|
||||
export function useAiChatsQuery() {
|
||||
/**
|
||||
* 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) {
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: AI_CHATS_RQ_KEY,
|
||||
queryFn: ({ pageParam }) =>
|
||||
@@ -61,6 +65,7 @@ export function useAiChatsQuery() {
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.meta.hasNextPage ? (lastPage.meta.nextCursor ?? undefined) : undefined,
|
||||
enabled,
|
||||
});
|
||||
|
||||
const data = useMemo<IPagination<IAiChat> | undefined>(() => {
|
||||
@@ -83,7 +88,10 @@ export function useAiChatsQuery() {
|
||||
* 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) {
|
||||
export function useAiChatMessagesQuery(
|
||||
chatId: string | undefined,
|
||||
enabled: boolean = true,
|
||||
) {
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: AI_CHAT_MESSAGES_RQ_KEY(chatId ?? ""),
|
||||
queryFn: ({ pageParam }) =>
|
||||
@@ -91,7 +99,7 @@ export function useAiChatMessagesQuery(chatId: string | undefined) {
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.meta.hasNextPage ? (lastPage.meta.nextCursor ?? undefined) : undefined,
|
||||
enabled: !!chatId,
|
||||
enabled: !!chatId && enabled,
|
||||
});
|
||||
|
||||
// useInfiniteQuery only fetches the first page on its own. The hook's contract
|
||||
|
||||
@@ -15,6 +15,7 @@ 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 { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { usePageMetaQuery } 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 } = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
|
||||
const { data: page } = usePageMetaQuery({ pageId: extractPageSlugId(pageSlug) });
|
||||
const {
|
||||
data: comments,
|
||||
isLoading: isCommentsLoading,
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { atom } from "jotai";
|
||||
// Type-only: these atoms only hold an Editor reference for typing. A value
|
||||
// import would drag the whole @tiptap/core engine into the eager graph of every
|
||||
// shell component that reads one of these atoms.
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||
import type { DictationUnavailableReason } from "@/features/dictation/dictation-status";
|
||||
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { EditorMenuProps } from "@/features/editor/components/table/types/types.ts";
|
||||
|
||||
// Lazily load the drawio bubble menu so it is split out of the editor chunk and
|
||||
// fetched only when an editable editor is mounted (mirrors excalidraw-menu-lazy).
|
||||
const DrawioMenu = lazy(
|
||||
() => import("@/features/editor/components/drawio/drawio-menu.tsx"),
|
||||
);
|
||||
|
||||
export default function DrawioMenuLazy(props: EditorMenuProps) {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<DrawioMenu {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { NodeViewProps } from "@tiptap/react";
|
||||
|
||||
// Lazily load the drawio node view so the heavy react-drawio embed runtime is
|
||||
// split into its own chunk and fetched only when a drawio diagram is actually
|
||||
// rendered (mirrors excalidraw-view-lazy).
|
||||
const DrawioView = lazy(
|
||||
() => import("@/features/editor/components/drawio/drawio-view.tsx"),
|
||||
);
|
||||
|
||||
export default function DrawioViewLazy(props: NodeViewProps) {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<DrawioView {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -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 { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { usePageMetaQuery } 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 } = usePageQuery({
|
||||
const { data: linkedPage } = usePageMetaQuery({
|
||||
pageId: isPopoverVisible && slugId && !isShareRoute ? slugId : null,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { NodeViewProps } from "@tiptap/react";
|
||||
|
||||
// Lazily load the KaTeX-backed block math view so the katex chunk is fetched
|
||||
// only when a document actually contains a math node (mirrors the mermaid/
|
||||
// excalidraw lazy pattern). The local Suspense keeps a slow katex chunk from
|
||||
// crashing or blocking the whole editor: while it loads we render the raw
|
||||
// LaTeX source as a node-sized placeholder.
|
||||
const MathBlockView = lazy(
|
||||
() => import("@/features/editor/components/math/math-block.tsx"),
|
||||
);
|
||||
|
||||
export default function MathBlockViewLazy(props: NodeViewProps) {
|
||||
return (
|
||||
<Suspense fallback={<div data-katex="true">{props.node.attrs.text}</div>}>
|
||||
<MathBlockView {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { lazy, Suspense } from "react";
|
||||
import { NodeViewProps } from "@tiptap/react";
|
||||
|
||||
// Lazily load the KaTeX-backed inline math view so the katex chunk is fetched
|
||||
// only when a document actually contains a math node (mirrors the mermaid/
|
||||
// excalidraw lazy pattern). The local Suspense keeps a slow katex chunk from
|
||||
// crashing or blocking the whole editor: while it loads we render the raw
|
||||
// LaTeX source as a node-sized placeholder.
|
||||
const MathInlineView = lazy(
|
||||
() => import("@/features/editor/components/math/math-inline.tsx"),
|
||||
);
|
||||
|
||||
export default function MathInlineViewLazy(props: NodeViewProps) {
|
||||
return (
|
||||
<Suspense fallback={<span data-katex="true">{props.node.attrs.text}</span>}>
|
||||
<MathInlineView {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -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 } from "jotai";
|
||||
import { useAtom, useSetAtom, useStore } 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,
|
||||
usePageQuery,
|
||||
usePageMetaQuery,
|
||||
} 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,12 +50,16 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
const [countAnnouncement, setCountAnnouncement] = useState("");
|
||||
const [selectionAnnouncement, setSelectionAnnouncement] = useState("");
|
||||
const { pageSlug, spaceSlug } = useParams();
|
||||
const { data: page } = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
|
||||
const { data: page } = usePageMetaQuery({ pageId: extractPageSlugId(pageSlug) });
|
||||
const { data: space } = useSpaceQuery(spaceSlug);
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
const [renderItems, setRenderItems] = useState<MentionSuggestionItem[]>([]);
|
||||
const { t } = useTranslation();
|
||||
const [data, setData] = useAtom(treeDataAtom);
|
||||
// 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 createPageMutation = useCreatePageMutation();
|
||||
const emit = useQueryEmit();
|
||||
const isInCommentContext = props.isInCommentContext ?? false;
|
||||
@@ -272,9 +276,11 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
|
||||
children: [],
|
||||
};
|
||||
|
||||
const lastIndex = data.length;
|
||||
// Read the live tree imperatively at call time.
|
||||
const currentTree = store.get(treeDataAtom);
|
||||
const lastIndex = currentTree.length;
|
||||
|
||||
setData(treeModel.insert(data, parentId, newNode, lastIndex));
|
||||
setData(treeModel.insert(currentTree, 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 { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { usePageMetaQuery } 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,
|
||||
} = usePageQuery({ pageId: isPageMention && !isShareRoute ? slugId : null });
|
||||
} = usePageMetaQuery({ pageId: isPageMention && !isShareRoute ? slugId : null });
|
||||
|
||||
const { data: sharedPage } = useSharePageQuery({
|
||||
pageId: isPageMention && isShareRoute ? slugId : undefined,
|
||||
|
||||
@@ -81,8 +81,8 @@ import {
|
||||
createResizeHandle,
|
||||
buildResizeClasses,
|
||||
} from "@/features/editor/components/common/node-resize-handles.ts";
|
||||
import MathInlineView from "@/features/editor/components/math/math-inline-lazy.tsx";
|
||||
import MathBlockView from "@/features/editor/components/math/math-block-lazy.tsx";
|
||||
import MathInlineView from "@/features/editor/components/math/math-inline.tsx";
|
||||
import MathBlockView from "@/features/editor/components/math/math-block.tsx";
|
||||
import ImageView from "@/features/editor/components/image/image-view.tsx";
|
||||
import CalloutView from "@/features/editor/components/callout/callout-view.tsx";
|
||||
import StatusView from "@/features/editor/components/status/status-view.tsx";
|
||||
@@ -90,7 +90,7 @@ import VideoView from "@/features/editor/components/video/video-view.tsx";
|
||||
import AudioView from "@/features/editor/components/audio/audio-view.tsx";
|
||||
import AttachmentView from "@/features/editor/components/attachment/attachment-view.tsx";
|
||||
import CodeBlockView from "@/features/editor/components/code-block/code-block-view.tsx";
|
||||
import DrawioView from "../components/drawio/drawio-view-lazy.tsx";
|
||||
import DrawioView from "../components/drawio/drawio-view";
|
||||
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view-lazy.tsx";
|
||||
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
|
||||
import HtmlEmbedView from "@/features/editor/components/html-embed/html-embed-view.tsx";
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { getDefaultStore } from "jotai";
|
||||
|
||||
// Literal value of WebSocketStatus.Connected from @hocuspocus/provider. Inlined
|
||||
// so this always-mounted global bridge does not statically import
|
||||
// @hocuspocus/provider — that import pulls Yjs (and, through a shared chunk, the
|
||||
// whole TipTap engine) into the eager startup graph. yjsConnectionStatusAtom
|
||||
// already stores these raw status strings.
|
||||
const YJS_STATUS_CONNECTED = "connected";
|
||||
// Type-only: importing Editor as a type keeps @tiptap/core (the whole editor
|
||||
// engine) out of the eager global-shell graph — the bridge only uses it for
|
||||
// annotations/casts, never as a runtime value.
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import { WebSocketStatus } from "@hocuspocus/provider";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import {
|
||||
pageEditorAtom,
|
||||
yjsConnectionStatusAtom,
|
||||
@@ -25,19 +16,15 @@ import {
|
||||
getSidebarPages,
|
||||
} from "@/features/page/services/page-service.ts";
|
||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||
// Types are erased at build time, so importing them does not pull the module's
|
||||
// runtime (which drags in @tiptap + the editor-ext barrel). The actual recording
|
||||
// helpers are dynamically imported at call time inside createPageWithRecording,
|
||||
// keeping the editor engine out of the eager global-shell startup graph — the
|
||||
// bridge is mounted for every authenticated user but recording is a rare,
|
||||
// native-host-driven action.
|
||||
import type {
|
||||
import {
|
||||
GitmostBridge,
|
||||
GitmostCreatePagePayload,
|
||||
GitmostCreatePageResult,
|
||||
GitmostListPagesPayload,
|
||||
GitmostListPagesResult,
|
||||
GitmostListSpacesResult,
|
||||
gitmostDecodePayloadToFile,
|
||||
gitmostUploadFileToEditor,
|
||||
} from "@/features/editor/gitmost/gitmost-recording.ts";
|
||||
|
||||
// How long to wait for a freshly-navigated page's editor to mount, become
|
||||
@@ -70,7 +57,7 @@ function gitmostWaitForEditor(
|
||||
!editor.isDestroyed &&
|
||||
editor.isEditable &&
|
||||
editorPageId === pageId &&
|
||||
yjsStatus === YJS_STATUS_CONNECTED;
|
||||
yjsStatus === WebSocketStatus.Connected;
|
||||
if (ready) {
|
||||
resolve(editor);
|
||||
return;
|
||||
@@ -184,12 +171,6 @@ export default function GitmostGlobalBridge() {
|
||||
};
|
||||
}
|
||||
|
||||
// Load the recording helpers on demand (see the import note above). This
|
||||
// is the only place they are needed, so the @tiptap/editor-ext code they
|
||||
// pull in stays out of the eager startup graph.
|
||||
const { gitmostDecodePayloadToFile, gitmostUploadFileToEditor } =
|
||||
await import("@/features/editor/gitmost/gitmost-recording.ts");
|
||||
|
||||
// Validate/decode the recording BEFORE creating the page so a bad
|
||||
// payload never leaves an empty junk page behind. Per the createPage
|
||||
// error contract, any decode failure collapses to "insert-failed" (the
|
||||
|
||||
@@ -59,7 +59,7 @@ import {
|
||||
handlePaste,
|
||||
} from "@/features/editor/components/common/editor-paste-handler.tsx";
|
||||
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu-lazy";
|
||||
import DrawioMenu from "./components/drawio/drawio-menu-lazy";
|
||||
import DrawioMenu from "./components/drawio/drawio-menu";
|
||||
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
||||
import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx";
|
||||
import { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks";
|
||||
|
||||
@@ -24,7 +24,6 @@ export function useFavoritesQuery(type?: FavoriteType, spaceId?: string) {
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,
|
||||
refetchOnMount: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -32,7 +31,6 @@ 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 { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { usePageMetaQuery } 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 } = usePageQuery({
|
||||
const { data: page } = usePageMetaQuery({
|
||||
pageId: extractPageSlugId(pageSlug),
|
||||
});
|
||||
const pageEditor = useAtomValue(pageEditorAtom);
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
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,7 +1,9 @@
|
||||
import { useAtomValue } from "jotai";
|
||||
import { selectAtom } from "jotai/utils";
|
||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { computeBreadcrumbState } from "./breadcrumb.utils";
|
||||
import { findBreadcrumbPath } from "@/features/page/tree/utils";
|
||||
import {
|
||||
Button,
|
||||
Anchor,
|
||||
@@ -18,7 +20,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 {
|
||||
usePageQuery,
|
||||
usePageMetaQuery,
|
||||
usePageBreadcrumbsQuery,
|
||||
} from "@/features/page/queries/page-query.ts";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
@@ -32,39 +34,84 @@ 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 } = usePageQuery({
|
||||
const { data: currentPage } = usePageMetaQuery({
|
||||
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(currentPage?.id);
|
||||
const { data: ancestors } = usePageBreadcrumbsQuery(currentPageId);
|
||||
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). 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.
|
||||
// (#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).
|
||||
setBreadcrumbNodes((previous) =>
|
||||
treePath ??
|
||||
computeBreadcrumbState(
|
||||
treeData,
|
||||
null,
|
||||
ancestors as IPage[] | undefined,
|
||||
currentPage.id,
|
||||
previous,
|
||||
),
|
||||
);
|
||||
}, [currentPage?.id, treeData, ancestors]);
|
||||
}, [currentPage?.id, treePath, 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 { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { usePageMetaQuery } 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 } = usePageQuery({
|
||||
const { data: page } = usePageMetaQuery({
|
||||
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 } = usePageQuery({
|
||||
const { data: page, isLoading } = usePageMetaQuery({
|
||||
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 { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { usePageMetaQuery } 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 } = usePageQuery({ pageId: slugId });
|
||||
const { data: page } = usePageMetaQuery({ pageId: slugId });
|
||||
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
||||
const spaceAbility = useSpaceAbility(space?.membership?.permissions);
|
||||
const expiresTimeAgo = useTimeAgo(page?.temporaryExpiresAt);
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
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,6 +51,10 @@ 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(() => {
|
||||
@@ -66,6 +70,61 @@ 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>>({
|
||||
@@ -351,6 +410,12 @@ 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,
|
||||
});
|
||||
}
|
||||
@@ -367,6 +432,9 @@ 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,
|
||||
});
|
||||
}
|
||||
@@ -380,8 +448,14 @@ export function useDeletedPagesQuery(
|
||||
queryFn: () => getDeletedPages(spaceId, params),
|
||||
enabled: !!spaceId,
|
||||
placeholderData: keepPreviousData,
|
||||
refetchOnMount: true,
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -516,7 +590,35 @@ export function invalidateOnUpdatePage(
|
||||
title: string,
|
||||
icon: string,
|
||||
) {
|
||||
invalidatePageTree();
|
||||
// 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,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
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,
|
||||
usePageQuery,
|
||||
usePageMetaQuery,
|
||||
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 } = usePageQuery({ pageId: slugId });
|
||||
const { data: page } = usePageMetaQuery({ 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 { useAtom } from "jotai";
|
||||
import { useSetAtom, useStore } from "jotai";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { ActionIcon, Menu, rem } from "@mantine/core";
|
||||
@@ -52,7 +52,11 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
|
||||
const clipboard = useClipboard({ timeout: 500 });
|
||||
const { spaceSlug } = useParams();
|
||||
const { handleDelete } = useTreeMutation(node.spaceId);
|
||||
const [data, setData] = useAtom(treeDataAtom);
|
||||
// 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 emit = useQueryEmit();
|
||||
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
||||
useDisclosure(false);
|
||||
@@ -125,8 +129,8 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
|
||||
try {
|
||||
const duplicatedPage = await duplicatePage({ pageId: node.id });
|
||||
|
||||
// figure out parent + insertion index
|
||||
const siblings = treeModel.siblingsOf(data, node.id);
|
||||
// figure out parent + insertion index (read the live tree imperatively)
|
||||
const siblings = treeModel.siblingsOf(store.get(treeDataAtom), 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 } from "jotai";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ActionIcon, rem, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
@@ -51,7 +51,11 @@ export function SpaceTreeRow({
|
||||
const { t } = useTranslation();
|
||||
const { spaceSlug } = useParams();
|
||||
const updatePageMutation = useUpdatePageMutation();
|
||||
const [, setTreeData] = useAtom(treeDataAtom);
|
||||
// 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 emit = useQueryEmit();
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [mobileSidebarOpened] = useAtom(mobileSidebarAtom);
|
||||
|
||||
@@ -35,6 +35,7 @@ vi.mock("@/features/page/queries/page-query.ts", () => ({
|
||||
isFetching: false,
|
||||
}),
|
||||
usePageQuery: () => ({ data: undefined }),
|
||||
usePageMetaQuery: () => ({ data: undefined }),
|
||||
fetchAllAncestorChildren: (...args: unknown[]) =>
|
||||
fetchAllAncestorChildrenMock(...args),
|
||||
}));
|
||||
|
||||
@@ -26,6 +26,7 @@ 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,
|
||||
usePageQuery,
|
||||
usePageMetaQuery,
|
||||
} 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 } = usePageQuery({
|
||||
const { data: currentPage } = usePageMetaQuery({
|
||||
pageId: extractPageSlugId(pageSlug),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback } from "react";
|
||||
import { useAtom, useSetAtom, useStore } from "jotai";
|
||||
import { useSetAtom, useStore } from "jotai";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
@@ -34,7 +34,10 @@ export type UseTreeMutation = {
|
||||
|
||||
export function useTreeMutation(spaceId: string): UseTreeMutation {
|
||||
const { t } = useTranslation();
|
||||
const [, setData] = useAtom(treeDataAtom);
|
||||
// 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);
|
||||
// `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.
|
||||
|
||||
@@ -1,20 +1,10 @@
|
||||
import { Suspense } from "react";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { Center, Loader } from "@mantine/core";
|
||||
import ShareShell from "@/features/share/components/share-shell.tsx";
|
||||
|
||||
export default function ShareLayout() {
|
||||
return (
|
||||
<ShareShell>
|
||||
<Suspense
|
||||
fallback={
|
||||
<Center h="60vh">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
}
|
||||
>
|
||||
<Outlet />
|
||||
</Suspense>
|
||||
<Outlet />
|
||||
</ShareShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ 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 { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { usePageMetaQuery } 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 } = usePageQuery({ pageId: pageSlugId });
|
||||
const { data: page } = usePageMetaQuery({ pageId: pageSlugId });
|
||||
const pageId = page?.id;
|
||||
const { data: share } = useShareForPageQuery(pageId);
|
||||
const { spaceSlug } = useParams();
|
||||
|
||||
@@ -38,6 +38,11 @@ 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,7 +16,6 @@ export function useWatchedSpaceIds(): Set<string> {
|
||||
const { data } = useQuery({
|
||||
queryKey: [WATCHED_SPACE_IDS_KEY],
|
||||
queryFn: () => getWatchedSpaceIds(),
|
||||
refetchOnMount: true,
|
||||
});
|
||||
|
||||
const items = data?.items;
|
||||
|
||||
@@ -19,7 +19,11 @@ export const useQuerySubscription = () => {
|
||||
const [socket] = useAtom(socketAtom);
|
||||
|
||||
React.useEffect(() => {
|
||||
socket?.on("message", (event) => {
|
||||
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) => {
|
||||
const data: WebSocketEvent = event;
|
||||
|
||||
let entity = null;
|
||||
@@ -163,6 +167,11 @@ 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 } from "jotai";
|
||||
import { useAtom, useSetAtom } 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,7 +16,10 @@ import localEmitter from "@/lib/local-emitter.ts";
|
||||
|
||||
export const useTreeSocket = () => {
|
||||
const [socket] = useAtom(socketAtom);
|
||||
const [, setTreeData] = useAtom(treeDataAtom);
|
||||
// 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 queryClient = useQueryClient();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -37,7 +40,11 @@ export const useTreeSocket = () => {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
socket?.on("message", (event: WebSocketEvent) => {
|
||||
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) => {
|
||||
switch (event.operation) {
|
||||
case "updateOne":
|
||||
if (event.entity[0] === "pages") {
|
||||
@@ -64,6 +71,11 @@ export const useTreeSocket = () => {
|
||||
});
|
||||
break;
|
||||
}
|
||||
});
|
||||
}, [socket]);
|
||||
};
|
||||
|
||||
socket.on("message", handleMessage);
|
||||
return () => {
|
||||
socket.off("message", handleMessage);
|
||||
};
|
||||
}, [socket, queryClient, setTreeData]);
|
||||
};
|
||||
|
||||
@@ -243,6 +243,5 @@ export function useAppVersion(
|
||||
queryFn: () => getAppVersion(),
|
||||
staleTime: 60 * 60 * 1000, // 1 hr
|
||||
enabled: isEnabled,
|
||||
refetchOnMount: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Source: https://github.com/mantinedev/mantine/blob/master/packages/@mantine/hooks/src/use-clipboard/use-clipboard.ts
|
||||
// polyfilled to support execCommand fallback
|
||||
import { useState } from "react";
|
||||
import { execCommandCopy } from "@/lib/copy-to-clipboard.ts";
|
||||
import { execCommandCopy } from "@docmost/editor-ext";
|
||||
|
||||
export type UseClipboardOptions = {
|
||||
timeout?: number;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import bytes from "bytes";
|
||||
import { castToBoolean } from "@/lib/utils.tsx";
|
||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||
import { sanitizeUrl } from "@/lib/sanitize-url.ts";
|
||||
import { sanitizeUrl } from "@docmost/editor-ext";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
// Client-local execCommand copy fallback (previously imported from
|
||||
// @docmost/editor-ext). It lives here so the ubiquitous useClipboard / CopyButton
|
||||
// path does not pull in the editor-ext barrel — and with it the whole TipTap
|
||||
// engine — through the eager startup graph. Behavior is identical to the
|
||||
// editor-ext helper it replaces.
|
||||
export function execCommandCopy(text: string): void {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.left = "-9999px";
|
||||
textarea.style.top = "-9999px";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { sanitizeUrl } from "./sanitize-url";
|
||||
|
||||
// `sanitizeUrl` is a byte-identical client-local copy of editor-ext's wrapper
|
||||
// around @braintree/sanitize-url: it maps the sanitizer's "about:blank" XSS
|
||||
// sentinel to "". These assertions mirror editor-ext's own security-contract
|
||||
// test so the extracted copy keeps the same guarantees.
|
||||
describe("sanitizeUrl", () => {
|
||||
it("blocks dangerous schemes (returns empty string)", () => {
|
||||
expect(sanitizeUrl("javascript:alert(1)")).toBe("");
|
||||
expect(sanitizeUrl("data:text/html,<script>alert(1)</script>")).toBe("");
|
||||
expect(sanitizeUrl("vbscript:msgbox(1)")).toBe("");
|
||||
// Case / whitespace obfuscation must not slip past the sanitizer.
|
||||
expect(sanitizeUrl(" JaVaScRiPt:alert(1)")).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string for empty / undefined input", () => {
|
||||
expect(sanitizeUrl(undefined)).toBe("");
|
||||
expect(sanitizeUrl("")).toBe("");
|
||||
});
|
||||
|
||||
it("allows safe https, relative file and mailto URLs", () => {
|
||||
expect(sanitizeUrl("https://example.com/page")).toMatch(
|
||||
/^https:\/\/example\.com\/page/,
|
||||
);
|
||||
expect(sanitizeUrl("/api/files/abc-123")).toBe("/api/files/abc-123");
|
||||
expect(sanitizeUrl("mailto:user@example.com")).toBe(
|
||||
"mailto:user@example.com",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
import { sanitizeUrl as braintreeSanitizeUrl } from "@braintree/sanitize-url";
|
||||
|
||||
// Client-local copy of editor-ext's sanitizeUrl wrapper. Importing it from the
|
||||
// editor-ext barrel dragged the whole TipTap engine into the eager startup graph
|
||||
// via the app-wide config module (getFileUrl). This keeps the exact same
|
||||
// behavior (braintree sanitize + normalize "about:blank" -> "") without that
|
||||
// dependency.
|
||||
export function sanitizeUrl(url: string | undefined): string {
|
||||
if (!url) return "";
|
||||
|
||||
const sanitized = braintreeSanitizeUrl(url);
|
||||
|
||||
// Return an empty string instead of "about:blank".
|
||||
return sanitized === "about:blank" ? "" : sanitized;
|
||||
}
|
||||
+27
-60
@@ -13,14 +13,15 @@ import { ModalsProvider } from "@mantine/modals";
|
||||
import { Notifications } from "@mantine/notifications";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { HelmetProvider } from "react-helmet-async";
|
||||
import { ChunkLoadErrorBoundary } from "@/components/chunk-load-error-boundary.tsx";
|
||||
import "./i18n";
|
||||
import { PostHogProvider } from "posthog-js/react";
|
||||
import {
|
||||
getPostHogHost,
|
||||
getPostHogKey,
|
||||
isCloud,
|
||||
isPostHogEnabled,
|
||||
} from "@/lib/config.ts";
|
||||
import posthog from "posthog-js";
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -33,65 +34,31 @@ export const queryClient = new QueryClient({
|
||||
},
|
||||
});
|
||||
|
||||
if (isCloud() && isPostHogEnabled) {
|
||||
posthog.init(getPostHogKey(), {
|
||||
api_host: getPostHogHost(),
|
||||
defaults: "2025-05-24",
|
||||
disable_session_recording: true,
|
||||
capture_pageleave: false,
|
||||
});
|
||||
}
|
||||
|
||||
const container = document.getElementById("root") as HTMLElement;
|
||||
const root = (container as any).__reactRoot ??= ReactDOM.createRoot(container);
|
||||
|
||||
function renderApp() {
|
||||
root.render(
|
||||
<BrowserRouter>
|
||||
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
|
||||
<ModalsProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Notifications position="bottom-center" limit={3} zIndex={10000} />
|
||||
<HelmetProvider>
|
||||
{/* Root boundary above every lazy route's Suspense: a stale-chunk
|
||||
404 after a deploy is caught and recovered here instead of
|
||||
blanking the whole app. */}
|
||||
<ChunkLoadErrorBoundary>
|
||||
<App />
|
||||
</ChunkLoadErrorBoundary>
|
||||
</HelmetProvider>
|
||||
</QueryClientProvider>
|
||||
</ModalsProvider>
|
||||
</MantineProvider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
async function initAnalytics() {
|
||||
// posthog-js is only pulled in for cloud deployments with analytics enabled, so
|
||||
// self-hosted builds never download it. The gate is kept identical to the
|
||||
// previous eager code so cloud analytics behavior is unchanged; the import is
|
||||
// simply deferred behind it.
|
||||
//
|
||||
// Crucially this runs AFTER the immediate first render below, so first paint is
|
||||
// never gated on the analytics chunk. Any failure (network, stale 404, or an
|
||||
// ad-blocker blocking a chunk named "posthog") is swallowed so the user keeps a
|
||||
// working app without analytics instead of a permanently blank page.
|
||||
//
|
||||
// NOTE: we init the posthog SINGLETON only and do NOT wrap the tree in
|
||||
// <PostHogProvider>. The app has zero consumers of the PostHog React context
|
||||
// (no usePostHog / useFeatureFlag* / PostHogFeature), and PostHogProvider given
|
||||
// an already-initialized `client` is a no-op — all capture goes through the
|
||||
// singleton. Re-rendering to attach the provider would only REMOUNT the whole
|
||||
// App (running every mount effect twice and dropping local state / focus /
|
||||
// in-progress input on cloud cold-load) for no functional gain.
|
||||
if (!(isCloud() && isPostHogEnabled)) return;
|
||||
try {
|
||||
const { default: posthog } = await import("posthog-js");
|
||||
posthog.init(getPostHogKey(), {
|
||||
api_host: getPostHogHost(),
|
||||
defaults: "2025-05-24",
|
||||
disable_session_recording: true,
|
||||
capture_pageleave: false,
|
||||
});
|
||||
} catch {
|
||||
// Analytics failed to load — degrade gracefully; the app already rendered.
|
||||
}
|
||||
}
|
||||
|
||||
// Paint immediately for everyone (self-hosted stays exactly as instant as before,
|
||||
// cloud no longer blocks on the analytics import). The posthog singleton is
|
||||
// initialized after, without re-rendering the tree.
|
||||
renderApp();
|
||||
void initAnalytics();
|
||||
root.render(
|
||||
<BrowserRouter>
|
||||
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
|
||||
<ModalsProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Notifications position="bottom-center" limit={3} zIndex={10000} />
|
||||
<HelmetProvider>
|
||||
<PostHogProvider client={posthog}>
|
||||
<App />
|
||||
</PostHogProvider>
|
||||
</HelmetProvider>
|
||||
</QueryClientProvider>
|
||||
</ModalsProvider>
|
||||
</MantineProvider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useEffect } from "react";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query";
|
||||
import { usePageMetaQuery } 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,
|
||||
} = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
|
||||
} = usePageMetaQuery({ 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 } from "@mantine/core";
|
||||
import { Button, Skeleton } 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 <></>;
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
|
||||
if (isError || !page) {
|
||||
@@ -87,7 +87,7 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
|
||||
}
|
||||
|
||||
if (!space) {
|
||||
return <></>;
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -116,3 +116,18 @@ 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -63,20 +63,6 @@ export default defineConfig(({ mode }) => {
|
||||
name: "vendor-mantine",
|
||||
test: /[\\/]node_modules[\\/]@mantine[\\/]/,
|
||||
},
|
||||
// NOTE: TipTap/ProseMirror/Yjs are intentionally NOT force-grouped
|
||||
// into a single vendor chunk. Doing so backfires: rolldown co-locates
|
||||
// a small module shared with the (eager) react-i18next runtime into
|
||||
// that group chunk, which then drags the whole ~590KB editor engine
|
||||
// into the eager modulepreload graph. Left to the default splitting,
|
||||
// the editor engine stays in lazily-loaded chunks pulled only by the
|
||||
// route-split editor/share pages. KaTeX is safe to group (nothing
|
||||
// eager references it).
|
||||
// KaTeX in its own stable chunk; loaded on demand by the lazy math
|
||||
// node views (never in the startup path).
|
||||
{
|
||||
name: "vendor-katex",
|
||||
test: /[\\/]node_modules[\\/]katex[\\/]/,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 :::", md.data.content.includes(":::info"));
|
||||
check("get_page md: callout exported as Obsidian '> [!info]'", md.data.content.includes("> [!info]"));
|
||||
|
||||
// 9. comments: create / list / reply / update / check_new / delete
|
||||
const beforeComments = new Date(Date.now() - 1000).toISOString();
|
||||
|
||||
Generated
+1
-4
@@ -269,9 +269,6 @@ importers:
|
||||
'@atlaskit/pragmatic-drag-and-drop-live-region':
|
||||
specifier: 1.3.4
|
||||
version: 1.3.4
|
||||
'@braintree/sanitize-url':
|
||||
specifier: 7.1.2
|
||||
version: 7.1.2
|
||||
'@casl/react':
|
||||
specifier: 5.0.1
|
||||
version: 5.0.1(@casl/ability@6.8.0)(react@18.3.1)
|
||||
@@ -16156,7 +16153,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@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: 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/expect@4.1.6':
|
||||
dependencies:
|
||||
|
||||
Reference in New Issue
Block a user