Compare commits

..

3 Commits

Author SHA1 Message Date
agent_coder fdb6f39a8e fix(#342 review round-2 F5-F6): drop the posthog re-render remount + test chunk detector
- F5 [stability/regression]: the round-1 F2 fix re-rendered the root with
  <PostHogProvider><App/></PostHogProvider> after the analytics chunk loaded. In
  the ChunkLoadErrorBoundary child slot the element TYPE changes App ->
  PostHogProvider, so React does NOT reconcile in place — it REMOUNTS the whole
  App: every mount effect runs twice (websocket connect/disconnect, origin
  tracking, subscriptions) and local state / focus / scroll / in-progress input is
  lost on cloud cold-load (e.g. typing in /login before analytics loads). And it
  was USELESS: the app has ZERO consumers of the PostHog React context (no
  usePostHog / useFeatureFlag* / PostHogFeature), and PostHogProvider given an
  initialized client is a no-op — all capture goes through the posthog singleton.
  Fix: initAnalytics now inits the posthog SINGLETON only (no posthog-js/react
  import, no second render); renderApp() renders <App/> once. First paint stays
  instant, cloud analytics behavior unchanged, no remount.
- F6 [test]: exported isChunkLoadError + chunk-load-error-boundary.test.ts —
  pins the detector (ChunkLoadError name + the 3 dynamic-import failure messages,
  case-insensitive → true; null/undefined/ordinary errors → false) so a
  false-negative that re-blanks the app on a real chunk-404 is caught.

Gate: client tsc 0, chunk-load + sanitize tests 14 passed. Entry chunk unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 00:28:46 +03:00
agent_coder 6475cb81e0 fix(#342 review F1-F4): chunk-load error boundary + non-blocking posthog + tests
- F1 [HIGH]: added a root ChunkLoadErrorBoundary (react-error-boundary) wrapping
  the routed app in main.tsx, ABOVE all the route-level/Aside/AiChatWindow
  Suspense boundaries. A stale-deploy chunk 404 (React.lazy reject) is caught and
  auto-reloads once (sessionStorage-guarded against a reload loop), else shows a
  manual "new version available" reload UI — instead of unmounting the whole tree
  to a white screen. Existing per-feature ErrorBoundaries untouched.
- F2 [MED-HIGH]: posthog no longer blocks/blanks the cloud first paint. main.tsx
  now renders <App/> immediately for everyone, then `void initAnalytics()` — which
  keeps the exact cloud gate, dynamically imports posthog, and RE-RENDERS the same
  React root wrapped in PostHogProvider (React reconciles onto the painted DOM, so
  cloud ends up wrapped exactly as before). The import+init is try/catch'd: a
  failed analytics chunk (network / stale-404 / ad-blocker on a "posthog" chunk)
  degrades to no-analytics instead of a permanently blank page.
- F3: sanitize-url.test.ts mirroring editor-ext's security contract (javascript:/
  data:/vbscript:/obfuscated → ""; https/relative/mailto preserved).
- F4: the idle-warm `void import(...)` prefetch in layout.tsx gets `.catch(()=>{})`
  so a failed best-effort prefetch can't surface as an unhandledrejection.

No new deps (lockfile unchanged). Gate: client tsc 0, sanitize test 3/3, client
build succeeds (entry chunk still 556K, posthog in separate dynamic chunks).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 23:40:46 +03:00
agent_coder 51925e955f perf(client): route + component code-splitting — eager JS 3.5MB -> 1.12MB (#342)
Everything sat in the eager startup graph (App.tsx statically imported all 28
routes; the editor pulled TipTap + KaTeX + ~45 lowlight grammars + drawio;
posthog + AI SDK loaded for everyone) — a /login visitor downloaded+compiled the
whole editor. Client-only; functionality 1:1, only WHEN code loads changed.

Result (prod build): eager JS 3.5MB -> ~1.12MB, entry 1920KB -> 552KB; KaTeX
(250KB) and the TipTap engine (~586KB) are now lazy chunks, off the startup path.

- App.tsx: route-level React.lazy + Suspense (editor Page, all settings/*, share,
  space/home routes). Auth/redirect/cold-start routes stay eager. Suspense lives
  inside Layout/ShareLayout around the Outlet so the shell stays mounted.
- Lazy KaTeX node views (math-inline-lazy/math-block-lazy) + lazy drawio
  (drawio-view-lazy/drawio-menu-lazy), mirroring mermaid/excalidraw, each with a
  node-sized Suspense placeholder so a slow chunk can't crash the editor.
- posthog-js is now a conditional dynamic import under the unchanged
  isCloud() && isPostHogEnabled gate — self-hosted never downloads it.
- AiChatWindow is React.lazy, mounted on first open and kept mounted (a live AI
  stream isn't torn down); renders null while closed (identical behavior).
- Cut eager TipTap pulls from always-loaded shell modules: editor-atoms /
  global-bridge Editor -> import type; Aside lazily loaded (page routes only);
  config.ts sanitizeUrl and use-clipboard execCommandCopy moved to client-local
  src/lib/{sanitize-url,copy-to-clipboard}.ts (byte-identical to the editor-ext
  originals, dropping the barrel's top-level @tiptap import); WebSocketStatus
  import replaced with the "connected" literal the status atom already stores.
- vite.config.ts: a vendor-katex chunk group (TipTap/PM/Yjs intentionally NOT
  grouped — grouping dragged the engine eager; documented in the config).
- lowlight grammar registration is left inside the (now-lazy) editor chunk:
  listLanguages()/highlighting are synchronous, so deferring registration would
  change behavior for marginal in-chunk gain — the route split already removes it
  from startup, which was the complaint.

Gate: client build succeeds, tsc --noEmit clean, frozen install EXIT 0 (added
@braintree/sanitize-url as a direct client dep + regenerated the lock).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 22:15:44 +03:00
26 changed files with 509 additions and 134 deletions
+11 -42
View File
@@ -18,48 +18,12 @@ env:
IMAGE: ghcr.io/vvzvlad/gitmost
jobs:
# Run the reusable test suite. Together with the e2e jobs below it gates the
# publish job (the image push), not the build itself — build runs in parallel.
# Run the reusable test suite first so a failing test blocks the image build.
test:
uses: ./.github/workflows/test.yml
# Runs in parallel with the test/e2e jobs and only warms the buildx cache
# (GHA cache, scope develop-amd64). No push happens here — the publish job
# below is the only one that pushes the image.
build:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Resolve version
id: version
run: echo "value=$(git describe --tags --always)" >> "$GITHUB_OUTPUT"
- name: Build develop image (warm cache, no push)
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64
build-args: |
APP_VERSION=${{ steps.version.outputs.value }}
AI_AGENT_ROLES_CATALOG_URL=https://raw.githubusercontent.com/vvzvlad/gitmost/develop/agent-roles-catalog
push: false
cache-from: type=gha,scope=develop-amd64
cache-to: type=gha,scope=develop-amd64,mode=max,ignore-error=true
# The gate: rebuilds from the cache the build job just wrote (near-instant on
# a cache hit; worst case — cache eviction — a full rebuild, which matches the
# old sequential timing) and pushes :develop only when unit tests AND both
# e2e suites AND the build are green.
publish:
needs: [test, e2e-server, e2e-mcp, build]
needs: test
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
@@ -93,10 +57,13 @@ jobs:
push: true
tags: ${{ env.IMAGE }}:develop
cache-from: type=gha,scope=develop-amd64
cache-to: type=gha,scope=develop-amd64,mode=max,ignore-error=true
# e2e jobs gate the publish (image push), not the build: the :develop image
# is pushed only when unit tests AND both e2e suites pass (publish.needs
# lists them all).
# e2e jobs run on every develop push but DO NOT gate the build/publish above:
# `build` stays `needs: test` only, so the :develop image still ships even if
# e2e fails. A failing e2e job turns the run red and triggers GitHub's email
# to the pusher — that red run + email is the intended notification, not a
# deploy block.
e2e-server:
runs-on: ubuntu-latest
# Hard cap: the full-AppModule e2e leaks open handles and hung jest to the 6h max.
@@ -157,7 +124,9 @@ jobs:
- name: Run server e2e
run: pnpm --filter ./apps/server test:e2e
# Gates the publish too — see the comment above e2e-server.
# Same rationale as e2e-server: this job is intentionally NOT in
# `build.needs`. Deploy of the :develop image must not be blocked by e2e;
# a red run plus GitHub's email to the pusher is the notification mechanism.
e2e-mcp:
runs-on: ubuntu-latest
timeout-minutes: 20
+2 -16
View File
@@ -5,13 +5,6 @@ RUN npm install -g pnpm@10.4.0
FROM base AS builder
# re2 (packages/mcp) always compiles from source under pnpm (the prebuilt-binary
# download cannot identify the GitHub repo), so node-gyp needs python3/make/g++.
# This stage is discarded, so the toolchain can stay installed.
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY . .
@@ -64,17 +57,10 @@ COPY --from=builder /app/patches /app/patches
RUN chown -R node:node /app
# Toolchain is needed transiently to compile re2 during the prod install; install
# and purge it in one layer to keep the final image slim. The install itself runs
# as the node user via su to keep node_modules ownership without a costly chown layer.
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ \
&& su node -c "pnpm install --frozen-lockfile --prod" \
&& apt-get purge -y --auto-remove python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
USER node
RUN pnpm install --frozen-lockfile --prod
RUN mkdir -p /app/data/storage
VOLUME ["/app/data/storage"]
+1
View File
@@ -13,6 +13,7 @@
},
"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",
+58 -24
View File
@@ -1,38 +1,72 @@
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 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 PageRedirect from "@/pages/page/page-redirect.tsx";
import ShareRedirect from "@/pages/share/share-redirect.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";
// 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"));
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 />} />
@@ -83,6 +117,6 @@ export default function App() {
<Route path="*" element={<Error404 />} />
</Routes>
</>
</Suspense>
);
}
@@ -0,0 +1,37 @@
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);
});
});
@@ -0,0 +1,71 @@
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,9 +1,10 @@
import { AppShell, Container } from "@mantine/core";
import React, { useEffect, useRef, useState } from "react";
import React, { Suspense, 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 } from "jotai";
import { useAtom, useAtomValue } from "jotai";
import { aiChatWindowOpenAtom } from "@/features/ai-chat/atoms/ai-chat-atom.ts";
import {
APP_NAVBAR_ID,
NAVBAR_COLLAPSE_BREAKPOINT,
@@ -14,8 +15,6 @@ 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";
@@ -23,6 +22,21 @@ 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,
}: {
@@ -37,6 +51,15 @@ 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);
@@ -160,13 +183,21 @@ export default function GlobalAppShell({
: undefined
}
>
<Aside />
<Suspense fallback={null}>
<Aside />
</Suspense>
</AppShell.Aside>
)}
</AppShell>
{/* 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 />
{/* 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>
)}
{/* 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,5 +1,7 @@
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";
@@ -8,10 +10,39 @@ 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>
<Outlet />
<Suspense
fallback={
<Center h="60vh">
<Loader size="sm" />
</Center>
}
>
<Outlet />
</Suspense>
</GlobalAppShell>
<SearchSpotlight spaceId={space?.id} />
</UserProvider>
@@ -1,5 +1,8 @@
import { atom } from "jotai";
import { Editor } from "@tiptap/core";
// 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 { PageEditMode } from "@/features/user/types/user.types.ts";
import type { DictationUnavailableReason } from "@/features/dictation/dictation-status";
@@ -0,0 +1,16 @@
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>
);
}
@@ -0,0 +1,17 @@
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>
);
}
@@ -0,0 +1,19 @@
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>
);
}
@@ -0,0 +1,19 @@
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>
);
}
@@ -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.tsx";
import MathBlockView from "@/features/editor/components/math/math-block.tsx";
import MathInlineView from "@/features/editor/components/math/math-inline-lazy.tsx";
import MathBlockView from "@/features/editor/components/math/math-block-lazy.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";
import DrawioView from "../components/drawio/drawio-view-lazy.tsx";
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,8 +1,17 @@
import { useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { getDefaultStore } from "jotai";
import { WebSocketStatus } from "@hocuspocus/provider";
import { Editor } from "@tiptap/core";
// 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 {
pageEditorAtom,
yjsConnectionStatusAtom,
@@ -16,15 +25,19 @@ import {
getSidebarPages,
} from "@/features/page/services/page-service.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import {
// 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 {
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
@@ -57,7 +70,7 @@ function gitmostWaitForEditor(
!editor.isDestroyed &&
editor.isEditable &&
editorPageId === pageId &&
yjsStatus === WebSocketStatus.Connected;
yjsStatus === YJS_STATUS_CONNECTED;
if (ready) {
resolve(editor);
return;
@@ -171,6 +184,12 @@ 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";
import DrawioMenu from "./components/drawio/drawio-menu-lazy";
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";
@@ -1,10 +1,20 @@
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>
<Outlet />
<Suspense
fallback={
<Center h="60vh">
<Loader size="sm" />
</Center>
}
>
<Outlet />
</Suspense>
</ShareShell>
);
}
+1 -1
View File
@@ -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 "@docmost/editor-ext";
import { execCommandCopy } from "@/lib/copy-to-clipboard.ts";
export type UseClipboardOptions = {
timeout?: number;
+1 -1
View File
@@ -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 "@docmost/editor-ext";
import { sanitizeUrl } from "@/lib/sanitize-url.ts";
declare global {
interface Window {
+16
View File
@@ -0,0 +1,16 @@
// 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);
}
+31
View File
@@ -0,0 +1,31 @@
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",
);
});
});
+15
View File
@@ -0,0 +1,15 @@
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;
}
+60 -27
View File
@@ -13,15 +13,14 @@ 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: {
@@ -34,31 +33,65 @@ 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);
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>,
);
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();
+14
View File
@@ -63,6 +63,20 @@ 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[\\/]/,
},
],
},
},
+1 -1
View File
@@ -450,7 +450,7 @@ async function main() {
// 8. get_page markdown round-trip sanity (table separator present)
const md = await client.getPage(pageId);
check("get_page md: table separator emitted", md.data.content.includes("| --- |"), "");
check("get_page md: callout exported as Obsidian '> [!info]'", md.data.content.includes("> [!info]"));
check("get_page md: callout exported as :::", md.data.content.includes(":::info"));
// 9. comments: create / list / reply / update / check_new / delete
const beforeComments = new Date(Date.now() - 1000).toISOString();
+4 -1
View File
@@ -269,6 +269,9 @@ 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)
@@ -16153,7 +16156,7 @@ snapshots:
obug: 2.1.1
std-env: 4.1.0
tinyrainbow: 3.1.0
vitest: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@27.4.0(@noble/hashes@2.0.1))(vite@8.0.5(@types/node@25.5.0)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3))
vitest: 4.1.6(@opentelemetry/api@1.9.0)(@types/node@22.19.1)(@vitest/coverage-v8@4.1.6)(happy-dom@20.8.9)(jsdom@25.0.0)(vite@8.0.5(@types/node@22.19.1)(esbuild@0.28.0)(jiti@2.4.2)(less@4.2.0)(sugarss@5.0.1(postcss@8.5.14))(terser@5.39.0)(tsx@4.21.0)(yaml@2.8.3))
'@vitest/expect@4.1.6':
dependencies: