Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0050ad7ebb | |||
| 7b4617db70 | |||
| 459d636ffb | |||
| 5336f06d10 | |||
| 4bd579f7f6 | |||
| 7bf1c91a95 | |||
| 6c82c54470 | |||
| 382e5196da | |||
| 76e0c08cec |
@@ -18,12 +18,48 @@ env:
|
|||||||
IMAGE: ghcr.io/vvzvlad/gitmost
|
IMAGE: ghcr.io/vvzvlad/gitmost
|
||||||
|
|
||||||
jobs:
|
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:
|
test:
|
||||||
uses: ./.github/workflows/test.yml
|
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:
|
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
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
@@ -57,13 +93,10 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
tags: ${{ env.IMAGE }}:develop
|
tags: ${{ env.IMAGE }}:develop
|
||||||
cache-from: type=gha,scope=develop-amd64
|
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:
|
# e2e jobs gate the publish (image push), not the build: the :develop image
|
||||||
# `build` stays `needs: test` only, so the :develop image still ships even if
|
# is pushed only when unit tests AND both e2e suites pass (publish.needs
|
||||||
# e2e fails. A failing e2e job turns the run red and triggers GitHub's email
|
# lists them all).
|
||||||
# to the pusher — that red run + email is the intended notification, not a
|
|
||||||
# deploy block.
|
|
||||||
e2e-server:
|
e2e-server:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
# Hard cap: the full-AppModule e2e leaks open handles and hung jest to the 6h max.
|
# 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
|
- name: Run server e2e
|
||||||
run: pnpm --filter ./apps/server test:e2e
|
run: pnpm --filter ./apps/server test:e2e
|
||||||
|
|
||||||
# Same rationale as e2e-server: this job is intentionally NOT in
|
# Gates the publish too — see the comment above e2e-server.
|
||||||
# `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:
|
e2e-mcp:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
|
|||||||
@@ -13,6 +13,49 @@ permissions:
|
|||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
# Guard against a long-lived branch adding a migration whose timestamped
|
||||||
|
# filename sorts BEFORE migrations already applied on the target branch (and
|
||||||
|
# thus in prod). The Kysely startup migrator rejects that as "corrupted
|
||||||
|
# migrations" and crash-loops the app on boot (incident #361). This gate fails
|
||||||
|
# the PR so the migration is renamed to a current timestamp before merge. Only
|
||||||
|
# runs for pull_request events (needs a base branch to diff against).
|
||||||
|
migration-order:
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 5
|
||||||
|
steps:
|
||||||
|
- name: Checkout (full history for the base-branch diff)
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Added migrations must sort after the newest on the base branch
|
||||||
|
env:
|
||||||
|
TARGET_BRANCH: ${{ github.base_ref }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
MIG_DIR="apps/server/src/database/migrations"
|
||||||
|
# checkout above already did fetch-depth:0 (full history). Fetch the base
|
||||||
|
# WITHOUT --depth (a shallow graft would truncate the base history and
|
||||||
|
# break the merge-base when the base has moved ahead of the PR merge —
|
||||||
|
# exactly the long-branch-vs-moving-base case this gate guards, #361).
|
||||||
|
git fetch --no-tags origin "$TARGET_BRANCH"
|
||||||
|
newest_on_target=$(git ls-tree -r --name-only "origin/${TARGET_BRANCH}" "$MIG_DIR" | sort | tail -1)
|
||||||
|
# NO `|| true`: a diff failure (e.g. an unresolved merge-base) must fail
|
||||||
|
# the job CLOSED — a gate whose job is to BLOCK must never pass on error.
|
||||||
|
# `set -e` above already aborts on a non-zero diff exit.
|
||||||
|
added=$(git diff --diff-filter=A --name-only "origin/${TARGET_BRANCH}...HEAD" -- "$MIG_DIR")
|
||||||
|
bad=0
|
||||||
|
for f in $added; do
|
||||||
|
if [[ "$f" < "$newest_on_target" || "$f" == "$newest_on_target" ]]; then
|
||||||
|
echo "::error::Migration $f sorts at or before the newest on ${TARGET_BRANCH} ($newest_on_target) — rename it with a CURRENT timestamp before merge (do not change its contents). See incident #361."
|
||||||
|
bad=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ "$bad" -eq 0 ]; then
|
||||||
|
echo "Migration order OK (added migrations all sort after $newest_on_target)."
|
||||||
|
fi
|
||||||
|
exit $bad
|
||||||
|
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
|
|||||||
@@ -250,7 +250,10 @@ pnpm --filter server migration:codegen # regenerate src/databa
|
|||||||
```
|
```
|
||||||
Migration files live in `apps/server/src/database/migrations/` and are named `YYYYMMDDThhmmss-description.ts`. Fork-specific migrations only **add** tables (`page_embeddings`, `ai_chats`, `ai_chat_messages`, `ai_provider_credentials`, `ai_mcp_servers`, `page_template_references`) and columns (e.g. `pages.is_template`, a `NOT NULL DEFAULT false` boolean) — never drop/rewrite Docmost data.
|
Migration files live in `apps/server/src/database/migrations/` and are named `YYYYMMDDThhmmss-description.ts`. Fork-specific migrations only **add** tables (`page_embeddings`, `ai_chats`, `ai_chat_messages`, `ai_provider_credentials`, `ai_mcp_servers`, `page_template_references`) and columns (e.g. `pages.is_template`, a `NOT NULL DEFAULT false` boolean) — never drop/rewrite Docmost data.
|
||||||
|
|
||||||
**Migration ordering — always check when merging branches/features.** Kysely runs migrations in **alphabetical (= timestamp) order** and refuses to start if a *new* migration sorts **before** one already applied to the DB (`corrupted migrations: ... must always have a name that comes alphabetically after the last executed migration`). When you merge a branch or land a feature, verify your migration's timestamp still sorts **after every migration that may already be applied on the target** (`/bin/ls -1 apps/server/src/database/migrations | sort | tail`). Branches developed in parallel routinely break this: a feature branch adds `…T130000-…`, `main` meanwhile ships and deploys `…T150000-…`, and after the merge the older-timestamped file is rejected at boot. **Fix = rename your migration to a timestamp after the latest one already in the target** (content unchanged — the filename is the ordering key), then rebuild so the compiled `dist/database/migrations/` picks up the new name.
|
**Migration ordering — always check when merging branches/features.** Kysely runs migrations in **alphabetical (= timestamp) order**. A *new* migration that sorts **before** one already applied to the DB is a "back-dated" migration, which branches developed in parallel routinely produce: a feature branch adds `…T130000-…`, `develop` meanwhile ships and deploys `…T150000-…`, and after the merge the older-timestamped file has been skipped. Two layers guard this (both added for incident #361, where a back-dated migration crash-looped prod for ~11 min):
|
||||||
|
|
||||||
|
- **CI gate (primary):** the `migration-order` job in `.github/workflows/test.yml` fails a PR whose added migration sorts at/before the newest on the base branch. **So the fix is to rename your migration to a timestamp after the latest one already in the target** (`/bin/ls -1 apps/server/src/database/migrations | sort | tail`; content unchanged — the filename is the ordering key), then rebuild so the compiled `dist/database/migrations/` picks up the new name.
|
||||||
|
- **Runtime safety net:** both Migrators (`migration.service.ts` startup auto-migrate + `migrate.ts` CLI) set `allowUnorderedMigrations: true`, so the app does **not** refuse to start on an out-of-order migration — it applies the skipped older one instead of crash-looping. Kysely's `#ensureNoMissingMigrations` guard is still on (a *removed* applied migration is still an error). Because apply order can then differ from lexicographic across instances, migrations must stay **independent** (each creates its own objects) — the CI gate remains the primary line; this net only covers a gate bypass (manual push / hotfix branch).
|
||||||
|
|
||||||
## Architecture — the big picture
|
## Architecture — the big picture
|
||||||
|
|
||||||
|
|||||||
+16
-2
@@ -5,6 +5,13 @@ RUN npm install -g pnpm@10.4.0
|
|||||||
|
|
||||||
FROM base AS builder
|
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
|
WORKDIR /app
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
@@ -57,9 +64,16 @@ COPY --from=builder /app/patches /app/patches
|
|||||||
|
|
||||||
RUN chown -R node:node /app
|
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
|
RUN mkdir -p /app/data/storage
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/react": "^3.0.208",
|
"@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": "1.8.1",
|
||||||
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "2.1.5",
|
"@atlaskit/pragmatic-drag-and-drop-auto-scroll": "2.1.5",
|
||||||
"@atlaskit/pragmatic-drag-and-drop-flourish": "2.0.15",
|
"@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 { 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 SetupWorkspace from "@/pages/auth/setup-workspace.tsx";
|
||||||
import LoginPage from "@/pages/auth/login";
|
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 InviteSignup from "@/pages/auth/invite-signup.tsx";
|
||||||
import ForgotPassword from "@/pages/auth/forgot-password.tsx";
|
import ForgotPassword from "@/pages/auth/forgot-password.tsx";
|
||||||
import PasswordReset from "./pages/auth/password-reset";
|
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";
|
import ShareRedirect from "@/pages/share/share-redirect.tsx";
|
||||||
|
import { useTrackOrigin } from "@/hooks/use-track-origin";
|
||||||
// Heavy / leaf pages are route-split with React.lazy so their code (most
|
import SpacesPage from "@/pages/spaces/spaces.tsx";
|
||||||
// importantly the whole TipTap editor + KaTeX + lowlight grammars + drawio that
|
import SpaceTrash from "@/pages/space/space-trash.tsx";
|
||||||
// the page editor and the readonly share editor pull in) is fetched only when
|
import FavoritesPage from "@/pages/favorites/favorites-page";
|
||||||
// the matching route is actually visited. The <Suspense> boundaries live inside
|
import LabelPage from "@/pages/label/label-page";
|
||||||
// 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() {
|
export default function App() {
|
||||||
useTrackOrigin();
|
useTrackOrigin();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense
|
<>
|
||||||
fallback={
|
|
||||||
<Center h="100vh">
|
|
||||||
<Loader size="sm" />
|
|
||||||
</Center>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route index element={<Navigate to="/home" />} />
|
<Route index element={<Navigate to="/home" />} />
|
||||||
<Route path={"/login"} element={<LoginPage />} />
|
<Route path={"/login"} element={<LoginPage />} />
|
||||||
@@ -117,6 +83,6 @@ export default function App() {
|
|||||||
|
|
||||||
<Route path="*" element={<Error404 />} />
|
<Route path="*" element={<Error404 />} />
|
||||||
</Routes>
|
</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 { 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 { useLocation } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import SettingsSidebar from "@/components/settings/settings-sidebar.tsx";
|
import SettingsSidebar from "@/components/settings/settings-sidebar.tsx";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { aiChatWindowOpenAtom } from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
|
||||||
import {
|
import {
|
||||||
APP_NAVBAR_ID,
|
APP_NAVBAR_ID,
|
||||||
NAVBAR_COLLAPSE_BREAKPOINT,
|
NAVBAR_COLLAPSE_BREAKPOINT,
|
||||||
@@ -15,6 +14,8 @@ import {
|
|||||||
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||||
import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.tsx";
|
import { SpaceSidebar } from "@/features/space/components/sidebar/space-sidebar.tsx";
|
||||||
import { AppHeader } from "@/components/layouts/global/app-header.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 GitmostGlobalBridge from "@/features/editor/gitmost/gitmost-global-bridge.tsx";
|
||||||
import classes from "./app-shell.module.css";
|
import classes from "./app-shell.module.css";
|
||||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
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 { ASIDE_PANEL_ID } from "@/hooks/use-toggle-aside.tsx";
|
||||||
import { MAIN_CONTENT_ID, SkipToMain } from "@/components/ui/skip-to-main.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({
|
export default function GlobalAppShell({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
@@ -51,15 +37,6 @@ export default function GlobalAppShell({
|
|||||||
const [isResizing, setIsResizing] = useState(false);
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
const sidebarRef = useRef(null);
|
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) => {
|
const startResizing = React.useCallback((mouseDownEvent) => {
|
||||||
mouseDownEvent.preventDefault();
|
mouseDownEvent.preventDefault();
|
||||||
setIsResizing(true);
|
setIsResizing(true);
|
||||||
@@ -183,21 +160,13 @@ export default function GlobalAppShell({
|
|||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Suspense fallback={null}>
|
<Aside />
|
||||||
<Aside />
|
|
||||||
</Suspense>
|
|
||||||
</AppShell.Aside>
|
</AppShell.Aside>
|
||||||
)}
|
)}
|
||||||
</AppShell>
|
</AppShell>
|
||||||
{/* Floating AI chat window. Mounted once globally on first open; it is
|
{/* Floating AI chat window. Mounted once globally; it is position: fixed
|
||||||
position: fixed and self-hides when closed, so its place in the tree is
|
and self-hides when closed, so its place in the tree is not critical. */}
|
||||||
not critical. Kept mounted after the first open so a live stream is not
|
<AiChatWindow />
|
||||||
aborted. */}
|
|
||||||
{aiChatEverOpened && (
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<AiChatWindow />
|
|
||||||
</Suspense>
|
|
||||||
)}
|
|
||||||
{/* Global gitmost native bridge: registers listSpaces / listPages /
|
{/* Global gitmost native bridge: registers listSpaces / listPages /
|
||||||
createPageWithRecording on window.gitmost so the native host can
|
createPageWithRecording on window.gitmost so the native host can
|
||||||
create a page with a recording even when no page editor is open. */}
|
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 { UserProvider } from "@/features/user/user-provider.tsx";
|
||||||
import { Outlet, useParams } from "react-router-dom";
|
import { Outlet, useParams } from "react-router-dom";
|
||||||
import { Center, Loader } from "@mantine/core";
|
|
||||||
import GlobalAppShell from "@/components/layouts/global/global-app-shell.tsx";
|
import GlobalAppShell from "@/components/layouts/global/global-app-shell.tsx";
|
||||||
import { SearchSpotlight } from "@/features/search/components/search-spotlight.tsx";
|
import { SearchSpotlight } from "@/features/search/components/search-spotlight.tsx";
|
||||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||||
@@ -10,39 +8,10 @@ export default function Layout() {
|
|||||||
const { spaceSlug } = useParams();
|
const { spaceSlug } = useParams();
|
||||||
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
|
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 (
|
return (
|
||||||
<UserProvider>
|
<UserProvider>
|
||||||
<GlobalAppShell>
|
<GlobalAppShell>
|
||||||
<Suspense
|
<Outlet />
|
||||||
fallback={
|
|
||||||
<Center h="60vh">
|
|
||||||
<Loader size="sm" />
|
|
||||||
</Center>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Outlet />
|
|
||||||
</Suspense>
|
|
||||||
</GlobalAppShell>
|
</GlobalAppShell>
|
||||||
<SearchSpotlight spaceId={space?.id} />
|
<SearchSpotlight spaceId={space?.id} />
|
||||||
</UserProvider>
|
</UserProvider>
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import { atom } from "jotai";
|
import { atom } from "jotai";
|
||||||
// Type-only: these atoms only hold an Editor reference for typing. A value
|
import { Editor } from "@tiptap/core";
|
||||||
// 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 { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||||
import type { DictationUnavailableReason } from "@/features/dictation/dictation-status";
|
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -81,8 +81,8 @@ import {
|
|||||||
createResizeHandle,
|
createResizeHandle,
|
||||||
buildResizeClasses,
|
buildResizeClasses,
|
||||||
} from "@/features/editor/components/common/node-resize-handles.ts";
|
} from "@/features/editor/components/common/node-resize-handles.ts";
|
||||||
import MathInlineView from "@/features/editor/components/math/math-inline-lazy.tsx";
|
import MathInlineView from "@/features/editor/components/math/math-inline.tsx";
|
||||||
import MathBlockView from "@/features/editor/components/math/math-block-lazy.tsx";
|
import MathBlockView from "@/features/editor/components/math/math-block.tsx";
|
||||||
import ImageView from "@/features/editor/components/image/image-view.tsx";
|
import ImageView from "@/features/editor/components/image/image-view.tsx";
|
||||||
import CalloutView from "@/features/editor/components/callout/callout-view.tsx";
|
import CalloutView from "@/features/editor/components/callout/callout-view.tsx";
|
||||||
import StatusView from "@/features/editor/components/status/status-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 AudioView from "@/features/editor/components/audio/audio-view.tsx";
|
||||||
import AttachmentView from "@/features/editor/components/attachment/attachment-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 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 ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view-lazy.tsx";
|
||||||
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
|
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
|
||||||
import HtmlEmbedView from "@/features/editor/components/html-embed/html-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 { useEffect, useRef } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { getDefaultStore } from "jotai";
|
import { getDefaultStore } from "jotai";
|
||||||
|
import { WebSocketStatus } from "@hocuspocus/provider";
|
||||||
// Literal value of WebSocketStatus.Connected from @hocuspocus/provider. Inlined
|
import { Editor } from "@tiptap/core";
|
||||||
// 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 {
|
import {
|
||||||
pageEditorAtom,
|
pageEditorAtom,
|
||||||
yjsConnectionStatusAtom,
|
yjsConnectionStatusAtom,
|
||||||
@@ -25,19 +16,15 @@ import {
|
|||||||
getSidebarPages,
|
getSidebarPages,
|
||||||
} from "@/features/page/services/page-service.ts";
|
} from "@/features/page/services/page-service.ts";
|
||||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||||
// Types are erased at build time, so importing them does not pull the module's
|
import {
|
||||||
// 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,
|
GitmostBridge,
|
||||||
GitmostCreatePagePayload,
|
GitmostCreatePagePayload,
|
||||||
GitmostCreatePageResult,
|
GitmostCreatePageResult,
|
||||||
GitmostListPagesPayload,
|
GitmostListPagesPayload,
|
||||||
GitmostListPagesResult,
|
GitmostListPagesResult,
|
||||||
GitmostListSpacesResult,
|
GitmostListSpacesResult,
|
||||||
|
gitmostDecodePayloadToFile,
|
||||||
|
gitmostUploadFileToEditor,
|
||||||
} from "@/features/editor/gitmost/gitmost-recording.ts";
|
} from "@/features/editor/gitmost/gitmost-recording.ts";
|
||||||
|
|
||||||
// How long to wait for a freshly-navigated page's editor to mount, become
|
// How long to wait for a freshly-navigated page's editor to mount, become
|
||||||
@@ -70,7 +57,7 @@ function gitmostWaitForEditor(
|
|||||||
!editor.isDestroyed &&
|
!editor.isDestroyed &&
|
||||||
editor.isEditable &&
|
editor.isEditable &&
|
||||||
editorPageId === pageId &&
|
editorPageId === pageId &&
|
||||||
yjsStatus === YJS_STATUS_CONNECTED;
|
yjsStatus === WebSocketStatus.Connected;
|
||||||
if (ready) {
|
if (ready) {
|
||||||
resolve(editor);
|
resolve(editor);
|
||||||
return;
|
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
|
// Validate/decode the recording BEFORE creating the page so a bad
|
||||||
// payload never leaves an empty junk page behind. Per the createPage
|
// payload never leaves an empty junk page behind. Per the createPage
|
||||||
// error contract, any decode failure collapses to "insert-failed" (the
|
// error contract, any decode failure collapses to "insert-failed" (the
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ import {
|
|||||||
handlePaste,
|
handlePaste,
|
||||||
} from "@/features/editor/components/common/editor-paste-handler.tsx";
|
} from "@/features/editor/components/common/editor-paste-handler.tsx";
|
||||||
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu-lazy";
|
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 { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
||||||
import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx";
|
import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx";
|
||||||
import { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks";
|
import { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks";
|
||||||
|
|||||||
@@ -1,20 +1,10 @@
|
|||||||
import { Suspense } from "react";
|
|
||||||
import { Outlet } from "react-router-dom";
|
import { Outlet } from "react-router-dom";
|
||||||
import { Center, Loader } from "@mantine/core";
|
|
||||||
import ShareShell from "@/features/share/components/share-shell.tsx";
|
import ShareShell from "@/features/share/components/share-shell.tsx";
|
||||||
|
|
||||||
export default function ShareLayout() {
|
export default function ShareLayout() {
|
||||||
return (
|
return (
|
||||||
<ShareShell>
|
<ShareShell>
|
||||||
<Suspense
|
<Outlet />
|
||||||
fallback={
|
|
||||||
<Center h="60vh">
|
|
||||||
<Loader size="sm" />
|
|
||||||
</Center>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Outlet />
|
|
||||||
</Suspense>
|
|
||||||
</ShareShell>
|
</ShareShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Source: https://github.com/mantinedev/mantine/blob/master/packages/@mantine/hooks/src/use-clipboard/use-clipboard.ts
|
// Source: https://github.com/mantinedev/mantine/blob/master/packages/@mantine/hooks/src/use-clipboard/use-clipboard.ts
|
||||||
// polyfilled to support execCommand fallback
|
// polyfilled to support execCommand fallback
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { execCommandCopy } from "@/lib/copy-to-clipboard.ts";
|
import { execCommandCopy } from "@docmost/editor-ext";
|
||||||
|
|
||||||
export type UseClipboardOptions = {
|
export type UseClipboardOptions = {
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import bytes from "bytes";
|
import bytes from "bytes";
|
||||||
import { castToBoolean } from "@/lib/utils.tsx";
|
import { castToBoolean } from "@/lib/utils.tsx";
|
||||||
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
|
||||||
import { sanitizeUrl } from "@/lib/sanitize-url.ts";
|
import { sanitizeUrl } from "@docmost/editor-ext";
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
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 { Notifications } from "@mantine/notifications";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { HelmetProvider } from "react-helmet-async";
|
import { HelmetProvider } from "react-helmet-async";
|
||||||
import { ChunkLoadErrorBoundary } from "@/components/chunk-load-error-boundary.tsx";
|
|
||||||
import "./i18n";
|
import "./i18n";
|
||||||
|
import { PostHogProvider } from "posthog-js/react";
|
||||||
import {
|
import {
|
||||||
getPostHogHost,
|
getPostHogHost,
|
||||||
getPostHogKey,
|
getPostHogKey,
|
||||||
isCloud,
|
isCloud,
|
||||||
isPostHogEnabled,
|
isPostHogEnabled,
|
||||||
} from "@/lib/config.ts";
|
} from "@/lib/config.ts";
|
||||||
|
import posthog from "posthog-js";
|
||||||
|
|
||||||
export const queryClient = new QueryClient({
|
export const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
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 container = document.getElementById("root") as HTMLElement;
|
||||||
const root = (container as any).__reactRoot ??= ReactDOM.createRoot(container);
|
const root = (container as any).__reactRoot ??= ReactDOM.createRoot(container);
|
||||||
|
|
||||||
function renderApp() {
|
root.render(
|
||||||
root.render(
|
<BrowserRouter>
|
||||||
<BrowserRouter>
|
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
|
||||||
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
|
<ModalsProvider>
|
||||||
<ModalsProvider>
|
<QueryClientProvider client={queryClient}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<Notifications position="bottom-center" limit={3} zIndex={10000} />
|
||||||
<Notifications position="bottom-center" limit={3} zIndex={10000} />
|
<HelmetProvider>
|
||||||
<HelmetProvider>
|
<PostHogProvider client={posthog}>
|
||||||
{/* Root boundary above every lazy route's Suspense: a stale-chunk
|
<App />
|
||||||
404 after a deploy is caught and recovered here instead of
|
</PostHogProvider>
|
||||||
blanking the whole app. */}
|
</HelmetProvider>
|
||||||
<ChunkLoadErrorBoundary>
|
</QueryClientProvider>
|
||||||
<App />
|
</ModalsProvider>
|
||||||
</ChunkLoadErrorBoundary>
|
</MantineProvider>
|
||||||
</HelmetProvider>
|
</BrowserRouter>,
|
||||||
</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();
|
|
||||||
|
|||||||
@@ -63,20 +63,6 @@ export default defineConfig(({ mode }) => {
|
|||||||
name: "vendor-mantine",
|
name: "vendor-mantine",
|
||||||
test: /[\\/]node_modules[\\/]@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[\\/]/,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ const migrator = new Migrator({
|
|||||||
path,
|
path,
|
||||||
migrationFolder,
|
migrationFolder,
|
||||||
}),
|
}),
|
||||||
|
// Match the startup auto-migrator (migration.service.ts): a back-dated
|
||||||
|
// migration from a long-lived branch must be applied, not rejected as
|
||||||
|
// "corrupted migrations" (incident #361). See that file for the full rationale.
|
||||||
|
allowUnorderedMigrations: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
run(db, migrator, migrationFolder);
|
run(db, migrator, migrationFolder);
|
||||||
|
|||||||
@@ -19,6 +19,16 @@ export class MigrationService {
|
|||||||
path,
|
path,
|
||||||
migrationFolder: path.join(__dirname, '..', 'migrations'),
|
migrationFolder: path.join(__dirname, '..', 'migrations'),
|
||||||
}),
|
}),
|
||||||
|
// A long-lived branch can add a migration whose timestamped filename sorts
|
||||||
|
// BEFORE migrations already applied in prod (e.g. #234's 20260627 landing
|
||||||
|
// after 20260704 was live). With the default (ordered) setting the startup
|
||||||
|
// migrator then sees "corrupted migrations" — the applied set is no longer a
|
||||||
|
// prefix of the sorted list — throws, and the app crash-loops on boot
|
||||||
|
// (incident #361: 502s for ~11 min). allowUnorderedMigrations runs any
|
||||||
|
// not-yet-applied migration regardless of filename order, so a back-dated
|
||||||
|
// migration is applied instead of bricking startup. A CI order-gate still
|
||||||
|
// discourages back-dating; this is the runtime safety net.
|
||||||
|
allowUnorderedMigrations: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { error, results } = await migrator.migrateToLatest();
|
const { error, results } = await migrator.migrateToLatest();
|
||||||
|
|||||||
@@ -450,7 +450,7 @@ async function main() {
|
|||||||
// 8. get_page markdown round-trip sanity (table separator present)
|
// 8. get_page markdown round-trip sanity (table separator present)
|
||||||
const md = await client.getPage(pageId);
|
const md = await client.getPage(pageId);
|
||||||
check("get_page md: table separator emitted", md.data.content.includes("| --- |"), "");
|
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
|
// 9. comments: create / list / reply / update / check_new / delete
|
||||||
const beforeComments = new Date(Date.now() - 1000).toISOString();
|
const beforeComments = new Date(Date.now() - 1000).toISOString();
|
||||||
|
|||||||
Generated
+1
-4
@@ -269,9 +269,6 @@ importers:
|
|||||||
'@atlaskit/pragmatic-drag-and-drop-live-region':
|
'@atlaskit/pragmatic-drag-and-drop-live-region':
|
||||||
specifier: 1.3.4
|
specifier: 1.3.4
|
||||||
version: 1.3.4
|
version: 1.3.4
|
||||||
'@braintree/sanitize-url':
|
|
||||||
specifier: 7.1.2
|
|
||||||
version: 7.1.2
|
|
||||||
'@casl/react':
|
'@casl/react':
|
||||||
specifier: 5.0.1
|
specifier: 5.0.1
|
||||||
version: 5.0.1(@casl/ability@6.8.0)(react@18.3.1)
|
version: 5.0.1(@casl/ability@6.8.0)(react@18.3.1)
|
||||||
@@ -16156,7 +16153,7 @@ snapshots:
|
|||||||
obug: 2.1.1
|
obug: 2.1.1
|
||||||
std-env: 4.1.0
|
std-env: 4.1.0
|
||||||
tinyrainbow: 3.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':
|
'@vitest/expect@4.1.6':
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
Reference in New Issue
Block a user