Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cd3f2b7166 | |||
| 0eecdcba23 | |||
| 03314d747f | |||
| dd186406b6 | |||
| 47f37072ab | |||
| 5eb92f2cef | |||
| 57b77c35e5 | |||
| 411c05a9d6 | |||
| e8805b39c8 | |||
| 67a3663fc5 | |||
| 2cf30c7690 | |||
| ca26af9e9d | |||
| 3d6f48c3bd | |||
| 2f5b520af2 | |||
| 655970dd49 | |||
| 7ceef2bae6 | |||
| 77aa9443e9 | |||
| 1ac9a8df98 | |||
| 8cfc4c3c40 | |||
| 85ad697cd4 | |||
| ccc5e97000 | |||
| df02f2d672 | |||
| 7ac7fcba2d | |||
| caeb555039 | |||
| e05495ba4f |
@@ -92,6 +92,19 @@ IFRAME_EMBED_ALLOWED=false
|
|||||||
# Example: https://intranet.example.com,https://portal.example.com
|
# Example: https://intranet.example.com,https://portal.example.com
|
||||||
IFRAME_ALLOWED_ORIGINS=
|
IFRAME_ALLOWED_ORIGINS=
|
||||||
|
|
||||||
|
# Comma-separated list of additional origins allowed to call the API via CORS.
|
||||||
|
# The APP_URL origin and native mobile (Capacitor) origins are always allowed.
|
||||||
|
# Leave empty for a same-origin (web-only) deployment.
|
||||||
|
CORS_ALLOWED_ORIGINS=
|
||||||
|
|
||||||
|
# Expose OpenAPI/Swagger docs at /api/docs (development/debugging aid only).
|
||||||
|
SWAGGER_ENABLED=false
|
||||||
|
|
||||||
|
# Capacitor (mobile shell): hosted client URL loaded by the iOS shell so the
|
||||||
|
# AGPL web client is NOT bundled into the .ipa (see docs/mobile-app-plan.md §9).
|
||||||
|
# Leave empty for Android bundled mode / local development.
|
||||||
|
CAP_SERVER_URL=
|
||||||
|
|
||||||
# Enable debug logging in production (default: false)
|
# Enable debug logging in production (default: false)
|
||||||
DEBUG_MODE=false
|
DEBUG_MODE=false
|
||||||
|
|
||||||
|
|||||||
@@ -49,3 +49,8 @@ lerna-debug.log*
|
|||||||
|
|
||||||
# Self-hosted VAD / onnxruntime-web assets (copied from node_modules at dev/build time)
|
# Self-hosted VAD / onnxruntime-web assets (copied from node_modules at dev/build time)
|
||||||
apps/client/public/vad/
|
apps/client/public/vad/
|
||||||
|
|
||||||
|
# Capacitor native platform projects (generated locally via 'npx cap add ios|android')
|
||||||
|
/ios
|
||||||
|
/android
|
||||||
|
.capacitor
|
||||||
|
|||||||
@@ -72,10 +72,7 @@ git log -1 --format='Author: %an <%ae>%nCommitter: %cn <%ce>'
|
|||||||
|
|
||||||
### 4. Push and PR to develop
|
### 4. Push and PR to develop
|
||||||
|
|
||||||
PRs always target `develop`. Two different mechanisms are involved: **pushing
|
PRs always target `develop`. The `claude_code` password lives in the macOS
|
||||||
commits is git-native** (the Gitea MCP cannot push local git history, so the
|
|
||||||
branch is still pushed with `git push`), while **the PR itself is opened through
|
|
||||||
the Gitea MCP** (see below). The `claude_code` password lives in the macOS
|
|
||||||
keychain as a **generic password** under service `gitea-claude-code` (do not
|
keychain as a **generic password** under service `gitea-claude-code` (do not
|
||||||
duplicate it as an internet-password for `gitea.vvzvlad.xyz` — that creates a
|
duplicate it as an internet-password for `gitea.vvzvlad.xyz` — that creates a
|
||||||
conflict with the owner's account in the git credential helper):
|
conflict with the owner's account in the git credential helper):
|
||||||
@@ -97,24 +94,18 @@ git remote set-url gitea "$ORIG_URL"
|
|||||||
unset AGENT_PASS SAFE_PASS
|
unset AGENT_PASS SAFE_PASS
|
||||||
```
|
```
|
||||||
|
|
||||||
The PR is opened through the **Gitea MCP** (server `gitea`), not `curl`/`tea` —
|
The PR is created via the Gitea REST API (Basic Auth as `claude_code`):
|
||||||
the MCP authenticates in-process, so no keychain lookup or Basic-Auth is needed.
|
|
||||||
Call `pull_request_write` with:
|
|
||||||
|
|
||||||
- `method: "create"`
|
```bash
|
||||||
- `owner: "vvzvlad"`, `repo: "gitmost"`
|
curl -s -X POST \
|
||||||
- `base: "develop"`, `head: "<branch>"`
|
-u "claude_code:$(security find-generic-password -s gitea-claude-code -w)" \
|
||||||
- `title`, `body` — in the body: what was done, what is out of scope,
|
-H "Content-Type: application/json" \
|
||||||
verification results (tsc/lint/tests).
|
-d @pr_body.json \
|
||||||
|
"https://gitea.vvzvlad.xyz/api/v1/repos/vvzvlad/gitmost/pulls"
|
||||||
|
```
|
||||||
|
|
||||||
Manage and read PRs through the same server: `list_pull_requests`,
|
`base: develop`, `head: <branch>`. In the PR body: what was done, what is out
|
||||||
`pull_request_read` (`get`, `get_diff`, `get_files`, `get_status`),
|
of scope, verification results (tsc/lint/tests).
|
||||||
`pull_request_review_write`.
|
|
||||||
|
|
||||||
**Identity note:** the MCP acts under its **own** configured Gitea token (verify
|
|
||||||
with `get_me`), a different account from the `claude_code` used for git
|
|
||||||
commits/pushes in §3. Only the forge API calls (PR / issue / review) go through
|
|
||||||
the MCP account; the commits themselves stay authored as `claude_code`.
|
|
||||||
|
|
||||||
> If push fails with `User permission denied for writing`, then `claude_code`
|
> If push fails with `User permission denied for writing`, then `claude_code`
|
||||||
> lacks collaborator rights on the repo. Ask the owner to add them (once, via
|
> lacks collaborator rights on the repo. Ask the owner to add them (once, via
|
||||||
@@ -161,25 +152,23 @@ below.
|
|||||||
| Agent user (Gitea/git) | `claude_code` |
|
| Agent user (Gitea/git) | `claude_code` |
|
||||||
| Agent email | `claude_code@vvzvlad.xyz` |
|
| Agent email | `claude_code@vvzvlad.xyz` |
|
||||||
| Keychain password | `security find-generic-password -s gitea-claude-code -w` |
|
| Keychain password | `security find-generic-password -s gitea-claude-code -w` |
|
||||||
| Forge API (PR / issue / review / reads) | **Gitea MCP** — server `gitea` (`pull_request_write`, `issue_write`, `list_pull_requests`, `pull_request_read`, `label_read`, …). Authenticated in-process; acts under its own token — check with `get_me`. Repo slug on the server is `gitmost`. |
|
| PR API | `https://gitea.vvzvlad.xyz/api/v1/repos/vvzvlad/gitmost/pulls` (here `gitmost` is the repo's real slug on the server) |
|
||||||
| Base branch | `develop` |
|
| Base branch | `develop` |
|
||||||
| `origin` | GitHub mirror `vvzvlad/gitmost` — **do not push**, updated by the owner's CI |
|
| `origin` | GitHub mirror `vvzvlad/gitmost` — **do not push**, updated by the owner's CI |
|
||||||
| `upstream` | The original Docmost — **never push** |
|
| `upstream` | The original Docmost — **never push** |
|
||||||
|
|
||||||
## Creating issues (Gitea MCP)
|
## Creating issues (Gitea `tea` CLI)
|
||||||
|
|
||||||
File issues through the **Gitea MCP** (server `gitea`), not a CLI — call
|
Issues are filed with the official Gitea CLI `tea`, already logged in as
|
||||||
`issue_write` with:
|
`claude_code` (`tea logins list` shows the `gitea` login as default):
|
||||||
|
|
||||||
- `method: "create"`
|
```bash
|
||||||
- `owner: "vvzvlad"`, `repo: "gitmost"`
|
tea issues create --repo vvzvlad/gitmost --labels feature \
|
||||||
- `title`, `body`
|
--title '<title>' --description "$(cat body.md)"
|
||||||
- `labels` — an array of label **IDs** (numbers), *not* names. Resolve a name
|
```
|
||||||
such as `feature` to its id first with `label_read` (`method: "list"`), then
|
|
||||||
pass e.g. `labels: [<id>]`.
|
|
||||||
|
|
||||||
Read issues with `list_issues`, `issue_read`, or `search_issues`. The MCP is
|
> Gotcha (tea 0.14.1): the issue body flag is `--description`/`-d`, **not**
|
||||||
authenticated in-process, so no `tea`/`curl` and no keychain lookup are needed.
|
> `--body` — passing `--body` fails with `flag provided but not defined: -body`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+25
-70
@@ -14,10 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
- **Place several images side by side in a row.** A new "Inline (side by
|
- **Place several images side by side in a row.** A new "Inline (side by
|
||||||
side)" alignment mode in the image bubble menu renders consecutive inline
|
side)" alignment mode in the image bubble menu renders consecutive inline
|
||||||
images as a row that wraps onto the next line on narrow screens. The row is
|
images as a row that wraps onto the next line on narrow screens. Unlike the
|
||||||
centered horizontally by default in modern browsers (CSS `:has()`), falling
|
float modes, text does not wrap around inline images. The mode round-trips
|
||||||
back to start-aligned rows in browsers without support. Unlike the float
|
|
||||||
modes, text does not wrap around inline images. The mode round-trips
|
|
||||||
losslessly through markdown as `data-align`, like the other alignment
|
losslessly through markdown as `data-align`, like the other alignment
|
||||||
values.
|
values.
|
||||||
|
|
||||||
@@ -81,58 +79,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
`nosniff` + restrictive CSP + attachment disposition for non-image mimes) and
|
`nosniff` + restrictive CSP + attachment disposition for non-image mimes) and
|
||||||
are RAM-only, bound to the instance that created them. Tunable via five
|
are RAM-only, bound to the instance that created them. Tunable via five
|
||||||
`SANDBOX_*` env vars (see `.env.example`). (#243)
|
`SANDBOX_*` env vars (see `.env.example`). (#243)
|
||||||
|
- **Offline reading support**: opened pages, their sidebar tree, breadcrumb
|
||||||
|
children, and comments are cached in IndexedDB (TanStack Query persister plus
|
||||||
|
`y-indexeddb` for the page's Yjs document), and a PWA service worker
|
||||||
|
(vite-plugin-pwa) serves an app shell so previously opened pages stay readable
|
||||||
|
offline. The two offline stores (the persisted query cache and the Yjs page
|
||||||
|
documents) are cleared on logout AND on sign-in so a previous user's private
|
||||||
|
data does not remain in the browser; the same purge also defensively drops any
|
||||||
|
legacy service-worker `api-get-cache` left by older clients (current builds
|
||||||
|
serve `/api` as NetworkOnly, so there is no active service-worker API cache).
|
||||||
|
- **Mobile bootstrap**: a `returnToken` opt-in on login so native/mobile clients
|
||||||
|
can request the access JWT in the response body (`data.authToken`) in addition
|
||||||
|
to the httpOnly cookie (the web client stays cookie-only); an optional
|
||||||
|
OpenAPI/Swagger UI at `/api/docs` gated by `SWAGGER_ENABLED` (off by default);
|
||||||
|
and new env vars `CORS_ALLOWED_ORIGINS`, `SWAGGER_ENABLED`, `CAP_SERVER_URL`.
|
||||||
- **Inline spoiler mark — hide text behind click-to-reveal blur.** Selected text
|
- **Inline spoiler mark — hide text behind click-to-reveal blur.** Selected text
|
||||||
can be marked as a spoiler from a new bubble-menu toggle, or typed Discord-style
|
can be marked as a spoiler from a new bubble-menu toggle, or typed Discord-style
|
||||||
with the `||text||` input rule; the rendered span blurs until clicked to reveal.
|
with the `||text||` input rule; the rendered span blurs until clicked to reveal.
|
||||||
The mark is preserved losslessly through Markdown export/import (as a raw
|
The mark is preserved losslessly through Markdown export/import (as a raw
|
||||||
`<span data-spoiler="true">…</span>`) and on public shares. (#259)
|
`<span data-spoiler="true">…</span>`) and on public shares. (#259)
|
||||||
- **Dock the AI chat window into the side menu.** The floating chat window can
|
|
||||||
be pinned to the sidebar — drag it onto the navbar (a drop-zone highlight
|
|
||||||
shows where it lands) or use the new "Dock to sidebar" header button; while
|
|
||||||
docked it fills the sidebar area and follows its live size. "Undock" (or
|
|
||||||
dragging it back out) restores the floating window, a collapsed/absent
|
|
||||||
sidebar falls back to floating, and the docked state survives a reload.
|
|
||||||
(#276, #282)
|
|
||||||
- **Hovering commented text shows the comment thread in a tooltip.** Pointing
|
|
||||||
at a highlighted comment mark pops a small card with the author and plain
|
|
||||||
text of the root comment and its replies, so a thread can be skimmed without
|
|
||||||
opening the side panel. The card appears after a short delay (no flicker on a
|
|
||||||
passing glance), skips resolved and text-less threads, and dismisses on
|
|
||||||
scroll or click — clicking a mark still opens the comments panel. (#268,
|
|
||||||
#271)
|
|
||||||
- **"Move to trash" button in the temporary-note banner.** Besides "Make
|
|
||||||
permanent", the banner on an open temporary note now also offers to trash the
|
|
||||||
note immediately instead of waiting out its lifetime. It reuses the regular
|
|
||||||
soft-delete path, so the "Page moved to trash" undo toast is the safety net —
|
|
||||||
no confirmation dialog. (#273, #277)
|
|
||||||
- **Code-block controls float as an overlay instead of taking a row above the
|
|
||||||
code.** The language selector and copy button now sit in the block's top-right
|
|
||||||
corner, and the selector stays invisible until the block is hovered or the
|
|
||||||
selector is focused, so reading code is chrome-free. In read-only views only
|
|
||||||
the copy button renders. (#275, #278)
|
|
||||||
- **The AI agent is told about your page edits between turns.** The server
|
|
||||||
snapshots the open page's Markdown at the end of every agent turn and, on the
|
|
||||||
next turn, injects a unified diff of what changed in between, so the agent
|
|
||||||
knows its earlier copy of the page is stale and builds on the user's edits
|
|
||||||
instead of reverting or overwriting them. The diff is whitespace-normalized
|
|
||||||
(pure formatting churn injects nothing) and size-capped, with a hint to
|
|
||||||
re-read the full page via `getPage` when truncated. (#274, #281)
|
|
||||||
- **Stress-accent button (U+0301) in the bubble menu.** Select a vowel and
|
|
||||||
toggle a combining acute accent over it — a Russian-style stress mark. The
|
|
||||||
accent is stored as plain text (no custom mark), so it survives Markdown/HTML
|
|
||||||
export, full-text search and public shares unchanged; the toggle is a single
|
|
||||||
undo step and re-clicking removes the accent. (#270, #280)
|
|
||||||
- **Reading position survives a reload.** The editor remembers how far you
|
|
||||||
scrolled in each page (per tab, in `sessionStorage`) and restores that
|
|
||||||
position after an F5 or reopening the document, waiting for the collaborative
|
|
||||||
content to finish laying out first. A URL `#hash` anchor still wins — restore
|
|
||||||
is a no-op then. (#266, #267)
|
|
||||||
- **The slash menu finds commands typed in the wrong keyboard layout.** A query
|
|
||||||
typed with the wrong layout active (e.g. `/сщву` for `/code`, or `/cyjcrf`
|
|
||||||
for the Cyrillic «сноска» → Footnote) is additionally remapped ЙЦУКЕН↔QWERTY
|
|
||||||
by physical key position and matched against the commands; genuine Cyrillic
|
|
||||||
search terms keep priority over remapped candidates, and short wrong-layout
|
|
||||||
prefixes match by command title. (#283, #285, #287)
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
@@ -153,6 +118,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
JSON-compatible schema (no custom tags / no code execution) behind the same
|
JSON-compatible schema (no custom tags / no code execution) behind the same
|
||||||
size-cap, redirect and path-traversal guards. The `AI_AGENT_ROLES_CATALOG_URL`
|
size-cap, redirect and path-traversal guards. The `AI_AGENT_ROLES_CATALOG_URL`
|
||||||
base-URL contract is unchanged. (#229)
|
base-URL contract is unchanged. (#229)
|
||||||
|
- **CORS is now an explicit allowlist** (replaces the previous unconfigured
|
||||||
|
`app.enableCors()`). The same-origin web client is unaffected, but any
|
||||||
|
separately-hosted cross-domain client must now be listed in
|
||||||
|
`CORS_ALLOWED_ORIGINS` (native Capacitor/Ionic/localhost WebView origins are
|
||||||
|
allowed automatically). Requests with no `Origin` header (server-to-server)
|
||||||
|
are still allowed. **Upgrade note:** the old bare `app.enableCors()` reflected
|
||||||
|
*any* origin (with `credentials:false`), so any previously-working cross-domain
|
||||||
|
REST/browser client is now rejected until its origin is added to
|
||||||
|
`CORS_ALLOWED_ORIGINS` (see `.env.example`).
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
@@ -198,25 +172,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
emits a single-use "intentional clear" signal that lets exactly that one empty
|
emits a single-use "intentional clear" signal that lets exactly that one empty
|
||||||
write through the guard, so genuinely emptying a page is persisted while
|
write through the guard, so genuinely emptying a page is persisted while
|
||||||
accidental empties are blocked. (#248, #251)
|
accidental empties are blocked. (#248, #251)
|
||||||
- **Ctrl+Z works again right after using a table menu.** Closing a table
|
|
||||||
row/column menu (grip or chevron) left focus on the menu's portaled target
|
|
||||||
outside the editor, so undo keystrokes went nowhere until you clicked back
|
|
||||||
into a cell. The editor is now refocused after the menu closes — unless you
|
|
||||||
deliberately moved focus to another input or editable (e.g. the page title).
|
|
||||||
(#269, #279)
|
|
||||||
- **The AI reindex progress counter no longer freezes at 0.** Right after
|
|
||||||
"Reindex now" the client could read the stale pre-reindex snapshot of an
|
|
||||||
already-indexed workspace (`reindexing=false`, all pages counted) as
|
|
||||||
"finished" and stop polling on the very first tick, leaving the counter
|
|
||||||
frozen until a manual reload. Polling now keeps going until it has actually
|
|
||||||
observed the active run. (#262, #264)
|
|
||||||
- **An MCP edit can no longer be silently lost to a duplicate collab document.**
|
|
||||||
When the agent addressed a page by its short slugId, the MCP opened a
|
|
||||||
collaboration document named after that slugId while the web editor always
|
|
||||||
uses the page's canonical UUID — two independent live documents for one page,
|
|
||||||
whose debounced stores clobbered each other. The MCP now resolves every page
|
|
||||||
id to the canonical UUID before opening the collab doc (a UUID input
|
|
||||||
short-circuits locally; a slugId is resolved once and cached). (#260, #265)
|
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ community feature, with no enterprise license. Open it from the page header; the
|
|||||||
- ✅ **Page templates** — flag a page as a template and embed its whole content live into other pages; edits to the template propagate to every place it is inserted (whole-page transclusion on top of the existing synced blocks).
|
- ✅ **Page templates** — flag a page as a template and embed its whole content live into other pages; edits to the template propagate to every place it is inserted (whole-page transclusion on top of the existing synced blocks).
|
||||||
- ✅ **Public-share AI assistant** — anonymous visitors of a shared page can ask the AI agent, scoped strictly to that share's page tree (read-only, share-scoped search), behind a workspace toggle.
|
- ✅ **Public-share AI assistant** — anonymous visitors of a shared page can ask the AI agent, scoped strictly to that share's page tree (read-only, share-scoped search), behind a workspace toggle.
|
||||||
- ✅ **Footnotes** — academic-style footnotes: a numbered superscript reference inline (read it in place via a hover popover), with the note text living as a real, editable block at the bottom of the page; auto-numbered, collaboration-safe, and round-trips through Markdown export/import and the AI agent / MCP.
|
- ✅ **Footnotes** — academic-style footnotes: a numbered superscript reference inline (read it in place via a hover popover), with the note text living as a real, editable block at the bottom of the page; auto-numbered, collaboration-safe, and round-trips through Markdown export/import and the AI agent / MCP.
|
||||||
- ✅ **Temporary notes** — create a note as temporary and it auto-moves to Trash after a configurable per-workspace lifetime (default 24h) unless made permanent first; create one in a click from the Home screen, any space overview.
|
- ✅ **Temporary notes** — mark a note as temporary and it auto-moves to Trash after a configurable per-workspace lifetime (default 24h) unless made permanent first; create one in a click from the Home screen, any space overview, or the space sidebar, with a "Make permanent" rescue banner on the open note.
|
||||||
|
|
||||||
### In progress
|
### In progress
|
||||||
|
|
||||||
@@ -187,17 +187,14 @@ start the new migrations apply on top of your existing schema (`CREATE EXTENSION
|
|||||||
- Spaces
|
- Spaces
|
||||||
- Permissions management
|
- Permissions management
|
||||||
- Groups
|
- Groups
|
||||||
- Comments (with resolve / re-open and hover tooltips showing the comment text)
|
- Comments (with resolve / re-open)
|
||||||
- Page history
|
- Page history
|
||||||
- Search
|
- Search
|
||||||
- File attachments
|
- File attachments
|
||||||
- Embeds (Airtable, Loom, Miro and more)
|
- Embeds (Airtable, Loom, Miro and more)
|
||||||
- Translations (10+ languages)
|
- Translations (10+ languages)
|
||||||
- Embedded MCP server (`/mcp`)
|
- Embedded MCP server (`/mcp`)
|
||||||
- AI agent chat over your wiki (read + write, RAG search, external MCP / web access); the chat window docks into the side menu, and the agent is told about your in-page edits between turns
|
- AI agent chat over your wiki (read + write, RAG search, external MCP / web access)
|
||||||
- Code-block buttons as an overlay, with the language selector revealed on hover
|
|
||||||
- Stress-accent button (U+0301) in the bubble menu
|
|
||||||
- Reading scroll position restored on reload
|
|
||||||
|
|
||||||
### Screenshots
|
### Screenshots
|
||||||
|
|
||||||
|
|||||||
+3
-7
@@ -105,7 +105,7 @@ real-time-коллаборации Docmost, поэтому запись нико
|
|||||||
- ✅ **Шаблоны страниц** — пометить страницу шаблоном и вставлять её содержимое живой ссылкой в другие страницы; правки шаблона распространяются на все места вставки (whole-page-транслюзия поверх существующих synced-блоков).
|
- ✅ **Шаблоны страниц** — пометить страницу шаблоном и вставлять её содержимое живой ссылкой в другие страницы; правки шаблона распространяются на все места вставки (whole-page-транслюзия поверх существующих synced-блоков).
|
||||||
- ✅ **AI-ассистент на публичных шарах** — анонимный зритель расшаренной страницы может спросить AI-агента, который ищет строго по дереву этой шары (read-only, share-scoped поиск), за тумблером воркспейса.
|
- ✅ **AI-ассистент на публичных шарах** — анонимный зритель расшаренной страницы может спросить AI-агента, который ищет строго по дереву этой шары (read-only, share-scoped поиск), за тумблером воркспейса.
|
||||||
- ✅ **Сноски** — сноски академического вида: нумерованная ссылка-надстрочник прямо в тексте (читается на месте во всплывающем окне по наведению), а текст сноски живёт реальным редактируемым блоком внизу страницы; авто-нумерация, безопасна для совместного редактирования, переживает экспорт/импорт Markdown и доступна AI-агенту / MCP.
|
- ✅ **Сноски** — сноски академического вида: нумерованная ссылка-надстрочник прямо в тексте (читается на месте во всплывающем окне по наведению), а текст сноски живёт реальным редактируемым блоком внизу страницы; авто-нумерация, безопасна для совместного редактирования, переживает экспорт/импорт Markdown и доступна AI-агенту / MCP.
|
||||||
- ✅ **Временные заметки** — создайте временную заметку, и она автоматически уедет в корзину по истечении настраиваемого срока жизни (по умолчанию 24 ч); создать такую можно в один клик с домашнего экрана, с обзора любого пространства или из сайдбара пространства.
|
- ✅ **Временные заметки** — пометьте заметку временной, и она автоматически уедет в корзину по истечении настраиваемого срока жизни воркспейса (по умолчанию 24 ч), если её предварительно не сделать постоянной; создать такую можно в один клик с домашнего экрана, с обзора любого пространства или из сайдбара пространства, а на открытой заметке есть баннер «Сделать постоянной».
|
||||||
|
|
||||||
### В процессе
|
### В процессе
|
||||||
|
|
||||||
@@ -174,18 +174,14 @@ dump/restore, существующий каталог данных переис
|
|||||||
- Пространства (Spaces)
|
- Пространства (Spaces)
|
||||||
- Управление правами доступа
|
- Управление правами доступа
|
||||||
- Группы
|
- Группы
|
||||||
- Комментарии (с резолвом / переоткрытием и всплывающими подсказками с текстом комментария при наведении)
|
- Комментарии (с резолвом / переоткрытием)
|
||||||
- История страниц
|
- История страниц
|
||||||
- Поиск
|
- Поиск
|
||||||
- Вложения файлов
|
- Вложения файлов
|
||||||
- Встраивания (Airtable, Loom, Miro и другие)
|
- Встраивания (Airtable, Loom, Miro и другие)
|
||||||
- Переводы (10+ языков)
|
- Переводы (10+ языков)
|
||||||
- Встроенный MCP-сервер (`/mcp`)
|
- Встроенный MCP-сервер (`/mcp`)
|
||||||
- Чат с AI-агентом по вики (чтение + запись, RAG-поиск, внешние MCP / доступ в интернет); окно чата закрепляется в боковом меню, а агент узнаёт о ваших правках страницы между ходами
|
- Чат с AI-агентом по вики (чтение + запись, RAG-поиск, внешние MCP / доступ в интернет)
|
||||||
- Кнопки код-блока оверлеем, селектор языка появляется при наведении
|
|
||||||
- Кнопка «Ударение» (U+0301) в bubble-меню
|
|
||||||
- Позиция чтения (прокрутка) восстанавливается после перезагрузки
|
|
||||||
- Slash-меню терпимо к неправильной раскладке (ЙЦУКЕН↔QWERTY)
|
|
||||||
|
|
||||||
### Скриншоты
|
### Скриншоты
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
<meta name="theme-color" content="#0E1117" media="(prefers-color-scheme: dark)" />
|
<meta name="theme-color" content="#0E1117" media="(prefers-color-scheme: dark)" />
|
||||||
<meta name="theme-color" content="#f6f7f9" media="(prefers-color-scheme: light)" />
|
<meta name="theme-color" content="#f6f7f9" media="(prefers-color-scheme: light)" />
|
||||||
<link rel="manifest" href="/manifest.json" />
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<link rel="apple-touch-icon" href="/icons/app-icon-192x192.png" />
|
||||||
<meta name="mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-touch-fullscreen" content="yes" />
|
<meta name="apple-touch-fullscreen" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Gitmost" />
|
<meta name="apple-mobile-web-app-title" content="Gitmost" />
|
||||||
|
|||||||
@@ -33,7 +33,9 @@
|
|||||||
"@slidoapp/emoji-mart-data": "1.2.4",
|
"@slidoapp/emoji-mart-data": "1.2.4",
|
||||||
"@slidoapp/emoji-mart-react": "1.1.5",
|
"@slidoapp/emoji-mart-react": "1.1.5",
|
||||||
"@tabler/icons-react": "3.40.0",
|
"@tabler/icons-react": "3.40.0",
|
||||||
|
"@tanstack/query-async-storage-persister": "5.90.17",
|
||||||
"@tanstack/react-query": "5.90.17",
|
"@tanstack/react-query": "5.90.17",
|
||||||
|
"@tanstack/react-query-persist-client": "5.90.17",
|
||||||
"@tanstack/react-virtual": "3.13.24",
|
"@tanstack/react-virtual": "3.13.24",
|
||||||
"ai": "6.0.207",
|
"ai": "6.0.207",
|
||||||
"alfaaz": "1.1.0",
|
"alfaaz": "1.1.0",
|
||||||
@@ -45,6 +47,7 @@
|
|||||||
"highlightjs-sap-abap": "0.3.0",
|
"highlightjs-sap-abap": "0.3.0",
|
||||||
"i18next": "25.10.1",
|
"i18next": "25.10.1",
|
||||||
"i18next-http-backend": "3.0.6",
|
"i18next-http-backend": "3.0.6",
|
||||||
|
"idb-keyval": "6.2.5",
|
||||||
"jotai": "2.18.1",
|
"jotai": "2.18.1",
|
||||||
"jotai-optics": "0.4.0",
|
"jotai-optics": "0.4.0",
|
||||||
"js-cookie": "3.0.7",
|
"js-cookie": "3.0.7",
|
||||||
@@ -95,6 +98,7 @@
|
|||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
"typescript-eslint": "8.57.1",
|
"typescript-eslint": "8.57.1",
|
||||||
"vite": "8.0.5",
|
"vite": "8.0.5",
|
||||||
|
"vite-plugin-pwa": "1.3.0",
|
||||||
"vitest": "4.1.6"
|
"vitest": "4.1.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -471,6 +471,18 @@
|
|||||||
"Move page": "Move page",
|
"Move page": "Move page",
|
||||||
"Move page to a different space.": "Move page to a different space.",
|
"Move page to a different space.": "Move page to a different space.",
|
||||||
"Real-time editor connection lost. Retrying...": "Real-time editor connection lost. Retrying...",
|
"Real-time editor connection lost. Retrying...": "Real-time editor connection lost. Retrying...",
|
||||||
|
"Offline — changes are saved locally and will sync when you reconnect": "Offline — changes are saved locally and will sync when you reconnect",
|
||||||
|
"Syncing changes…": "Syncing changes…",
|
||||||
|
"All changes synced": "All changes synced",
|
||||||
|
"Update available": "Update available",
|
||||||
|
"Reload": "Reload",
|
||||||
|
"Make available offline": "Make available offline",
|
||||||
|
"Saving page for offline use...": "Saving page for offline use...",
|
||||||
|
"Page is now available offline": "Page is now available offline",
|
||||||
|
"Failed to make page available offline": "Failed to make page available offline",
|
||||||
|
"You're offline": "You're offline",
|
||||||
|
"This page hasn't been saved for offline use, so it can't be loaded right now. Reconnect to the internet and try again.": "This page hasn't been saved for offline use, so it can't be loaded right now. Reconnect to the internet and try again.",
|
||||||
|
"Retry": "Retry",
|
||||||
"Table of contents": "Table of contents",
|
"Table of contents": "Table of contents",
|
||||||
"Add headings (H1, H2, H3) to generate a table of contents.": "Add headings (H1, H2, H3) to generate a table of contents.",
|
"Add headings (H1, H2, H3) to generate a table of contents.": "Add headings (H1, H2, H3) to generate a table of contents.",
|
||||||
"Share": "Share",
|
"Share": "Share",
|
||||||
@@ -1222,8 +1234,8 @@
|
|||||||
"Commented": "Commented",
|
"Commented": "Commented",
|
||||||
"Resolved comment": "Resolved comment",
|
"Resolved comment": "Resolved comment",
|
||||||
"Ran tool {{name}}": "Ran tool {{name}}",
|
"Ran tool {{name}}": "Ran tool {{name}}",
|
||||||
"AI agent «{{role}}» on behalf of {{person}}": "AI agent «{{role}}» on behalf of {{person}}",
|
"AI-agent": "AI-agent",
|
||||||
"AI agent {{name}}": "AI agent {{name}}",
|
"Edited by AI agent on behalf of {{name}}": "Edited by AI agent on behalf of {{name}}",
|
||||||
"Endpoints": "Endpoints",
|
"Endpoints": "Endpoints",
|
||||||
"where we fetch models": "where we fetch models",
|
"where we fetch models": "where we fetch models",
|
||||||
"All endpoints are OpenAI-compatible. Point the Base URL at OpenAI, OpenRouter, a local Ollama, or any self-hosted server.": "All endpoints are OpenAI-compatible. Point the Base URL at OpenAI, OpenRouter, a local Ollama, or any self-hosted server.",
|
"All endpoints are OpenAI-compatible. Point the Base URL at OpenAI, OpenRouter, a local Ollama, or any self-hosted server.": "All endpoints are OpenAI-compatible. Point the Base URL at OpenAI, OpenRouter, a local Ollama, or any self-hosted server.",
|
||||||
|
|||||||
@@ -476,6 +476,18 @@
|
|||||||
"Move page": "Переместить страницу",
|
"Move page": "Переместить страницу",
|
||||||
"Move page to a different space.": "Переместите страницу в другое пространство.",
|
"Move page to a different space.": "Переместите страницу в другое пространство.",
|
||||||
"Real-time editor connection lost. Retrying...": "Соединение с редактором в реальном времени потеряно. Повторная попытка...",
|
"Real-time editor connection lost. Retrying...": "Соединение с редактором в реальном времени потеряно. Повторная попытка...",
|
||||||
|
"Offline — changes are saved locally and will sync when you reconnect": "Нет сети — изменения сохраняются локально и синхронизируются при восстановлении соединения",
|
||||||
|
"Syncing changes…": "Синхронизация изменений…",
|
||||||
|
"All changes synced": "Все изменения синхронизированы",
|
||||||
|
"Update available": "Доступно обновление",
|
||||||
|
"Reload": "Перезагрузить",
|
||||||
|
"Make available offline": "Сделать доступным офлайн",
|
||||||
|
"Saving page for offline use...": "Сохраняем страницу для офлайн-доступа…",
|
||||||
|
"Page is now available offline": "Страница доступна офлайн",
|
||||||
|
"Failed to make page available offline": "Не удалось сделать страницу доступной офлайн",
|
||||||
|
"You're offline": "Вы офлайн",
|
||||||
|
"This page hasn't been saved for offline use, so it can't be loaded right now. Reconnect to the internet and try again.": "Эта страница не была сохранена для офлайн-доступа, поэтому её нельзя загрузить сейчас. Подключитесь к интернету и попробуйте снова.",
|
||||||
|
"Retry": "Повторить",
|
||||||
"Table of contents": "Оглавление",
|
"Table of contents": "Оглавление",
|
||||||
"Add headings (H1, H2, H3) to generate a table of contents.": "Добавьте заголовки (H1, H2, H3), чтобы создать оглавление.",
|
"Add headings (H1, H2, H3) to generate a table of contents.": "Добавьте заголовки (H1, H2, H3), чтобы создать оглавление.",
|
||||||
"Share": "Поделиться",
|
"Share": "Поделиться",
|
||||||
@@ -724,8 +736,7 @@
|
|||||||
"Shown as used / total in the chat header. Leave empty to hide the limit.": "Показывается в шапке чата как использовано / всего. Пусто — лимит скрыт.",
|
"Shown as used / total in the chat header. Leave empty to hide the limit.": "Показывается в шапке чата как использовано / всего. Пусто — лимит скрыт.",
|
||||||
"Delete this chat?": "Удалить этот чат?",
|
"Delete this chat?": "Удалить этот чат?",
|
||||||
"Deleted successfully": "Успешно удалено",
|
"Deleted successfully": "Успешно удалено",
|
||||||
"AI agent «{{role}}» on behalf of {{person}}": "AI-агент «{{role}}» от имени {{person}}",
|
"Edited by AI agent on behalf of {{name}}": "Отредактировано AI-агентом от имени {{name}}",
|
||||||
"AI agent {{name}}": "AI-агент {{name}}",
|
|
||||||
"Failed to delete chat": "Не удалось удалить чат",
|
"Failed to delete chat": "Не удалось удалить чат",
|
||||||
"Failed to rename chat": "Не удалось переименовать чат",
|
"Failed to rename chat": "Не удалось переименовать чат",
|
||||||
"Failed": "Ошибка",
|
"Failed": "Ошибка",
|
||||||
|
|||||||
@@ -1,30 +1,19 @@
|
|||||||
{
|
{
|
||||||
|
"id": "/",
|
||||||
"name": "Gitmost",
|
"name": "Gitmost",
|
||||||
"short_name": "Gitmost",
|
"short_name": "Gitmost",
|
||||||
|
"description": "Gitmost - open-source collaborative documentation and knowledge base.",
|
||||||
|
"lang": "en",
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
|
"orientation": "any",
|
||||||
"background_color": "#0E1117",
|
"background_color": "#0E1117",
|
||||||
"theme_color": "#0E1117",
|
"theme_color": "#0E1117",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{ "src": "icons/favicon-16x16.png", "type": "image/png", "sizes": "16x16" },
|
||||||
"src": "icons/favicon-16x16.png",
|
{ "src": "icons/favicon-32x32.png", "type": "image/png", "sizes": "32x32" },
|
||||||
"type": "image/png",
|
{ "src": "icons/app-icon-192x192.png", "type": "image/png", "sizes": "192x192", "purpose": "any" },
|
||||||
"sizes": "16x16"
|
{ "src": "icons/app-icon-512x512.png", "type": "image/png", "sizes": "512x512", "purpose": "any" }
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "icons/favicon-32x32.png",
|
|
||||||
"type": "image/png",
|
|
||||||
"sizes": "32x32"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "icons/app-icon-192x192.png",
|
|
||||||
"type": "image/png",
|
|
||||||
"sizes": "180x180 192x192"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "icons/app-icon-512x512.png",
|
|
||||||
"type": "image/png",
|
|
||||||
"sizes": "512x512"
|
|
||||||
}
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,22 +14,6 @@ import { notifications } from "@mantine/notifications";
|
|||||||
import { exportSpace } from "@/features/space/services/space-service";
|
import { exportSpace } from "@/features/space/services/space-service";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
// The export request uses `responseType: "blob"`, so a server error body arrives
|
|
||||||
// as a Blob rather than parsed JSON — `err.response?.data.message` is therefore
|
|
||||||
// always undefined. Read and parse the blob to surface the real error message.
|
|
||||||
async function extractExportError(err: any): Promise<string> {
|
|
||||||
const data = err?.response?.data;
|
|
||||||
if (data instanceof Blob) {
|
|
||||||
try {
|
|
||||||
const json = JSON.parse(await data.text());
|
|
||||||
return json?.message ?? "";
|
|
||||||
} catch {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return data?.message ?? err?.message ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ExportModalProps {
|
interface ExportModalProps {
|
||||||
id: string;
|
id: string;
|
||||||
type: "space" | "page";
|
type: "space" | "page";
|
||||||
@@ -68,9 +52,8 @@ export default function ExportModal({
|
|||||||
});
|
});
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = await extractExportError(err);
|
|
||||||
notifications.show({
|
notifications.show({
|
||||||
message: t("Export failed") + (message ? `: ${message}` : ""),
|
message: "Export failed:" + err.response?.data.message,
|
||||||
color: "red",
|
color: "red",
|
||||||
});
|
});
|
||||||
console.error("export error", err);
|
console.error("export error", err);
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import TopMenu from "@/components/layouts/global/top-menu.tsx";
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import {
|
import {
|
||||||
NAVBAR_COLLAPSE_BREAKPOINT,
|
|
||||||
desktopSidebarAtom,
|
desktopSidebarAtom,
|
||||||
mobileSidebarAtom,
|
mobileSidebarAtom,
|
||||||
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||||
@@ -54,13 +53,7 @@ export function AppHeader() {
|
|||||||
aria-label={t("Sidebar toggle")}
|
aria-label={t("Sidebar toggle")}
|
||||||
opened={mobileOpened}
|
opened={mobileOpened}
|
||||||
onClick={toggleMobile}
|
onClick={toggleMobile}
|
||||||
// Must match the AppShell navbar breakpoint (md). The navbar
|
hiddenFrom="sm"
|
||||||
// collapses to the MOBILE drawer below md, so the mobile toggle
|
|
||||||
// (which flips mobileOpened) must be the one visible across the
|
|
||||||
// whole <md band — otherwise at 768-991 the desktop toggle showed
|
|
||||||
// but flipped the wrong atom, leaving the drawer unopenable (the
|
|
||||||
// regression from the initial sm->md navbar change).
|
|
||||||
hiddenFrom={NAVBAR_COLLAPSE_BREAKPOINT}
|
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -70,7 +63,7 @@ export function AppHeader() {
|
|||||||
aria-label={t("Sidebar toggle")}
|
aria-label={t("Sidebar toggle")}
|
||||||
opened={desktopOpened}
|
opened={desktopOpened}
|
||||||
onClick={toggleDesktop}
|
onClick={toggleDesktop}
|
||||||
visibleFrom={NAVBAR_COLLAPSE_BREAKPOINT}
|
visibleFrom="sm"
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import SettingsSidebar from "@/components/settings/settings-sidebar.tsx";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import {
|
import {
|
||||||
APP_NAVBAR_ID,
|
APP_NAVBAR_ID,
|
||||||
NAVBAR_COLLAPSE_BREAKPOINT,
|
|
||||||
asideStateAtom,
|
asideStateAtom,
|
||||||
desktopSidebarAtom,
|
desktopSidebarAtom,
|
||||||
mobileSidebarAtom,
|
mobileSidebarAtom,
|
||||||
@@ -89,13 +88,7 @@ export default function GlobalAppShell({
|
|||||||
header={{ height: 45 }}
|
header={{ height: 45 }}
|
||||||
navbar={{
|
navbar={{
|
||||||
width: isSpaceRoute ? sidebarWidth : 300,
|
width: isSpaceRoute ? sidebarWidth : 300,
|
||||||
// `md` (not `sm`): below 992px the fixed ~300px sidebar leaves too little
|
breakpoint: "sm",
|
||||||
// room for content — the settings tables (Members/…) overflow the offset
|
|
||||||
// content area on tablet (~768px) and clip the Role/actions columns
|
|
||||||
// off-screen with no horizontal scroll. Collapsing the navbar to a toggle
|
|
||||||
// drawer across the whole tablet band frees the full width for content
|
|
||||||
// (the mobile drawer is closed by default, so nothing overlaps on load).
|
|
||||||
breakpoint: NAVBAR_COLLAPSE_BREAKPOINT,
|
|
||||||
collapsed: {
|
collapsed: {
|
||||||
mobile: !mobileOpened,
|
mobile: !mobileOpened,
|
||||||
desktop: !desktopOpened,
|
desktop: !desktopOpened,
|
||||||
@@ -104,7 +97,7 @@ export default function GlobalAppShell({
|
|||||||
aside={
|
aside={
|
||||||
isPageRoute && {
|
isPageRoute && {
|
||||||
width: 420,
|
width: 420,
|
||||||
breakpoint: "md",
|
breakpoint: "sm",
|
||||||
collapsed: { mobile: !isAsideOpen, desktop: !isAsideOpen },
|
collapsed: { mobile: !isAsideOpen, desktop: !isAsideOpen },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,13 +7,6 @@ import { atom } from "jotai";
|
|||||||
// would create a shell -> chat-window -> shell import cycle).
|
// would create a shell -> chat-window -> shell import cycle).
|
||||||
export const APP_NAVBAR_ID = "app-shell-navbar";
|
export const APP_NAVBAR_ID = "app-shell-navbar";
|
||||||
|
|
||||||
// Single source of truth for the navbar collapse breakpoint. The AppShell navbar
|
|
||||||
// `breakpoint` and BOTH burger toggles' `hiddenFrom`/`visibleFrom` MUST use this
|
|
||||||
// exact value: if they drift, the sidebar becomes unreachable on tablet widths
|
|
||||||
// (the round-1 regression of #292). Kept here so the shell and the header share
|
|
||||||
// one constant the compiler enforces, instead of three hand-synced string literals.
|
|
||||||
export const NAVBAR_COLLAPSE_BREAKPOINT = "md";
|
|
||||||
|
|
||||||
export const mobileSidebarAtom = atom<boolean>(false);
|
export const mobileSidebarAtom = atom<boolean>(false);
|
||||||
|
|
||||||
export const desktopSidebarAtom = atomWithWebStorage<boolean>(
|
export const desktopSidebarAtom = atomWithWebStorage<boolean>(
|
||||||
|
|||||||
@@ -1,136 +0,0 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
|
||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
|
||||||
import { MantineProvider } from "@mantine/core";
|
|
||||||
import { Provider, createStore } from "jotai";
|
|
||||||
import { AgentAvatarStack, agentGlyphBackground } from "./agent-avatar-stack";
|
|
||||||
import {
|
|
||||||
activeAiChatIdAtom,
|
|
||||||
aiChatWindowOpenAtom,
|
|
||||||
aiChatDraftAtom,
|
|
||||||
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
|
||||||
|
|
||||||
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
|
||||||
|
|
||||||
type Props = React.ComponentProps<typeof AgentAvatarStack>;
|
|
||||||
|
|
||||||
function renderStack(props: Props) {
|
|
||||||
const store = createStore();
|
|
||||||
store.set(aiChatDraftAtom, "leftover draft from another chat");
|
|
||||||
const utils = render(
|
|
||||||
<Provider store={store}>
|
|
||||||
<MantineProvider>
|
|
||||||
<AgentAvatarStack {...props} />
|
|
||||||
</MantineProvider>
|
|
||||||
</Provider>,
|
|
||||||
);
|
|
||||||
return { store, ...utils };
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("agentGlyphBackground", () => {
|
|
||||||
it("is deterministic for a given agent name", () => {
|
|
||||||
expect(agentGlyphBackground("Researcher")).toBe(
|
|
||||||
agentGlyphBackground("Researcher"),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("differs by name and stays a fixed dark shade (readable emoji)", () => {
|
|
||||||
expect(agentGlyphBackground("Researcher")).not.toBe(
|
|
||||||
agentGlyphBackground("Нарратор"),
|
|
||||||
);
|
|
||||||
// Only the hue varies; saturation/lightness are pinned low so the glyph is
|
|
||||||
// always a dark circle.
|
|
||||||
expect(agentGlyphBackground("Нарратор")).toMatch(/^hsl\(\d+, 45%, 24%\)$/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("AgentAvatarStack", () => {
|
|
||||||
it("internal chat WITH role: emoji glyph in front + human launcher behind", () => {
|
|
||||||
const { container } = renderStack({
|
|
||||||
agent: { name: "Researcher", emoji: "🔬", avatarUrl: null },
|
|
||||||
launcher: { name: "Alice", avatarUrl: null },
|
|
||||||
aiChatId: "chat-1",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Emoji is used as the glyph (priority 2), NOT the sparkles fallback.
|
|
||||||
expect(screen.getByText("🔬")).toBeDefined();
|
|
||||||
expect(container.querySelector(".tabler-icon-sparkles")).toBeNull();
|
|
||||||
// Label: bold role name + dimmed "· launcher".
|
|
||||||
expect(screen.getByText("Researcher")).toBeDefined();
|
|
||||||
expect(screen.getByText(/·/)).toBeDefined();
|
|
||||||
expect(screen.getByText("Alice")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("showName=false: renders only the avatars, no inline name label", () => {
|
|
||||||
renderStack({
|
|
||||||
agent: { name: "Researcher", emoji: "🔬", avatarUrl: null },
|
|
||||||
launcher: { name: "Alice", avatarUrl: null },
|
|
||||||
aiChatId: "chat-1",
|
|
||||||
showName: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// The agent glyph is still rendered...
|
|
||||||
expect(screen.getByText("🔬")).toBeDefined();
|
|
||||||
// ...but neither the agent NOR the launcher inline name label is rendered
|
|
||||||
// (they live only in the hover tooltip, which is not mounted in the initial
|
|
||||||
// DOM) — guards against suppressing only the agent name and leaking the
|
|
||||||
// launcher name.
|
|
||||||
expect(screen.queryByText("Researcher")).toBeNull();
|
|
||||||
expect(screen.queryByText("Alice")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("internal chat WITHOUT role: sparkles fallback + 'AI agent' + launcher", () => {
|
|
||||||
const { container } = renderStack({
|
|
||||||
agent: { name: "AI agent", avatarUrl: null },
|
|
||||||
launcher: { name: "Bob", avatarUrl: null },
|
|
||||||
aiChatId: "chat-2",
|
|
||||||
});
|
|
||||||
|
|
||||||
// No avatarUrl and no emoji => sparkles glyph (priority 3).
|
|
||||||
expect(container.querySelector(".tabler-icon-sparkles")).not.toBeNull();
|
|
||||||
expect(screen.getByText("AI agent")).toBeDefined();
|
|
||||||
expect(screen.getByText("Bob")).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("external MCP: agent avatar in front, NO launcher behind", () => {
|
|
||||||
const { container } = renderStack({
|
|
||||||
agent: { name: "MCP Bot", avatarUrl: "http://example.test/a.png" },
|
|
||||||
launcher: null,
|
|
||||||
aiChatId: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
// avatarUrl provided (priority 1) => not the sparkles fallback.
|
|
||||||
expect(container.querySelector(".tabler-icon-sparkles")).toBeNull();
|
|
||||||
expect(screen.getByText("MCP Bot")).toBeDefined();
|
|
||||||
// No human behind => no "·" separator is rendered.
|
|
||||||
expect(screen.queryByText(/·/)).toBeNull();
|
|
||||||
// No internal chat => the stack is not an interactive deep-link button.
|
|
||||||
expect(screen.queryByRole("button")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("click deep-links into the chat when aiChatId is present", () => {
|
|
||||||
const { store } = renderStack({
|
|
||||||
agent: { name: "Researcher", emoji: "🔬", avatarUrl: null },
|
|
||||||
launcher: { name: "Alice", avatarUrl: null },
|
|
||||||
aiChatId: "chat-1",
|
|
||||||
});
|
|
||||||
|
|
||||||
const button = screen.getByRole("button");
|
|
||||||
fireEvent.click(button);
|
|
||||||
|
|
||||||
expect(store.get(activeAiChatIdAtom)).toBe("chat-1");
|
|
||||||
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
|
|
||||||
expect(store.get(aiChatDraftAtom)).toBe(""); // draft cleared on switch
|
|
||||||
});
|
|
||||||
|
|
||||||
it("click is a no-op / not interactive without a chat target", () => {
|
|
||||||
const onActivate = vi.fn();
|
|
||||||
renderStack({
|
|
||||||
agent: { name: "MCP Bot", avatarUrl: "http://example.test/a.png" },
|
|
||||||
launcher: null,
|
|
||||||
aiChatId: null,
|
|
||||||
onActivate,
|
|
||||||
});
|
|
||||||
expect(screen.queryByRole("button")).toBeNull();
|
|
||||||
expect(onActivate).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
import { Avatar, Box, Group, Text, Tooltip } from "@mantine/core";
|
|
||||||
import { IconSparkles } from "@tabler/icons-react";
|
|
||||||
import { useCallback } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useSetAtom } from "jotai";
|
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
|
||||||
import {
|
|
||||||
activeAiChatIdAtom,
|
|
||||||
aiChatWindowOpenAtom,
|
|
||||||
aiChatDraftAtom,
|
|
||||||
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
|
||||||
|
|
||||||
// The FRONT identity (the acting agent) and the BEHIND identity (the human who
|
|
||||||
// launched it). Both are computed server-side (#300) so the client never branches
|
|
||||||
// on the internal-vs-MCP provenance — it just renders whatever it is handed.
|
|
||||||
export interface AgentInfo {
|
|
||||||
name: string;
|
|
||||||
emoji?: string | null;
|
|
||||||
avatarUrl?: string | null;
|
|
||||||
}
|
|
||||||
export interface LauncherInfo {
|
|
||||||
name: string;
|
|
||||||
avatarUrl?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const GLYPH_SIZE = 38;
|
|
||||||
const LAUNCHER_SIZE = 22;
|
|
||||||
// How far the launcher avatar sticks out past the agent's top-right corner, so
|
|
||||||
// the "human behind" reads as behind (lower z-index) yet stays clearly visible.
|
|
||||||
const LAUNCHER_OVERHANG = 8;
|
|
||||||
|
|
||||||
// Small deterministic string hash (same algorithm as custom-avatar's initials
|
|
||||||
// hash) used to pick a stable per-agent glyph color.
|
|
||||||
function hashName(input: string): number {
|
|
||||||
let hash = 0;
|
|
||||||
for (let i = 0; i < input.length; i += 1) {
|
|
||||||
hash = (hash << 5) - hash + input.charCodeAt(i);
|
|
||||||
hash |= 0;
|
|
||||||
}
|
|
||||||
return Math.abs(hash);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deterministic DARK background for an emoji/sparkles agent glyph. The hue is
|
|
||||||
* derived from the agent-name hash so distinct agents get distinct circles;
|
|
||||||
* saturation and lightness are pinned low ("shifted into darkness") so a bright
|
|
||||||
* emoji or the white sparkles icon stays legible on top (#300).
|
|
||||||
*/
|
|
||||||
export function agentGlyphBackground(name: string): string {
|
|
||||||
const hue = hashName(name) % 360;
|
|
||||||
return `hsl(${hue}, 45%, 24%)`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The front avatar. Image-source priority (#300):
|
|
||||||
* 1. agent.avatarUrl -> a real avatar image (external MCP agent account).
|
|
||||||
* 2. agent.emoji -> the role emoji on a violet circle.
|
|
||||||
* 3. otherwise -> the IconSparkles glyph on a violet circle (fallback).
|
|
||||||
*/
|
|
||||||
function AgentGlyph({ agent }: { agent: AgentInfo }) {
|
|
||||||
if (agent.avatarUrl) {
|
|
||||||
return (
|
|
||||||
<CustomAvatar
|
|
||||||
size={GLYPH_SIZE}
|
|
||||||
avatarUrl={agent.avatarUrl}
|
|
||||||
name={agent.name}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emoji/sparkles glyphs sit on a per-agent dark circle (hashed from the agent
|
|
||||||
// name) so different agents are visually distinct, while the dark background
|
|
||||||
// keeps the emoji / white sparkles icon readable.
|
|
||||||
const bg = agentGlyphBackground(agent.name);
|
|
||||||
const glyphStyles = {
|
|
||||||
root: { background: bg },
|
|
||||||
placeholder: { background: bg, color: "var(--mantine-color-white)" },
|
|
||||||
};
|
|
||||||
|
|
||||||
if (agent.emoji) {
|
|
||||||
return (
|
|
||||||
<Avatar size={GLYPH_SIZE} radius="xl" variant="filled" styles={glyphStyles}>
|
|
||||||
<span style={{ fontSize: Math.round(GLYPH_SIZE * 0.5) }} aria-hidden>
|
|
||||||
{agent.emoji}
|
|
||||||
</span>
|
|
||||||
</Avatar>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Avatar size={GLYPH_SIZE} radius="xl" variant="filled" styles={glyphStyles}>
|
|
||||||
<IconSparkles size={Math.round(GLYPH_SIZE * 0.55)} stroke={2} />
|
|
||||||
</Avatar>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AgentAvatarStackProps {
|
|
||||||
agent: AgentInfo;
|
|
||||||
// null/absent => external MCP (front agent avatar only, no human behind).
|
|
||||||
launcher?: LauncherInfo | null;
|
|
||||||
// Deep-links into the internal AI chat when present (null for external MCP).
|
|
||||||
aiChatId?: string | null;
|
|
||||||
// Fired after the stack deep-links into its chat, so the caller can react
|
|
||||||
// (e.g. the page-history row closes the history modal). Keeps this ui/ primitive
|
|
||||||
// free of cross-feature coupling (inherited from the old AiAgentBadge, #143).
|
|
||||||
onActivate?: () => void;
|
|
||||||
// Whether to render the inline name label next to the avatars (default true).
|
|
||||||
// Set false when the caller renders the name itself (e.g. the comment row).
|
|
||||||
showName?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The "agent avatar stack" (#300): the AGENT glyph in front, and — for an
|
|
||||||
* internal AI chat — the HUMAN who launched it as a smaller avatar offset behind.
|
|
||||||
* Replaces the old text `AI-agent` badge. When the item carries an `aiChatId` the
|
|
||||||
* whole stack is a deep-link into that chat (the click the old badge owned moved
|
|
||||||
* here); the click is contained (stopPropagation) so it does not also trigger an
|
|
||||||
* enclosing row handler.
|
|
||||||
*/
|
|
||||||
export function AgentAvatarStack({
|
|
||||||
agent,
|
|
||||||
launcher,
|
|
||||||
aiChatId,
|
|
||||||
onActivate,
|
|
||||||
showName = true,
|
|
||||||
}: AgentAvatarStackProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const setAiChatWindowOpen = useSetAtom(aiChatWindowOpenAtom);
|
|
||||||
const setActiveChatId = useSetAtom(activeAiChatIdAtom);
|
|
||||||
const setDraft = useSetAtom(aiChatDraftAtom);
|
|
||||||
|
|
||||||
const clickable = !!aiChatId;
|
|
||||||
|
|
||||||
const openChat = useCallback(
|
|
||||||
(event: React.SyntheticEvent) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
if (!aiChatId) return;
|
|
||||||
setActiveChatId(aiChatId);
|
|
||||||
// Switching chats must start with a clean composer — clear any unsent draft
|
|
||||||
// so it does not leak from the previously open chat.
|
|
||||||
setDraft("");
|
|
||||||
setAiChatWindowOpen(true);
|
|
||||||
onActivate?.();
|
|
||||||
},
|
|
||||||
[aiChatId, setActiveChatId, setDraft, setAiChatWindowOpen, onActivate],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Internal chat => "role on behalf of person"; external MCP => just the agent.
|
|
||||||
const tooltip = launcher
|
|
||||||
? t("AI agent «{{role}}» on behalf of {{person}}", {
|
|
||||||
role: agent.name,
|
|
||||||
person: launcher.name,
|
|
||||||
})
|
|
||||||
: t("AI agent {{name}}", { name: agent.name });
|
|
||||||
|
|
||||||
// The container is only enlarged when there is a launcher to overhang; with no
|
|
||||||
// human behind it stays tight at the agent glyph size.
|
|
||||||
const stackSize = launcher ? GLYPH_SIZE + LAUNCHER_OVERHANG : GLYPH_SIZE;
|
|
||||||
|
|
||||||
const stack = (
|
|
||||||
<Box
|
|
||||||
pos="relative"
|
|
||||||
style={{
|
|
||||||
width: stackSize,
|
|
||||||
height: stackSize,
|
|
||||||
flexShrink: 0,
|
|
||||||
// Center the (in-flow) agent glyph vertically so it lines up with its
|
|
||||||
// name label; the absolutely-positioned launcher is unaffected by flex.
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
cursor: clickable ? "pointer" : undefined,
|
|
||||||
}}
|
|
||||||
{...(clickable
|
|
||||||
? {
|
|
||||||
role: "button",
|
|
||||||
tabIndex: 0,
|
|
||||||
onClick: openChat,
|
|
||||||
onKeyDown: (event: React.KeyboardEvent) => {
|
|
||||||
if (event.key === "Enter" || event.key === " ") {
|
|
||||||
event.preventDefault();
|
|
||||||
openChat(event);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {})}
|
|
||||||
>
|
|
||||||
{launcher && (
|
|
||||||
<Box pos="absolute" top={0} right={0} style={{ zIndex: 0 }}>
|
|
||||||
<CustomAvatar
|
|
||||||
size={LAUNCHER_SIZE}
|
|
||||||
avatarUrl={launcher.avatarUrl}
|
|
||||||
name={launcher.name}
|
|
||||||
style={{ border: "2px solid var(--mantine-color-body)" }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
{/* The agent glyph keeps its own size (flex-centered in the container); the
|
|
||||||
launcher overhangs it by LAUNCHER_OVERHANG at the top-right and stays visible. */}
|
|
||||||
<Box
|
|
||||||
style={{
|
|
||||||
position: "relative",
|
|
||||||
zIndex: 1,
|
|
||||||
width: GLYPH_SIZE,
|
|
||||||
height: GLYPH_SIZE,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AgentGlyph agent={agent} />
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
|
|
||||||
<Tooltip label={tooltip} withArrow>
|
|
||||||
{stack}
|
|
||||||
</Tooltip>
|
|
||||||
{showName && (
|
|
||||||
<Group gap={4} wrap="nowrap" style={{ minWidth: 0 }}>
|
|
||||||
<Text size="xs" fw={600} lineClamp={1} lh={1.2}>
|
|
||||||
{agent.name}
|
|
||||||
</Text>
|
|
||||||
{launcher && (
|
|
||||||
<>
|
|
||||||
<Text size="xs" c="dimmed" fw={400} aria-hidden>
|
|
||||||
·
|
|
||||||
</Text>
|
|
||||||
<Text size="xs" c="dimmed" fw={400} lineClamp={1} lh={1.2}>
|
|
||||||
{launcher.name}
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AgentAvatarStack;
|
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
import { Provider, createStore } from "jotai";
|
||||||
|
import { AiAgentBadge } from "./ai-agent-badge";
|
||||||
|
import {
|
||||||
|
activeAiChatIdAtom,
|
||||||
|
aiChatWindowOpenAtom,
|
||||||
|
aiChatDraftAtom,
|
||||||
|
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
||||||
|
|
||||||
|
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||||
|
|
||||||
|
function renderBadge(props: { authorName?: string; aiChatId?: string | null }) {
|
||||||
|
return render(
|
||||||
|
<MantineProvider>
|
||||||
|
<AiAgentBadge {...props} />
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render a clickable badge inside an explicit jotai store, with a leftover draft
|
||||||
|
// and an onActivate + parent-click spy, so the deep-link side effects are
|
||||||
|
// assertable. Returns the store and spies.
|
||||||
|
function setupClickable() {
|
||||||
|
const store = createStore();
|
||||||
|
store.set(aiChatDraftAtom, "leftover draft from another chat");
|
||||||
|
const onActivate = vi.fn();
|
||||||
|
const onParentClick = vi.fn();
|
||||||
|
render(
|
||||||
|
<Provider store={store}>
|
||||||
|
<MantineProvider>
|
||||||
|
<div onClick={onParentClick}>
|
||||||
|
<AiAgentBadge authorName="Bot" aiChatId="chat-1" onActivate={onActivate} />
|
||||||
|
</div>
|
||||||
|
</MantineProvider>
|
||||||
|
</Provider>,
|
||||||
|
);
|
||||||
|
return { store, onActivate, onParentClick, badge: screen.getByRole("button") };
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectDeepLinked(store: ReturnType<typeof createStore>, onActivate: ReturnType<typeof vi.fn>) {
|
||||||
|
expect(store.get(activeAiChatIdAtom)).toBe("chat-1");
|
||||||
|
expect(store.get(aiChatWindowOpenAtom)).toBe(true);
|
||||||
|
expect(store.get(aiChatDraftAtom)).toBe(""); // draft cleared
|
||||||
|
expect(onActivate).toHaveBeenCalledTimes(1); // caller closes its own modal etc.
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("AiAgentBadge", () => {
|
||||||
|
it("renders the AI-agent label", () => {
|
||||||
|
renderBadge({ authorName: "Bot" });
|
||||||
|
expect(screen.getByText("AI-agent")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is clickable (accessible button) when aiChatId is present", () => {
|
||||||
|
renderBadge({ authorName: "Bot", aiChatId: "chat-1" });
|
||||||
|
const badge = screen.getByRole("button");
|
||||||
|
expect(badge).toBeDefined();
|
||||||
|
expect(badge.textContent).toContain("AI-agent");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("click deep-links: sets active chat, clears draft, opens window, fires onActivate, stops propagation", () => {
|
||||||
|
const { store, onActivate, onParentClick, badge } = setupClickable();
|
||||||
|
fireEvent.click(badge);
|
||||||
|
expectDeepLinked(store, onActivate);
|
||||||
|
expect(onParentClick).not.toHaveBeenCalled(); // stopPropagation contained the click
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["Enter", " "])(
|
||||||
|
"keyboard %j activates the deep-link (same side effects as click)",
|
||||||
|
(key) => {
|
||||||
|
const { store, onActivate, badge } = setupClickable();
|
||||||
|
fireEvent.keyDown(badge, { key });
|
||||||
|
expectDeepLinked(store, onActivate);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("an unrelated key does NOT activate the badge", () => {
|
||||||
|
const { store, onActivate, badge } = setupClickable();
|
||||||
|
fireEvent.keyDown(badge, { key: "Tab" });
|
||||||
|
expect(store.get(activeAiChatIdAtom)).toBeNull();
|
||||||
|
expect(store.get(aiChatWindowOpenAtom)).toBe(false);
|
||||||
|
expect(store.get(aiChatDraftAtom)).toBe("leftover draft from another chat");
|
||||||
|
expect(onActivate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([{ aiChatId: null }, {}])(
|
||||||
|
"is a plain non-clickable label without a chat target (%o)",
|
||||||
|
(props) => {
|
||||||
|
renderBadge({ authorName: "Bot", ...props });
|
||||||
|
expect(screen.getByText("AI-agent")).toBeDefined();
|
||||||
|
// No interactive role is exposed when there is no chat to deep-link into.
|
||||||
|
expect(screen.queryByRole("button")).toBeNull();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { Badge, Tooltip } from "@mantine/core";
|
||||||
|
import { IconSparkles } from "@tabler/icons-react";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useSetAtom } from "jotai";
|
||||||
|
import {
|
||||||
|
activeAiChatIdAtom,
|
||||||
|
aiChatWindowOpenAtom,
|
||||||
|
aiChatDraftAtom,
|
||||||
|
} from "@/features/ai-chat/atoms/ai-chat-atom.ts";
|
||||||
|
|
||||||
|
interface AiAgentBadgeProps {
|
||||||
|
authorName?: string;
|
||||||
|
aiChatId?: string | null;
|
||||||
|
// Fired after the badge deep-links into its chat. The caller handles its own
|
||||||
|
// context (e.g. the page-history row closes the history modal) so this generic
|
||||||
|
// ui/ primitive stays free of cross-feature coupling (#143 review Arch B).
|
||||||
|
onActivate?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Badge marking content written by the AI agent (provenance C3 / §7.4). It is
|
||||||
|
* ADDITIVE — shown next to the human author, never replacing them. Reused by the
|
||||||
|
* page-history list and the comments sidebar.
|
||||||
|
*
|
||||||
|
* When the item carries an `aiChatId` (an internal AI-chat edit), clicking the
|
||||||
|
* badge deep-links into that chat: it sets the active-chat atom and opens the
|
||||||
|
* floating AI-chat window, then invokes `onActivate` so the caller can react
|
||||||
|
* (e.g. the history modal closes itself). When `aiChatId` is null/absent (an
|
||||||
|
* external MCP write with no internal ai_chats row), the badge is a plain
|
||||||
|
* non-clickable label. The click is contained (stopPropagation) so it does not
|
||||||
|
* also trigger an enclosing row's click handler.
|
||||||
|
*/
|
||||||
|
export function AiAgentBadge({
|
||||||
|
authorName,
|
||||||
|
aiChatId,
|
||||||
|
onActivate,
|
||||||
|
}: AiAgentBadgeProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const setAiChatWindowOpen = useSetAtom(aiChatWindowOpenAtom);
|
||||||
|
const setActiveChatId = useSetAtom(activeAiChatIdAtom);
|
||||||
|
const setDraft = useSetAtom(aiChatDraftAtom);
|
||||||
|
|
||||||
|
const tooltip = t("Edited by AI agent on behalf of {{name}}", {
|
||||||
|
name: authorName ?? "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const openChat = useCallback(
|
||||||
|
(event: React.SyntheticEvent) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (!aiChatId) return;
|
||||||
|
setActiveChatId(aiChatId);
|
||||||
|
// Switching to another chat must start with a clean composer — clear any
|
||||||
|
// unsent draft so it does not leak from the previously open chat.
|
||||||
|
setDraft("");
|
||||||
|
setAiChatWindowOpen(true);
|
||||||
|
onActivate?.();
|
||||||
|
},
|
||||||
|
[aiChatId, setActiveChatId, setDraft, setAiChatWindowOpen, onActivate],
|
||||||
|
);
|
||||||
|
|
||||||
|
const badge = (
|
||||||
|
<Badge
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
color="violet"
|
||||||
|
radius="sm"
|
||||||
|
leftSection={<IconSparkles size={12} stroke={2} />}
|
||||||
|
style={aiChatId ? { cursor: "pointer" } : undefined}
|
||||||
|
{...(aiChatId
|
||||||
|
? {
|
||||||
|
// Keep the default Badge root element (not a <button>) to avoid an
|
||||||
|
// invalid <button>-in-<button> nesting inside a row's
|
||||||
|
// UnstyledButton; expose it as an accessible button via
|
||||||
|
// role/keyboard.
|
||||||
|
role: "button",
|
||||||
|
tabIndex: 0,
|
||||||
|
onClick: openChat,
|
||||||
|
onKeyDown: (event: React.KeyboardEvent) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
openChat(event);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {})}
|
||||||
|
>
|
||||||
|
{t("AI-agent")}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip label={tooltip} withArrow>
|
||||||
|
{badge}
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AiAgentBadge;
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { renderHook, act } from "@testing-library/react";
|
||||||
|
|
||||||
|
// react-i18next: identity t() so the hook renders without an i18n provider.
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({ t: (k: string) => k }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// react-router-dom: only useNavigate is used by the hook.
|
||||||
|
const navigateMock = vi.fn();
|
||||||
|
vi.mock("react-router-dom", () => ({
|
||||||
|
useNavigate: () => navigateMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// The auth service is the network boundary; stub login/logout per test.
|
||||||
|
const loginMock = vi.fn();
|
||||||
|
const logoutMock = vi.fn();
|
||||||
|
vi.mock("@/features/auth/services/auth-service", () => ({
|
||||||
|
login: (...args: unknown[]) => loginMock(...args),
|
||||||
|
logout: (...args: unknown[]) => logoutMock(...args),
|
||||||
|
forgotPassword: vi.fn(),
|
||||||
|
passwordReset: vi.fn(),
|
||||||
|
setupWorkspace: vi.fn(),
|
||||||
|
verifyUserToken: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/workspace/services/workspace-service.ts", () => ({
|
||||||
|
acceptInvitation: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// The offline cache purge is the unit under test — assert it is invoked.
|
||||||
|
const clearOfflineCacheMock = vi.fn();
|
||||||
|
vi.mock("@/features/offline/clear-offline-cache", () => ({
|
||||||
|
clearOfflineCache: () => clearOfflineCacheMock(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// app-route helpers are pure config; provide deterministic values.
|
||||||
|
vi.mock("@/lib/app-route.ts", () => ({
|
||||||
|
default: { AUTH: { LOGIN: "/login" }, HOME: "/home" },
|
||||||
|
getPostLoginRedirect: () => "/home",
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mantine notifications: avoid touching the DOM-bound notification system.
|
||||||
|
vi.mock("@mantine/notifications", () => ({
|
||||||
|
notifications: { show: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import useAuth from "./use-auth";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
navigateMock.mockReset();
|
||||||
|
loginMock.mockReset();
|
||||||
|
loginMock.mockResolvedValue(undefined);
|
||||||
|
logoutMock.mockReset();
|
||||||
|
logoutMock.mockResolvedValue(undefined);
|
||||||
|
clearOfflineCacheMock.mockReset();
|
||||||
|
clearOfflineCacheMock.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useAuth.handleSignIn", () => {
|
||||||
|
it("clears the offline cache BEFORE logging in (cross-user leak guard)", async () => {
|
||||||
|
const order: string[] = [];
|
||||||
|
clearOfflineCacheMock.mockImplementation(async () => {
|
||||||
|
order.push("clear");
|
||||||
|
});
|
||||||
|
loginMock.mockImplementation(async () => {
|
||||||
|
order.push("login");
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAuth());
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.signIn({ email: "b@x", password: "pw" } as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(clearOfflineCacheMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(loginMock).toHaveBeenCalledTimes(1);
|
||||||
|
// The purge must run before the new session's login resolves.
|
||||||
|
expect(order).toEqual(["clear", "login"]);
|
||||||
|
expect(navigateMock).toHaveBeenCalledWith("/home");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not block sign-in when the cache purge throws (best-effort)", async () => {
|
||||||
|
clearOfflineCacheMock.mockRejectedValue(new Error("idb unavailable"));
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAuth());
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.signIn({ email: "b@x", password: "pw" } as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Login still proceeds despite the cleanup failure.
|
||||||
|
expect(loginMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(navigateMock).toHaveBeenCalledWith("/home");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useAuth.handleLogout", () => {
|
||||||
|
const replaceMock = vi.fn();
|
||||||
|
let originalLocation: Location;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
replaceMock.mockReset();
|
||||||
|
// window.location.replace is the post-logout redirect. jsdom's real `replace`
|
||||||
|
// is a non-configurable method that warns "not implemented", so swap the
|
||||||
|
// whole location object for one whose `replace` we can capture.
|
||||||
|
originalLocation = window.location;
|
||||||
|
Object.defineProperty(window, "location", {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: { replace: replaceMock },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Object.defineProperty(window, "location", {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: originalLocation,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("purges the offline cache exactly once BEFORE redirecting (cross-user leak guard)", async () => {
|
||||||
|
const order: string[] = [];
|
||||||
|
clearOfflineCacheMock.mockImplementation(async () => {
|
||||||
|
order.push("clear");
|
||||||
|
});
|
||||||
|
replaceMock.mockImplementation((url: string) => {
|
||||||
|
order.push(`replace:${url}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAuth());
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.logout();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(clearOfflineCacheMock).toHaveBeenCalledTimes(1);
|
||||||
|
// Purge must complete before the redirect (which would otherwise interrupt
|
||||||
|
// the async cleanup).
|
||||||
|
expect(order).toEqual(["clear", "replace:/login?logout=1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still redirects when the cache purge throws (best-effort, never blocks logout)", async () => {
|
||||||
|
clearOfflineCacheMock.mockRejectedValue(new Error("idb unavailable"));
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAuth());
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.logout();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The thrown purge error is swallowed and the redirect still fires.
|
||||||
|
expect(clearOfflineCacheMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(replaceMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(replaceMock).toHaveBeenCalledWith("/login?logout=1");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -23,7 +23,7 @@ import { acceptInvitation } from "@/features/workspace/services/workspace-servic
|
|||||||
import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route.ts";
|
import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route.ts";
|
||||||
import { RESET } from "jotai/utils";
|
import { RESET } from "jotai/utils";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { clearPersistedTreeCaches } from "@/features/page/tree/atoms/tree-data-atom";
|
import { clearOfflineCache } from "@/features/offline/clear-offline-cache";
|
||||||
|
|
||||||
export default function useAuth() {
|
export default function useAuth() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -34,6 +34,20 @@ export default function useAuth() {
|
|||||||
const handleSignIn = async (data: ILogin) => {
|
const handleSignIn = async (data: ILogin) => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
|
// Purge any previous user's offline data BEFORE signing in (mirrors logout).
|
||||||
|
// On a shared/kiosk device the prior session may have ended WITHOUT an
|
||||||
|
// explicit logout (cookie/JWT expiry, tab close, force-quit), leaving user
|
||||||
|
// A's persisted query cache (gitmost-rq-cache) and Yjs page bodies
|
||||||
|
// (page.<id>) in IndexedDB. Without this purge user B would briefly read A's
|
||||||
|
// cached currentUser/pages/comments on first render (UserProvider serves the
|
||||||
|
// cached user) and A's page bodies would stay readable offline. Best-effort:
|
||||||
|
// never block sign-in on cache cleanup.
|
||||||
|
try {
|
||||||
|
await clearOfflineCache();
|
||||||
|
} catch {
|
||||||
|
// best-effort: never block sign-in on cache cleanup
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await login(data);
|
await login(data);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
@@ -123,12 +137,14 @@ export default function useAuth() {
|
|||||||
|
|
||||||
const handleLogout = async () => {
|
const handleLogout = async () => {
|
||||||
setCurrentUser(RESET);
|
setCurrentUser(RESET);
|
||||||
// Purge the persisted sidebar tree caches (they contain page titles) so the
|
|
||||||
// cached page titles aren't left readable in localStorage on a shared
|
|
||||||
// machine. (Only the tree caches are swept; other localStorage entries
|
|
||||||
// remain.)
|
|
||||||
clearPersistedTreeCaches();
|
|
||||||
await logout();
|
await logout();
|
||||||
|
// Purge the previous user's offline data while the page is still alive —
|
||||||
|
// window.location.replace below would otherwise interrupt async cleanup.
|
||||||
|
try {
|
||||||
|
await clearOfflineCache();
|
||||||
|
} catch {
|
||||||
|
// best-effort: never block logout on cache cleanup
|
||||||
|
}
|
||||||
window.location.replace(`${APP_ROUTE.AUTH.LOGIN}?logout=1`);
|
window.location.replace(`${APP_ROUTE.AUTH.LOGIN}?logout=1`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { AxiosError } from "axios";
|
||||||
|
import { collabTokenRetry } from "./auth-query";
|
||||||
|
|
||||||
|
// Regression for the offline white-screen (#237/#238): offline the collab-token
|
||||||
|
// POST rejects as an axios NETWORK error (isAxiosError === true but
|
||||||
|
// error.response === undefined). The old predicate read `error.response.status`
|
||||||
|
// without a guard and threw an uncaught TypeError inside the React Query retryer
|
||||||
|
// BEFORE React mounted, blanking the whole app. The predicate must stay total.
|
||||||
|
describe("collabTokenRetry", () => {
|
||||||
|
it("does NOT throw and returns a retryable value for a network error with no response (offline)", () => {
|
||||||
|
// An axios error with no `response` is exactly the offline/network-failure shape.
|
||||||
|
const networkError = new AxiosError("Network Error");
|
||||||
|
expect(networkError.response).toBeUndefined();
|
||||||
|
|
||||||
|
let result: boolean | number = false;
|
||||||
|
expect(() => {
|
||||||
|
result = collabTokenRetry(0, networkError);
|
||||||
|
}).not.toThrow();
|
||||||
|
// Network failures stay retryable (truthy), matching the original intent.
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false (no retry) for a real 404 response", () => {
|
||||||
|
const notFound = new AxiosError("Not Found");
|
||||||
|
notFound.response = { status: 404 } as AxiosError["response"];
|
||||||
|
expect(collabTokenRetry(0, notFound)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries for a non-404 response (e.g. 500)", () => {
|
||||||
|
const serverError = new AxiosError("Server Error");
|
||||||
|
serverError.response = { status: 500 } as AxiosError["response"];
|
||||||
|
expect(collabTokenRetry(0, serverError)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not throw and retries for a non-axios error", () => {
|
||||||
|
let result: boolean | number = false;
|
||||||
|
expect(() => {
|
||||||
|
result = collabTokenRetry(0, new Error("boom"));
|
||||||
|
}).not.toThrow();
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,27 @@ import { getCollabToken, verifyUserToken } from "../services/auth-service";
|
|||||||
import { ICollabToken, IVerifyUserToken } from "../types/auth.types";
|
import { ICollabToken, IVerifyUserToken } from "../types/auth.types";
|
||||||
import { isAxiosError } from "axios";
|
import { isAxiosError } from "axios";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retry predicate for the collab-token query.
|
||||||
|
*
|
||||||
|
* Offline (or any network failure) the POST rejects as an axios NETWORK error:
|
||||||
|
* `isAxiosError(error) === true` but `error.response === undefined`. Reading
|
||||||
|
* `error.response.status` without a guard threw an uncaught TypeError inside the
|
||||||
|
* React Query retryer BEFORE React mounted, white-screening the whole app on an
|
||||||
|
* offline cold boot (#237/#238). Optional-chaining `error.response?.status`
|
||||||
|
* keeps the predicate total: a network error (no response) is retryable, a real
|
||||||
|
* 404 is not. Extracted (and exported) so it can be unit-tested in isolation.
|
||||||
|
*/
|
||||||
|
export function collabTokenRetry(
|
||||||
|
_failureCount: number,
|
||||||
|
error: Error,
|
||||||
|
): boolean {
|
||||||
|
if (isAxiosError(error) && error.response?.status === 404) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
export function useVerifyUserTokenQuery(
|
export function useVerifyUserTokenQuery(
|
||||||
verify: IVerifyUserToken,
|
verify: IVerifyUserToken,
|
||||||
): UseQueryResult<any, Error> {
|
): UseQueryResult<any, Error> {
|
||||||
@@ -22,13 +43,7 @@ export function useCollabToken(): UseQueryResult<ICollabToken, Error> {
|
|||||||
//refetchInterval: 12 * 60 * 60 * 1000, // 12hrs
|
//refetchInterval: 12 * 60 * 60 * 1000, // 12hrs
|
||||||
//refetchIntervalInBackground: true,
|
//refetchIntervalInBackground: true,
|
||||||
refetchOnMount: true,
|
refetchOnMount: true,
|
||||||
//@ts-ignore
|
retry: collabTokenRetry,
|
||||||
retry: (failureCount, error) => {
|
|
||||||
if (isAxiosError(error) && error.response.status === 404) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return 10;
|
|
||||||
},
|
|
||||||
retryDelay: (retryAttempt) => {
|
retryDelay: (retryAttempt) => {
|
||||||
// Exponential backoff: 5s, 10s, 20s, etc.
|
// Exponential backoff: 5s, 10s, 20s, etc.
|
||||||
return 5000 * Math.pow(2, retryAttempt - 1);
|
return 5000 * Math.pow(2, retryAttempt - 1);
|
||||||
|
|||||||
@@ -40,50 +40,20 @@ function renderItem(comment: IComment) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("CommentListItem — agent avatar stack", () => {
|
describe("CommentListItem — AI badge", () => {
|
||||||
it('flips the hierarchy for an agent comment: agent primary, launcher shown once', () => {
|
it('renders the AI-agent badge when createdSource === "agent"', () => {
|
||||||
// Internal-chat shape with DISTINCT names so absence-of-duplication is
|
renderItem(baseComment({ createdSource: "agent", aiChatId: null }));
|
||||||
// assertable: creator is the human "Alice", the acting agent is "Researcher".
|
expect(screen.getByText("AI-agent")).toBeDefined();
|
||||||
renderItem(
|
|
||||||
baseComment({
|
|
||||||
creator: { id: "user-1", name: "Alice", avatarUrl: null } as any,
|
|
||||||
createdSource: "agent",
|
|
||||||
aiChatId: "chat-1",
|
|
||||||
agent: { name: "Researcher", emoji: "🔬", avatarUrl: null },
|
|
||||||
launcher: { name: "Alice", avatarUrl: null },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
// The AGENT is the primary label (the flipped hierarchy).
|
|
||||||
expect(screen.getByText("Researcher")).toBeDefined();
|
|
||||||
// The human launcher name shows exactly once — it is no longer duplicated as
|
|
||||||
// a separate creator name (that duplication is the bug this fixes).
|
|
||||||
expect(screen.getAllByText("Alice").length).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('external MCP agent comment (no launcher): shows the agent name, no separator', () => {
|
|
||||||
// aiChatId null => external MCP: the agent IS the account, no human behind.
|
|
||||||
renderItem(
|
|
||||||
baseComment({
|
|
||||||
creator: { id: "bot-1", name: "MCP Bot", avatarUrl: null } as any,
|
|
||||||
createdSource: "agent",
|
|
||||||
aiChatId: null,
|
|
||||||
agent: { name: "MCP Bot", avatarUrl: null },
|
|
||||||
launcher: null,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(screen.getByText("MCP Bot")).toBeDefined();
|
|
||||||
// No launcher => no dimmed "·" separator in the header.
|
|
||||||
expect(screen.queryByText("·")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does NOT render the stack for a normal user comment (createdSource "user")', () => {
|
|
||||||
const { container } = renderItem(baseComment({ createdSource: "user" }));
|
|
||||||
// No agent glyph (sparkles) is present for a plain human comment.
|
|
||||||
expect(container.querySelector(".tabler-icon-sparkles")).toBeNull();
|
|
||||||
expect(screen.getByText("Service Bot")).toBeDefined();
|
expect(screen.getByText("Service Bot")).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
// The stack's own behaviors (glyph priority, launcher-behind, deep-link click)
|
it('does NOT render the badge for a normal user comment (createdSource "user")', () => {
|
||||||
// are covered directly in agent-avatar-stack.test.tsx; this integration suite
|
renderItem(baseComment({ createdSource: "user" }));
|
||||||
// only guards the insertion gate (agent → stack, user → no stack).
|
expect(screen.queryByText("AI-agent")).toBeNull();
|
||||||
|
expect(screen.getByText("Service Bot")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The non-clickable (null aiChatId) branch is a property of AiAgentBadge itself
|
||||||
|
// and is covered in ai-agent-badge.test.tsx; this integration suite only needs
|
||||||
|
// the insertion gate (agent → badge, user → no badge) above (#143 review).
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Group, Text, Box } from "@mantine/core";
|
import { Group, Text, Box } from "@mantine/core";
|
||||||
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
|
import { AiAgentBadge } from "@/components/ui/ai-agent-badge.tsx";
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import classes from "./comment.module.css";
|
import classes from "./comment.module.css";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
@@ -119,44 +119,24 @@ function CommentListItem({
|
|||||||
return (
|
return (
|
||||||
<Box ref={ref} pb={6}>
|
<Box ref={ref} pb={6}>
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
{comment.createdSource === "agent" && comment.agent ? (
|
<CustomAvatar
|
||||||
<AgentAvatarStack
|
size="sm"
|
||||||
agent={comment.agent}
|
avatarUrl={comment.creator.avatarUrl}
|
||||||
launcher={comment.launcher}
|
name={comment.creator.name}
|
||||||
aiChatId={comment.aiChatId}
|
/>
|
||||||
showName={false}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<CustomAvatar
|
|
||||||
size="sm"
|
|
||||||
avatarUrl={comment.creator.avatarUrl}
|
|
||||||
name={comment.creator.name}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<Group justify="space-between" wrap="nowrap">
|
<Group justify="space-between" wrap="nowrap">
|
||||||
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
|
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
{comment.createdSource === "agent" && comment.agent ? (
|
<Text size="xs" fw={500} lineClamp={1} lh={1.2}>
|
||||||
<>
|
{comment.creator.name}
|
||||||
<Text size="xs" fw={600} lineClamp={1} lh={1.2}>
|
</Text>
|
||||||
{comment.agent.name}
|
|
||||||
</Text>
|
{comment.createdSource === "agent" && (
|
||||||
{comment.launcher && (
|
<AiAgentBadge
|
||||||
<>
|
authorName={comment.creator?.name}
|
||||||
<Text size="xs" c="dimmed" fw={400} aria-hidden>
|
aiChatId={comment.aiChatId}
|
||||||
·
|
/>
|
||||||
</Text>
|
|
||||||
<Text size="xs" c="dimmed" fw={400} lineClamp={1} lh={1.2}>
|
|
||||||
{comment.launcher.name}
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Text size="xs" fw={500} lineClamp={1} lh={1.2}>
|
|
||||||
{comment.creator.name}
|
|
||||||
</Text>
|
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { notifications } from "@mantine/notifications";
|
|||||||
import { IPagination } from "@/lib/types.ts";
|
import { IPagination } from "@/lib/types.ts";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo } from "react";
|
||||||
|
import { offlineMutationKeys } from "@/features/offline/offline-mutations";
|
||||||
|
|
||||||
export const RQ_KEY = (pageId: string) => ["comments", pageId];
|
export const RQ_KEY = (pageId: string) => ["comments", pageId];
|
||||||
|
|
||||||
@@ -60,6 +61,9 @@ export function useCreateCommentMutation() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return useMutation<IComment, Error, Partial<IComment>>({
|
return useMutation<IComment, Error, Partial<IComment>>({
|
||||||
|
// Stable key so a paused comment-create restored from IndexedDB after an
|
||||||
|
// offline reload finds its default mutationFn and is replayed on reconnect.
|
||||||
|
mutationKey: offlineMutationKeys.createComment,
|
||||||
mutationFn: (data) => createComment(data),
|
mutationFn: (data) => createComment(data),
|
||||||
onSuccess: (newComment) => {
|
onSuccess: (newComment) => {
|
||||||
const cache = queryClient.getQueryData(
|
const cache = queryClient.getQueryData(
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import { IUser } from "@/features/user/types/user.types";
|
import { IUser } from "@/features/user/types/user.types";
|
||||||
import { QueryParams } from "@/lib/types.ts";
|
import { QueryParams } from "@/lib/types.ts";
|
||||||
import type {
|
|
||||||
AgentInfo,
|
|
||||||
LauncherInfo,
|
|
||||||
} from "@/components/ui/agent-avatar-stack.tsx";
|
|
||||||
|
|
||||||
export interface IComment {
|
export interface IComment {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -28,11 +24,6 @@ export interface IComment {
|
|||||||
createdSource?: string;
|
createdSource?: string;
|
||||||
aiChatId?: string | null;
|
aiChatId?: string | null;
|
||||||
resolvedSource?: string | null;
|
resolvedSource?: string | null;
|
||||||
// Server-normalized "agent avatar stack" provenance (#300), present only when
|
|
||||||
// createdSource === "agent": `agent` is the front identity, `launcher` the
|
|
||||||
// human behind it (null for an external MCP agent).
|
|
||||||
agent?: AgentInfo | null;
|
|
||||||
launcher?: LauncherInfo | null;
|
|
||||||
yjsSelection?: {
|
yjsSelection?: {
|
||||||
anchor: any;
|
anchor: any;
|
||||||
head: any;
|
head: any;
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ export const readOnlyEditorAtom = atom<Editor | null>(null);
|
|||||||
|
|
||||||
export const yjsConnectionStatusAtom = atom<string>("");
|
export const yjsConnectionStatusAtom = atom<string>("");
|
||||||
|
|
||||||
|
// Local (IndexedDB) persistence sync state for the current page's Y.Doc.
|
||||||
|
export const isLocalSyncedAtom = atom<boolean>(false);
|
||||||
|
|
||||||
|
// Remote (Hocuspocus) sync state for the current page's Y.Doc.
|
||||||
|
export const isRemoteSyncedAtom = atom<boolean>(false);
|
||||||
|
|
||||||
export const showLinkMenuAtom = atom(false);
|
export const showLinkMenuAtom = atom(false);
|
||||||
|
|
||||||
// Current page's edit mode — initialized from the user's saved preference on
|
// Current page's edit mode — initialized from the user's saved preference on
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { createContext, useContext } from "react";
|
||||||
|
import type { HocuspocusProvider } from "@hocuspocus/provider";
|
||||||
|
import type * as Y from "yjs";
|
||||||
|
|
||||||
|
// Shared collaboration providers lifted above the title/body editors so that
|
||||||
|
// both siblings bind to the SAME Y.Doc and HocuspocusProvider. The title lives
|
||||||
|
// in a dedicated 'title' fragment of the same doc as the body.
|
||||||
|
export interface EditorProvidersContextValue {
|
||||||
|
ydoc: Y.Doc;
|
||||||
|
remote: HocuspocusProvider;
|
||||||
|
providersReady: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EditorProvidersContext =
|
||||||
|
createContext<EditorProvidersContextValue | null>(null);
|
||||||
|
|
||||||
|
// Returns the shared providers, or null when rendered outside of a provider.
|
||||||
|
// Consumers must be null-safe (the body editor falls back to a non-collab mode).
|
||||||
|
export function useEditorProviders(): EditorProvidersContextValue | null {
|
||||||
|
return useContext(EditorProvidersContext);
|
||||||
|
}
|
||||||
@@ -1,231 +0,0 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
|
||||||
import { Editor } from "@tiptap/core";
|
|
||||||
import { Document } from "@tiptap/extension-document";
|
|
||||||
import { Paragraph } from "@tiptap/extension-paragraph";
|
|
||||||
import { Text } from "@tiptap/extension-text";
|
|
||||||
import { ySyncPluginKey } from "@tiptap/y-tiptap";
|
|
||||||
import {
|
|
||||||
CustomTypography,
|
|
||||||
undoGuardKey,
|
|
||||||
findChangedRange,
|
|
||||||
mapRangeThroughChange,
|
|
||||||
} from "./custom-typography";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PR #296 — the collab-safe typography undo-guard is exercised through the REAL
|
|
||||||
* editor path: a fresh Editor with the CustomTypography extension, transactions
|
|
||||||
* tagged exactly the way prosemirror-history / y-tiptap tag undo & remote
|
|
||||||
* changes (`setMeta("history$", …)` and `setMeta(ySyncPluginKey, …)`), plus
|
|
||||||
* direct unit tests of the two pure diff helpers. No hand-poke of plugin state.
|
|
||||||
*
|
|
||||||
* ARMING MECHANISM (verified against custom-typography.ts source):
|
|
||||||
* - A transaction arms the guard only when it is BOTH history/remote
|
|
||||||
* (`getMeta("history$")` truthy, or `isChangeOrigin` via the ySync meta)
|
|
||||||
* AND an undo/redo (`getMeta("history$")` truthy, or ySync
|
|
||||||
* `isUndoRedoOperation`), AND its whole-doc diff is a REPLACE
|
|
||||||
* (change.oldTo > change.from && change.newTo > change.from).
|
|
||||||
* - `history$` is the stringified PluginKey of the single prosemirror-history
|
|
||||||
* plugin; ProseMirror stores meta under `key.key`, so setMeta("history$")
|
|
||||||
* in a test is read identically by the extension's getMeta("history$").
|
|
||||||
*/
|
|
||||||
|
|
||||||
const singlePara = (text: string) => ({
|
|
||||||
type: "doc",
|
|
||||||
content: [{ type: "paragraph", content: [{ type: "text", text }] }],
|
|
||||||
});
|
|
||||||
|
|
||||||
const makeEditor = (text: string) =>
|
|
||||||
new Editor({
|
|
||||||
extensions: [Document, Paragraph, Text, CustomTypography],
|
|
||||||
content: singlePara(text),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Build a before/after EditorState pair by applying one plain transaction.
|
|
||||||
const mutate = (text: string, apply: (tr: any, schema: any) => void) => {
|
|
||||||
const editor = new Editor({
|
|
||||||
extensions: [Document, Paragraph, Text],
|
|
||||||
content: singlePara(text),
|
|
||||||
});
|
|
||||||
const before = editor.state;
|
|
||||||
const tr = before.tr;
|
|
||||||
apply(tr, before.schema);
|
|
||||||
editor.view.dispatch(tr);
|
|
||||||
const after = editor.state;
|
|
||||||
return { before, after, editor };
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("findChangedRange", () => {
|
|
||||||
it("returns null for identical docs", () => {
|
|
||||||
const editor = new Editor({
|
|
||||||
extensions: [Document, Paragraph, Text],
|
|
||||||
content: singlePara("hello"),
|
|
||||||
});
|
|
||||||
expect(findChangedRange(editor.state, editor.state)).toBeNull();
|
|
||||||
editor.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns the minimal range for a normal middle insertion", () => {
|
|
||||||
// "hello world" (text at 1..12); insert "there " at pos 6.
|
|
||||||
const { before, after, editor } = mutate("hello world", (tr) =>
|
|
||||||
tr.insertText("there ", 6),
|
|
||||||
);
|
|
||||||
expect(findChangedRange(before, after)).toEqual({
|
|
||||||
from: 6,
|
|
||||||
oldTo: 6,
|
|
||||||
newTo: 12,
|
|
||||||
});
|
|
||||||
editor.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("normalizes the INSERTION overlapping-bounds branch (repeated content)", () => {
|
|
||||||
// Insert one more 'a' into "aaaaa" at pos 3. findDiffStart lands at the end
|
|
||||||
// (6) while findDiffEnd reports an end BEFORE it ({a:1,b:2}); both ends must
|
|
||||||
// be pushed forward by the same delta -> a non-degenerate range.
|
|
||||||
const { before, after, editor } = mutate("aaaaa", (tr) =>
|
|
||||||
tr.insertText("a", 3),
|
|
||||||
);
|
|
||||||
const change = findChangedRange(before, after)!;
|
|
||||||
expect(change).toEqual({ from: 6, oldTo: 6, newTo: 7 });
|
|
||||||
// Invariant the guard logic relies on: never degenerate.
|
|
||||||
expect(change.from).toBeLessThanOrEqual(change.oldTo);
|
|
||||||
expect(change.from).toBeLessThanOrEqual(change.newTo);
|
|
||||||
editor.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("normalizes the DELETION overlapping-bounds branch (F2 fix)", () => {
|
|
||||||
// Delete one repeated 'a' from the middle of "aaaaa" ([3,4)). Here
|
|
||||||
// findDiffEnd reports newTo < start, the symmetric case the old one-sided
|
|
||||||
// normalization missed -> it used to yield a degenerate range (newTo < from).
|
|
||||||
const { before, after, editor } = mutate("aaaaa", (tr) => tr.delete(3, 4));
|
|
||||||
const change = findChangedRange(before, after)!;
|
|
||||||
expect(change).toEqual({ from: 5, oldTo: 6, newTo: 5 });
|
|
||||||
// The whole point of F2: from <= newTo (and from <= oldTo) still holds.
|
|
||||||
expect(change.from).toBeLessThanOrEqual(change.newTo);
|
|
||||||
expect(change.from).toBeLessThanOrEqual(change.oldTo);
|
|
||||||
editor.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("normalizes a multi-char repeated deletion (F2 fix)", () => {
|
|
||||||
const { before, after, editor } = mutate("aaaaa", (tr) => tr.delete(2, 4));
|
|
||||||
const change = findChangedRange(before, after)!;
|
|
||||||
expect(change).toEqual({ from: 4, oldTo: 6, newTo: 4 });
|
|
||||||
expect(change.from).toBeLessThanOrEqual(change.newTo);
|
|
||||||
editor.destroy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("mapRangeThroughChange", () => {
|
|
||||||
const range = { from: 5, to: 10 };
|
|
||||||
|
|
||||||
it("RELEASES on a strict intersection (edit inside the guarded range)", () => {
|
|
||||||
// change straddles the interior of the guard.
|
|
||||||
expect(
|
|
||||||
mapRangeThroughChange(range, { from: 6, oldTo: 8, newTo: 7 }),
|
|
||||||
).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT release on a boundary touch at the guard END", () => {
|
|
||||||
// Edit begins exactly at range.to (10): from < to is false -> no intersect.
|
|
||||||
expect(
|
|
||||||
mapRangeThroughChange(range, { from: 10, oldTo: 10, newTo: 12 }),
|
|
||||||
).toEqual(range);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT release on a boundary touch at the guard START", () => {
|
|
||||||
// Edit ends exactly at range.from (5): oldTo > from is false -> no intersect;
|
|
||||||
// it is treated as a change fully before, shifting the guard.
|
|
||||||
expect(
|
|
||||||
mapRangeThroughChange(range, { from: 3, oldTo: 5, newTo: 8 }),
|
|
||||||
).toEqual({ from: 8, to: 13 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("SHIFTS the guard for a change fully before it", () => {
|
|
||||||
// Insert 2 chars entirely before the range (oldTo 3 <= from 5): +2 delta.
|
|
||||||
expect(
|
|
||||||
mapRangeThroughChange(range, { from: 2, oldTo: 3, newTo: 5 }),
|
|
||||||
).toEqual({ from: 7, to: 12 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves the guard untouched for a change fully after it", () => {
|
|
||||||
expect(
|
|
||||||
mapRangeThroughChange(range, { from: 12, oldTo: 14, newTo: 16 }),
|
|
||||||
).toBe(range);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("undo-guard arming (integration)", () => {
|
|
||||||
it("arms {from, to:newTo} on a LOCAL undo-replace (history meta)", () => {
|
|
||||||
// Undo of an em-dash substitution: "a—b" restored to "a--b" — the em-dash
|
|
||||||
// (pos 2..3) is REPLACED by "--", tagged with the history plugin's meta.
|
|
||||||
const editor = makeEditor("a—b");
|
|
||||||
const { state } = editor;
|
|
||||||
const tr = state.tr
|
|
||||||
.replaceWith(2, 3, state.schema.text("--"))
|
|
||||||
.setMeta("history$", { redo: false });
|
|
||||||
editor.view.dispatch(tr);
|
|
||||||
|
|
||||||
expect(editor.state.doc.textContent).toBe("a--b");
|
|
||||||
// from = diff start (2), to = newTo = end of the inserted "--" (4).
|
|
||||||
expect(undoGuardKey.getState(editor.state)).toEqual({ from: 2, to: 4 });
|
|
||||||
editor.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT arm on a REMOTE change-origin replace (no undo meta)", () => {
|
|
||||||
// Same replace, but tagged only as a y-sync remote change: history/remote
|
|
||||||
// yes, undo/redo NO -> must not arm.
|
|
||||||
const editor = makeEditor("a—b");
|
|
||||||
const { state } = editor;
|
|
||||||
const tr = state.tr
|
|
||||||
.replaceWith(2, 3, state.schema.text("--"))
|
|
||||||
.setMeta(ySyncPluginKey, { isChangeOrigin: true });
|
|
||||||
editor.view.dispatch(tr);
|
|
||||||
|
|
||||||
expect(editor.state.doc.textContent).toBe("a--b");
|
|
||||||
expect(undoGuardKey.getState(editor.state)).toBeNull();
|
|
||||||
editor.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT arm on an ordinary local edit", () => {
|
|
||||||
const editor = makeEditor("a—b");
|
|
||||||
editor.view.dispatch(
|
|
||||||
editor.state.tr.replaceWith(2, 3, editor.state.schema.text("--")),
|
|
||||||
);
|
|
||||||
expect(undoGuardKey.getState(editor.state)).toBeNull();
|
|
||||||
editor.destroy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("undo-guard release / shift (integration)", () => {
|
|
||||||
it("RELEASES when a later edit lands inside the guarded region", () => {
|
|
||||||
const editor = makeEditor("a—b");
|
|
||||||
editor.view.dispatch(
|
|
||||||
editor.state.tr
|
|
||||||
.replaceWith(2, 3, editor.state.schema.text("--"))
|
|
||||||
.setMeta("history$", { redo: false }),
|
|
||||||
);
|
|
||||||
const guard = undoGuardKey.getState(editor.state)!;
|
|
||||||
expect(guard).toEqual({ from: 2, to: 4 });
|
|
||||||
|
|
||||||
// Type a character inside the restored region -> guard is dropped.
|
|
||||||
editor.view.dispatch(editor.state.tr.insertText("x", guard.from + 1));
|
|
||||||
expect(undoGuardKey.getState(editor.state)).toBeNull();
|
|
||||||
editor.destroy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps and SHIFTS the guard when a later edit lands before it", () => {
|
|
||||||
const editor = makeEditor("zz a—b");
|
|
||||||
// "zz a—b": em-dash at pos 5; replace the 'a' at 4..5 with "--" to arm.
|
|
||||||
editor.view.dispatch(
|
|
||||||
editor.state.tr
|
|
||||||
.replaceWith(4, 5, editor.state.schema.text("--"))
|
|
||||||
.setMeta("history$", { redo: false }),
|
|
||||||
);
|
|
||||||
const guard = undoGuardKey.getState(editor.state)!;
|
|
||||||
expect(guard).toEqual({ from: 4, to: 6 });
|
|
||||||
|
|
||||||
// Insert one char at the very start (before the guard) -> guard shifts +1.
|
|
||||||
editor.view.dispatch(editor.state.tr.insertText("Q", 1));
|
|
||||||
expect(undoGuardKey.getState(editor.state)).toEqual({ from: 5, to: 7 });
|
|
||||||
editor.destroy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
import { InputRule } from "@tiptap/core";
|
|
||||||
import {
|
|
||||||
Plugin,
|
|
||||||
PluginKey,
|
|
||||||
type EditorState,
|
|
||||||
type Transaction,
|
|
||||||
} from "@tiptap/pm/state";
|
|
||||||
import { Typography } from "@tiptap/extension-typography";
|
|
||||||
import { isChangeOrigin } from "@tiptap/extension-collaboration";
|
|
||||||
import { ySyncPluginKey } from "@tiptap/y-tiptap";
|
|
||||||
|
|
||||||
// Region restored by the latest undo — while it is intact, typography
|
|
||||||
// input rules overlapping it must not fire again.
|
|
||||||
interface UndoGuardRange {
|
|
||||||
from: number;
|
|
||||||
to: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exported for tests: the plugin key lets a test read the armed guard state,
|
|
||||||
// and the two pure helpers below are unit-tested directly.
|
|
||||||
export const undoGuardKey = new PluginKey<UndoGuardRange | null>(
|
|
||||||
"typographyUndoGuard",
|
|
||||||
);
|
|
||||||
|
|
||||||
// prosemirror-history does not export its plugin key, so template-editor
|
|
||||||
// undo/redo is detected via the stable stringified key. Only one
|
|
||||||
// PluginKey("history") exists in the dependency tree, so "history$" is stable.
|
|
||||||
const HISTORY_META = "history$";
|
|
||||||
|
|
||||||
const isUndoRedoTransaction = (tr: Transaction): boolean => {
|
|
||||||
if (tr.getMeta(HISTORY_META)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
// Read yjs undo/redo meta via the real ySyncPluginKey object (imported, not
|
|
||||||
// a fragile stringified key), which y-tiptap sets on Y.UndoManager changes.
|
|
||||||
const ySyncMeta = tr.getMeta(ySyncPluginKey) as
|
|
||||||
| { isUndoRedoOperation?: boolean }
|
|
||||||
| undefined;
|
|
||||||
return !!ySyncMeta?.isUndoRedoOperation;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface DocChange {
|
|
||||||
from: number;
|
|
||||||
oldTo: number;
|
|
||||||
newTo: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute the minimal changed region between two docs. yjs undo/redo (and any
|
|
||||||
// remote change) arrives as a whole-document replace step, so the transaction
|
|
||||||
// step maps are useless — diff the docs to recover the real minimal change.
|
|
||||||
// Returns null when the docs are identical.
|
|
||||||
export const findChangedRange = (
|
|
||||||
oldState: EditorState,
|
|
||||||
newState: EditorState,
|
|
||||||
): DocChange | null => {
|
|
||||||
const start = oldState.doc.content.findDiffStart(newState.doc.content);
|
|
||||||
const end = oldState.doc.content.findDiffEnd(newState.doc.content);
|
|
||||||
if (start == null || end == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
let { a: oldTo, b: newTo } = end;
|
|
||||||
// findDiffEnd can report an end BEFORE the diff start when the changed text
|
|
||||||
// abuts repeated content (insertion -> oldTo<start, deletion -> newTo<start).
|
|
||||||
// Push both ends forward by the same delta so the range stays non-degenerate
|
|
||||||
// (from <= oldTo and from <= newTo), matching ProseMirror's own diff bounds.
|
|
||||||
const minTo = Math.min(oldTo, newTo);
|
|
||||||
if (minTo < start) {
|
|
||||||
const delta = start - minTo;
|
|
||||||
oldTo += delta;
|
|
||||||
newTo += delta;
|
|
||||||
}
|
|
||||||
return { from: start, oldTo, newTo };
|
|
||||||
};
|
|
||||||
|
|
||||||
// Map an armed guard range across a single document change described by a diff.
|
|
||||||
// Returns null when the change touches the guarded text itself (the restored
|
|
||||||
// substitution was edited, so the guard must be released).
|
|
||||||
export const mapRangeThroughChange = (
|
|
||||||
range: UndoGuardRange,
|
|
||||||
change: DocChange,
|
|
||||||
): UndoGuardRange | null => {
|
|
||||||
// Strict intersection: an edit exactly at a guard boundary (e.g. the user
|
|
||||||
// typing the suppressed space right after the restored text, or deleting it)
|
|
||||||
// must NOT drop the guard.
|
|
||||||
if (change.from < range.to && change.oldTo > range.from) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
// Change fully before the guard: shift the guard by the length delta.
|
|
||||||
if (change.oldTo <= range.from) {
|
|
||||||
const delta = change.newTo - change.oldTo;
|
|
||||||
return { from: range.from + delta, to: range.to + delta };
|
|
||||||
}
|
|
||||||
// Change fully after the guard: positions are unaffected.
|
|
||||||
return range;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Detect history/remote transactions that may arrive as a whole-document
|
|
||||||
// replace step: prosemirror-history undo/redo, or any yjs remote-origin change
|
|
||||||
// (isChangeOrigin is the canonical predicate already used across the app).
|
|
||||||
const isHistoryOrRemoteTransaction = (tr: Transaction): boolean =>
|
|
||||||
!!tr.getMeta(HISTORY_META) || isChangeOrigin(tr);
|
|
||||||
|
|
||||||
export const CustomTypography = Typography.extend({
|
|
||||||
addProseMirrorPlugins() {
|
|
||||||
return [
|
|
||||||
...(this.parent?.() ?? []),
|
|
||||||
new Plugin({
|
|
||||||
key: undoGuardKey,
|
|
||||||
state: {
|
|
||||||
init: () => null,
|
|
||||||
apply(tr, prev, oldState, newState): UndoGuardRange | null {
|
|
||||||
if (tr.docChanged && isHistoryOrRemoteTransaction(tr)) {
|
|
||||||
const change = findChangedRange(oldState, newState);
|
|
||||||
if (change == null) {
|
|
||||||
// Attribute-only or otherwise content-neutral change: keep the
|
|
||||||
// guard.
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
// Arm the guard only when the LOCAL user's undo/redo REPLACED text
|
|
||||||
// (deleted + inserted) — the signature of reverting an input-rule
|
|
||||||
// substitution. Pure insertions/deletions and remote peer edits
|
|
||||||
// must not arm it.
|
|
||||||
if (
|
|
||||||
isUndoRedoTransaction(tr) &&
|
|
||||||
change.oldTo > change.from &&
|
|
||||||
change.newTo > change.from
|
|
||||||
) {
|
|
||||||
return { from: change.from, to: change.newTo };
|
|
||||||
}
|
|
||||||
// Non-arming history/remote change: map the existing guard through
|
|
||||||
// the real diff instead of the (whole-document) step map.
|
|
||||||
if (!prev) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return mapRangeThroughChange(prev, change);
|
|
||||||
}
|
|
||||||
if (!prev) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (!tr.docChanged) {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
// Ordinary local edit: minimal step maps are accurate and cheap.
|
|
||||||
let range: UndoGuardRange | null = prev;
|
|
||||||
for (const stepMap of tr.mapping.maps) {
|
|
||||||
const { from: rangeFrom, to: rangeTo } = range;
|
|
||||||
let touched = false;
|
|
||||||
stepMap.forEach((fromA, toA) => {
|
|
||||||
if (fromA < rangeTo && toA > rangeFrom) {
|
|
||||||
touched = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (touched) {
|
|
||||||
range = null;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
range = {
|
|
||||||
from: stepMap.map(rangeFrom, 1),
|
|
||||||
to: stepMap.map(rangeTo, -1),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return range && range.to > range.from ? range : null;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
},
|
|
||||||
|
|
||||||
addInputRules() {
|
|
||||||
// Wrap every typography rule: skip it when its match overlaps the text
|
|
||||||
// just restored by undo, so an undone substitution is not re-applied.
|
|
||||||
return (this.parent?.() ?? []).map(
|
|
||||||
(rule) =>
|
|
||||||
new InputRule({
|
|
||||||
find: rule.find,
|
|
||||||
undoable: rule.undoable,
|
|
||||||
handler: (props) => {
|
|
||||||
const guard = undoGuardKey.getState(props.state);
|
|
||||||
if (
|
|
||||||
guard &&
|
|
||||||
props.range.from < guard.to &&
|
|
||||||
props.range.to > guard.from
|
|
||||||
) {
|
|
||||||
// Returning null skips this rule and lets the typed character
|
|
||||||
// be inserted as plain text.
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return rule.handler(props);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -6,7 +6,7 @@ import { TaskList, TaskItem } from "@tiptap/extension-list";
|
|||||||
import { Placeholder, CharacterCount, UndoRedo } from "@tiptap/extensions";
|
import { Placeholder, CharacterCount, UndoRedo } from "@tiptap/extensions";
|
||||||
import { Superscript } from "@tiptap/extension-superscript";
|
import { Superscript } from "@tiptap/extension-superscript";
|
||||||
import SubScript from "@tiptap/extension-subscript";
|
import SubScript from "@tiptap/extension-subscript";
|
||||||
import { CustomTypography } from "./custom-typography";
|
import { Typography } from "@tiptap/extension-typography";
|
||||||
import { TextStyle } from "@tiptap/extension-text-style";
|
import { TextStyle } from "@tiptap/extension-text-style";
|
||||||
import { Color } from "@tiptap/extension-color";
|
import { Color } from "@tiptap/extension-color";
|
||||||
import { Youtube } from "@tiptap/extension-youtube";
|
import { Youtube } from "@tiptap/extension-youtube";
|
||||||
@@ -245,9 +245,7 @@ export const mainExtensions = [
|
|||||||
return ReactMarkViewRenderer(SpoilerView);
|
return ReactMarkViewRenderer(SpoilerView);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
// Typography with an undo guard: does not re-apply a substitution the user
|
Typography,
|
||||||
// just undid (e.g. Ctrl+Z on "1/2" -> "½" followed by another space).
|
|
||||||
CustomTypography,
|
|
||||||
TrailingNode,
|
TrailingNode,
|
||||||
GlobalDragHandle.configure({
|
GlobalDragHandle.configure({
|
||||||
customNodes: ["transclusionSource", "transclusionReference", "pageEmbed"],
|
customNodes: ["transclusionSource", "transclusionReference", "pageEmbed"],
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { htmlToMarkdown } from "@docmost/editor-ext";
|
import { normalizeTableColumnWidths } from "./markdown-clipboard";
|
||||||
import {
|
|
||||||
normalizeTableColumnWidths,
|
|
||||||
classifyClipboardSelection,
|
|
||||||
} from "./markdown-clipboard";
|
|
||||||
|
|
||||||
// normalizeTableColumnWidths mutates a DOM subtree (jsdom provides document).
|
// normalizeTableColumnWidths mutates a DOM subtree (jsdom provides document).
|
||||||
function root(html: string): HTMLElement {
|
function root(html: string): HTMLElement {
|
||||||
@@ -128,171 +124,3 @@ describe("normalizeTableColumnWidths", () => {
|
|||||||
).toEqual([null, null]);
|
).toEqual([null, null]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("classifyClipboardSelection", () => {
|
|
||||||
it("serializes a list of 2+ items as markdown", () => {
|
|
||||||
expect(
|
|
||||||
classifyClipboardSelection([{ name: "bulletList", childCount: 2 }]),
|
|
||||||
).toEqual({ asMarkdown: true, wrapBareRows: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves a single-item list as plain text", () => {
|
|
||||||
expect(
|
|
||||||
classifyClipboardSelection([{ name: "bulletList", childCount: 1 }]),
|
|
||||||
).toEqual({ asMarkdown: false, wrapBareRows: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("serializes a whole table without wrapping bare rows", () => {
|
|
||||||
expect(
|
|
||||||
classifyClipboardSelection([{ name: "table", childCount: 3 }]),
|
|
||||||
).toEqual({ asMarkdown: true, wrapBareRows: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("serializes a partial cell selection (bare rows) and flags wrapping", () => {
|
|
||||||
expect(
|
|
||||||
classifyClipboardSelection([
|
|
||||||
{ name: "tableRow", childCount: 2 },
|
|
||||||
{ name: "tableRow", childCount: 2 },
|
|
||||||
]),
|
|
||||||
).toEqual({ asMarkdown: true, wrapBareRows: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves plain paragraphs as plain text", () => {
|
|
||||||
expect(
|
|
||||||
classifyClipboardSelection([{ name: "paragraph", childCount: 1 }]),
|
|
||||||
).toEqual({ asMarkdown: false, wrapBareRows: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not wrap when rows are mixed with other block types", () => {
|
|
||||||
expect(
|
|
||||||
classifyClipboardSelection([
|
|
||||||
{ name: "tableRow", childCount: 2 },
|
|
||||||
{ name: "paragraph", childCount: 1 },
|
|
||||||
]),
|
|
||||||
).toEqual({ asMarkdown: false, wrapBareRows: false });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Output-level tests for the table clipboard regression: copying a table must
|
|
||||||
// yield a real GFM pipe table, NOT one-value-per-line concatenated cells.
|
|
||||||
// These exercise the actual markdown produced by htmlToMarkdown (the same
|
|
||||||
// serializer step the clipboardTextSerializer runs), so they pin the OUTPUT
|
|
||||||
// shape that the classifier-flag tests above do not cover.
|
|
||||||
describe("table clipboard markdown output (htmlToMarkdown)", () => {
|
|
||||||
// Trim each line and drop blanks so structural assertions are whitespace-robust.
|
|
||||||
function lines(md: string): string[] {
|
|
||||||
return md
|
|
||||||
.split("\n")
|
|
||||||
.map((l) => l.trim())
|
|
||||||
.filter((l) => l.length > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A GFM separator row like "| --- | --- |" (any number of columns), tolerant
|
|
||||||
// of the padding turndown emits.
|
|
||||||
function isSeparatorRow(line: string): boolean {
|
|
||||||
const compact = line.replace(/\s+/g, "");
|
|
||||||
return /^\|(?:-{3,}\|)+$/.test(compact);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Split a pipe-delimited row into trimmed cell values.
|
|
||||||
function cells(line: string): string[] {
|
|
||||||
return line
|
|
||||||
.replace(/^\|/, "")
|
|
||||||
.replace(/\|$/, "")
|
|
||||||
.split("|")
|
|
||||||
.map((c) => c.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
it("serializes a header-less partial cell selection (bare rows) as a valid GFM pipe table", () => {
|
|
||||||
// Mirror the serializer's `wrapBareRows` branch exactly: bare <tr> nodes are
|
|
||||||
// wrapped in <table><tbody> and htmlToMarkdown(div.innerHTML) is called.
|
|
||||||
// See markdown-clipboard.ts clipboardTextSerializer:
|
|
||||||
// const table = document.createElement("table");
|
|
||||||
// const tbody = document.createElement("tbody");
|
|
||||||
// tbody.appendChild(fragment); table.appendChild(tbody);
|
|
||||||
// div.appendChild(table);
|
|
||||||
// return htmlToMarkdown(div.innerHTML);
|
|
||||||
const div = document.createElement("div");
|
|
||||||
const table = document.createElement("table");
|
|
||||||
const tbody = document.createElement("tbody");
|
|
||||||
for (const [c1, c2] of [
|
|
||||||
["a", "b"],
|
|
||||||
["c", "d"],
|
|
||||||
]) {
|
|
||||||
const tr = document.createElement("tr");
|
|
||||||
const td1 = document.createElement("td");
|
|
||||||
td1.textContent = c1;
|
|
||||||
const td2 = document.createElement("td");
|
|
||||||
td2.textContent = c2;
|
|
||||||
tr.appendChild(td1);
|
|
||||||
tr.appendChild(td2);
|
|
||||||
tbody.appendChild(tr);
|
|
||||||
}
|
|
||||||
table.appendChild(tbody);
|
|
||||||
div.appendChild(table);
|
|
||||||
|
|
||||||
const md = htmlToMarkdown(div.innerHTML);
|
|
||||||
const ls = lines(md);
|
|
||||||
|
|
||||||
// Valid GFM: a header/data separator row is present (an empty header is
|
|
||||||
// synthesized by the GFM turndown plugin for a header-less table — fine).
|
|
||||||
expect(ls.some(isSeparatorRow)).toBe(true);
|
|
||||||
// NOT the old broken "one value per line" shape: every line is pipe-delimited
|
|
||||||
// and no line is a bare cell value on its own.
|
|
||||||
expect(ls.every((l) => l.includes("|"))).toBe(true);
|
|
||||||
expect(md).not.toMatch(/^\s*(a|b|c|d)\s*$/m);
|
|
||||||
// The cell values land in real pipe-delimited data rows.
|
|
||||||
const dataRows = ls.filter((l) => !isSeparatorRow(l)).map(cells);
|
|
||||||
expect(dataRows).toContainEqual(["a", "b"]);
|
|
||||||
expect(dataRows).toContainEqual(["c", "d"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("serializes a whole table with a header row as a proper GFM table (headline regression)", () => {
|
|
||||||
// Mirror the serializer's non-wrap branch: the full <table> node is appended
|
|
||||||
// directly (div.appendChild(fragment)) and htmlToMarkdown(div.innerHTML) runs.
|
|
||||||
const div = document.createElement("div");
|
|
||||||
const table = document.createElement("table");
|
|
||||||
|
|
||||||
const thead = document.createElement("thead");
|
|
||||||
const headerRow = document.createElement("tr");
|
|
||||||
for (const h of ["Name", "Age"]) {
|
|
||||||
const th = document.createElement("th");
|
|
||||||
th.textContent = h;
|
|
||||||
headerRow.appendChild(th);
|
|
||||||
}
|
|
||||||
thead.appendChild(headerRow);
|
|
||||||
table.appendChild(thead);
|
|
||||||
|
|
||||||
const tbody = document.createElement("tbody");
|
|
||||||
for (const [name, age] of [
|
|
||||||
["Alice", "30"],
|
|
||||||
["Bob", "25"],
|
|
||||||
]) {
|
|
||||||
const tr = document.createElement("tr");
|
|
||||||
const td1 = document.createElement("td");
|
|
||||||
td1.textContent = name;
|
|
||||||
const td2 = document.createElement("td");
|
|
||||||
td2.textContent = age;
|
|
||||||
tr.appendChild(td1);
|
|
||||||
tr.appendChild(td2);
|
|
||||||
tbody.appendChild(tr);
|
|
||||||
}
|
|
||||||
table.appendChild(tbody);
|
|
||||||
div.appendChild(table);
|
|
||||||
|
|
||||||
const md = htmlToMarkdown(div.innerHTML);
|
|
||||||
const ls = lines(md);
|
|
||||||
|
|
||||||
// Proper GFM structure: separator row + all rows pipe-delimited.
|
|
||||||
expect(ls.some(isSeparatorRow)).toBe(true);
|
|
||||||
expect(ls.every((l) => l.includes("|"))).toBe(true);
|
|
||||||
|
|
||||||
const rows = ls.filter((l) => !isSeparatorRow(l)).map(cells);
|
|
||||||
// Header row comes first, followed by both data rows.
|
|
||||||
expect(rows[0]).toEqual(["Name", "Age"]);
|
|
||||||
expect(rows).toContainEqual(["Alice", "30"]);
|
|
||||||
expect(rows).toContainEqual(["Bob", "25"]);
|
|
||||||
// Headline regression: the table is NOT concatenated one-value-per-line.
|
|
||||||
expect(md).not.toMatch(/^\s*(Name|Age|Alice|Bob|30|25)\s*$/m);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -27,36 +27,24 @@ export const MarkdownClipboard = Extension.create({
|
|||||||
key: new PluginKey("markdownClipboard"),
|
key: new PluginKey("markdownClipboard"),
|
||||||
props: {
|
props: {
|
||||||
clipboardTextSerializer: (slice) => {
|
clipboardTextSerializer: (slice) => {
|
||||||
const topLevelNodes: { name: string; childCount: number }[] = [];
|
const listTypes = ["bulletList", "orderedList", "taskList"];
|
||||||
|
let topLevelCount = 0;
|
||||||
|
let hasList = false;
|
||||||
slice.content.forEach((node) => {
|
slice.content.forEach((node) => {
|
||||||
topLevelNodes.push({
|
if (listTypes.includes(node.type.name)) {
|
||||||
name: node.type.name,
|
hasList = true;
|
||||||
childCount: node.childCount,
|
topLevelCount += node.childCount;
|
||||||
});
|
} else {
|
||||||
|
topLevelCount++;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const { asMarkdown, wrapBareRows } =
|
if (!hasList || topLevelCount < 2) return null;
|
||||||
classifyClipboardSelection(topLevelNodes);
|
|
||||||
if (!asMarkdown) return null;
|
|
||||||
|
|
||||||
const div = document.createElement("div");
|
const div = document.createElement("div");
|
||||||
const serializer = DOMSerializer.fromSchema(this.editor.schema);
|
const serializer = DOMSerializer.fromSchema(this.editor.schema);
|
||||||
const fragment = serializer.serializeFragment(slice.content);
|
const fragment = serializer.serializeFragment(slice.content);
|
||||||
|
div.appendChild(fragment);
|
||||||
if (wrapBareRows) {
|
|
||||||
// A partial table cell-selection serializes to bare <tr> nodes
|
|
||||||
// (prosemirror-tables returns the whole `table` node only when the
|
|
||||||
// entire table is selected). Bare <tr> would be foster-parented
|
|
||||||
// away by the HTML parser inside htmlToMarkdown, so wrap them in
|
|
||||||
// <table><tbody> first for the GFM turndown rule to detect them.
|
|
||||||
const table = document.createElement("table");
|
|
||||||
const tbody = document.createElement("tbody");
|
|
||||||
tbody.appendChild(fragment);
|
|
||||||
table.appendChild(tbody);
|
|
||||||
div.appendChild(table);
|
|
||||||
} else {
|
|
||||||
div.appendChild(fragment);
|
|
||||||
}
|
|
||||||
return htmlToMarkdown(div.innerHTML);
|
return htmlToMarkdown(div.innerHTML);
|
||||||
},
|
},
|
||||||
handlePaste: (view, event, slice) => {
|
handlePaste: (view, event, slice) => {
|
||||||
@@ -165,55 +153,6 @@ export const MarkdownClipboard = Extension.create({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* Decide whether a copied slice's plain-text clipboard payload should be
|
|
||||||
* serialized as Markdown (instead of ProseMirror's default text serializer,
|
|
||||||
* which joins block leaves with newlines — the "one value per line" bug for
|
|
||||||
* tables).
|
|
||||||
*
|
|
||||||
* Serialize as Markdown for structured content:
|
|
||||||
* - lists with 2+ total items (a single copied bullet stays literal text);
|
|
||||||
* - a whole table (top-level `table` node);
|
|
||||||
* - a partial table cell-selection, which prosemirror-tables copies as bare
|
|
||||||
* `tableRow` nodes (only a full-table selection yields a `table` node).
|
|
||||||
*
|
|
||||||
* `wrapBareRows` flags the bare-rows case so the caller wraps the serialized
|
|
||||||
* <tr> nodes in <table><tbody> before the HTML->Markdown step. Plain paragraphs
|
|
||||||
* return asMarkdown=false so a simple text copy stays literal, and internal
|
|
||||||
* copy/paste keeps using the richer text/html clipboard payload.
|
|
||||||
*/
|
|
||||||
export function classifyClipboardSelection(
|
|
||||||
nodes: { name: string; childCount: number }[],
|
|
||||||
): { asMarkdown: boolean; wrapBareRows: boolean } {
|
|
||||||
const listTypes = ["bulletList", "orderedList", "taskList"];
|
|
||||||
let topLevelCount = 0;
|
|
||||||
let hasList = false;
|
|
||||||
let hasTable = false;
|
|
||||||
let tableRowCount = 0;
|
|
||||||
let nonRowCount = 0;
|
|
||||||
|
|
||||||
for (const node of nodes) {
|
|
||||||
if (listTypes.includes(node.name)) {
|
|
||||||
hasList = true;
|
|
||||||
topLevelCount += node.childCount;
|
|
||||||
nonRowCount++;
|
|
||||||
} else {
|
|
||||||
if (node.name === "table") hasTable = true;
|
|
||||||
if (node.name === "tableRow") tableRowCount++;
|
|
||||||
else nonRowCount++;
|
|
||||||
topLevelCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bare tableRow nodes at the top level only occur for a partial cell
|
|
||||||
// selection; a slice never mixes bare rows with other block types, so
|
|
||||||
// "every top-level node is a row" is a safe signal to wrap-and-serialize.
|
|
||||||
const wrapBareRows = tableRowCount > 0 && nonRowCount === 0;
|
|
||||||
const asMarkdown =
|
|
||||||
(hasList && topLevelCount >= 2) || hasTable || wrapBareRows;
|
|
||||||
return { asMarkdown, wrapBareRows };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reorder/dedup the footnotes of a SELF-CONTAINED pasted markdown block to the
|
* Reorder/dedup the footnotes of a SELF-CONTAINED pasted markdown block to the
|
||||||
* canonical invariant (the live footnoteSyncPlugin never reorders an existing
|
* canonical invariant (the live footnoteSyncPlugin never reorders an existing
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ import {
|
|||||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
} from "@/features/editor/atoms/editor-atoms.ts";
|
||||||
import { DictationGroup } from "@/features/editor/components/fixed-toolbar/groups/dictation-group";
|
import { DictationGroup } from "@/features/editor/components/fixed-toolbar/groups/dictation-group";
|
||||||
import { GenerateTitleGroup } from "@/features/editor/components/fixed-toolbar/groups/generate-title-group";
|
import { GenerateTitleGroup } from "@/features/editor/components/fixed-toolbar/groups/generate-title-group";
|
||||||
|
import { usePageCollabProviders } from "@/features/editor/hooks/use-page-collab-providers";
|
||||||
|
import { EditorProvidersContext } from "@/features/editor/contexts/editor-providers-context";
|
||||||
|
|
||||||
const MemoizedTitleEditor = React.memo(TitleEditor);
|
const MemoizedTitleEditor = React.memo(TitleEditor);
|
||||||
const MemoizedPageEditor = React.memo(PageEditor);
|
const MemoizedPageEditor = React.memo(PageEditor);
|
||||||
@@ -80,16 +82,24 @@ export function FullEditor({
|
|||||||
// AI title generation is gated by the general AI chat flag (the same toggle
|
// AI title generation is gated by the general AI chat flag (the same toggle
|
||||||
// that enables the chat agent); the server enforces it too (#199).
|
// that enables the chat agent); the server enforces it too (#199).
|
||||||
const isTitleGenEnabled = workspace?.settings?.ai?.chat === true;
|
const isTitleGenEnabled = workspace?.settings?.ai?.chat === true;
|
||||||
const fullPageWidth = user.settings?.preferences?.fullPageWidth;
|
// `user` can momentarily be null during logout teardown (the currentUser atom
|
||||||
|
// is reset before this subtree unmounts). Optional-chain every access so the
|
||||||
|
// teardown render does not throw "Cannot read properties of null (reading
|
||||||
|
// 'settings')".
|
||||||
|
const fullPageWidth = user?.settings?.preferences?.fullPageWidth;
|
||||||
const editorToolbarEnabled =
|
const editorToolbarEnabled =
|
||||||
user.settings?.preferences?.editorToolbar ?? false;
|
user?.settings?.preferences?.editorToolbar ?? false;
|
||||||
const [currentPageEditMode, setCurrentPageEditMode] = useAtom(
|
const [currentPageEditMode, setCurrentPageEditMode] = useAtom(
|
||||||
currentPageEditModeAtom,
|
currentPageEditModeAtom,
|
||||||
);
|
);
|
||||||
const userPageEditMode =
|
const userPageEditMode =
|
||||||
user.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
|
user?.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
|
||||||
const isEditMode = currentPageEditMode === PageEditMode.Edit;
|
const isEditMode = currentPageEditMode === PageEditMode.Edit;
|
||||||
|
|
||||||
|
// Single shared Y.Doc + HocuspocusProvider for both the title and body
|
||||||
|
// editors (title lives in the 'title' fragment of the same doc).
|
||||||
|
const { ydoc, remote, providersReady } = usePageCollabProviders(pageId);
|
||||||
|
|
||||||
// Apply the user's saved preference only once on initial load, not on every
|
// Apply the user's saved preference only once on initial load, not on every
|
||||||
// page navigation — so the mode sticks across navigations within a session.
|
// page navigation — so the mode sticks across navigations within a session.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -110,28 +120,32 @@ export function FullEditor({
|
|||||||
)}
|
)}
|
||||||
<MemoizedDeletedPageBanner slugId={slugId} />
|
<MemoizedDeletedPageBanner slugId={slugId} />
|
||||||
<MemoizedTemporaryNoteBanner slugId={slugId} />
|
<MemoizedTemporaryNoteBanner slugId={slugId} />
|
||||||
<MemoizedTitleEditor
|
<EditorProvidersContext.Provider
|
||||||
pageId={pageId}
|
value={ydoc && remote ? { ydoc, remote, providersReady } : null}
|
||||||
slugId={slugId}
|
>
|
||||||
title={title}
|
<MemoizedTitleEditor
|
||||||
spaceSlug={spaceSlug}
|
pageId={pageId}
|
||||||
editable={editable}
|
slugId={slugId}
|
||||||
/>
|
title={title}
|
||||||
<PageByline
|
spaceSlug={spaceSlug}
|
||||||
pageId={pageId}
|
editable={editable}
|
||||||
creator={creator}
|
/>
|
||||||
contributors={contributors}
|
<PageByline
|
||||||
editable={editable}
|
pageId={pageId}
|
||||||
isEditMode={isEditMode}
|
creator={creator}
|
||||||
isDictationEnabled={isDictationEnabled}
|
contributors={contributors}
|
||||||
isTitleGenEnabled={isTitleGenEnabled}
|
editable={editable}
|
||||||
/>
|
isEditMode={isEditMode}
|
||||||
<MemoizedPageEditor
|
isDictationEnabled={isDictationEnabled}
|
||||||
pageId={pageId}
|
isTitleGenEnabled={isTitleGenEnabled}
|
||||||
editable={editable}
|
/>
|
||||||
content={content}
|
<MemoizedPageEditor
|
||||||
canComment={canComment}
|
pageId={pageId}
|
||||||
/>
|
editable={editable}
|
||||||
|
content={content}
|
||||||
|
canComment={canComment}
|
||||||
|
/>
|
||||||
|
</EditorProvidersContext.Provider>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
// jwt-decode is mocked so we can drive the four token states deterministically
|
||||||
|
// (decode success with a chosen exp, or a thrown decode error).
|
||||||
|
const decodeMock = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("jwt-decode", () => ({
|
||||||
|
jwtDecode: decodeMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { collabTokenNeedsRefresh } from "./collab-token";
|
||||||
|
|
||||||
|
const NOW_MS = 1_000_000_000; // fixed "now" in ms (so NOW_MS/1000 seconds)
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
decodeMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("collabTokenNeedsRefresh", () => {
|
||||||
|
it("returns true when there is no token (fetch a fresh one)", () => {
|
||||||
|
expect(collabTokenNeedsRefresh(undefined, NOW_MS)).toBe(true);
|
||||||
|
// jwtDecode must not even be called for a missing token.
|
||||||
|
expect(decodeMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when the token is malformed (jwtDecode throws)", () => {
|
||||||
|
decodeMock.mockImplementation(() => {
|
||||||
|
throw new Error("invalid token");
|
||||||
|
});
|
||||||
|
expect(collabTokenNeedsRefresh("garbage", NOW_MS)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for a valid, not-yet-expired token (no reconnect)", () => {
|
||||||
|
// exp is in the future relative to NOW.
|
||||||
|
decodeMock.mockReturnValue({ exp: NOW_MS / 1000 + 60 });
|
||||||
|
expect(collabTokenNeedsRefresh("good", NOW_MS)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true for a valid but expired token (refresh + reconnect)", () => {
|
||||||
|
// exp is in the past relative to NOW.
|
||||||
|
decodeMock.mockReturnValue({ exp: NOW_MS / 1000 - 60 });
|
||||||
|
expect(collabTokenNeedsRefresh("expired", NOW_MS)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats exp exactly equal to now as expired (>= boundary)", () => {
|
||||||
|
decodeMock.mockReturnValue({ exp: NOW_MS / 1000 });
|
||||||
|
expect(collabTokenNeedsRefresh("boundary", NOW_MS)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { jwtDecode } from "jwt-decode";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide whether a collab token must be refreshed before reconnecting after an
|
||||||
|
* onAuthenticationFailed event. Pure and side-effect free so the four token
|
||||||
|
* states can be unit-tested directly:
|
||||||
|
* - no token -> true (fetch a fresh one and reconnect)
|
||||||
|
* - undecodable/malformed -> true (jwtDecode throws -> refresh)
|
||||||
|
* - valid, not expired -> false (token is still good; do NOT reconnect)
|
||||||
|
* - valid, expired -> true (refresh + reconnect)
|
||||||
|
*
|
||||||
|
* `nowMs` is injectable for deterministic tests; it defaults to `Date.now()`.
|
||||||
|
*/
|
||||||
|
export function collabTokenNeedsRefresh(
|
||||||
|
token: string | undefined,
|
||||||
|
nowMs: number = Date.now(),
|
||||||
|
): boolean {
|
||||||
|
if (!token) return true;
|
||||||
|
try {
|
||||||
|
const payload = jwtDecode<{ exp: number }>(token);
|
||||||
|
return nowMs / 1000 >= payload.exp;
|
||||||
|
} catch {
|
||||||
|
// malformed/undecodable token -> refresh
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -139,7 +139,7 @@ describe("useGeneratePageTitle", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("happy path: applies the title, refreshes cache, writes the field, broadcasts", async () => {
|
it("happy path: applies the title, refreshes cache, broadcasts, and does NOT write the editor", async () => {
|
||||||
const store = createStore();
|
const store = createStore();
|
||||||
const titleEditor = makeTitleEditor();
|
const titleEditor = makeTitleEditor();
|
||||||
store.set(pageEditorAtom as never, makePageEditor("pageA"));
|
store.set(pageEditorAtom as never, makePageEditor("pageA"));
|
||||||
@@ -157,9 +157,11 @@ describe("useGeneratePageTitle", () => {
|
|||||||
title: "Generated Title",
|
title: "Generated Title",
|
||||||
});
|
});
|
||||||
expect(updatePageDataMock).toHaveBeenCalledWith(PAGE_A);
|
expect(updatePageDataMock).toHaveBeenCalledWith(PAGE_A);
|
||||||
expect(titleEditor.commands.setContent).toHaveBeenCalledWith(
|
// The title editor is bound to the Yjs `title` fragment; the server REST
|
||||||
"Generated Title",
|
// update reseeds that fragment and the reseed reaches the bound editor on
|
||||||
);
|
// its own. Writing here too would double/garble the title, so the hook must
|
||||||
|
// NOT touch the editor (regression guard for the Yjs duplication trap).
|
||||||
|
expect(titleEditor.commands.setContent).not.toHaveBeenCalled();
|
||||||
expect(localEmitMock).toHaveBeenCalled();
|
expect(localEmitMock).toHaveBeenCalled();
|
||||||
expect(emitMock).toHaveBeenCalled();
|
expect(emitMock).toHaveBeenCalled();
|
||||||
expect(notificationsShowMock).toHaveBeenCalledWith(
|
expect(notificationsShowMock).toHaveBeenCalledWith(
|
||||||
@@ -167,7 +169,7 @@ describe("useGeneratePageTitle", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does NOT write the visible title field when the user navigated away during generation", async () => {
|
it("keeps the DB write keyed by the captured pageId and still broadcasts after navigation", async () => {
|
||||||
const store = createStore();
|
const store = createStore();
|
||||||
const titleEditor = makeTitleEditor(); // persistent across navigation
|
const titleEditor = makeTitleEditor(); // persistent across navigation
|
||||||
store.set(pageEditorAtom as never, makePageEditor("pageA"));
|
store.set(pageEditorAtom as never, makePageEditor("pageA"));
|
||||||
@@ -203,55 +205,9 @@ describe("useGeneratePageTitle", () => {
|
|||||||
pageId: "pageA",
|
pageId: "pageA",
|
||||||
title: "Generated Title",
|
title: "Generated Title",
|
||||||
});
|
});
|
||||||
// ...but we must NOT stamp page A's title into page B's visible field.
|
// ...the hook never writes the editor regardless of navigation...
|
||||||
expect(titleEditor.commands.setContent).not.toHaveBeenCalled();
|
expect(titleEditor.commands.setContent).not.toHaveBeenCalled();
|
||||||
// The change is still broadcast to other clients.
|
// ...and the change is still broadcast to other clients.
|
||||||
expect(emitMock).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT write the visible title field when the title editor is focused", async () => {
|
|
||||||
const store = createStore();
|
|
||||||
const titleEditor = makeTitleEditor();
|
|
||||||
store.set(pageEditorAtom as never, makePageEditor("pageA"));
|
|
||||||
store.set(titleEditorAtom as never, titleEditor);
|
|
||||||
|
|
||||||
// Resolve generation under our control so we can mark the live title editor
|
|
||||||
// as focused before the post-generation write runs.
|
|
||||||
let resolveTitle!: (t: string) => void;
|
|
||||||
generatePageTitleMock.mockReturnValue(
|
|
||||||
new Promise<string>((res) => {
|
|
||||||
resolveTitle = res;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
updateTitleMock.mockResolvedValue(PAGE_A);
|
|
||||||
const { result } = setup("pageA", store);
|
|
||||||
|
|
||||||
let pending!: Promise<void>;
|
|
||||||
act(() => {
|
|
||||||
pending = result.current.mutateAsync();
|
|
||||||
});
|
|
||||||
|
|
||||||
// The user clicked into the title field while the model ran — overwriting it
|
|
||||||
// now would clobber what they are actively typing.
|
|
||||||
act(() => {
|
|
||||||
(titleEditor as { isFocused: boolean }).isFocused = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
await act(async () => {
|
|
||||||
resolveTitle("Generated Title");
|
|
||||||
await pending;
|
|
||||||
});
|
|
||||||
|
|
||||||
// The DB write still persists the value...
|
|
||||||
expect(updateTitleMock).toHaveBeenCalledWith({
|
|
||||||
pageId: "pageA",
|
|
||||||
title: "Generated Title",
|
|
||||||
});
|
|
||||||
expect(updatePageDataMock).toHaveBeenCalledWith(PAGE_A);
|
|
||||||
// ...but the visible field is left alone while it is focused.
|
|
||||||
expect(titleEditor.commands.setContent).not.toHaveBeenCalled();
|
|
||||||
// The change is still broadcast to other clients.
|
|
||||||
expect(localEmitMock).toHaveBeenCalled();
|
|
||||||
expect(emitMock).toHaveBeenCalled();
|
expect(emitMock).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
import { useRef } from "react";
|
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { htmlToMarkdown } from "@docmost/editor-ext";
|
import { htmlToMarkdown } from "@docmost/editor-ext";
|
||||||
import {
|
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
||||||
pageEditorAtom,
|
|
||||||
titleEditorAtom,
|
|
||||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
|
||||||
import {
|
import {
|
||||||
updatePageData,
|
updatePageData,
|
||||||
useUpdateTitlePageMutation,
|
useUpdateTitlePageMutation,
|
||||||
@@ -33,18 +29,9 @@ const MAX_CONTENT_CHARS = 20000;
|
|||||||
export function useGeneratePageTitle(pageId: string) {
|
export function useGeneratePageTitle(pageId: string) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const pageEditor = useAtomValue(pageEditorAtom);
|
const pageEditor = useAtomValue(pageEditorAtom);
|
||||||
const titleEditor = useAtomValue(titleEditorAtom);
|
|
||||||
const { mutateAsync: updateTitle } = useUpdateTitlePageMutation();
|
const { mutateAsync: updateTitle } = useUpdateTitlePageMutation();
|
||||||
const emit = useQueryEmit();
|
const emit = useQueryEmit();
|
||||||
|
|
||||||
// The page/title editors come from GLOBAL atoms that re-point when the user
|
|
||||||
// navigates to another page. The mutation below awaits the model for 1-3s, and
|
|
||||||
// its closure captures the editors from the render that started it. Keep a live
|
|
||||||
// reference so the post-generation write targets whatever page is on screen
|
|
||||||
// *now*, not the page the generation was started from.
|
|
||||||
const editorsRef = useRef({ pageEditor, titleEditor });
|
|
||||||
editorsRef.current = { pageEditor, titleEditor };
|
|
||||||
|
|
||||||
return useMutation<void, Error, void>({
|
return useMutation<void, Error, void>({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
if (!pageEditor || pageEditor.isDestroyed) return;
|
if (!pageEditor || pageEditor.isDestroyed) return;
|
||||||
@@ -70,33 +57,15 @@ export function useGeneratePageTitle(pageId: string) {
|
|||||||
const page = await updateTitle({ pageId, title }); // POST /pages/update
|
const page = await updateTitle({ pageId, title }); // POST /pages/update
|
||||||
updatePageData(page); // refresh the react-query cache
|
updatePageData(page); // refresh the react-query cache
|
||||||
|
|
||||||
// Reflect the new title in the field immediately. The button lives in the
|
// Do NOT write the title into the editor here. The title editor is bound to
|
||||||
// byline, so the title editor is not focused — setContent is safe and stays
|
// the Yjs `title` fragment and Yjs is the source of truth. The server REST
|
||||||
// undoable through its History extension (Ctrl/Cmd+Z reverts the change).
|
// /pages/update reseeds that fragment (writePageTitle → writeTitleFragment,
|
||||||
//
|
// a full clear+replace) and the reseed reaches the bound title editor on
|
||||||
// Guard against navigation during generation: if the user switched pages
|
// its own as a remote provider update. The old REST-era setContent here
|
||||||
// while the model ran, the (persistent) title editor now shows ANOTHER
|
// would race that reseed and double/garble the title (the "Yjs duplication
|
||||||
// page, so writing here would drop page A's title into page B's visible
|
// trap"), so it is intentionally omitted. The DB write above is keyed by
|
||||||
// field. page-editor.tsx stamps the live page editor with its pageId
|
// the captured `pageId`, so it stays correct even if the user navigated
|
||||||
// (`editor.storage.pageId`), mirroring TitleEditor's `activePageId !==
|
// away during generation.
|
||||||
// pageId` guard — bail the visible write unless that live editor still
|
|
||||||
// belongs to the page this title was generated for. The DB write above is
|
|
||||||
// already correct (keyed by the captured `pageId`), and the broadcast below
|
|
||||||
// still propagates page A's change to other clients.
|
|
||||||
const livePageEditor = editorsRef.current.pageEditor;
|
|
||||||
const liveTitleEditor = editorsRef.current.titleEditor;
|
|
||||||
// `storage.pageId` is stamped untyped in page-editor.tsx's onCreate.
|
|
||||||
const livePageId = (livePageEditor?.storage as { pageId?: string })
|
|
||||||
?.pageId;
|
|
||||||
const stillOnPage = livePageId === pageId;
|
|
||||||
if (
|
|
||||||
stillOnPage &&
|
|
||||||
liveTitleEditor &&
|
|
||||||
!liveTitleEditor.isDestroyed &&
|
|
||||||
!liveTitleEditor.isFocused
|
|
||||||
) {
|
|
||||||
liveTitleEditor.commands.setContent(page.title);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Broadcast to other clients, mirroring TitleEditor.saveTitle's event shape.
|
// Broadcast to other clients, mirroring TitleEditor.saveTitle's event shape.
|
||||||
const event: UpdateEvent = {
|
const event: UpdateEvent = {
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { IndexeddbPersistence } from "y-indexeddb";
|
||||||
|
import * as Y from "yjs";
|
||||||
|
import {
|
||||||
|
HocuspocusProvider,
|
||||||
|
onStatusParameters,
|
||||||
|
WebSocketStatus,
|
||||||
|
HocuspocusProviderWebsocket,
|
||||||
|
onSyncedParameters,
|
||||||
|
onStatelessParameters,
|
||||||
|
} from "@hocuspocus/provider";
|
||||||
|
import { useAtom, useSetAtom } from "jotai";
|
||||||
|
import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url";
|
||||||
|
import {
|
||||||
|
isLocalSyncedAtom,
|
||||||
|
isRemoteSyncedAtom,
|
||||||
|
yjsConnectionStatusAtom,
|
||||||
|
} from "@/features/editor/atoms/editor-atoms";
|
||||||
|
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
||||||
|
import { useDocumentVisibility } from "@mantine/hooks";
|
||||||
|
import { useIdle } from "@/hooks/use-idle.ts";
|
||||||
|
import { queryClient } from "@/main.tsx";
|
||||||
|
import { IPage } from "@/features/page/types/page.types.ts";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { extractPageSlugId } from "@/lib";
|
||||||
|
import { FIVE_MINUTES } from "@/lib/constants.ts";
|
||||||
|
import { collabTokenNeedsRefresh } from "@/features/editor/hooks/collab-token";
|
||||||
|
import { pageYdocName } from "@/features/editor/page-ydoc-name";
|
||||||
|
import { pageKeys } from "@/features/page/queries/page-query";
|
||||||
|
|
||||||
|
export interface PageCollabProviders {
|
||||||
|
ydoc: Y.Doc | null;
|
||||||
|
remote: HocuspocusProvider | null;
|
||||||
|
socket: HocuspocusProviderWebsocket | null;
|
||||||
|
providersReady: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the full collaboration provider lifecycle for a page so that the title
|
||||||
|
* and body editors can share a single Y.Doc + HocuspocusProvider. The behavior
|
||||||
|
* is relocated verbatim from page-editor.tsx: it creates the providers once per
|
||||||
|
* pageId, connects/disconnects on idle/visibility, attaches each render,
|
||||||
|
* destroys on unmount, refreshes the collab token on auth failure, and applies
|
||||||
|
* the onStateless 'page.updated' cache update.
|
||||||
|
*/
|
||||||
|
export function usePageCollabProviders(pageId: string): PageCollabProviders {
|
||||||
|
const collaborationURL = useCollaborationUrl();
|
||||||
|
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
|
||||||
|
yjsConnectionStatusAtom,
|
||||||
|
);
|
||||||
|
const setIsLocalSyncedAtom = useSetAtom(isLocalSyncedAtom);
|
||||||
|
const setIsRemoteSyncedAtom = useSetAtom(isRemoteSyncedAtom);
|
||||||
|
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
|
||||||
|
// The provider-creating effect runs only once per pageId, so any token read
|
||||||
|
// inside its handlers would be captured STALE (the old token at first render).
|
||||||
|
// Mirror the latest token into a ref the auth-failure handler can read live.
|
||||||
|
const collabTokenRef = useRef<string | undefined>(undefined);
|
||||||
|
useEffect(() => {
|
||||||
|
collabTokenRef.current = collabQuery?.token;
|
||||||
|
}, [collabQuery?.token]);
|
||||||
|
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
|
||||||
|
const documentState = useDocumentVisibility();
|
||||||
|
const { pageSlug } = useParams();
|
||||||
|
const slugId = extractPageSlugId(pageSlug);
|
||||||
|
|
||||||
|
// Providers only created once per pageId
|
||||||
|
const providersRef = useRef<{
|
||||||
|
ydoc: Y.Doc;
|
||||||
|
local: IndexeddbPersistence;
|
||||||
|
remote: HocuspocusProvider;
|
||||||
|
socket: HocuspocusProviderWebsocket;
|
||||||
|
} | null>(null);
|
||||||
|
const [providersReady, setProvidersReady] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!providersRef.current) {
|
||||||
|
const documentName = pageYdocName(pageId);
|
||||||
|
const ydoc = new Y.Doc();
|
||||||
|
const local = new IndexeddbPersistence(documentName, ydoc);
|
||||||
|
const socket = new HocuspocusProviderWebsocket({
|
||||||
|
url: collaborationURL,
|
||||||
|
});
|
||||||
|
const onLocalSyncedHandler = () => {
|
||||||
|
setIsLocalSyncedAtom(true);
|
||||||
|
};
|
||||||
|
const onStatusHandler = (event: onStatusParameters) => {
|
||||||
|
setYjsConnectionStatus(event.status);
|
||||||
|
};
|
||||||
|
const onSyncedHandler = (event: onSyncedParameters) => {
|
||||||
|
setIsRemoteSyncedAtom(event.state);
|
||||||
|
};
|
||||||
|
const onStatelessHandler = ({ payload }: onStatelessParameters) => {
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(payload);
|
||||||
|
if (message?.type !== "page.updated" || !message.updatedAt) return;
|
||||||
|
const pageData = queryClient.getQueryData<IPage>(
|
||||||
|
pageKeys.detail(slugId),
|
||||||
|
);
|
||||||
|
if (pageData) {
|
||||||
|
queryClient.setQueryData(pageKeys.detail(slugId), {
|
||||||
|
...pageData,
|
||||||
|
updatedAt: message.updatedAt,
|
||||||
|
...(message.lastUpdatedBy && {
|
||||||
|
lastUpdatedBy: message.lastUpdatedBy,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore unrelated stateless messages
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onAuthenticationFailedHandler = () => {
|
||||||
|
// Read the token from the ref, not the closed-over `collabQuery`: this
|
||||||
|
// handler is created once and would otherwise decode a stale token after
|
||||||
|
// a refetch. A missing/malformed token must NOT crash the handler —
|
||||||
|
// jwtDecode(undefined) throws — so treat any decode failure as "needs
|
||||||
|
// refresh" and proceed to refetch + reconnect instead of getting stuck.
|
||||||
|
if (!collabTokenNeedsRefresh(collabTokenRef.current)) return;
|
||||||
|
refetchCollabToken().then((result) => {
|
||||||
|
if (result.data?.token) {
|
||||||
|
socket.disconnect();
|
||||||
|
setTimeout(() => {
|
||||||
|
remote.configuration.token = result.data.token;
|
||||||
|
socket.connect();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const remote = new HocuspocusProvider({
|
||||||
|
websocketProvider: socket,
|
||||||
|
name: documentName,
|
||||||
|
document: ydoc,
|
||||||
|
token: collabQuery?.token,
|
||||||
|
onAuthenticationFailed: onAuthenticationFailedHandler,
|
||||||
|
onStatus: onStatusHandler,
|
||||||
|
onSynced: onSyncedHandler,
|
||||||
|
onStateless: onStatelessHandler,
|
||||||
|
});
|
||||||
|
|
||||||
|
local.on("synced", onLocalSyncedHandler);
|
||||||
|
providersRef.current = { ydoc, socket, local, remote };
|
||||||
|
setProvidersReady(true);
|
||||||
|
} else {
|
||||||
|
setProvidersReady(true);
|
||||||
|
}
|
||||||
|
// Only destroy on final unmount
|
||||||
|
return () => {
|
||||||
|
providersRef.current?.socket.destroy();
|
||||||
|
providersRef.current?.remote.destroy();
|
||||||
|
providersRef.current?.local.destroy();
|
||||||
|
providersRef.current = null;
|
||||||
|
// Reset shared sync state on page change/unmount.
|
||||||
|
setIsLocalSyncedAtom(false);
|
||||||
|
setIsRemoteSyncedAtom(false);
|
||||||
|
};
|
||||||
|
}, [pageId]);
|
||||||
|
|
||||||
|
// Only connect/disconnect on tab/idle, not destroy
|
||||||
|
useEffect(() => {
|
||||||
|
if (!providersReady || !providersRef.current) return;
|
||||||
|
const socket = providersRef.current.socket;
|
||||||
|
|
||||||
|
if (
|
||||||
|
isIdle &&
|
||||||
|
documentState === "hidden" &&
|
||||||
|
yjsConnectionStatus === WebSocketStatus.Connected
|
||||||
|
) {
|
||||||
|
socket.disconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
documentState === "visible" &&
|
||||||
|
yjsConnectionStatus === WebSocketStatus.Disconnected
|
||||||
|
) {
|
||||||
|
resetIdle();
|
||||||
|
socket.connect();
|
||||||
|
}
|
||||||
|
}, [isIdle, documentState, providersReady, resetIdle]);
|
||||||
|
|
||||||
|
// Attach here, to make sure the connection gets properly established
|
||||||
|
providersRef.current?.remote.attach();
|
||||||
|
|
||||||
|
return {
|
||||||
|
ydoc: providersRef.current?.ydoc ?? null,
|
||||||
|
remote: providersRef.current?.remote ?? null,
|
||||||
|
socket: providersRef.current?.socket ?? null,
|
||||||
|
providersReady,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { renderHook, act } from "@testing-library/react";
|
import { renderHook, act } from "@testing-library/react";
|
||||||
import { useScrollPosition, hasSavedReadingPosition } from "./use-scroll-position";
|
import { useScrollPosition } from "./use-scroll-position";
|
||||||
|
|
||||||
const KEY_PREFIX = "gitmost:scroll-position:";
|
const KEY_PREFIX = "gitmost:scroll-position:";
|
||||||
|
|
||||||
@@ -100,7 +100,7 @@ describe("useScrollPosition", () => {
|
|||||||
expect(window.scrollTo).toHaveBeenCalledWith({ top: 500, behavior: "auto" });
|
expect(window.scrollTo).toHaveBeenCalledWith({ top: 500, behavior: "auto" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("(a3) is idempotent: re-asserting the same target does not scroll again", () => {
|
it("(a3) restores at most once per mount even if called again", () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
window.sessionStorage.setItem(`${KEY_PREFIX}once`, "500");
|
window.sessionStorage.setItem(`${KEY_PREFIX}once`, "500");
|
||||||
setScrollHeight(2000); // tall enough to restore synchronously
|
setScrollHeight(2000); // tall enough to restore synchronously
|
||||||
@@ -111,12 +111,8 @@ describe("useScrollPosition", () => {
|
|||||||
});
|
});
|
||||||
expect(window.scrollTo).toHaveBeenCalledTimes(1);
|
expect(window.scrollTo).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
// Simulate the browser now being at the restored position.
|
|
||||||
setScrollY(500);
|
|
||||||
|
|
||||||
// A second call (e.g. the wiring effect re-running on [showStatic, editor,
|
// A second call (e.g. the wiring effect re-running on [showStatic, editor,
|
||||||
// restoreScrollPosition]) must NOT scroll again: the redundancy guard sees
|
// restoreScrollPosition]) must NOT scroll again and yank the reader.
|
||||||
// the window is already at the target and does nothing.
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.restoreScrollPosition();
|
result.current.restoreScrollPosition();
|
||||||
});
|
});
|
||||||
@@ -166,84 +162,6 @@ describe("useScrollPosition", () => {
|
|||||||
expect(window.scrollTo).not.toHaveBeenCalled();
|
expect(window.scrollTo).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("(g) does not restore if the reader scrolled (wheel) before restore fires", () => {
|
|
||||||
window.sessionStorage.setItem(`${KEY_PREFIX}g1`, "500");
|
|
||||||
setScrollHeight(2000); // tall enough to restore synchronously
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useScrollPosition("g1"));
|
|
||||||
|
|
||||||
// The reader shows scroll intent before restore is triggered.
|
|
||||||
act(() => {
|
|
||||||
window.dispatchEvent(new Event("wheel"));
|
|
||||||
});
|
|
||||||
act(() => {
|
|
||||||
result.current.restoreScrollPosition();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(window.scrollTo).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("(h) aborts an in-flight restore poll when the reader scrolls", () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
window.sessionStorage.setItem(`${KEY_PREFIX}h1`, "500");
|
|
||||||
setInnerHeight(800);
|
|
||||||
setScrollHeight(100); // maxScroll = -700: target not reachable yet, so it polls.
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useScrollPosition("h1"));
|
|
||||||
act(() => {
|
|
||||||
result.current.restoreScrollPosition();
|
|
||||||
});
|
|
||||||
expect(window.scrollTo).not.toHaveBeenCalled(); // still polling
|
|
||||||
|
|
||||||
// The reader takes over mid-poll: this cancels the in-flight poll.
|
|
||||||
act(() => {
|
|
||||||
window.dispatchEvent(new Event("wheel"));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Content of the page grows tall enough and time passes: the cancelled poll
|
|
||||||
// must NOT resurrect and yank the reader.
|
|
||||||
setScrollHeight(2000);
|
|
||||||
act(() => {
|
|
||||||
vi.advanceTimersByTime(5000);
|
|
||||||
});
|
|
||||||
expect(window.scrollTo).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("(i) a non-scroll keydown does NOT abort restore", () => {
|
|
||||||
window.sessionStorage.setItem(`${KEY_PREFIX}i1`, "500");
|
|
||||||
setScrollHeight(2000); // tall enough to restore synchronously
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useScrollPosition("i1"));
|
|
||||||
|
|
||||||
// A non-scroll key (e.g. typing, a shortcut) must NOT count as scroll intent.
|
|
||||||
act(() => {
|
|
||||||
window.dispatchEvent(new KeyboardEvent("keydown", { key: "a" }));
|
|
||||||
});
|
|
||||||
act(() => {
|
|
||||||
result.current.restoreScrollPosition();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Restore still happens: the innocuous keypress did not disable it.
|
|
||||||
expect(window.scrollTo).toHaveBeenCalledWith({ top: 500, behavior: "auto" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("(j) a scroll keydown (Space) DOES abort restore", () => {
|
|
||||||
window.sessionStorage.setItem(`${KEY_PREFIX}j1`, "500");
|
|
||||||
setScrollHeight(2000); // tall enough to restore synchronously
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useScrollPosition("j1"));
|
|
||||||
|
|
||||||
// Space scrolls the page: this is real scroll intent and must abort restore.
|
|
||||||
act(() => {
|
|
||||||
window.dispatchEvent(new KeyboardEvent("keydown", { key: " " }));
|
|
||||||
});
|
|
||||||
act(() => {
|
|
||||||
result.current.restoreScrollPosition();
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(window.scrollTo).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("(c) does nothing when nothing is saved or the saved value is <= 0", () => {
|
it("(c) does nothing when nothing is saved or the saved value is <= 0", () => {
|
||||||
// Nothing saved.
|
// Nothing saved.
|
||||||
const a = renderHook(() => useScrollPosition("nope"));
|
const a = renderHook(() => useScrollPosition("nope"));
|
||||||
@@ -303,55 +221,6 @@ describe("useScrollPosition", () => {
|
|||||||
expect(window.scrollTo).toHaveBeenCalledWith({ top: 200, behavior: "auto" });
|
expect(window.scrollTo).toHaveBeenCalledWith({ top: 200, behavior: "auto" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("(k) shares ONE timeout budget across re-triggers (does not restart the clock)", () => {
|
|
||||||
// The static->live editor swap re-invokes restore. The shared budget
|
|
||||||
// (restoreStartRef) must measure the MAX_RESTORE_WAIT_MS (5000) deadline
|
|
||||||
// from the FIRST trigger, not restart it on every re-trigger. This pins
|
|
||||||
// the `if (restoreStartRef.current === null)` guard: a mutant that resets
|
|
||||||
// `restoreStartRef.current = Date.now()` on every trigger would push the
|
|
||||||
// deadline out to t=8000 (3000 + 5000) and fail the t=5000 assertion below.
|
|
||||||
vi.useFakeTimers();
|
|
||||||
vi.setSystemTime(0);
|
|
||||||
window.sessionStorage.setItem(`${KEY_PREFIX}k1`, "5000");
|
|
||||||
setInnerHeight(800);
|
|
||||||
setScrollHeight(1000); // maxScroll = 200, never reaches 5000 -> it polls.
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useScrollPosition("k1"));
|
|
||||||
|
|
||||||
// First trigger at t=0: starts the shared budget and begins polling.
|
|
||||||
act(() => {
|
|
||||||
result.current.restoreScrollPosition();
|
|
||||||
});
|
|
||||||
expect(window.scrollTo).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
// Advance to t=3000 (still polling: content short, not yet timed out).
|
|
||||||
act(() => {
|
|
||||||
vi.advanceTimersByTime(3000);
|
|
||||||
});
|
|
||||||
expect(window.scrollTo).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
// Second trigger at t=3000 (the swap re-assert). Under the real code the
|
|
||||||
// budget is shared, so `start` stays 0; under the reset-mutant it becomes 3000.
|
|
||||||
act(() => {
|
|
||||||
result.current.restoreScrollPosition();
|
|
||||||
});
|
|
||||||
|
|
||||||
// At t=4900 the FIRST budget has not yet elapsed (4900 - 0 < 5000): no clamp.
|
|
||||||
act(() => {
|
|
||||||
vi.advanceTimersByTime(1900);
|
|
||||||
});
|
|
||||||
expect(window.scrollTo).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
// At t=5000 the shared budget (measured from t=0) times out and clamps to the
|
|
||||||
// furthest reachable position (maxScroll = 200). The reset-mutant, measuring
|
|
||||||
// from t=3000, would still be waiting (5000 - 3000 = 2000 < 5000) and would
|
|
||||||
// NOT have scrolled here -> this assertion fails against that mutant.
|
|
||||||
act(() => {
|
|
||||||
vi.advanceTimersByTime(100);
|
|
||||||
});
|
|
||||||
expect(window.scrollTo).toHaveBeenCalledWith({ top: 200, behavior: "auto" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("(e) never throws when storage access throws", () => {
|
it("(e) never throws when storage access throws", () => {
|
||||||
const err = new Error("storage denied");
|
const err = new Error("storage denied");
|
||||||
vi.spyOn(window.sessionStorage, "getItem").mockImplementation(() => {
|
vi.spyOn(window.sessionStorage, "getItem").mockImplementation(() => {
|
||||||
@@ -372,23 +241,3 @@ describe("useScrollPosition", () => {
|
|||||||
}).not.toThrow();
|
}).not.toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("hasSavedReadingPosition", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
window.sessionStorage.clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false when nothing is saved for the page", () => {
|
|
||||||
expect(hasSavedReadingPosition("none")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false when the saved value is 0 (page stays at the top)", () => {
|
|
||||||
window.sessionStorage.setItem(`${KEY_PREFIX}zero`, "0");
|
|
||||||
expect(hasSavedReadingPosition("zero")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns true when a positive position is saved", () => {
|
|
||||||
window.sessionStorage.setItem(`${KEY_PREFIX}deep`, "500");
|
|
||||||
expect(hasSavedReadingPosition("deep")).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
import type { Editor } from "@tiptap/react";
|
|
||||||
|
|
||||||
// Throttle interval for persisting the scroll position while the user reads.
|
// Throttle interval for persisting the scroll position while the user reads.
|
||||||
const SAVE_THROTTLE_MS = 250;
|
const SAVE_THROTTLE_MS = 250;
|
||||||
@@ -14,18 +13,6 @@ const RESTORE_POLL_MS = 100;
|
|||||||
// "remember where I was reading" feature (self-limiting, no cross-tab leak).
|
// "remember where I was reading" feature (self-limiting, no cross-tab leak).
|
||||||
const STORAGE_PREFIX = "gitmost:scroll-position:";
|
const STORAGE_PREFIX = "gitmost:scroll-position:";
|
||||||
|
|
||||||
// Keys that scroll the window. Only these count as scroll intent for keydown;
|
|
||||||
// other keys (shortcuts, modifiers, typing) must NOT disable scroll restore.
|
|
||||||
const SCROLL_KEYS = new Set([
|
|
||||||
"ArrowUp",
|
|
||||||
"ArrowDown",
|
|
||||||
"PageUp",
|
|
||||||
"PageDown",
|
|
||||||
"Home",
|
|
||||||
"End",
|
|
||||||
" ", // Space (and Shift+Space) scroll the page
|
|
||||||
]);
|
|
||||||
|
|
||||||
function storageKey(pageId: string): string {
|
function storageKey(pageId: string): string {
|
||||||
return `${STORAGE_PREFIX}${pageId}`;
|
return `${STORAGE_PREFIX}${pageId}`;
|
||||||
}
|
}
|
||||||
@@ -57,56 +44,36 @@ function writeStorage(pageId: string, scrollY: number): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Whether a positive reading position is saved for this page — i.e. the page
|
|
||||||
* will be scrolled away from the top on load. Used by the title editor to avoid
|
|
||||||
* auto-focusing (and thus placing the caret in) the now-off-screen title.
|
|
||||||
* Returns false when nothing is saved or storage is unavailable.
|
|
||||||
*/
|
|
||||||
export function hasSavedReadingPosition(pageId: string): boolean {
|
|
||||||
const y = readStorage(pageId);
|
|
||||||
return typeof y === "number" && y > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persists and restores the window scroll position per page so a reader keeps
|
* Persists and restores the window scroll position per page so a reader keeps
|
||||||
* their place across a reload (F5) or reopening the document.
|
* their place across a reload (F5) or reopening the document.
|
||||||
*
|
*
|
||||||
* Returns `restoreScrollPosition`, which the page editor calls from two triggers
|
* Returns `restoreScrollPosition`, which the page editor calls once the live
|
||||||
* (early, while the static/cached content is laid out, and again after the
|
* (non-static) content is laid out. The two scroll mechanisms are mutually
|
||||||
* static->live editor swap); it is idempotent, so re-asserting the same target is
|
* exclusive: if the URL has a `#hash` anchor, the existing anchor-scroll logic
|
||||||
* a no-op. The two scroll mechanisms are mutually exclusive: if the URL has a
|
* wins and restore is a no-op.
|
||||||
* `#hash` anchor, the existing anchor-scroll logic wins and restore is a no-op.
|
|
||||||
*/
|
*/
|
||||||
export function useScrollPosition(pageId: string): {
|
export function useScrollPosition(pageId: string): {
|
||||||
restoreScrollPosition: () => void;
|
restoreScrollPosition: () => void;
|
||||||
} {
|
} {
|
||||||
// CONTRACT: this hook assumes PageEditor REMOUNTS per page — page.tsx renders
|
// CONTRACT: this hook assumes PageEditor REMOUNTS per page — page.tsx renders
|
||||||
// `<MemoizedFullEditor key={page.id} ...>`, so switching pages creates a fresh
|
// `<MemoizedFullEditor key={page.id} ...>`, so switching pages creates a fresh
|
||||||
// hook instance with fresh refs. Restore is idempotent and interaction-gated
|
// hook instance with fresh refs. These refs latch per-mount and are NOT reset
|
||||||
// (not single-shot): it may be called from several triggers and re-asserts the
|
// when `pageId` changes in place (only the effect re-runs on [pageId]). If that
|
||||||
// SAME captured target, which is a no-op once the window is already positioned.
|
// `key={page.id}` is ever removed, restore would silently break on the 2nd page
|
||||||
// The per-mount refs that latch are `initialTargetRef` (the captured target)
|
// (refs would hold the first page's target / already-restored flag) — in that
|
||||||
// and `userInteractedRef` (the reader has taken over scrolling). They are NOT
|
// case the refs must be reset on a pageId change.
|
||||||
// reset when `pageId` changes in place (only the effect re-runs on [pageId]).
|
|
||||||
// If that `key={page.id}` is ever removed, restore would silently break on the
|
|
||||||
// 2nd page (refs would hold the first page's target / interaction flag) — in
|
|
||||||
// that case the refs must be reset on a pageId change.
|
|
||||||
//
|
//
|
||||||
// The target Y captured synchronously at mount, BEFORE any scroll/visibility
|
// The target Y captured synchronously at mount, BEFORE any scroll/visibility
|
||||||
// handler can overwrite the stored value with a fresh 0 (the page starts
|
// handler can overwrite the stored value with a fresh 0 (the page starts
|
||||||
// scrolled to top on load). `null` means "not yet captured".
|
// scrolled to top on load). `null` means "not yet captured".
|
||||||
const initialTargetRef = useRef<number | null>(null);
|
const initialTargetRef = useRef<number | null>(null);
|
||||||
// Set once the reader shows unambiguous scroll intent; restore must never yank
|
// Guards so restore runs at most once per page mount.
|
||||||
// a reader who has already started scrolling.
|
const hasRestoredRef = useRef(false);
|
||||||
const userInteractedRef = useRef(false);
|
|
||||||
// Holds the in-flight restore poll timer so the cleanup can cancel it: without
|
// Holds the in-flight restore poll timer so the cleanup can cancel it: without
|
||||||
// this, a fast SPA navigation away mid-poll would let the old page's poll fire
|
// this, a fast SPA navigation away mid-poll would let the old page's poll fire
|
||||||
// window.scrollTo against the NEW page's document (visible wrong-page scroll).
|
// window.scrollTo against the NEW page's document (visible wrong-page scroll).
|
||||||
const pollTimerRef = useRef<number | null>(null);
|
const pollTimerRef = useRef<number | null>(null);
|
||||||
// Timestamp of the FIRST restore attempt so re-triggers (e.g. the static→live
|
|
||||||
// editor swap) share ONE bounded timeout budget instead of restarting it.
|
|
||||||
const restoreStartRef = useRef<number | null>(null);
|
|
||||||
|
|
||||||
// Capture the previously-saved value synchronously during render, before the
|
// Capture the previously-saved value synchronously during render, before the
|
||||||
// effect below registers handlers that would persist the current (0) scrollY.
|
// effect below registers handlers that would persist the current (0) scrollY.
|
||||||
@@ -147,43 +114,14 @@ export function useScrollPosition(pageId: string): {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// User scroll-intent signals. wheel and touch are unconditional scroll
|
|
||||||
// intent; keydown is filtered to actual scroll keys only (SCROLL_KEYS) so
|
|
||||||
// shortcuts, lone modifiers, and typing do not abort restore. Our own
|
|
||||||
// window.scrollTo does NOT emit these, so restore can never self-abort via
|
|
||||||
// them. Once the reader shows intent we mark it and cancel any in-flight
|
|
||||||
// restore poll so restore can never yank them back. (Scrollbar-drag via
|
|
||||||
// pointer is an accepted small gap — it is not covered here.)
|
|
||||||
const onUserIntent = (event: Event) => {
|
|
||||||
// wheel/touchstart are unambiguous scroll intent; for keydown, only real
|
|
||||||
// scroll keys count — a shortcut or typing must not abort restore.
|
|
||||||
if (
|
|
||||||
event.type === "keydown" &&
|
|
||||||
!SCROLL_KEYS.has((event as KeyboardEvent).key)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
userInteractedRef.current = true;
|
|
||||||
if (pollTimerRef.current !== null) {
|
|
||||||
window.clearTimeout(pollTimerRef.current);
|
|
||||||
pollTimerRef.current = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("scroll", onScroll, { passive: true });
|
window.addEventListener("scroll", onScroll, { passive: true });
|
||||||
window.addEventListener("pagehide", onPageHide);
|
window.addEventListener("pagehide", onPageHide);
|
||||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||||
window.addEventListener("wheel", onUserIntent, { passive: true });
|
|
||||||
window.addEventListener("touchstart", onUserIntent, { passive: true });
|
|
||||||
window.addEventListener("keydown", onUserIntent);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("scroll", onScroll);
|
window.removeEventListener("scroll", onScroll);
|
||||||
window.removeEventListener("pagehide", onPageHide);
|
window.removeEventListener("pagehide", onPageHide);
|
||||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||||
window.removeEventListener("wheel", onUserIntent);
|
|
||||||
window.removeEventListener("touchstart", onUserIntent);
|
|
||||||
window.removeEventListener("keydown", onUserIntent);
|
|
||||||
if (throttleTimer !== null) {
|
if (throttleTimer !== null) {
|
||||||
window.clearTimeout(throttleTimer);
|
window.clearTimeout(throttleTimer);
|
||||||
throttleTimer = null;
|
throttleTimer = null;
|
||||||
@@ -199,8 +137,9 @@ export function useScrollPosition(pageId: string): {
|
|||||||
}, [pageId]);
|
}, [pageId]);
|
||||||
|
|
||||||
const restoreScrollPosition = useCallback(() => {
|
const restoreScrollPosition = useCallback(() => {
|
||||||
// The reader took over — never yank them back.
|
// Run at most once per page mount.
|
||||||
if (userInteractedRef.current) return;
|
if (hasRestoredRef.current) return;
|
||||||
|
hasRestoredRef.current = true;
|
||||||
|
|
||||||
// Anchor priority: a `#hash` in the URL is handled by useEditorScroll.
|
// Anchor priority: a `#hash` in the URL is handled by useEditorScroll.
|
||||||
if (window.location.hash) return;
|
if (window.location.hash) return;
|
||||||
@@ -209,26 +148,9 @@ export function useScrollPosition(pageId: string): {
|
|||||||
// Nothing meaningful to restore to.
|
// Nothing meaningful to restore to.
|
||||||
if (targetY <= 0) return;
|
if (targetY <= 0) return;
|
||||||
|
|
||||||
// Cancel any in-flight poll before (re)starting, so overlapping triggers can
|
const start = Date.now();
|
||||||
// never run two concurrent polls against the same target.
|
|
||||||
if (pollTimerRef.current !== null) {
|
|
||||||
window.clearTimeout(pollTimerRef.current);
|
|
||||||
pollTimerRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Share one timeout budget across re-triggers instead of restarting it.
|
|
||||||
if (restoreStartRef.current === null) {
|
|
||||||
restoreStartRef.current = Date.now();
|
|
||||||
}
|
|
||||||
const start = restoreStartRef.current;
|
|
||||||
|
|
||||||
const tryRestore = () => {
|
const tryRestore = () => {
|
||||||
// Bail mid-poll if the reader started scrolling while we were waiting.
|
|
||||||
if (userInteractedRef.current) {
|
|
||||||
pollTimerRef.current = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxScroll =
|
const maxScroll =
|
||||||
document.documentElement.scrollHeight - window.innerHeight;
|
document.documentElement.scrollHeight - window.innerHeight;
|
||||||
const timedOut = Date.now() - start >= MAX_RESTORE_WAIT_MS;
|
const timedOut = Date.now() - start >= MAX_RESTORE_WAIT_MS;
|
||||||
@@ -236,12 +158,10 @@ export function useScrollPosition(pageId: string): {
|
|||||||
// Restore once the content is tall enough to reach the target, or bail out
|
// Restore once the content is tall enough to reach the target, or bail out
|
||||||
// after the timeout and scroll as far as currently possible.
|
// after the timeout and scroll as far as currently possible.
|
||||||
if (maxScroll >= targetY || timedOut) {
|
if (maxScroll >= targetY || timedOut) {
|
||||||
const top = Math.min(targetY, Math.max(maxScroll, 0));
|
window.scrollTo({
|
||||||
// Redundancy guard: re-asserting the SAME target when already positioned
|
top: Math.min(targetY, Math.max(maxScroll, 0)),
|
||||||
// is a no-op, so this hook can be called from multiple triggers safely.
|
behavior: "auto",
|
||||||
if (Math.abs(window.scrollY - top) > 1) {
|
});
|
||||||
window.scrollTo({ top, behavior: "auto" });
|
|
||||||
}
|
|
||||||
pollTimerRef.current = null;
|
pollTimerRef.current = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -255,37 +175,3 @@ export function useScrollPosition(pageId: string): {
|
|||||||
|
|
||||||
return { restoreScrollPosition };
|
return { restoreScrollPosition };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Wires `useScrollPosition` to the page editor's static->live swap lifecycle.
|
|
||||||
*
|
|
||||||
* Extracted from PageEditor so the exact restore triggers (their deps and the
|
|
||||||
* post-swap `&& editor` guard) are directly unit-testable rather than mirrored.
|
|
||||||
* Behaviour is unchanged: `restoreScrollPosition` is idempotent, so re-asserting
|
|
||||||
* the same target from either trigger is a no-op.
|
|
||||||
*
|
|
||||||
* @param pageId the page whose scroll position is persisted/restored.
|
|
||||||
* @param editor the tiptap editor instance, or `null` until it is ready.
|
|
||||||
* @param showStatic whether the static (cached) content is still shown.
|
|
||||||
*/
|
|
||||||
export function useScrollRestoreOnSwap(
|
|
||||||
pageId: string,
|
|
||||||
editor: Editor | null,
|
|
||||||
showStatic: boolean,
|
|
||||||
): void {
|
|
||||||
const { restoreScrollPosition } = useScrollPosition(pageId);
|
|
||||||
|
|
||||||
// Restore as early as the static (cached) content is laid out, before paint,
|
|
||||||
// so the reader's position is applied without a visible jump. Aborts itself if
|
|
||||||
// the reader has already started scrolling (handled inside the hook).
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
restoreScrollPosition();
|
|
||||||
}, [restoreScrollPosition]);
|
|
||||||
|
|
||||||
// Re-assert once after the static -> live editor swap in case the swap reset
|
|
||||||
// the window scroll. Idempotent: a no-op when the position is already correct,
|
|
||||||
// and a no-op after the reader has interacted.
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (!showStatic && editor) restoreScrollPosition();
|
|
||||||
}, [showStatic, editor, restoreScrollPosition]);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
||||||
import { renderHook, act } from "@testing-library/react";
|
|
||||||
import { useTitleAutofocus } from "./use-title-autofocus";
|
|
||||||
|
|
||||||
const KEY_PREFIX = "gitmost:scroll-position:";
|
|
||||||
|
|
||||||
function fakeEditor(overrides = {}) {
|
|
||||||
return { isInitialized: true, commands: { focus: vi.fn() }, ...overrides } as any;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("useTitleAutofocus", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
window.sessionStorage.clear();
|
|
||||||
vi.useFakeTimers();
|
|
||||||
});
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("skips auto-focus when a saved reading position exists", () => {
|
|
||||||
window.sessionStorage.setItem(`${KEY_PREFIX}saved`, "500");
|
|
||||||
const editor = fakeEditor();
|
|
||||||
renderHook(() => useTitleAutofocus(editor, "saved"));
|
|
||||||
act(() => vi.advanceTimersByTime(300));
|
|
||||||
expect(editor.commands.focus).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("auto-focuses a new page (no saved position) with scrollIntoView: false", () => {
|
|
||||||
const editor = fakeEditor();
|
|
||||||
renderHook(() => useTitleAutofocus(editor, "fresh"));
|
|
||||||
act(() => vi.advanceTimersByTime(300));
|
|
||||||
expect(editor.commands.focus).toHaveBeenCalledWith("end", { scrollIntoView: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not focus before initialization", () => {
|
|
||||||
const editor = fakeEditor({ isInitialized: false });
|
|
||||||
renderHook(() => useTitleAutofocus(editor, "fresh2"));
|
|
||||||
act(() => vi.advanceTimersByTime(300));
|
|
||||||
expect(editor.commands.focus).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("cancels the pending focus on unmount", () => {
|
|
||||||
const editor = fakeEditor();
|
|
||||||
const { unmount } = renderHook(() => useTitleAutofocus(editor, "fresh3"));
|
|
||||||
unmount();
|
|
||||||
act(() => vi.advanceTimersByTime(300));
|
|
||||||
expect(editor.commands.focus).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
import { useEffect, useRef } from "react";
|
|
||||||
import type { Editor } from "@tiptap/react";
|
|
||||||
import { hasSavedReadingPosition } from "./use-scroll-position";
|
|
||||||
|
|
||||||
// Delay before auto-focusing the title on load — guards a tiptap init race
|
|
||||||
// ("Cannot access view['hasFocus']" if focused too early).
|
|
||||||
const TITLE_AUTOFOCUS_DELAY_MS = 300;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Auto-focus the page title shortly after mount — UNLESS a saved reading position
|
|
||||||
* will be restored (then the viewport scrolls away from the top, and focusing the
|
|
||||||
* top-of-page title would drop the caret off-screen). When it does focus, it uses
|
|
||||||
* `{ scrollIntoView: false }` so placing the caret never moves the viewport
|
|
||||||
* (tiptap's focus scrolls the focused node into view by default, which otherwise
|
|
||||||
* yanks the window to the top and fights scroll-position restoration).
|
|
||||||
*
|
|
||||||
* Extracted from TitleEditor so this exact decision is unit-testable.
|
|
||||||
*
|
|
||||||
* CONTRACT: relies on TitleEditor remounting per page (page.tsx renders
|
|
||||||
* `<MemoizedFullEditor key={page.id}>`), so `hasSavedScrollRef` is captured fresh
|
|
||||||
* per page. It is read synchronously on first render, before any scroll-save
|
|
||||||
* handler can clobber the stored value to 0 — matching `useScrollPosition`'s own
|
|
||||||
* synchronous capture of `initialTargetRef`.
|
|
||||||
*/
|
|
||||||
export function useTitleAutofocus(
|
|
||||||
titleEditor: Editor | null,
|
|
||||||
pageId: string,
|
|
||||||
): void {
|
|
||||||
const hasSavedScrollRef = useRef<boolean | null>(null);
|
|
||||||
if (hasSavedScrollRef.current === null) {
|
|
||||||
hasSavedScrollRef.current = hasSavedReadingPosition(pageId);
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (hasSavedScrollRef.current) return;
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
// guard against "Cannot access view['hasFocus']" before init
|
|
||||||
if (!titleEditor?.isInitialized) return;
|
|
||||||
titleEditor?.commands?.focus("end", { scrollIntoView: false });
|
|
||||||
}, TITLE_AUTOFOCUS_DELAY_MS);
|
|
||||||
// Clear the pending focus if the editor changes or the component unmounts
|
|
||||||
// (also fixes the previously-uncancelled timer).
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, [titleEditor]);
|
|
||||||
}
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
||||||
import { render, act } from "@testing-library/react";
|
|
||||||
import type { Editor } from "@tiptap/react";
|
|
||||||
import { useScrollRestoreOnSwap } from "./hooks/use-scroll-position";
|
|
||||||
|
|
||||||
const KEY_PREFIX = "gitmost:scroll-position:";
|
|
||||||
|
|
||||||
// NOTE ON SCOPE (F2 — reviewer-approved lighter variant).
|
|
||||||
//
|
|
||||||
// The real UX wiring lives in the exported `useScrollRestoreOnSwap` hook (two
|
|
||||||
// useLayoutEffects around useScrollPosition), which PageEditor calls with the
|
|
||||||
// same signature. A FULL PageEditor component test is impractical here and has no
|
|
||||||
// precedent in this client: PageEditor directly constructs a
|
|
||||||
// HocuspocusProviderWebsocket + IndexeddbPersistence, a tiptap `useEditor` with
|
|
||||||
// collab extensions, reads jotai atoms, react-router params, the shared
|
|
||||||
// `queryClient` from main.tsx, i18n, and mounts ~12 editor menu children. Worse,
|
|
||||||
// the static->live swap (`showStatic` -> false) is gated on
|
|
||||||
// `isCollabSynced(status, isLocalSynced && isRemoteSynced)`, which can only flip
|
|
||||||
// by driving the mocked collab provider's async sync callbacks. The heaviest
|
|
||||||
// component-test precedent in the repo (comment-hover-preview.test.tsx) mounts a
|
|
||||||
// single leaf component with ONE mocked query; nothing mounts a feature root of
|
|
||||||
// this weight. Reproducing all of that would test the mocks, not the wiring.
|
|
||||||
//
|
|
||||||
// So this file tests the REAL `useScrollRestoreOnSwap` hook — the exact code
|
|
||||||
// PageEditor imports and calls — driving its `showStatic`/`editor` inputs the way
|
|
||||||
// the swap does. Because it exercises the real hook (not a copy), dropping the
|
|
||||||
// `&& editor` guard or changing the effect deps makes these tests fail; they
|
|
||||||
// guard the production code directly (verified: removing `&& editor` reddens the
|
|
||||||
// first test).
|
|
||||||
//
|
|
||||||
// Both tests observe the real effect via `window.scrollTo`. The stubbed
|
|
||||||
// `window.scrollTo` never mutates `window.scrollY`, and the target is left
|
|
||||||
// unreached, so every restore invocation that passes the guard yields exactly one
|
|
||||||
// `scrollTo` call — making the call count a faithful proxy for restore invocations.
|
|
||||||
|
|
||||||
function setScrollY(value: number): void {
|
|
||||||
Object.defineProperty(window, "scrollY", { configurable: true, value });
|
|
||||||
}
|
|
||||||
function setScrollHeight(value: number): void {
|
|
||||||
Object.defineProperty(document.documentElement, "scrollHeight", {
|
|
||||||
configurable: true,
|
|
||||||
value,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function setInnerHeight(value: number): void {
|
|
||||||
Object.defineProperty(window, "innerHeight", { configurable: true, value });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Minimal stand-in for the tiptap editor: the hook only truthiness-checks it.
|
|
||||||
const fakeEditor = { id: "editor" } as unknown as Editor;
|
|
||||||
|
|
||||||
// Thin host that calls the REAL hook so a rerender drives showStatic/editor
|
|
||||||
// exactly like the page-editor swap does.
|
|
||||||
function Host({
|
|
||||||
pageId,
|
|
||||||
showStatic,
|
|
||||||
editor,
|
|
||||||
}: {
|
|
||||||
pageId: string;
|
|
||||||
showStatic: boolean;
|
|
||||||
editor: Editor | null;
|
|
||||||
}) {
|
|
||||||
useScrollRestoreOnSwap(pageId, editor, showStatic);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("PageEditor scroll-restore wiring (useScrollRestoreOnSwap)", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
window.sessionStorage.clear();
|
|
||||||
setScrollY(0);
|
|
||||||
setScrollHeight(0);
|
|
||||||
setInnerHeight(800);
|
|
||||||
window.scrollTo = vi.fn();
|
|
||||||
window.location.hash = "";
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
vi.useRealTimers();
|
|
||||||
window.location.hash = "";
|
|
||||||
});
|
|
||||||
|
|
||||||
it("re-invokes restore after the swap, with the [showStatic, editor] deps/guard", () => {
|
|
||||||
// Target is immediately reachable, so each restore that passes the guard
|
|
||||||
// scrolls synchronously. `window.scrollY` stays 0 (stubbed scrollTo never
|
|
||||||
// updates it), so scrollTo is called once per effective restore — a proxy for
|
|
||||||
// the restore invocation count.
|
|
||||||
window.sessionStorage.setItem(`${KEY_PREFIX}guard`, "500");
|
|
||||||
setInnerHeight(800);
|
|
||||||
setScrollHeight(2000); // maxScroll = 1200 >= 500: reachable, no polling.
|
|
||||||
|
|
||||||
// Pre-swap: static content shown, live editor not ready. Only the early
|
|
||||||
// pre-paint restore fires; the post-swap effect's guard (!showStatic) blocks it.
|
|
||||||
const { rerender } = render(
|
|
||||||
<Host pageId="guard" showStatic={true} editor={null} />,
|
|
||||||
);
|
|
||||||
expect(window.scrollTo).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
// Collab reports synced (showStatic flips false) but the editor is not ready
|
|
||||||
// yet: the swap effect re-runs (deps [showStatic, editor] changed) but the
|
|
||||||
// `&& editor` guard must keep it a no-op. The early effect does NOT re-fire
|
|
||||||
// (its dep [restoreScrollPosition] is a stable useCallback([])).
|
|
||||||
// (Pins the guard: dropping `&& editor` would restore against a null editor,
|
|
||||||
// producing a 2nd scrollTo and failing this expectation.)
|
|
||||||
rerender(<Host pageId="guard" showStatic={false} editor={null} />);
|
|
||||||
expect(window.scrollTo).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
// The static -> live swap completes (showStatic false AND editor present): the
|
|
||||||
// post-swap effect re-asserts the restore exactly once more, driven solely by
|
|
||||||
// the [showStatic, editor] deps changing.
|
|
||||||
rerender(<Host pageId="guard" showStatic={false} editor={fakeEditor} />);
|
|
||||||
expect(window.scrollTo).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("the post-swap re-assert drives a REAL restore (window.scrollTo) via the hook", () => {
|
|
||||||
// End-to-end through the real useScrollPosition (inside the hook): the swap
|
|
||||||
// re-invocation is the CAUSE of the scroll (nothing scrolls before it).
|
|
||||||
vi.useFakeTimers();
|
|
||||||
window.sessionStorage.setItem(`${KEY_PREFIX}peg`, "500");
|
|
||||||
setInnerHeight(800);
|
|
||||||
setScrollHeight(100); // maxScroll = -700: target not reachable yet -> polls.
|
|
||||||
|
|
||||||
// Pre-swap: the early restore runs but content is too short, so it starts
|
|
||||||
// polling (a pending timer) without scrolling. We never advance timers, so the
|
|
||||||
// early poll cannot fire on its own — isolating the swap as the sole cause.
|
|
||||||
const { rerender } = render(
|
|
||||||
<Host pageId="peg" showStatic={true} editor={null} />,
|
|
||||||
);
|
|
||||||
expect(window.scrollTo).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
// The live content is now laid out tall enough to reach the target.
|
|
||||||
setScrollHeight(2000); // maxScroll = 1200 >= 500
|
|
||||||
|
|
||||||
// The static -> live swap: the post-swap useLayoutEffect re-invokes the real
|
|
||||||
// hook, whose synchronous tryRestore now reaches the target and scrolls.
|
|
||||||
act(() => {
|
|
||||||
rerender(<Host pageId="peg" showStatic={false} editor={fakeEditor} />);
|
|
||||||
});
|
|
||||||
expect(window.scrollTo).toHaveBeenCalledWith({ top: 500, behavior: "auto" });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -6,16 +6,7 @@ import React, {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { IndexeddbPersistence } from "y-indexeddb";
|
import { WebSocketStatus } from "@hocuspocus/provider";
|
||||||
import * as Y from "yjs";
|
|
||||||
import {
|
|
||||||
HocuspocusProvider,
|
|
||||||
onStatusParameters,
|
|
||||||
WebSocketStatus,
|
|
||||||
HocuspocusProviderWebsocket,
|
|
||||||
onSyncedParameters,
|
|
||||||
onStatelessParameters,
|
|
||||||
} from "@hocuspocus/provider";
|
|
||||||
import {
|
import {
|
||||||
Editor,
|
Editor,
|
||||||
EditorContent,
|
EditorContent,
|
||||||
@@ -28,13 +19,15 @@ import {
|
|||||||
mainExtensions,
|
mainExtensions,
|
||||||
} from "@/features/editor/extensions/extensions";
|
} from "@/features/editor/extensions/extensions";
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
import { useAtom, useAtomValue } from "jotai";
|
||||||
import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url";
|
|
||||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
||||||
import {
|
import {
|
||||||
currentPageEditModeAtom,
|
currentPageEditModeAtom,
|
||||||
|
isLocalSyncedAtom,
|
||||||
|
isRemoteSyncedAtom,
|
||||||
pageEditorAtom,
|
pageEditorAtom,
|
||||||
yjsConnectionStatusAtom,
|
yjsConnectionStatusAtom,
|
||||||
} from "@/features/editor/atoms/editor-atoms";
|
} from "@/features/editor/atoms/editor-atoms";
|
||||||
|
import { useEditorProviders } from "@/features/editor/contexts/editor-providers-context";
|
||||||
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
|
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom";
|
||||||
import {
|
import {
|
||||||
activeCommentIdAtom,
|
activeCommentIdAtom,
|
||||||
@@ -59,10 +52,8 @@ import {
|
|||||||
} 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";
|
import DrawioMenu from "./components/drawio/drawio-menu";
|
||||||
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
|
||||||
import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx";
|
import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx";
|
||||||
import { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks";
|
import { useDebouncedCallback } from "@mantine/hooks";
|
||||||
import { useIdle } from "@/hooks/use-idle.ts";
|
|
||||||
import { queryClient } from "@/main.tsx";
|
import { queryClient } from "@/main.tsx";
|
||||||
import { IPage } from "@/features/page/types/page.types.ts";
|
import { IPage } from "@/features/page/types/page.types.ts";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
@@ -73,12 +64,10 @@ import {
|
|||||||
GitmostInsertRecordingResult,
|
GitmostInsertRecordingResult,
|
||||||
gitmostInsertRecordingIntoEditor,
|
gitmostInsertRecordingIntoEditor,
|
||||||
} from "@/features/editor/gitmost/gitmost-recording.ts";
|
} from "@/features/editor/gitmost/gitmost-recording.ts";
|
||||||
import { FIVE_MINUTES } from "@/lib/constants.ts";
|
|
||||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||||
import { jwtDecode } from "jwt-decode";
|
|
||||||
import { searchSpotlight } from "@/features/search/constants.ts";
|
import { searchSpotlight } from "@/features/search/constants.ts";
|
||||||
import { useEditorScroll } from "./hooks/use-editor-scroll";
|
import { useEditorScroll } from "./hooks/use-editor-scroll";
|
||||||
import { useScrollRestoreOnSwap } from "./hooks/use-scroll-position";
|
import { useScrollPosition } from "./hooks/use-scroll-position";
|
||||||
import { EditorLinkMenu } from "@/features/editor/components/link/link-menu";
|
import { EditorLinkMenu } from "@/features/editor/components/link/link-menu";
|
||||||
import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx";
|
import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx";
|
||||||
import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context";
|
import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context";
|
||||||
@@ -105,7 +94,6 @@ export default function PageEditor({
|
|||||||
canComment,
|
canComment,
|
||||||
}: PageEditorProps) {
|
}: PageEditorProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const collaborationURL = useCollaborationUrl();
|
|
||||||
const isComponentMounted = useRef(false);
|
const isComponentMounted = useRef(false);
|
||||||
const editorRef = useRef<Editor | null>(null);
|
const editorRef = useRef<Editor | null>(null);
|
||||||
|
|
||||||
@@ -119,22 +107,10 @@ export default function PageEditor({
|
|||||||
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
||||||
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
||||||
const [showReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom);
|
const [showReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom);
|
||||||
const [isLocalSynced, setIsLocalSynced] = useState(false);
|
|
||||||
const [isRemoteSynced, setIsRemoteSynced] = useState(false);
|
|
||||||
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
|
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
|
||||||
yjsConnectionStatusAtom,
|
yjsConnectionStatusAtom,
|
||||||
);
|
);
|
||||||
const menuContainerRef = useRef(null);
|
const menuContainerRef = useRef(null);
|
||||||
const { data: collabQuery, refetch: refetchCollabToken } = useCollabToken();
|
|
||||||
// Always holds the latest collab token. The provider effect below runs once
|
|
||||||
// per pageId, so a handler created inside it would otherwise close over a
|
|
||||||
// stale `collabQuery`. Reading the ref gives the current token instead.
|
|
||||||
const collabTokenRef = useRef<string | undefined>(undefined);
|
|
||||||
useEffect(() => {
|
|
||||||
collabTokenRef.current = collabQuery?.token;
|
|
||||||
}, [collabQuery?.token]);
|
|
||||||
const { isIdle, resetIdle } = useIdle(FIVE_MINUTES, { initialState: false });
|
|
||||||
const documentState = useDocumentVisibility();
|
|
||||||
const { pageSlug } = useParams();
|
const { pageSlug } = useParams();
|
||||||
const slugId = extractPageSlugId(pageSlug);
|
const slugId = extractPageSlugId(pageSlug);
|
||||||
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
|
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
|
||||||
@@ -143,141 +119,34 @@ export default function PageEditor({
|
|||||||
[isComponentMounted],
|
[isComponentMounted],
|
||||||
);
|
);
|
||||||
const { handleScrollTo } = useEditorScroll({ canScroll });
|
const { handleScrollTo } = useEditorScroll({ canScroll });
|
||||||
// Providers only created once per pageId
|
// Scroll-position restore hook from develop. The provider state that develop
|
||||||
const providersRef = useRef<{
|
// also declared here (providersRef / providersReady useState) is intentionally
|
||||||
local: IndexeddbPersistence;
|
// dropped: the offline-sync feature moved provider creation into the shared
|
||||||
remote: HocuspocusProvider;
|
// usePageCollabProviders context, and `providersReady` is derived from that
|
||||||
socket: HocuspocusProviderWebsocket;
|
// context below (via useEditorProviders), so redeclaring it here would shadow
|
||||||
} | null>(null);
|
// and conflict with the branch's provider model.
|
||||||
const [providersReady, setProvidersReady] = useState(false);
|
const { restoreScrollPosition } = useScrollPosition(pageId);
|
||||||
|
|
||||||
useEffect(() => {
|
// Shared providers + Y.Doc lifted into full-editor via context. The provider
|
||||||
if (!providersRef.current) {
|
// lifecycle (creation, idle/visibility connect, attach, destroy, token
|
||||||
const documentName = `page.${pageId}`;
|
// refresh) lives in usePageCollabProviders. Null-safe when rendered without
|
||||||
const ydoc = new Y.Doc();
|
// the context (defensive) — in practice full-editor always provides it.
|
||||||
const local = new IndexeddbPersistence(documentName, ydoc);
|
const editorProviders = useEditorProviders();
|
||||||
const socket = new HocuspocusProviderWebsocket({
|
const remote = editorProviders?.remote ?? null;
|
||||||
url: collaborationURL,
|
const providersReady = editorProviders?.providersReady ?? false;
|
||||||
});
|
const isLocalSynced = useAtomValue(isLocalSyncedAtom);
|
||||||
const onLocalSyncedHandler = () => {
|
const isRemoteSynced = useAtomValue(isRemoteSyncedAtom);
|
||||||
setIsLocalSynced(true);
|
|
||||||
};
|
|
||||||
const onStatusHandler = (event: onStatusParameters) => {
|
|
||||||
setYjsConnectionStatus(event.status);
|
|
||||||
};
|
|
||||||
const onSyncedHandler = (event: onSyncedParameters) => {
|
|
||||||
setIsRemoteSynced(event.state);
|
|
||||||
};
|
|
||||||
const onStatelessHandler = ({ payload }: onStatelessParameters) => {
|
|
||||||
try {
|
|
||||||
const message = JSON.parse(payload);
|
|
||||||
if (message?.type !== "page.updated" || !message.updatedAt) return;
|
|
||||||
const pageData = queryClient.getQueryData<IPage>(["pages", slugId]);
|
|
||||||
if (pageData) {
|
|
||||||
queryClient.setQueryData(["pages", slugId], {
|
|
||||||
...pageData,
|
|
||||||
updatedAt: message.updatedAt,
|
|
||||||
...(message.lastUpdatedBy && {
|
|
||||||
lastUpdatedBy: message.lastUpdatedBy,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore unrelated stateless messages
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const onAuthenticationFailedHandler = () => {
|
|
||||||
// Read the latest token via the ref (the closure-captured `collabQuery`
|
|
||||||
// may be stale). Guard the decode: a missing or unparseable token must
|
|
||||||
// not throw "Invalid token specified" and should trigger a refresh so
|
|
||||||
// the editor reconnects even when the initial token fetch failed.
|
|
||||||
const token = collabTokenRef.current;
|
|
||||||
let needsRefresh = true; // no/unparseable token -> fetch a fresh one and reconnect
|
|
||||||
if (token) {
|
|
||||||
try {
|
|
||||||
// A token that decodes but lacks a numeric `exp` must be treated as
|
|
||||||
// expired (`Date.now()/1000 >= undefined` is `false`, which would
|
|
||||||
// otherwise skip the reconnect), so refresh on any missing/non-number exp.
|
|
||||||
const exp = jwtDecode<{ exp?: number }>(token).exp;
|
|
||||||
needsRefresh = typeof exp !== "number" || Date.now() / 1000 >= exp;
|
|
||||||
} catch {
|
|
||||||
needsRefresh = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!needsRefresh) return;
|
|
||||||
refetchCollabToken().then((result) => {
|
|
||||||
if (result.data?.token) {
|
|
||||||
socket.disconnect();
|
|
||||||
setTimeout(() => {
|
|
||||||
remote.configuration.token = result.data.token;
|
|
||||||
socket.connect();
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
const remote = new HocuspocusProvider({
|
|
||||||
websocketProvider: socket,
|
|
||||||
name: documentName,
|
|
||||||
document: ydoc,
|
|
||||||
token: collabQuery?.token,
|
|
||||||
onAuthenticationFailed: onAuthenticationFailedHandler,
|
|
||||||
onStatus: onStatusHandler,
|
|
||||||
onSynced: onSyncedHandler,
|
|
||||||
onStateless: onStatelessHandler,
|
|
||||||
});
|
|
||||||
|
|
||||||
local.on("synced", onLocalSyncedHandler);
|
|
||||||
providersRef.current = { socket, local, remote };
|
|
||||||
setProvidersReady(true);
|
|
||||||
} else {
|
|
||||||
setProvidersReady(true);
|
|
||||||
}
|
|
||||||
// Only destroy on final unmount
|
|
||||||
return () => {
|
|
||||||
providersRef.current?.socket.destroy();
|
|
||||||
providersRef.current?.remote.destroy();
|
|
||||||
providersRef.current?.local.destroy();
|
|
||||||
providersRef.current = null;
|
|
||||||
};
|
|
||||||
}, [pageId]);
|
|
||||||
|
|
||||||
// Only connect/disconnect on tab/idle, not destroy
|
|
||||||
useEffect(() => {
|
|
||||||
if (!providersReady || !providersRef.current) return;
|
|
||||||
const socket = providersRef.current.socket;
|
|
||||||
|
|
||||||
if (
|
|
||||||
isIdle &&
|
|
||||||
documentState === "hidden" &&
|
|
||||||
yjsConnectionStatus === WebSocketStatus.Connected
|
|
||||||
) {
|
|
||||||
socket.disconnect();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
documentState === "visible" &&
|
|
||||||
yjsConnectionStatus === WebSocketStatus.Disconnected
|
|
||||||
) {
|
|
||||||
resetIdle();
|
|
||||||
socket.connect();
|
|
||||||
}
|
|
||||||
}, [isIdle, documentState, providersReady, resetIdle]);
|
|
||||||
|
|
||||||
// Attach here, to make sure the connection gets properly established
|
|
||||||
providersRef.current?.remote.attach();
|
|
||||||
|
|
||||||
const extensions = useMemo(() => {
|
const extensions = useMemo(() => {
|
||||||
if (!providersReady || !providersRef.current || !currentUser?.user) {
|
if (!providersReady || !remote || !currentUser?.user) {
|
||||||
return mainExtensions;
|
return mainExtensions;
|
||||||
}
|
}
|
||||||
|
|
||||||
const remoteProvider = providersRef.current.remote;
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
...mainExtensions,
|
...mainExtensions,
|
||||||
...collabExtensions(remoteProvider, currentUser?.user),
|
...collabExtensions(remote, currentUser?.user),
|
||||||
];
|
];
|
||||||
}, [providersReady, currentUser?.user]);
|
}, [providersReady, remote, currentUser?.user]);
|
||||||
|
|
||||||
const editor = useEditor(
|
const editor = useEditor(
|
||||||
{
|
{
|
||||||
@@ -481,10 +350,10 @@ export default function PageEditor({
|
|||||||
}
|
}
|
||||||
}, [yjsConnectionStatus, isSynced]);
|
}, [yjsConnectionStatus, isSynced]);
|
||||||
|
|
||||||
// Restore the reader's scroll position across the static -> live editor swap.
|
// Restore the saved reading position once the live content is laid out.
|
||||||
// The wiring (early pre-paint restore + post-swap re-assert) lives in the hook
|
useEffect(() => {
|
||||||
// so its triggers/guard are directly unit-testable.
|
if (!showStatic && editor) restoreScrollPosition();
|
||||||
useScrollRestoreOnSwap(pageId, editor, showStatic);
|
}, [showStatic, editor, restoreScrollPosition]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TransclusionLookupProvider>
|
<TransclusionLookupProvider>
|
||||||
@@ -562,7 +431,7 @@ export default function PageEditor({
|
|||||||
{editor &&
|
{editor &&
|
||||||
!editorIsEditable &&
|
!editorIsEditable &&
|
||||||
(editable || canComment) &&
|
(editable || canComment) &&
|
||||||
providersRef.current && <ReadonlyBubbleMenu editor={editor} />}
|
remote && <ReadonlyBubbleMenu editor={editor} />}
|
||||||
{showCommentPopup && (
|
{showCommentPopup && (
|
||||||
<CommentDialog editor={editor} pageId={pageId} />
|
<CommentDialog editor={editor} pageId={pageId} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Single source of truth for the IndexedDB / Hocuspocus document name of a
|
||||||
|
* page's collaborative Yjs doc.
|
||||||
|
*
|
||||||
|
* The `page.<id>` convention is shared knowledge across three call sites: the
|
||||||
|
* live editor providers (`use-page-collab-providers`), the offline warm path
|
||||||
|
* (`make-offline`), and the offline purge (`clear-offline-cache`, which matches
|
||||||
|
* the databases to delete by this prefix). Centralizing it here stops those
|
||||||
|
* sites from silently drifting apart.
|
||||||
|
*/
|
||||||
|
export const PAGE_YDOC_NAME_PREFIX = "page.";
|
||||||
|
|
||||||
|
export const pageYdocName = (pageId: string): string =>
|
||||||
|
`${PAGE_YDOC_NAME_PREFIX}${pageId}`;
|
||||||
@@ -71,22 +71,3 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Inline image rows (#284): center the anonymous line boxes formed by
|
|
||||||
consecutive [data-image-align="inline"] node-view containers. A row has no
|
|
||||||
DOM wrapper of its own, so its horizontal placement is controlled by the
|
|
||||||
text-align of the nearest block ancestor (the editor root or a nested
|
|
||||||
block container: blockquote, callout, list item, table cell, details).
|
|
||||||
Centering is enabled only in containers that actually hold an inline
|
|
||||||
image (:has), and every other child of such a container gets its default
|
|
||||||
alignment back so ordinary text is unaffected. Explicit per-block
|
|
||||||
alignment from the toolbar is an inline style and still wins. Browsers
|
|
||||||
without :has() degrade to left-pinned rows. */
|
|
||||||
.ProseMirror:has(> [data-image-align="inline"]),
|
|
||||||
.ProseMirror :has(> [data-image-align="inline"]) {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ProseMirror:has(> [data-image-align="inline"]) > :not([data-image-align="inline"]),
|
|
||||||
.ProseMirror :has(> [data-image-align="inline"]) > :not([data-image-align="inline"]) {
|
|
||||||
text-align: start;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
// isChangeOrigin is mocked so we can simulate local vs remote/collab-origin
|
||||||
|
// transactions without constructing a real ProseMirror/Yjs transaction.
|
||||||
|
const isChangeOriginMock = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@tiptap/extension-collaboration", () => ({
|
||||||
|
isChangeOrigin: isChangeOriginMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { shouldPropagateTitleChange } from "./title-collab";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
isChangeOriginMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shouldPropagateTitleChange", () => {
|
||||||
|
it("propagates a genuine local edit (isChangeOrigin false)", () => {
|
||||||
|
isChangeOriginMock.mockReturnValue(false);
|
||||||
|
expect(shouldPropagateTitleChange({ local: true })).toBe(true);
|
||||||
|
expect(isChangeOriginMock).toHaveBeenCalledWith({ local: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips a remote/collab-origin update (isChangeOrigin true)", () => {
|
||||||
|
isChangeOriginMock.mockReturnValue(true);
|
||||||
|
expect(shouldPropagateTitleChange({ remote: true })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates when there is no transaction (treated as local)", () => {
|
||||||
|
expect(shouldPropagateTitleChange(undefined)).toBe(true);
|
||||||
|
// isChangeOrigin must not be called for a missing transaction.
|
||||||
|
expect(isChangeOriginMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { isChangeOrigin } from "@tiptap/extension-collaboration";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a TitleEditor `onUpdate` should drive URL + tree propagation.
|
||||||
|
*
|
||||||
|
* Only genuine LOCAL edits propagate. Remote/collab-origin Yjs updates
|
||||||
|
* (detected via `isChangeOrigin`) are skipped so a remote title change is not
|
||||||
|
* re-broadcast back, which would create a feedback loop. A missing transaction
|
||||||
|
* is treated as a local edit (propagate).
|
||||||
|
*
|
||||||
|
* Extracted as a pure helper so the skip decision is unit-testable without
|
||||||
|
* mounting the full collaborative editor.
|
||||||
|
*/
|
||||||
|
export function shouldPropagateTitleChange(transaction: unknown): boolean {
|
||||||
|
return !(
|
||||||
|
transaction &&
|
||||||
|
isChangeOrigin(transaction as Parameters<typeof isChangeOrigin>[0])
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
|
||||||
|
// Drive the fallback-vs-collaborative switch (titleReady = providersReady &&
|
||||||
|
// !!ydoc) by controlling what the editor-providers context returns.
|
||||||
|
const editorProvidersValue: { ydoc: unknown; providersReady: boolean } = {
|
||||||
|
ydoc: null,
|
||||||
|
providersReady: false,
|
||||||
|
};
|
||||||
|
vi.mock("@/features/editor/contexts/editor-providers-context", () => ({
|
||||||
|
useEditorProviders: () => editorProvidersValue,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock the tiptap React bindings so the test does not mount a real editor:
|
||||||
|
// useEditor returns a minimal stub and EditorContent renders a marker.
|
||||||
|
vi.mock("@tiptap/react", () => ({
|
||||||
|
useEditor: () => ({
|
||||||
|
isInitialized: true,
|
||||||
|
commands: { focus: vi.fn() },
|
||||||
|
setEditable: vi.fn(),
|
||||||
|
getText: () => "",
|
||||||
|
}),
|
||||||
|
EditorContent: () => <div data-testid="collab-editor" />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({ t: (k: string) => k }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const navigateMock = vi.fn();
|
||||||
|
vi.mock("react-router-dom", () => ({
|
||||||
|
useNavigate: () => navigateMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/websocket/use-query-emit.ts", () => ({
|
||||||
|
useQueryEmit: () => vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// page-query transitively imports @/main.tsx; mock it to a pure stub.
|
||||||
|
vi.mock("@/features/page/queries/page-query", () => ({
|
||||||
|
updatePageData: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/main.tsx", () => ({
|
||||||
|
queryClient: { getQueryData: vi.fn(), setQueryData: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { TitleEditor } from "./title-editor";
|
||||||
|
|
||||||
|
const baseProps = {
|
||||||
|
pageId: "p1",
|
||||||
|
slugId: "slug-1",
|
||||||
|
title: "My Page Title",
|
||||||
|
spaceSlug: "space",
|
||||||
|
editable: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
navigateMock.mockReset();
|
||||||
|
editorProvidersValue.ydoc = null;
|
||||||
|
editorProvidersValue.providersReady = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TitleEditor fallback vs collaborative switch", () => {
|
||||||
|
it("renders a static <h1> with the title before the shared doc is ready", () => {
|
||||||
|
editorProvidersValue.ydoc = null;
|
||||||
|
editorProvidersValue.providersReady = false;
|
||||||
|
|
||||||
|
render(<TitleEditor {...baseProps} />);
|
||||||
|
|
||||||
|
const heading = screen.getByRole("heading", { level: 1 });
|
||||||
|
expect(heading.textContent).toBe("My Page Title");
|
||||||
|
// The collaborative editor must NOT mount until the doc is ready.
|
||||||
|
expect(screen.queryByTestId("collab-editor")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the collaborative editor once the shared doc is ready", () => {
|
||||||
|
editorProvidersValue.ydoc = {}; // truthy shared doc
|
||||||
|
editorProvidersValue.providersReady = true;
|
||||||
|
|
||||||
|
render(<TitleEditor {...baseProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("collab-editor")).toBeDefined();
|
||||||
|
// The static fallback <h1> is gone — Yjs is the single source of truth and
|
||||||
|
// the prop is never seeded into the collaborative editor.
|
||||||
|
expect(screen.queryByRole("heading", { level: 1 })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import "@/features/editor/styles/index.css";
|
import "@/features/editor/styles/index.css";
|
||||||
import React, { useCallback, useEffect, useState } from "react";
|
import { useEffect } from "react";
|
||||||
import { EditorContent, useEditor } from "@tiptap/react";
|
import { EditorContent, useEditor } from "@tiptap/react";
|
||||||
import { Document } from "@tiptap/extension-document";
|
import { Document } from "@tiptap/extension-document";
|
||||||
import { Heading } from "@tiptap/extension-heading";
|
import { Heading } from "@tiptap/extension-heading";
|
||||||
@@ -11,14 +11,11 @@ import {
|
|||||||
pageEditorAtom,
|
pageEditorAtom,
|
||||||
titleEditorAtom,
|
titleEditorAtom,
|
||||||
} from "@/features/editor/atoms/editor-atoms";
|
} from "@/features/editor/atoms/editor-atoms";
|
||||||
import {
|
import { pageKeys, updatePageData } from "@/features/page/queries/page-query";
|
||||||
updatePageData,
|
|
||||||
useUpdateTitlePageMutation,
|
|
||||||
} from "@/features/page/queries/page-query";
|
|
||||||
import { useDebouncedCallback, getHotkeyHandler } from "@mantine/hooks";
|
import { useDebouncedCallback, getHotkeyHandler } from "@mantine/hooks";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
|
import { Collaboration } from "@tiptap/extension-collaboration";
|
||||||
import { History } from "@tiptap/extension-history";
|
import { shouldPropagateTitleChange } from "@/features/editor/title-collab";
|
||||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -28,7 +25,9 @@ import localEmitter from "@/lib/local-emitter.ts";
|
|||||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||||
import { searchSpotlight } from "@/features/search/constants.ts";
|
import { searchSpotlight } from "@/features/search/constants.ts";
|
||||||
import { platformModifierKey } from "@/lib";
|
import { platformModifierKey } from "@/lib";
|
||||||
import { useTitleAutofocus } from "@/features/editor/hooks/use-title-autofocus";
|
import { useEditorProviders } from "@/features/editor/contexts/editor-providers-context";
|
||||||
|
import { queryClient } from "@/main.tsx";
|
||||||
|
import { IPage } from "@/features/page/types/page.types.ts";
|
||||||
|
|
||||||
export interface TitleEditorProps {
|
export interface TitleEditorProps {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
@@ -46,65 +45,82 @@ export function TitleEditor({
|
|||||||
editable,
|
editable,
|
||||||
}: TitleEditorProps) {
|
}: TitleEditorProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { mutateAsync: updateTitlePageMutationAsync } =
|
|
||||||
useUpdateTitlePageMutation();
|
|
||||||
const pageEditor = useAtomValue(pageEditorAtom);
|
const pageEditor = useAtomValue(pageEditorAtom);
|
||||||
const [, setTitleEditor] = useAtom(titleEditorAtom);
|
const [, setTitleEditor] = useAtom(titleEditorAtom);
|
||||||
const emit = useQueryEmit();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [activePageId, setActivePageId] = useState(pageId);
|
|
||||||
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
|
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
|
||||||
|
|
||||||
const titleEditor = useEditor({
|
// Shared Y.Doc (title lives in its own 'title' fragment of the same doc as
|
||||||
extensions: [
|
// the body). Yjs is the source of truth for the title content.
|
||||||
Document.extend({
|
const editorProviders = useEditorProviders();
|
||||||
content: "heading",
|
const ydoc = editorProviders?.ydoc ?? null;
|
||||||
}),
|
const providersReady = editorProviders?.providersReady ?? false;
|
||||||
Heading.configure({
|
|
||||||
levels: [1],
|
// Until the shared doc is ready, the collaborative editor binds nothing and
|
||||||
}),
|
// would render an empty heading until the Yjs 'title' fragment hydrates. Show
|
||||||
Text,
|
// a non-editable static <h1> with the `title` prop in the meantime. The prop
|
||||||
Placeholder.configure({
|
// is NEVER fed into the collaborative editor (Yjs stays the single source of
|
||||||
placeholder: t("Untitled"),
|
// truth — seeding it would duplicate the title).
|
||||||
showOnlyWhenEditable: false,
|
const titleReady = providersReady && !!ydoc;
|
||||||
}),
|
|
||||||
History.configure({
|
const titleEditor = useEditor(
|
||||||
depth: 20,
|
{
|
||||||
}),
|
extensions: [
|
||||||
EmojiCommand,
|
Document.extend({
|
||||||
],
|
content: "heading",
|
||||||
onCreate({ editor }) {
|
}),
|
||||||
if (editor) {
|
Heading.configure({
|
||||||
// @ts-ignore
|
levels: [1],
|
||||||
setTitleEditor(editor);
|
}),
|
||||||
setActivePageId(pageId);
|
Text,
|
||||||
}
|
Placeholder.configure({
|
||||||
},
|
placeholder: t("Untitled"),
|
||||||
onUpdate({ editor }) {
|
showOnlyWhenEditable: false,
|
||||||
debounceUpdate();
|
}),
|
||||||
},
|
// Bind the title to the dedicated 'title' fragment of the shared doc.
|
||||||
editable: editable,
|
// Collaboration also manages undo/redo, so the History extension is
|
||||||
content: title,
|
// intentionally omitted (it would conflict with Yjs). When the doc is
|
||||||
immediatelyRender: true,
|
// not ready yet the editor renders empty until the doc arrives.
|
||||||
shouldRerenderOnTransaction: false,
|
...(ydoc
|
||||||
editorProps: {
|
? [Collaboration.configure({ document: ydoc, field: "title" })]
|
||||||
attributes: {
|
: []),
|
||||||
"aria-label": t("Page title"),
|
EmojiCommand,
|
||||||
|
],
|
||||||
|
onCreate({ editor }) {
|
||||||
|
if (editor) {
|
||||||
|
// @ts-ignore
|
||||||
|
setTitleEditor(editor);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
handleDOMEvents: {
|
onUpdate({ editor, transaction }) {
|
||||||
keydown: (_view, event) => {
|
// Drive URL + tree propagation only on genuine local edits; skip
|
||||||
if (platformModifierKey(event) && event.code === "KeyS") {
|
// remote/collab-origin Yjs updates to avoid feedback loops.
|
||||||
event.preventDefault();
|
if (!shouldPropagateTitleChange(transaction)) return;
|
||||||
return true;
|
debouncedPropagateTitle(editor.getText());
|
||||||
}
|
},
|
||||||
if (platformModifierKey(event) && event.code === "KeyK") {
|
editable: editable,
|
||||||
searchSpotlight.open();
|
immediatelyRender: true,
|
||||||
return true;
|
shouldRerenderOnTransaction: false,
|
||||||
}
|
editorProps: {
|
||||||
|
attributes: {
|
||||||
|
"aria-label": t("Page title"),
|
||||||
|
},
|
||||||
|
handleDOMEvents: {
|
||||||
|
keydown: (_view, event) => {
|
||||||
|
if (platformModifierKey(event) && event.code === "KeyS") {
|
||||||
|
event.preventDefault();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (platformModifierKey(event) && event.code === "KeyK") {
|
||||||
|
searchSpotlight.open();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
[pageId, ydoc],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const anchorId = window.location.hash
|
const anchorId = window.location.hash
|
||||||
@@ -114,68 +130,53 @@ export function TitleEditor({
|
|||||||
navigate(pageSlug, { replace: true });
|
navigate(pageSlug, { replace: true });
|
||||||
}, [title]);
|
}, [title]);
|
||||||
|
|
||||||
const saveTitle = useCallback(() => {
|
// On a local title change: update the URL slug and propagate the change to
|
||||||
if (!titleEditor || activePageId !== pageId) return;
|
// the live tree/breadcrumbs for online users. No REST round-trip — the title
|
||||||
|
// itself is persisted through Yjs. Offline this simply no-ops the socket
|
||||||
if (
|
// emit and the title syncs on reconnect.
|
||||||
titleEditor.getText() === title ||
|
const debouncedPropagateTitle = useDebouncedCallback((titleText: string) => {
|
||||||
(titleEditor.getText() === "" && title === null)
|
const anchorId = window.location.hash
|
||||||
) {
|
? window.location.hash.substring(1)
|
||||||
return;
|
: undefined;
|
||||||
}
|
navigate(buildPageUrl(spaceSlug, slugId, titleText, anchorId), {
|
||||||
|
replace: true,
|
||||||
updateTitlePageMutationAsync({
|
|
||||||
pageId: pageId,
|
|
||||||
title: titleEditor.getText(),
|
|
||||||
}).then((page) => {
|
|
||||||
const event: UpdateEvent = {
|
|
||||||
operation: "updateOne",
|
|
||||||
spaceId: page.spaceId,
|
|
||||||
entity: ["pages"],
|
|
||||||
id: page.id,
|
|
||||||
payload: {
|
|
||||||
title: page.title,
|
|
||||||
slugId: page.slugId,
|
|
||||||
parentPageId: page.parentPageId,
|
|
||||||
icon: page.icon,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
if (page.title !== titleEditor.getText()) return;
|
|
||||||
|
|
||||||
updatePageData(page);
|
|
||||||
|
|
||||||
localEmitter.emit("message", event);
|
|
||||||
emit(event);
|
|
||||||
});
|
});
|
||||||
}, [pageId, title, titleEditor]);
|
|
||||||
|
|
||||||
const debounceUpdate = useDebouncedCallback(saveTitle, 500);
|
const page =
|
||||||
|
queryClient.getQueryData<IPage>(pageKeys.detail(slugId)) ??
|
||||||
|
queryClient.getQueryData<IPage>(pageKeys.detail(pageId));
|
||||||
|
if (!page) return;
|
||||||
|
|
||||||
useEffect(() => {
|
const updatedPage: IPage = { ...page, title: titleText };
|
||||||
// Do not overwrite the title while the user is actively editing it. The
|
|
||||||
// server rebroadcasts PAGE_UPDATED to the author too, and that echo can
|
|
||||||
// carry a title that lags behind what the user has just typed; resetting
|
|
||||||
// content from it here would drop in-progress characters and jump the
|
|
||||||
// cursor. Apply external title changes only when the field is not focused.
|
|
||||||
if (
|
|
||||||
titleEditor &&
|
|
||||||
!titleEditor.isDestroyed &&
|
|
||||||
!titleEditor.isFocused &&
|
|
||||||
title !== titleEditor.getText()
|
|
||||||
) {
|
|
||||||
titleEditor.commands.setContent(title);
|
|
||||||
}
|
|
||||||
}, [pageId, title, titleEditor]);
|
|
||||||
|
|
||||||
useTitleAutofocus(titleEditor, pageId);
|
const event: UpdateEvent = {
|
||||||
|
operation: "updateOne",
|
||||||
useEffect(() => {
|
spaceId: page.spaceId,
|
||||||
return () => {
|
entity: ["pages"],
|
||||||
// force-save title on navigation
|
id: page.id,
|
||||||
saveTitle();
|
payload: {
|
||||||
|
title: titleText,
|
||||||
|
slugId: page.slugId,
|
||||||
|
parentPageId: page.parentPageId,
|
||||||
|
icon: page.icon,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}, [pageId]);
|
|
||||||
|
updatePageData(updatedPage);
|
||||||
|
// Drive the local (same-tab) tree/breadcrumb update. The cross-user tree
|
||||||
|
// refresh is handled server-side: the collab process extracts the renamed
|
||||||
|
// 'title' Yjs fragment and broadcasts a treeUpdate. The previous socket
|
||||||
|
// `emit(event)` here was a no-op (the gateway ignores it) and was removed.
|
||||||
|
localEmitter.emit("message", event);
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
// guard against Cannot access view['hasFocus'] error
|
||||||
|
if (!titleEditor?.isInitialized) return;
|
||||||
|
titleEditor?.commands?.focus("end");
|
||||||
|
}, 300);
|
||||||
|
}, [titleEditor]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!titleEditor) return;
|
if (!titleEditor) return;
|
||||||
@@ -243,16 +244,22 @@ export function TitleEditor({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-title">
|
<div className="page-title">
|
||||||
<EditorContent
|
{titleReady ? (
|
||||||
editor={titleEditor}
|
<EditorContent
|
||||||
onKeyDown={(event) => {
|
editor={titleEditor}
|
||||||
// First handle the search hotkey
|
onKeyDown={(event) => {
|
||||||
getHotkeyHandler([["mod+F", openSearchDialog]])(event);
|
// First handle the search hotkey
|
||||||
|
getHotkeyHandler([["mod+F", openSearchDialog]])(event);
|
||||||
|
|
||||||
// Then handle other key events
|
// Then handle other key events
|
||||||
handleTitleKeyDown(event);
|
handleTitleKeyDown(event);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
// Static, non-editable fallback so the title is visible before Yjs
|
||||||
|
// hydrates the 'title' fragment. Not wired into the collaborative editor.
|
||||||
|
<h1>{title}</h1>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { IconHourglass, IconPlus } from "@tabler/icons-react";
|
|||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { onlineManager } from "@tanstack/react-query";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
|
import { useGetSpacesQuery } from "@/features/space/queries/space-query.ts";
|
||||||
import { useCreatePageMutation } from "@/features/page/queries/page-query.ts";
|
import { useCreatePageMutation } from "@/features/page/queries/page-query.ts";
|
||||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||||
@@ -36,21 +38,39 @@ function CreateNoteButton({
|
|||||||
const createPageMutation = useCreatePageMutation();
|
const createPageMutation = useCreatePageMutation();
|
||||||
|
|
||||||
const createNote = async (space: ISpace) => {
|
const createNote = async (space: ISpace) => {
|
||||||
|
// `spaceId`/`temporary` are accepted by the create-page endpoint but are
|
||||||
|
// not part of the shared `IPageInput` type; cast to satisfy the mutation
|
||||||
|
// signature.
|
||||||
|
const variables = {
|
||||||
|
spaceId: space.id,
|
||||||
|
...(temporary ? { temporary: true } : {}),
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
if (!onlineManager.isOnline()) {
|
||||||
|
// Offline: the create is PAUSED and queued — its promise will not resolve
|
||||||
|
// until we are back online, so awaiting it here would spin the button
|
||||||
|
// forever. Fire it without awaiting (it persists and replays on reconnect)
|
||||||
|
// and tell the user it was saved offline instead of leaving a dead spinner.
|
||||||
|
createPageMutation.mutate(variables);
|
||||||
|
notifications.show({
|
||||||
|
color: "blue",
|
||||||
|
message: t("You're offline. This note will be created once you reconnect."),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// `spaceId`/`temporary` are accepted by the create-page endpoint but are
|
const createdPage = await createPageMutation.mutateAsync(variables);
|
||||||
// not part of the shared `IPageInput` type; cast to satisfy the mutation
|
|
||||||
// signature.
|
|
||||||
const createdPage = await createPageMutation.mutateAsync({
|
|
||||||
spaceId: space.id,
|
|
||||||
...(temporary ? { temporary: true } : {}),
|
|
||||||
} as any);
|
|
||||||
navigate(buildPageUrl(space.slug, createdPage.slugId, createdPage.title));
|
navigate(buildPageUrl(space.slug, createdPage.slugId, createdPage.title));
|
||||||
} catch {
|
} catch {
|
||||||
// useCreatePageMutation already surfaces a red notification on error.
|
// useCreatePageMutation already surfaces a red notification on error.
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isPending = createPageMutation.isPending;
|
// A paused (offline) mutation stays `isPending`, so gate the spinner on it NOT
|
||||||
|
// being paused — otherwise the button would spin forever after an offline
|
||||||
|
// create. The offline path above gives its own "saved offline" feedback.
|
||||||
|
const isPending = createPageMutation.isPending && !createPageMutation.isPaused;
|
||||||
|
|
||||||
// Exactly one writable space → create directly, no picker needed.
|
// Exactly one writable space → create directly, no picker needed.
|
||||||
if (writableSpaces.length === 1) {
|
if (writableSpaces.length === 1) {
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
|
||||||
|
// vi.mock factories are hoisted above imports, so the spies they reference must
|
||||||
|
// be declared via vi.hoisted (also hoisted). These are inspected by assertions.
|
||||||
|
const h = vi.hoisted(() => ({
|
||||||
|
clear: vi.fn(),
|
||||||
|
del: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// The module under test imports the app entry at load time — it must be mocked.
|
||||||
|
vi.mock("@/main.tsx", () => ({
|
||||||
|
queryClient: { clear: h.clear },
|
||||||
|
}));
|
||||||
|
vi.mock("idb-keyval", () => ({
|
||||||
|
del: h.del,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { clearOfflineCache } from "./clear-offline-cache";
|
||||||
|
import { OFFLINE_CACHE_KEY } from "./query-persister";
|
||||||
|
|
||||||
|
// jsdom does not provide indexedDB.databases() or Cache Storage, so the browser
|
||||||
|
// globals are stubbed per-test. We restore them afterwards.
|
||||||
|
const originalIndexedDB = (globalThis as any).indexedDB;
|
||||||
|
const originalCaches = (globalThis as any).caches;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
h.clear.mockClear();
|
||||||
|
h.del.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
(globalThis as any).indexedDB = originalIndexedDB;
|
||||||
|
(globalThis as any).caches = originalCaches;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("clearOfflineCache", () => {
|
||||||
|
it("resolves without throwing when the browser globals are absent", async () => {
|
||||||
|
(globalThis as any).indexedDB = undefined;
|
||||||
|
delete (globalThis as any).caches;
|
||||||
|
|
||||||
|
await expect(clearOfflineCache()).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
// The two store-agnostic steps still run.
|
||||||
|
expect(h.clear).toHaveBeenCalledTimes(1);
|
||||||
|
expect(h.del).toHaveBeenCalledWith(OFFLINE_CACHE_KEY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes only `page.*` IndexedDB databases and only `api-get-cache` caches", async () => {
|
||||||
|
const deleteDatabase = vi.fn((_name: string) => {
|
||||||
|
const request: any = {};
|
||||||
|
// Resolve the deletion on the next microtask, like a real IDBRequest.
|
||||||
|
queueMicrotask(() => request.onsuccess && request.onsuccess());
|
||||||
|
return request;
|
||||||
|
});
|
||||||
|
(globalThis as any).indexedDB = {
|
||||||
|
databases: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([
|
||||||
|
{ name: "page.aaa" },
|
||||||
|
{ name: "page.bbb" },
|
||||||
|
{ name: "keyval-store" },
|
||||||
|
{ name: undefined },
|
||||||
|
]),
|
||||||
|
deleteDatabase,
|
||||||
|
};
|
||||||
|
|
||||||
|
const cacheDelete = vi.fn().mockResolvedValue(true);
|
||||||
|
(globalThis as any).caches = {
|
||||||
|
keys: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([
|
||||||
|
"workbox-runtime-https://app/api-get-cache",
|
||||||
|
"other-cache",
|
||||||
|
]),
|
||||||
|
delete: cacheDelete,
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(clearOfflineCache()).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
// Only the two page.* databases are deleted.
|
||||||
|
expect(deleteDatabase).toHaveBeenCalledTimes(2);
|
||||||
|
expect(deleteDatabase).toHaveBeenCalledWith("page.aaa");
|
||||||
|
expect(deleteDatabase).toHaveBeenCalledWith("page.bbb");
|
||||||
|
|
||||||
|
// Only the api-get-cache entry is deleted.
|
||||||
|
expect(cacheDelete).toHaveBeenCalledTimes(1);
|
||||||
|
expect(cacheDelete).toHaveBeenCalledWith(
|
||||||
|
"workbox-runtime-https://app/api-get-cache",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never throws even if a step rejects (best-effort)", async () => {
|
||||||
|
h.del.mockRejectedValueOnce(new Error("idb boom"));
|
||||||
|
(globalThis as any).indexedDB = {
|
||||||
|
databases: vi.fn().mockRejectedValue(new Error("databases boom")),
|
||||||
|
deleteDatabase: vi.fn(),
|
||||||
|
};
|
||||||
|
(globalThis as any).caches = {
|
||||||
|
keys: vi.fn().mockRejectedValue(new Error("caches boom")),
|
||||||
|
delete: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(clearOfflineCache()).resolves.toBeUndefined();
|
||||||
|
expect(h.clear).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { del } from "idb-keyval";
|
||||||
|
|
||||||
|
import { queryClient } from "@/main.tsx";
|
||||||
|
import {
|
||||||
|
OFFLINE_CACHE_KEY,
|
||||||
|
freezeOfflinePersistence,
|
||||||
|
unfreezeOfflinePersistence,
|
||||||
|
} from "./query-persister";
|
||||||
|
import { PAGE_YDOC_NAME_PREFIX } from "@/features/editor/page-ydoc-name";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort purge of all of the current user's offline data from the browser.
|
||||||
|
*
|
||||||
|
* On logout the previous user's private data would otherwise linger locally and
|
||||||
|
* be readable by the next person on the device. This clears the three offline
|
||||||
|
* stores the app writes:
|
||||||
|
* 1. the in-memory + IndexedDB-persisted TanStack Query cache (idb-keyval key
|
||||||
|
* `OFFLINE_CACHE_KEY`),
|
||||||
|
* 2. the Yjs page documents (IndexedDB databases named `page.<id>` created by
|
||||||
|
* y-indexeddb in make-offline.ts), and
|
||||||
|
* 3. any legacy service worker `api-get-cache` Cache Storage entry. The
|
||||||
|
* Workbox runtime no longer creates this cache (the GET /api NetworkFirst
|
||||||
|
* rule was removed — offline reads come from the persisted RQ cache), so
|
||||||
|
* this is now a defensive cleanup for caches left by older app versions.
|
||||||
|
*
|
||||||
|
* Fully best-effort: every step is isolated so a single failure neither blocks
|
||||||
|
* the remaining steps nor throws to the caller (logout must never be blocked on
|
||||||
|
* cache cleanup). Callers may ignore the resolved value.
|
||||||
|
*
|
||||||
|
* Limitations:
|
||||||
|
* - Deleting the Yjs page databases relies on `indexedDB.databases()`, which
|
||||||
|
* is unavailable in some browsers (notably Firefox). There we skip silently;
|
||||||
|
* those `page.<id>` databases are then left in place.
|
||||||
|
* - Cache Storage clearing only runs where `caches` exists (secure contexts /
|
||||||
|
* service-worker-capable browsers).
|
||||||
|
*/
|
||||||
|
export async function clearOfflineCache(): Promise<void> {
|
||||||
|
// Freeze the throttled persister BEFORE touching the cache so the
|
||||||
|
// queryClient.clear() below cannot trigger a late re-write of the (still
|
||||||
|
// nearly-full) dehydrated snapshot after we del() the key — which would
|
||||||
|
// otherwise resurrect the previous user's persisted data in IndexedDB.
|
||||||
|
// Re-enabled in `finally` so the next (sign-in) session persists normally.
|
||||||
|
freezeOfflinePersistence();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1a. Drop the in-memory query cache immediately.
|
||||||
|
try {
|
||||||
|
queryClient.clear();
|
||||||
|
} catch {
|
||||||
|
// best-effort: ignore in-memory cache reset failures
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1b. Delete the persisted RQ cache from IndexedDB.
|
||||||
|
try {
|
||||||
|
await del(OFFLINE_CACHE_KEY);
|
||||||
|
} catch {
|
||||||
|
// best-effort: ignore persisted-cache deletion failures
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Delete the Yjs page IndexedDB databases (`page.<id>`).
|
||||||
|
// `indexedDB.databases()` is not implemented everywhere (e.g. Firefox); when
|
||||||
|
// it is missing we cannot enumerate the page databases, so we skip silently.
|
||||||
|
try {
|
||||||
|
if (
|
||||||
|
typeof indexedDB !== "undefined" &&
|
||||||
|
typeof indexedDB.databases === "function"
|
||||||
|
) {
|
||||||
|
const dbs = await indexedDB.databases();
|
||||||
|
for (const db of dbs) {
|
||||||
|
const name = db?.name;
|
||||||
|
if (typeof name !== "string" || !name.startsWith(PAGE_YDOC_NAME_PREFIX))
|
||||||
|
continue;
|
||||||
|
try {
|
||||||
|
// Fire-and-forget delete; await a thin wrapper so a slow delete does
|
||||||
|
// not race the page teardown, but never reject on it.
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const request = indexedDB.deleteDatabase(name);
|
||||||
|
request.onsuccess = () => resolve();
|
||||||
|
request.onerror = () => resolve();
|
||||||
|
request.onblocked = () => resolve();
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// best-effort per database
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// best-effort: ignore enumeration/deletion failures
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Clear any legacy service worker API cache. Current builds no longer
|
||||||
|
// create it, but an older client may have left an "api-get-cache" entry
|
||||||
|
// (Workbox may prefix the name), so match by substring rather than exact name.
|
||||||
|
try {
|
||||||
|
if ("caches" in window) {
|
||||||
|
const keys = await caches.keys();
|
||||||
|
await Promise.all(
|
||||||
|
keys
|
||||||
|
.filter((key) => key.includes("api-get-cache"))
|
||||||
|
.map((key) => caches.delete(key)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// best-effort: ignore Cache Storage failures
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Re-enable persistence for the next session (sign-in continues running in
|
||||||
|
// the same tab; logout reloads via window.location.replace, so this is a
|
||||||
|
// harmless no-op there).
|
||||||
|
unfreezeOfflinePersistence();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,475 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
|
||||||
|
// vi.mock factories are hoisted above imports, so any spy they reference must be
|
||||||
|
// declared with vi.hoisted (which is hoisted as well). These shared spies are
|
||||||
|
// inspected by the assertions below.
|
||||||
|
const h = vi.hoisted(() => ({
|
||||||
|
ydocDestroy: vi.fn(),
|
||||||
|
idbDestroy: vi.fn(),
|
||||||
|
providerOn: vi.fn(),
|
||||||
|
providerOff: vi.fn(),
|
||||||
|
providerDestroy: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// The module under test imports the app entry at load time — it must be mocked.
|
||||||
|
vi.mock("@/main.tsx", () => ({
|
||||||
|
queryClient: { setQueryData: vi.fn(), prefetchQuery: vi.fn() },
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/page/services/page-service", () => ({
|
||||||
|
getPageById: vi.fn(),
|
||||||
|
getPageBreadcrumbs: vi.fn(),
|
||||||
|
getSidebarPages: vi.fn(),
|
||||||
|
getAllSidebarPages: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/space/services/space-service.ts", () => ({
|
||||||
|
getSpaceById: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/comment/services/comment-service", () => ({
|
||||||
|
getPageComments: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Use the `function` form (not an arrow) so Vitest binds the constructor return
|
||||||
|
// value when the module under test calls `new Y.Doc()` etc.
|
||||||
|
vi.mock("yjs", () => ({
|
||||||
|
Doc: vi.fn(function () {
|
||||||
|
return { destroy: h.ydocDestroy };
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.mock("y-indexeddb", () => ({
|
||||||
|
IndexeddbPersistence: vi.fn(function () {
|
||||||
|
return { destroy: h.idbDestroy };
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.mock("@hocuspocus/provider", () => ({
|
||||||
|
HocuspocusProvider: vi.fn(function () {
|
||||||
|
return { on: h.providerOn, off: h.providerOff, destroy: h.providerDestroy };
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
warmInfiniteAll,
|
||||||
|
warmPageYdoc,
|
||||||
|
makePageAvailableOffline,
|
||||||
|
} from "./make-offline";
|
||||||
|
import { queryClient } from "@/main.tsx";
|
||||||
|
import {
|
||||||
|
getPageById,
|
||||||
|
getPageBreadcrumbs,
|
||||||
|
getSidebarPages,
|
||||||
|
} from "@/features/page/services/page-service";
|
||||||
|
import { getPageComments } from "@/features/comment/services/comment-service";
|
||||||
|
|
||||||
|
const setQueryData = (queryClient as any).setQueryData as ReturnType<
|
||||||
|
typeof vi.fn
|
||||||
|
>;
|
||||||
|
const prefetchQuery = (queryClient as any).prefetchQuery as ReturnType<
|
||||||
|
typeof vi.fn
|
||||||
|
>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Clear call history WITHOUT wiping the mock implementations the vi.mock
|
||||||
|
// factories installed (vi.clearAllMocks would drop the constructor return
|
||||||
|
// objects and break the provider/idb/yjs spies).
|
||||||
|
setQueryData.mockClear();
|
||||||
|
prefetchQuery.mockReset();
|
||||||
|
prefetchQuery.mockResolvedValue(undefined);
|
||||||
|
(getPageById as ReturnType<typeof vi.fn>).mockReset();
|
||||||
|
(getPageBreadcrumbs as ReturnType<typeof vi.fn>).mockReset();
|
||||||
|
(getSidebarPages as ReturnType<typeof vi.fn>).mockReset();
|
||||||
|
(getPageComments as ReturnType<typeof vi.fn>).mockReset();
|
||||||
|
h.ydocDestroy.mockClear();
|
||||||
|
h.idbDestroy.mockClear();
|
||||||
|
h.providerOn.mockClear();
|
||||||
|
h.providerOff.mockClear();
|
||||||
|
h.providerDestroy.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("warmInfiniteAll", () => {
|
||||||
|
it("warms a single page and writes the InfiniteData cache shape", async () => {
|
||||||
|
const res = { items: [{ id: 1 }], meta: { nextCursor: null } };
|
||||||
|
const fetchPage = vi.fn().mockResolvedValue(res);
|
||||||
|
|
||||||
|
await warmInfiniteAll(["comments", "p1"], fetchPage);
|
||||||
|
|
||||||
|
expect(fetchPage).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchPage).toHaveBeenCalledWith(undefined);
|
||||||
|
expect(setQueryData).toHaveBeenCalledTimes(1);
|
||||||
|
expect(setQueryData).toHaveBeenCalledWith(["comments", "p1"], {
|
||||||
|
pages: [res],
|
||||||
|
pageParams: [undefined],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("walks the cursor chain across multiple pages", async () => {
|
||||||
|
const r0 = { items: [], meta: { nextCursor: "c1" } };
|
||||||
|
const r1 = { items: [], meta: { nextCursor: "c2" } };
|
||||||
|
const r2 = { items: [], meta: { nextCursor: null } };
|
||||||
|
const fetchPage = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(r0)
|
||||||
|
.mockResolvedValueOnce(r1)
|
||||||
|
.mockResolvedValueOnce(r2);
|
||||||
|
|
||||||
|
await warmInfiniteAll(["comments", "p1"], fetchPage);
|
||||||
|
|
||||||
|
expect(fetchPage).toHaveBeenCalledTimes(3);
|
||||||
|
expect(fetchPage.mock.calls.map((c) => c[0])).toEqual([
|
||||||
|
undefined,
|
||||||
|
"c1",
|
||||||
|
"c2",
|
||||||
|
]);
|
||||||
|
const payload = setQueryData.mock.calls[0][1];
|
||||||
|
expect(payload.pages).toEqual([r0, r1, r2]);
|
||||||
|
expect(payload.pageParams).toEqual([undefined, "c1", "c2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps pagination at maxPages and reports the truncation (returns false)", async () => {
|
||||||
|
// Always returns a non-null cursor — the cap is the only thing that stops it.
|
||||||
|
const fetchPage = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ items: [], meta: { nextCursor: "more" } });
|
||||||
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
// Hitting maxPages with a cursor still pending is a truncated warm: the
|
||||||
|
// (partial) cache is still written, but the result is reported as false.
|
||||||
|
await expect(
|
||||||
|
warmInfiniteAll(["comments", "p1"], fetchPage, 2),
|
||||||
|
).resolves.toBe(false);
|
||||||
|
|
||||||
|
expect(fetchPage).toHaveBeenCalledTimes(2);
|
||||||
|
const payload = setQueryData.mock.calls[0][1];
|
||||||
|
expect(payload.pages).toHaveLength(2);
|
||||||
|
expect(errorSpy).toHaveBeenCalled();
|
||||||
|
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true on success", async () => {
|
||||||
|
const fetchPage = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ items: [], meta: { nextCursor: null } });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
warmInfiniteAll(["comments", "p1"], fetchPage),
|
||||||
|
).resolves.toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports errors (returns false) and never writes the cache on failure", async () => {
|
||||||
|
const fetchPage = vi.fn().mockRejectedValue(new Error("network"));
|
||||||
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
warmInfiniteAll(["comments", "p1"], fetchPage),
|
||||||
|
).resolves.toBe(false);
|
||||||
|
expect(setQueryData).not.toHaveBeenCalled();
|
||||||
|
expect(errorSpy).toHaveBeenCalled();
|
||||||
|
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("makePageAvailableOffline", () => {
|
||||||
|
const okPage = {
|
||||||
|
id: "uuid-1",
|
||||||
|
slugId: "slug-1",
|
||||||
|
space: { slug: "space-slug" },
|
||||||
|
};
|
||||||
|
|
||||||
|
it("returns ok:true with no failures when every step succeeds", async () => {
|
||||||
|
(getPageById as ReturnType<typeof vi.fn>).mockResolvedValue(okPage);
|
||||||
|
(getPageBreadcrumbs as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||||
|
(getSidebarPages as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
items: [],
|
||||||
|
meta: { nextCursor: null },
|
||||||
|
});
|
||||||
|
(getPageComments as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
items: [],
|
||||||
|
meta: { nextCursor: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await makePageAvailableOffline({
|
||||||
|
pageId: "uuid-1",
|
||||||
|
spaceId: "space-uuid",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: true, failed: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns ok:false with the failed step label when a warm step fails", async () => {
|
||||||
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
(getPageById as ReturnType<typeof vi.fn>).mockResolvedValue(okPage);
|
||||||
|
(getPageBreadcrumbs as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||||
|
(getSidebarPages as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
items: [],
|
||||||
|
meta: { nextCursor: null },
|
||||||
|
});
|
||||||
|
// Comments warm fails -> labeled "comments".
|
||||||
|
(getPageComments as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||||
|
new Error("network"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await makePageAvailableOffline({
|
||||||
|
pageId: "uuid-1",
|
||||||
|
spaceId: "space-uuid",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.failed).toContain("comments");
|
||||||
|
expect(errorSpy).toHaveBeenCalled();
|
||||||
|
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper: the page-ids passed to the sidebar-children warm (its query key is
|
||||||
|
// ["sidebar-pages", { pageId, spaceId }]) — i.e. which nodes were prefetched.
|
||||||
|
const warmedSidebarIds = () =>
|
||||||
|
prefetchQuery.mock.calls
|
||||||
|
.map((c) => c[0])
|
||||||
|
.filter((opts: any) => opts?.queryKey?.[0] === "sidebar-pages")
|
||||||
|
.map((opts: any) => opts.queryKey[1]?.pageId);
|
||||||
|
|
||||||
|
it("warms the page + every ancestor's children once and skips the self-ancestor guard", async () => {
|
||||||
|
(getPageById as ReturnType<typeof vi.fn>).mockResolvedValue(okPage);
|
||||||
|
// Breadcrumbs include two real ancestors, the page's OWN id (must be skipped
|
||||||
|
// by the ancestorId === pageId guard so it is not warmed twice), and a
|
||||||
|
// malformed entry with no id (also skipped).
|
||||||
|
(getPageBreadcrumbs as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||||
|
{ id: "anc-1" },
|
||||||
|
{ id: "uuid-1" }, // === pageId -> guard
|
||||||
|
{ id: "anc-2" },
|
||||||
|
{}, // no id -> skipped
|
||||||
|
]);
|
||||||
|
(getSidebarPages as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
items: [],
|
||||||
|
meta: { nextCursor: null },
|
||||||
|
});
|
||||||
|
(getPageComments as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
items: [],
|
||||||
|
meta: { nextCursor: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await makePageAvailableOffline({
|
||||||
|
pageId: "uuid-1",
|
||||||
|
spaceId: "space-uuid",
|
||||||
|
});
|
||||||
|
|
||||||
|
const ids = warmedSidebarIds();
|
||||||
|
// The page's own children (warmSidebarChildren(pageId)) plus each real
|
||||||
|
// ancestor — exactly once each. The self-ancestor (uuid-1 in breadcrumbs) is
|
||||||
|
// NOT a second warm: uuid-1 appears once (from the page's own children call).
|
||||||
|
expect(ids).toEqual(["uuid-1", "anc-1", "anc-2"]);
|
||||||
|
expect(ids.filter((id: string) => id === "uuid-1")).toHaveLength(1);
|
||||||
|
expect(result).toEqual({ ok: true, failed: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dedupes repeated tree failures into a single 'tree' label", async () => {
|
||||||
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
(getPageById as ReturnType<typeof vi.fn>).mockResolvedValue(okPage);
|
||||||
|
(getPageBreadcrumbs as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||||
|
{ id: "anc-1" },
|
||||||
|
{ id: "anc-2" },
|
||||||
|
]);
|
||||||
|
(getSidebarPages as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
items: [],
|
||||||
|
meta: { nextCursor: null },
|
||||||
|
});
|
||||||
|
(getPageComments as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
items: [],
|
||||||
|
meta: { nextCursor: null },
|
||||||
|
});
|
||||||
|
// Fail ONLY the sidebar-children prefetches (page-own + both ancestors = 3
|
||||||
|
// failures); the currentUser/space prefetches still resolve.
|
||||||
|
prefetchQuery.mockImplementation(async (opts: any) => {
|
||||||
|
if (opts?.queryKey?.[0] === "sidebar-pages") throw new Error("network");
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await makePageAvailableOffline({
|
||||||
|
pageId: "uuid-1",
|
||||||
|
spaceId: "space-uuid",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Three node warms failed but the contract collapses them to one "tree".
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.failed).toEqual(["tree"]);
|
||||||
|
expect(errorSpy).toHaveBeenCalled();
|
||||||
|
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records 'breadcrumbs' (not 'tree') when the breadcrumbs lookup rejects", async () => {
|
||||||
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
(getPageById as ReturnType<typeof vi.fn>).mockResolvedValue(okPage);
|
||||||
|
// Ancestor discovery fails -> the ancestor-walk is recorded as "breadcrumbs".
|
||||||
|
(getPageBreadcrumbs as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||||
|
new Error("network"),
|
||||||
|
);
|
||||||
|
(getSidebarPages as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
items: [],
|
||||||
|
meta: { nextCursor: null },
|
||||||
|
});
|
||||||
|
(getPageComments as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
items: [],
|
||||||
|
meta: { nextCursor: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await makePageAvailableOffline({
|
||||||
|
pageId: "uuid-1",
|
||||||
|
spaceId: "space-uuid",
|
||||||
|
});
|
||||||
|
|
||||||
|
// The page's own children still warmed fine (prefetch resolves), so the only
|
||||||
|
// failure is the breadcrumbs lookup.
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.failed).toEqual(["breadcrumbs"]);
|
||||||
|
expect(errorSpy).toHaveBeenCalled();
|
||||||
|
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records 'page' when the central document fetch (getPageById) rejects", async () => {
|
||||||
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
// The central page document fetch fails (the most realistic failure).
|
||||||
|
(getPageById as ReturnType<typeof vi.fn>).mockRejectedValue(
|
||||||
|
new Error("network"),
|
||||||
|
);
|
||||||
|
(getPageBreadcrumbs as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||||
|
(getSidebarPages as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
items: [],
|
||||||
|
meta: { nextCursor: null },
|
||||||
|
});
|
||||||
|
(getPageComments as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
items: [],
|
||||||
|
meta: { nextCursor: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await makePageAvailableOffline({
|
||||||
|
pageId: "uuid-1",
|
||||||
|
spaceId: "space-uuid",
|
||||||
|
});
|
||||||
|
|
||||||
|
// With no page document, the space step is skipped (no slug), so the only
|
||||||
|
// failure label is "page".
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.failed).toContain("page");
|
||||||
|
expect(result.failed).not.toContain("space");
|
||||||
|
expect(errorSpy).toHaveBeenCalled();
|
||||||
|
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records 'space' when ONLY the space prefetch rejects", async () => {
|
||||||
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
(getPageById as ReturnType<typeof vi.fn>).mockResolvedValue(okPage);
|
||||||
|
(getPageBreadcrumbs as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||||
|
(getSidebarPages as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
items: [],
|
||||||
|
meta: { nextCursor: null },
|
||||||
|
});
|
||||||
|
(getPageComments as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
items: [],
|
||||||
|
meta: { nextCursor: null },
|
||||||
|
});
|
||||||
|
// Fail ONLY the space prefetch (queryKey ["space", slug]); the currentUser
|
||||||
|
// and sidebar-children prefetches still resolve.
|
||||||
|
prefetchQuery.mockImplementation(async (opts: any) => {
|
||||||
|
if (opts?.queryKey?.[0] === "space") throw new Error("network");
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await makePageAvailableOffline({
|
||||||
|
pageId: "uuid-1",
|
||||||
|
spaceId: "space-uuid",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.failed).toContain("space");
|
||||||
|
expect(errorSpy).toHaveBeenCalled();
|
||||||
|
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records 'currentUser' when ONLY the currentUser prefetch rejects", async () => {
|
||||||
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
(getPageById as ReturnType<typeof vi.fn>).mockResolvedValue(okPage);
|
||||||
|
(getPageBreadcrumbs as ReturnType<typeof vi.fn>).mockResolvedValue([]);
|
||||||
|
(getSidebarPages as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
items: [],
|
||||||
|
meta: { nextCursor: null },
|
||||||
|
});
|
||||||
|
(getPageComments as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
items: [],
|
||||||
|
meta: { nextCursor: null },
|
||||||
|
});
|
||||||
|
// Fail ONLY the currentUser prefetch (queryKey ["currentUser"]); the space
|
||||||
|
// and sidebar-children prefetches still resolve.
|
||||||
|
prefetchQuery.mockImplementation(async (opts: any) => {
|
||||||
|
if (opts?.queryKey?.[0] === "currentUser") throw new Error("network");
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await makePageAvailableOffline({
|
||||||
|
pageId: "uuid-1",
|
||||||
|
spaceId: "space-uuid",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.failed).toContain("currentUser");
|
||||||
|
expect(errorSpy).toHaveBeenCalled();
|
||||||
|
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("warmPageYdoc", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves on synced, detaches the listener once, and tears everything down (settle-once)", async () => {
|
||||||
|
const promise = warmPageYdoc("p1", "ws://x");
|
||||||
|
|
||||||
|
// Grab the synced handler the provider registered.
|
||||||
|
expect(h.providerOn).toHaveBeenCalledWith("synced", expect.any(Function));
|
||||||
|
const handler = h.providerOn.mock.calls.find(
|
||||||
|
(c) => c[0] === "synced",
|
||||||
|
)![1] as () => void;
|
||||||
|
|
||||||
|
handler();
|
||||||
|
// Returns true because the real "synced" event fired.
|
||||||
|
await expect(promise).resolves.toBe(true);
|
||||||
|
|
||||||
|
// Listener detached and everything cleaned up.
|
||||||
|
expect(h.providerOff).toHaveBeenCalledWith("synced", expect.any(Function));
|
||||||
|
expect(h.providerDestroy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(h.idbDestroy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(h.ydocDestroy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Firing the handler again must NOT re-run cleanup (settled guard).
|
||||||
|
handler();
|
||||||
|
expect(h.providerDestroy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(h.idbDestroy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(h.ydocDestroy).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves false and cleans up after the timeout when synced never fires", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
const promise = warmPageYdoc("p1", "ws://x");
|
||||||
|
|
||||||
|
// Do not fire "synced"; let the 8s safety timeout settle it.
|
||||||
|
await vi.advanceTimersByTimeAsync(8000);
|
||||||
|
// Returns false (the doc never synced) and logs the timeout with the pageId.
|
||||||
|
await expect(promise).resolves.toBe(false);
|
||||||
|
expect(errorSpy).toHaveBeenCalledWith(
|
||||||
|
"warmPageYdoc: timed out before sync",
|
||||||
|
{ pageId: "p1" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(h.providerDestroy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(h.idbDestroy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(h.ydocDestroy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
errorSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
import * as Y from "yjs";
|
||||||
|
import { IndexeddbPersistence } from "y-indexeddb";
|
||||||
|
import { HocuspocusProvider } from "@hocuspocus/provider";
|
||||||
|
|
||||||
|
import { queryClient } from "@/main.tsx";
|
||||||
|
import {
|
||||||
|
getPageById,
|
||||||
|
getPageBreadcrumbs,
|
||||||
|
getSidebarPages,
|
||||||
|
} from "@/features/page/services/page-service";
|
||||||
|
import {
|
||||||
|
pageKeys,
|
||||||
|
sidebarPagesQueryOptions,
|
||||||
|
} from "@/features/page/queries/page-query";
|
||||||
|
import { spaceByIdQueryOptions } from "@/features/space/queries/space-query";
|
||||||
|
import { RQ_KEY } from "@/features/comment/queries/comment-query";
|
||||||
|
import { getPageComments } from "@/features/comment/services/comment-service";
|
||||||
|
import { getMyInfo } from "@/features/user/services/user-service";
|
||||||
|
import { userKeys } from "@/features/user/hooks/use-current-user";
|
||||||
|
import { IPage } from "@/features/page/types/page.types";
|
||||||
|
import { IPagination } from "@/lib/types.ts";
|
||||||
|
import { pageYdocName } from "@/features/editor/page-ydoc-name";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fully paginate an infinite query and write the @tanstack InfiniteData cache
|
||||||
|
* shape ({ pages, pageParams }) that the matching useInfiniteQuery hook reads.
|
||||||
|
*
|
||||||
|
* The default prefetchInfiniteQuery only warms the FIRST page, which leaves
|
||||||
|
* hooks that treat hasNextPage as still-loading (e.g. the comments panel)
|
||||||
|
* spinning forever offline, and silently truncates large lists. This walks the
|
||||||
|
* cursor chain until it runs out (or hits maxPages) so the whole list is cached.
|
||||||
|
*
|
||||||
|
* Best-effort: a failure does not throw (a partial/failed warm is still useful),
|
||||||
|
* but it is reported — the error is logged with context and `false` is returned
|
||||||
|
* so the caller can record the failed step instead of silently succeeding.
|
||||||
|
*
|
||||||
|
* Returns true ONLY if the cursor chain was fully exhausted and written. If the
|
||||||
|
* walk stops because it hit `maxPages` while a `nextCursor` is still pending,
|
||||||
|
* the cached list is truncated AND its last page keeps a nextCursor that cannot
|
||||||
|
* be re-fetched offline (hooks that gate on hasNextPage would spin forever), so
|
||||||
|
* that case is logged and returns false too — the caller records it as a failed
|
||||||
|
* warm instead of a silent truncated success. The (partial) cache is still
|
||||||
|
* written so what we did fetch is usable.
|
||||||
|
*
|
||||||
|
* Exported for unit testing of the cursor-walk / cache-write behavior.
|
||||||
|
*/
|
||||||
|
export async function warmInfiniteAll<T>(
|
||||||
|
queryKey: readonly unknown[],
|
||||||
|
fetchPage: (cursor: string | undefined) => Promise<IPagination<T>>,
|
||||||
|
maxPages = 50,
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const pages: IPagination<T>[] = [];
|
||||||
|
const pageParams: (string | undefined)[] = [];
|
||||||
|
let cursor: string | undefined = undefined;
|
||||||
|
let exhausted = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < maxPages; i++) {
|
||||||
|
const res = await fetchPage(cursor);
|
||||||
|
pages.push(res);
|
||||||
|
pageParams.push(cursor);
|
||||||
|
cursor = res?.meta?.nextCursor ?? undefined;
|
||||||
|
if (!cursor) {
|
||||||
|
exhausted = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
queryClient.setQueryData(queryKey, { pages, pageParams });
|
||||||
|
|
||||||
|
if (!exhausted) {
|
||||||
|
// Stopped at maxPages with a cursor still pending: the list is truncated
|
||||||
|
// and the last cached page's nextCursor is un-fetchable offline. Report it
|
||||||
|
// as a failed warm rather than a silent truncated success.
|
||||||
|
console.error("warmInfiniteAll truncated at maxPages", {
|
||||||
|
queryKey,
|
||||||
|
maxPages,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("warmInfiniteAll failed", { queryKey, error });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MakePageAvailableOfflineParams {
|
||||||
|
pageId: string;
|
||||||
|
spaceId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outcome of {@link makePageAvailableOffline}. `ok` is true only when every warm
|
||||||
|
* step succeeded; `failed` lists the labels of the steps that failed (a subset
|
||||||
|
* of: "currentUser", "page", "space", "tree", "breadcrumbs", "comments").
|
||||||
|
*/
|
||||||
|
export interface MakePageAvailableOfflineResult {
|
||||||
|
ok: boolean;
|
||||||
|
failed: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort prefetch of a page's read queries so they get persisted to
|
||||||
|
* IndexedDB and become readable offline.
|
||||||
|
*
|
||||||
|
* Each step is isolated and this function does NOT throw — a partial warm is
|
||||||
|
* still useful. Instead of silently succeeding, every failed step is logged
|
||||||
|
* with a label and recorded in the returned result: `{ ok, failed }` where
|
||||||
|
* `ok` is true only if no step failed and `failed` lists the failed step
|
||||||
|
* labels. Only meaningful while online (the underlying requests must succeed).
|
||||||
|
*/
|
||||||
|
export async function makePageAvailableOffline({
|
||||||
|
pageId,
|
||||||
|
spaceId,
|
||||||
|
}: MakePageAvailableOfflineParams): Promise<MakePageAvailableOfflineResult> {
|
||||||
|
const failed: string[] = [];
|
||||||
|
|
||||||
|
// Warm the current user (['currentUser']) so the auth-gated <Layout> can
|
||||||
|
// hydrate offline. UserProvider blanks the whole app while useCurrentUser has
|
||||||
|
// no data, and the offline POST /api/users/me fails as a network error, so
|
||||||
|
// without a persisted user a pinned page still white-screens after relaunch
|
||||||
|
// (#238). Persisted via OFFLINE_PERSIST_ROOTS; warmed here so the persisted
|
||||||
|
// cache actually has an entry to restore.
|
||||||
|
try {
|
||||||
|
await queryClient.prefetchQuery({
|
||||||
|
queryKey: userKeys.currentUser(),
|
||||||
|
queryFn: () => getMyInfo(),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("makePageAvailableOffline: currentUser step failed", {
|
||||||
|
pageId,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
failed.push("currentUser");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the page document ONCE and write it under BOTH cache keys, exactly
|
||||||
|
// like usePageQuery's onData effect. Every page consumer reads
|
||||||
|
// pageKeys.detail(slugId) (usePageQuery keys on the slugId for routed reads),
|
||||||
|
// so warming only the uuid key would leave the offline page blank.
|
||||||
|
let page: IPage | undefined;
|
||||||
|
try {
|
||||||
|
page = await getPageById({ pageId });
|
||||||
|
queryClient.setQueryData(pageKeys.detail(page.slugId), page);
|
||||||
|
queryClient.setQueryData(pageKeys.detail(page.id), page);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("makePageAvailableOffline: page step failed", {
|
||||||
|
pageId,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
failed.push("page");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warm the space — page.tsx renders nothing until the space query resolves
|
||||||
|
// (useGetSpaceBySlugQuery). Awaited (not the fire-and-forget prefetchSpace) so
|
||||||
|
// the space is actually persisted before the caller fires its toast. Shares
|
||||||
|
// spaceByIdQueryOptions so the key/fn cannot drift from the hook.
|
||||||
|
try {
|
||||||
|
const spaceSlug = page?.space?.slug;
|
||||||
|
if (spaceSlug) {
|
||||||
|
await queryClient.prefetchQuery(spaceByIdQueryOptions(spaceSlug));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("makePageAvailableOffline: space step failed", {
|
||||||
|
pageId,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
failed.push("space");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warm the sidebar tree root so the WHOLE root level renders offline (matches
|
||||||
|
// useGetRootSidebarPagesQuery's pageKeys.rootSidebar(spaceId) infinite cache).
|
||||||
|
// Fully paginated so large root levels are not truncated at 100.
|
||||||
|
if (spaceId) {
|
||||||
|
const ok = await warmInfiniteAll(pageKeys.rootSidebar(spaceId), (cursor) =>
|
||||||
|
getSidebarPages({ spaceId, cursor, limit: 100 }),
|
||||||
|
);
|
||||||
|
if (!ok) failed.push("tree");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Warm the children of the page and of every ancestor so the path to this
|
||||||
|
// page is expandable offline. We MIRROR fetchAllAncestorChildren exactly via
|
||||||
|
// sidebarPagesQueryOptions — same pageKeys.sidebar({ pageId, spaceId }) key,
|
||||||
|
// same getAllSidebarPages fn (which aggregates ALL children pages, so nothing
|
||||||
|
// is truncated at 100), same 30min staleTime — otherwise the warmed cache
|
||||||
|
// would never be read by the offline tree.
|
||||||
|
const warmSidebarChildren = async (id: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
// Keep EXACTLY { pageId, spaceId } so the key hashes identically to
|
||||||
|
// fetchAllAncestorChildren's (no parentPageId, no extra fields).
|
||||||
|
const params = { pageId: id, spaceId };
|
||||||
|
await queryClient.prefetchQuery(sidebarPagesQueryOptions(params));
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("makePageAvailableOffline: tree node step failed", {
|
||||||
|
pageId: id,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// The page's own children.
|
||||||
|
if (!(await warmSidebarChildren(pageId))) failed.push("tree");
|
||||||
|
|
||||||
|
// Each ancestor's children. Use the breadcrumbs endpoint ONLY to discover the
|
||||||
|
// ancestor ids — we intentionally do NOT cache the breadcrumbs themselves
|
||||||
|
// (the UI derives the path from the tree).
|
||||||
|
try {
|
||||||
|
const ancestors = (await getPageBreadcrumbs(pageId)) as
|
||||||
|
| Array<{ id?: string }>
|
||||||
|
| undefined;
|
||||||
|
for (const ancestor of ancestors ?? []) {
|
||||||
|
const ancestorId = ancestor?.id;
|
||||||
|
if (!ancestorId || ancestorId === pageId) continue;
|
||||||
|
if (!(await warmSidebarChildren(ancestorId))) failed.push("tree");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("makePageAvailableOffline: breadcrumbs step failed", {
|
||||||
|
pageId,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
failed.push("breadcrumbs");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comments (matches useCommentsQuery's RQ_KEY(pageId) infinite cache).
|
||||||
|
// useCommentsQuery reports isLoading while hasNextPage is true, so warming
|
||||||
|
// only the first page leaves the offline comments panel spinning forever on
|
||||||
|
// pages with >100 comments. Fully paginate so the last cached page has no
|
||||||
|
// nextCursor and the panel settles offline.
|
||||||
|
const commentsOk = await warmInfiniteAll(RQ_KEY(pageId), (cursor) =>
|
||||||
|
getPageComments({ pageId, cursor, limit: 100 }),
|
||||||
|
);
|
||||||
|
if (!commentsOk) failed.push("comments");
|
||||||
|
|
||||||
|
// Dedupe — the tree label can be recorded once per failed node/ancestor.
|
||||||
|
const uniqueFailed = [...new Set(failed)];
|
||||||
|
return { ok: uniqueFailed.length === 0, failed: uniqueFailed };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort warm-up of the page's Yjs document into IndexedDB so the editor
|
||||||
|
* can open offline.
|
||||||
|
*
|
||||||
|
* Opens a local IndexeddbPersistence plus a transient HocuspocusProvider to
|
||||||
|
* pull the server state into IndexedDB, then tears both down once synced (or
|
||||||
|
* after a timeout). Entirely wrapped in try/catch — NEVER throws.
|
||||||
|
*
|
||||||
|
* Returns true ONLY when the provider's real "synced" event fired — i.e. the
|
||||||
|
* server state actually landed in IndexedDB. The timeout and failure paths
|
||||||
|
* return false (and log with the pageId) so the caller does not report a page
|
||||||
|
* as offline-available when its editor body never warmed. For a wiki the editor
|
||||||
|
* body IS the page, so a silent timeout here is a real misreport.
|
||||||
|
*
|
||||||
|
* Only meaningful when online at warm time; offline it is a no-op that resolves.
|
||||||
|
*/
|
||||||
|
export async function warmPageYdoc(
|
||||||
|
pageId: string,
|
||||||
|
collabUrl: string,
|
||||||
|
token?: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
let ydoc: Y.Doc | null = null;
|
||||||
|
let local: IndexeddbPersistence | null = null;
|
||||||
|
let remote: HocuspocusProvider | null = null;
|
||||||
|
// Flipped to true ONLY inside the real "synced" handler; the timeout/failure
|
||||||
|
// paths leave it false. Returned so the caller can record a failed editor warm.
|
||||||
|
let didSync = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const documentName = pageYdocName(pageId);
|
||||||
|
ydoc = new Y.Doc();
|
||||||
|
local = new IndexeddbPersistence(documentName, ydoc);
|
||||||
|
remote = new HocuspocusProvider({
|
||||||
|
url: collabUrl,
|
||||||
|
name: documentName,
|
||||||
|
document: ydoc,
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
|
||||||
|
const provider = remote;
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
// `synced` is true only when called from the real "synced" handler; the
|
||||||
|
// timeout path passes false so didSync stays false on a give-up.
|
||||||
|
const finish = (synced: boolean) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
didSync = synced;
|
||||||
|
// Clear the pending timeout and detach the listener so neither leaks
|
||||||
|
// after we resolve.
|
||||||
|
if (timeoutId !== undefined) clearTimeout(timeoutId);
|
||||||
|
try {
|
||||||
|
provider.off("synced", onSynced);
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
if (!synced) {
|
||||||
|
// Gave up before the server synced: the page body never landed in
|
||||||
|
// IndexedDB. Log with the pageId (parity with the other warm steps)
|
||||||
|
// so the caller can report the editor step as failed.
|
||||||
|
console.error("warmPageYdoc: timed out before sync", { pageId });
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSynced = () => finish(true);
|
||||||
|
|
||||||
|
// Resolve once the server state has synced into the local doc...
|
||||||
|
provider.on("synced", onSynced);
|
||||||
|
// ...or give up after a short timeout so we never hang.
|
||||||
|
timeoutId = setTimeout(() => finish(false), 8000);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("warmPageYdoc: warm failed", { pageId, error });
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
remote?.destroy();
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
local?.destroy();
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ydoc?.destroy();
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return didSync;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { Button, Container, Group, Stack, Text, Title } from "@mantine/core";
|
||||||
|
import { Helmet } from "react-helmet-async";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { getAppName } from "@/lib/config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shown when the authenticated app shell cannot hydrate because the current
|
||||||
|
* user is unavailable AND there is no cached user to fall back on (e.g. an
|
||||||
|
* offline cold boot of a page that was never warmed for offline).
|
||||||
|
*
|
||||||
|
* Previously UserProvider returned a bare `<></>` in this situation, which
|
||||||
|
* white-screened the whole app on any offline reload (#237/#238). Rendering an
|
||||||
|
* explicit "you're offline" state with a retry instead gives the user a clear,
|
||||||
|
* non-blank fallback and a way to recover once the network returns.
|
||||||
|
*/
|
||||||
|
export function OfflineFallback() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Helmet>
|
||||||
|
<title>
|
||||||
|
{t("You're offline")} - {getAppName()}
|
||||||
|
</title>
|
||||||
|
</Helmet>
|
||||||
|
<Container size="sm" py={80}>
|
||||||
|
<Stack align="center" gap="md">
|
||||||
|
<Title order={2} ta="center">
|
||||||
|
{t("You're offline")}
|
||||||
|
</Title>
|
||||||
|
<Text c="dimmed" size="lg" ta="center">
|
||||||
|
{t(
|
||||||
|
"This page hasn't been saved for offline use, so it can't be loaded right now. Reconnect to the internet and try again.",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<Group justify="center">
|
||||||
|
<Button onClick={() => window.location.reload()} variant="subtle">
|
||||||
|
{t("Retry")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { QueryClient, hydrate, dehydrate } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
// Stub the network services so a replayed mutation hits a spy, not the network.
|
||||||
|
const h = vi.hoisted(() => ({
|
||||||
|
createPage: vi.fn(),
|
||||||
|
movePage: vi.fn(),
|
||||||
|
createComment: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/page/services/page-service", () => ({
|
||||||
|
createPage: h.createPage,
|
||||||
|
movePage: h.movePage,
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/comment/services/comment-service", () => ({
|
||||||
|
createComment: h.createComment,
|
||||||
|
}));
|
||||||
|
// page-query pulls in the app entry (queryClient) and a lot of UI deps via its
|
||||||
|
// cache helpers; we only need invalidateOnCreatePage to be a no-op here.
|
||||||
|
vi.mock("@/features/page/queries/page-query", () => ({
|
||||||
|
invalidateOnCreatePage: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
offlineMutationKeys,
|
||||||
|
registerOfflineMutationDefaults,
|
||||||
|
} from "./offline-mutations";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
h.createPage.mockReset().mockResolvedValue({ id: "new-page" });
|
||||||
|
h.movePage.mockReset().mockResolvedValue(undefined);
|
||||||
|
h.createComment.mockReset().mockResolvedValue({ id: "new-comment" });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("registerOfflineMutationDefaults", () => {
|
||||||
|
it("registers a default mutationFn for every offline mutation key", () => {
|
||||||
|
const qc = new QueryClient();
|
||||||
|
registerOfflineMutationDefaults(qc);
|
||||||
|
|
||||||
|
for (const key of Object.values(offlineMutationKeys)) {
|
||||||
|
const defaults = qc.getMutationDefaults(key);
|
||||||
|
expect(typeof defaults?.mutationFn).toBe("function");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// The headline durability guarantee: a paused mutation dehydrated into
|
||||||
|
// IndexedDB while offline must, after a reload, have a mutationFn so
|
||||||
|
// resumePausedMutations() actually replays the write on reconnect.
|
||||||
|
it("makes a rehydrated paused create replayable by resumePausedMutations", async () => {
|
||||||
|
// 1) Simulate the offline tab: a paused create mutation gets dehydrated.
|
||||||
|
const offlineClient = new QueryClient();
|
||||||
|
const observer = offlineClient.getMutationCache().build(offlineClient, {
|
||||||
|
mutationKey: offlineMutationKeys.createPage,
|
||||||
|
});
|
||||||
|
// Force the dehydrate-worthy paused state (offline = isPaused) with the
|
||||||
|
// payload the user submitted before losing connectivity.
|
||||||
|
observer.state.isPaused = true;
|
||||||
|
observer.state.status = "pending";
|
||||||
|
observer.state.variables = { spaceId: "s1", title: "Offline page" };
|
||||||
|
|
||||||
|
const dehydrated = dehydrate(offlineClient, {
|
||||||
|
shouldDehydrateMutation: () => true,
|
||||||
|
});
|
||||||
|
expect(dehydrated.mutations).toHaveLength(1);
|
||||||
|
// The dehydrated mutation carries NO mutationFn (functions aren't
|
||||||
|
// serializable) — only its key + variables survive the reload.
|
||||||
|
expect((dehydrated.mutations[0] as any).mutationFn).toBeUndefined();
|
||||||
|
|
||||||
|
// 2) Simulate the fresh page after reload: register defaults, then hydrate
|
||||||
|
// the persisted paused mutation back in.
|
||||||
|
const freshClient = new QueryClient();
|
||||||
|
registerOfflineMutationDefaults(freshClient);
|
||||||
|
hydrate(freshClient, dehydrated);
|
||||||
|
|
||||||
|
expect(freshClient.getMutationCache().getAll()).toHaveLength(1);
|
||||||
|
|
||||||
|
// 3) Reconnect: replay the paused mutations.
|
||||||
|
await freshClient.resumePausedMutations();
|
||||||
|
|
||||||
|
// The default mutationFn ran with the persisted variables — the write is
|
||||||
|
// NOT silently dropped.
|
||||||
|
expect(h.createPage).toHaveBeenCalledTimes(1);
|
||||||
|
expect(h.createPage).toHaveBeenCalledWith({
|
||||||
|
spaceId: "s1",
|
||||||
|
title: "Offline page",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("makes a rehydrated paused move replayable by resumePausedMutations", async () => {
|
||||||
|
const offlineClient = new QueryClient();
|
||||||
|
const observer = offlineClient.getMutationCache().build(offlineClient, {
|
||||||
|
mutationKey: offlineMutationKeys.movePage,
|
||||||
|
});
|
||||||
|
observer.state.isPaused = true;
|
||||||
|
observer.state.status = "pending";
|
||||||
|
observer.state.variables = { pageId: "p1", parentPageId: null, position: "a" };
|
||||||
|
|
||||||
|
const dehydrated = dehydrate(offlineClient, {
|
||||||
|
shouldDehydrateMutation: () => true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const freshClient = new QueryClient();
|
||||||
|
registerOfflineMutationDefaults(freshClient);
|
||||||
|
hydrate(freshClient, dehydrated);
|
||||||
|
await freshClient.resumePausedMutations();
|
||||||
|
|
||||||
|
expect(h.movePage).toHaveBeenCalledTimes(1);
|
||||||
|
expect(h.movePage).toHaveBeenCalledWith({
|
||||||
|
pageId: "p1",
|
||||||
|
parentPageId: null,
|
||||||
|
position: "a",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import type { QueryClient } from "@tanstack/react-query";
|
||||||
|
import { createPage, movePage } from "@/features/page/services/page-service";
|
||||||
|
import { createComment } from "@/features/comment/services/comment-service";
|
||||||
|
import { invalidateOnCreatePage } from "@/features/page/queries/page-query";
|
||||||
|
import type {
|
||||||
|
IMovePage,
|
||||||
|
IPage,
|
||||||
|
IPageInput,
|
||||||
|
} from "@/features/page/types/page.types";
|
||||||
|
import type { IComment } from "@/features/comment/types/comment.types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable mutation keys for the offline-relevant structural mutations.
|
||||||
|
*
|
||||||
|
* When the browser goes offline, React Query PAUSES these mutations and the
|
||||||
|
* PersistQueryClientProvider dehydrates the paused mutation into IndexedDB. On a
|
||||||
|
* reload-while-offline the mutation is restored, but a restored mutation has NO
|
||||||
|
* observer (no component is mounted) — so its replay relies entirely on the
|
||||||
|
* `mutationFn` registered via `setMutationDefaults` for its `mutationKey`.
|
||||||
|
* Without that, `resumePausedMutations()` finds a paused mutation with no
|
||||||
|
* `mutationFn` and silently no-ops, dropping the offline create/move/comment
|
||||||
|
* (#237/#238). Each offline mutation hook tags itself with the matching key so
|
||||||
|
* the rehydrated paused mutation can find its default `mutationFn` and replay.
|
||||||
|
*/
|
||||||
|
export const offlineMutationKeys = {
|
||||||
|
createPage: ["create-page"] as const,
|
||||||
|
movePage: ["move-page"] as const,
|
||||||
|
createComment: ["create-comment"] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register default `mutationFn`s (and the minimal success side effects safe to
|
||||||
|
* run without a mounted component) for the offline-relevant mutation keys, so a
|
||||||
|
* paused mutation restored from IndexedDB after an offline reload is replayable
|
||||||
|
* by `resumePausedMutations()` on reconnect.
|
||||||
|
*
|
||||||
|
* Called once when the QueryClient is created (see main.tsx). The hooks still
|
||||||
|
* carry their own inline `mutationFn`/`onSuccess` for the live in-session path;
|
||||||
|
* these defaults only take over for a rehydrated paused mutation that lost its
|
||||||
|
* observer across the reload.
|
||||||
|
*/
|
||||||
|
export function registerOfflineMutationDefaults(queryClient: QueryClient): void {
|
||||||
|
queryClient.setMutationDefaults(offlineMutationKeys.createPage, {
|
||||||
|
mutationFn: (data: Partial<IPageInput>) => createPage(data),
|
||||||
|
// Re-converge the sidebar tree / recent-changes from the authoritative
|
||||||
|
// create response. Pure cache writes — safe with no component mounted.
|
||||||
|
onSuccess: (data: IPage) => {
|
||||||
|
invalidateOnCreatePage(data);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
queryClient.setMutationDefaults(offlineMutationKeys.movePage, {
|
||||||
|
// Replay the server-side move. The tree re-converges from the next online
|
||||||
|
// sidebar fetch / websocket `moveTreeNode` echo, so no cache write is
|
||||||
|
// needed here (the optimistic tree state was local-only anyway).
|
||||||
|
mutationFn: (data: IMovePage) => movePage(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
queryClient.setMutationDefaults(offlineMutationKeys.createComment, {
|
||||||
|
// Replay the server-side comment create. The comments list refetches on the
|
||||||
|
// online reload, so the replay only needs to persist the write.
|
||||||
|
mutationFn: (data: Partial<IComment>) => createComment(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { QueryClient, onlineManager } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
persistQueryClientRestore,
|
||||||
|
persistQueryClientSave,
|
||||||
|
} from "@tanstack/react-query-persist-client";
|
||||||
|
|
||||||
|
// Stub the network services so a replayed mutation hits a spy, not the network.
|
||||||
|
const h = vi.hoisted(() => ({
|
||||||
|
createPage: vi.fn(),
|
||||||
|
movePage: vi.fn(),
|
||||||
|
createComment: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/page/services/page-service", () => ({
|
||||||
|
createPage: h.createPage,
|
||||||
|
movePage: h.movePage,
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/comment/services/comment-service", () => ({
|
||||||
|
createComment: h.createComment,
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/page/queries/page-query", () => ({
|
||||||
|
invalidateOnCreatePage: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// In-memory idb-keyval so the REAL queryPersister round-trips through a fake
|
||||||
|
// store (the actual persist -> reload -> restore path, not a hand-built blob).
|
||||||
|
const store = new Map<string, string>();
|
||||||
|
vi.mock("idb-keyval", () => ({
|
||||||
|
get: vi.fn((k: string) => Promise.resolve(store.get(k) ?? undefined)),
|
||||||
|
set: vi.fn((k: string, v: string) => {
|
||||||
|
store.set(k, v);
|
||||||
|
return Promise.resolve();
|
||||||
|
}),
|
||||||
|
del: vi.fn((k: string) => {
|
||||||
|
store.delete(k);
|
||||||
|
return Promise.resolve();
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { queryPersister } from "./query-persister";
|
||||||
|
import {
|
||||||
|
offlineMutationKeys,
|
||||||
|
registerOfflineMutationDefaults,
|
||||||
|
} from "./offline-mutations";
|
||||||
|
|
||||||
|
const BUSTER = "test-buster";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store.clear();
|
||||||
|
h.createPage.mockReset().mockResolvedValue({ id: "new-page" });
|
||||||
|
h.movePage.mockReset().mockResolvedValue(undefined);
|
||||||
|
h.createComment.mockReset().mockResolvedValue({ id: "new-comment" });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// onlineManager is a global singleton; leave it in the default online state.
|
||||||
|
onlineManager.setOnline(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("offline paused-mutation resume across a reload", () => {
|
||||||
|
// This is the #120 silent-data-loss reproduction: a paused mutation persisted
|
||||||
|
// to IndexedDB while offline, then the tab RELOADS while still offline, must
|
||||||
|
// resume on reconnect. It exercises the real persister round-trip plus the two
|
||||||
|
// boot-time fixes the app wiring relies on:
|
||||||
|
// (a) onlineManager seeded to the real offline state so the later reconnect
|
||||||
|
// is a true offline->online transition that auto-resumes, and
|
||||||
|
// (b) resumePausedMutations() called after the persister restores (what the
|
||||||
|
// PersistQueryClientProvider onSuccess does), with mutation defaults
|
||||||
|
// registered BEFORE the resume so the rehydrated mutation has a fn.
|
||||||
|
it("replays a rehydrated paused create on reconnect (mutationFn fires)", async () => {
|
||||||
|
// --- Tab 1, OFFLINE: user creates a page; it pauses and gets persisted. ---
|
||||||
|
onlineManager.setOnline(false); // (a) boot seeded offline
|
||||||
|
|
||||||
|
const client1 = new QueryClient();
|
||||||
|
registerOfflineMutationDefaults(client1);
|
||||||
|
const observer = client1.getMutationCache().build(client1, {
|
||||||
|
mutationKey: offlineMutationKeys.createPage,
|
||||||
|
});
|
||||||
|
observer.state.isPaused = true;
|
||||||
|
observer.state.status = "pending";
|
||||||
|
observer.state.variables = { spaceId: "s1", title: "Offline page" };
|
||||||
|
|
||||||
|
await persistQueryClientSave({
|
||||||
|
// Cast: persist-client-core and react-query may resolve to different
|
||||||
|
// @tanstack/query-core copies whose QueryClient brands are nominally
|
||||||
|
// incompatible (see query-persister.ts). Structurally identical at runtime.
|
||||||
|
queryClient: client1 as any,
|
||||||
|
persister: queryPersister,
|
||||||
|
buster: BUSTER,
|
||||||
|
dehydrateOptions: { shouldDehydrateMutation: () => true },
|
||||||
|
});
|
||||||
|
// The paused mutation is now in the persisted store.
|
||||||
|
expect(store.size).toBe(1);
|
||||||
|
|
||||||
|
// --- RELOAD while still offline: fresh client restores from the SAME
|
||||||
|
// persister. Defaults are registered BEFORE restore/resume. ---
|
||||||
|
const client2 = new QueryClient();
|
||||||
|
registerOfflineMutationDefaults(client2);
|
||||||
|
client2.mount(); // subscribes to onlineManager (auto-resume on reconnect)
|
||||||
|
|
||||||
|
await persistQueryClientRestore({
|
||||||
|
queryClient: client2 as any,
|
||||||
|
persister: queryPersister,
|
||||||
|
buster: BUSTER,
|
||||||
|
});
|
||||||
|
expect(client2.getMutationCache().getAll()).toHaveLength(1);
|
||||||
|
|
||||||
|
// (b) onSuccess wiring resumes after restore — but we are still OFFLINE, so
|
||||||
|
// the mutation must stay paused and NOT fire yet.
|
||||||
|
await client2.resumePausedMutations();
|
||||||
|
expect(h.createPage).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// --- RECONNECT: the offline->online transition auto-resumes the paused
|
||||||
|
// mutation and its registered default mutationFn finally fires. ---
|
||||||
|
onlineManager.setOnline(true);
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(h.createPage).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
expect(h.createPage).toHaveBeenCalledWith({
|
||||||
|
spaceId: "s1",
|
||||||
|
title: "Offline page",
|
||||||
|
});
|
||||||
|
|
||||||
|
client2.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
|
// The query modules transitively import the app entry (@/main.tsx) for the
|
||||||
|
// shared queryClient; mock it so importing the key factories has no side effects.
|
||||||
|
import { vi } from "vitest";
|
||||||
|
vi.mock("@/main.tsx", () => ({
|
||||||
|
queryClient: { setQueryData: vi.fn(), getQueryData: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { OFFLINE_PERSIST_ROOTS } from "./query-persister";
|
||||||
|
import { pageKeys } from "@/features/page/queries/page-query";
|
||||||
|
import { spaceKeys } from "@/features/space/queries/space-query";
|
||||||
|
import { RQ_KEY } from "@/features/comment/queries/comment-query";
|
||||||
|
import { userKeys } from "@/features/user/hooks/use-current-user";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Architecture guard (#13): every string persisted via OFFLINE_PERSIST_ROOTS
|
||||||
|
* must be the ROOT (queryKey[0]) of some exported query-key factory. If a
|
||||||
|
* factory's root is renamed without updating the persist registry — or vice
|
||||||
|
* versa — offline persist/warm silently breaks (persisted keys never match the
|
||||||
|
* live queries). This turns that silent regression into a red build.
|
||||||
|
*
|
||||||
|
* Each factory is invoked with throwaway args; only queryKey[0] is inspected.
|
||||||
|
*/
|
||||||
|
function rootOf(key: readonly unknown[]): string {
|
||||||
|
return String(key[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const FACTORY_ROOTS = new Set<string>([
|
||||||
|
rootOf(pageKeys.detail("x")),
|
||||||
|
rootOf(pageKeys.sidebar({})),
|
||||||
|
rootOf(pageKeys.rootSidebar("x")),
|
||||||
|
rootOf(pageKeys.breadcrumbs("x")),
|
||||||
|
rootOf(pageKeys.recentChanges("x")),
|
||||||
|
rootOf(spaceKeys.detail("x")),
|
||||||
|
rootOf(spaceKeys.list()),
|
||||||
|
rootOf(RQ_KEY("x")),
|
||||||
|
rootOf(userKeys.currentUser()),
|
||||||
|
]);
|
||||||
|
|
||||||
|
describe("OFFLINE_PERSIST_ROOTS is backed by real query-key factories", () => {
|
||||||
|
it("maps every persisted root to an exported factory root", () => {
|
||||||
|
const unbacked = [...OFFLINE_PERSIST_ROOTS].filter(
|
||||||
|
(root) => !FACTORY_ROOTS.has(root),
|
||||||
|
);
|
||||||
|
expect(unbacked).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||||
|
|
||||||
|
// In-memory idb-keyval so we can observe whether the persister actually writes.
|
||||||
|
const h = vi.hoisted(() => ({
|
||||||
|
get: vi.fn(() => Promise.resolve(undefined)),
|
||||||
|
set: vi.fn(() => Promise.resolve()),
|
||||||
|
del: vi.fn(() => Promise.resolve()),
|
||||||
|
}));
|
||||||
|
vi.mock("idb-keyval", () => h);
|
||||||
|
|
||||||
|
import {
|
||||||
|
shouldDehydrateOfflineQuery,
|
||||||
|
OFFLINE_PERSIST_ROOTS,
|
||||||
|
queryPersister,
|
||||||
|
freezeOfflinePersistence,
|
||||||
|
unfreezeOfflinePersistence,
|
||||||
|
} from "./query-persister";
|
||||||
|
|
||||||
|
// Small helper to build the structural query shape the predicate reads.
|
||||||
|
const makeQuery = (status: string, queryKey: readonly unknown[]) =>
|
||||||
|
({ state: { status }, queryKey }) as any;
|
||||||
|
|
||||||
|
describe("shouldDehydrateOfflineQuery", () => {
|
||||||
|
it("returns true for a successful query whose root is in the allowlist", () => {
|
||||||
|
expect(shouldDehydrateOfflineQuery(makeQuery("success", ["pages", "abc"]))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
shouldDehydrateOfflineQuery(
|
||||||
|
makeQuery("success", ["sidebar-pages", { pageId: "p", spaceId: "s" }]),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
shouldDehydrateOfflineQuery(makeQuery("success", ["comments", "p1"])),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
shouldDehydrateOfflineQuery(makeQuery("success", ["space", "s"])),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
shouldDehydrateOfflineQuery(makeQuery("success", ["recent-changes"])),
|
||||||
|
).toBe(true);
|
||||||
|
// currentUser is persisted so the auth-gated Layout can hydrate offline.
|
||||||
|
expect(
|
||||||
|
shouldDehydrateOfflineQuery(makeQuery("success", ["currentUser"])),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when the status is not success (status gate)", () => {
|
||||||
|
expect(
|
||||||
|
shouldDehydrateOfflineQuery(makeQuery("pending", ["pages", "abc"])),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
shouldDehydrateOfflineQuery(makeQuery("error", ["pages", "abc"])),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for a successful query whose root is NOT in the allowlist (privacy gate)", () => {
|
||||||
|
expect(
|
||||||
|
shouldDehydrateOfflineQuery(makeQuery("success", ["collab-token", "ws"])),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
shouldDehydrateOfflineQuery(makeQuery("success", ["trash", "s"])),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
shouldDehydrateOfflineQuery(makeQuery("success", ["unknown"])),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for an empty/undefined queryKey", () => {
|
||||||
|
// String(undefined) is not a member of the allowlist.
|
||||||
|
expect(shouldDehydrateOfflineQuery(makeQuery("success", []))).toBe(false);
|
||||||
|
expect(
|
||||||
|
shouldDehydrateOfflineQuery(makeQuery("success", undefined as any)),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("OFFLINE_PERSIST_ROOTS", () => {
|
||||||
|
it("contains exactly the expected 9 navigation/read roots", () => {
|
||||||
|
const expected = [
|
||||||
|
"pages",
|
||||||
|
"sidebar-pages",
|
||||||
|
"root-sidebar-pages",
|
||||||
|
"breadcrumbs",
|
||||||
|
"comments",
|
||||||
|
"space",
|
||||||
|
"spaces",
|
||||||
|
"recent-changes",
|
||||||
|
"currentUser",
|
||||||
|
];
|
||||||
|
expect(OFFLINE_PERSIST_ROOTS.size).toBe(9);
|
||||||
|
for (const root of expected) {
|
||||||
|
expect(OFFLINE_PERSIST_ROOTS.has(root)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT contain volatile/auth keys", () => {
|
||||||
|
expect(OFFLINE_PERSIST_ROOTS.has("collab-token")).toBe(false);
|
||||||
|
expect(OFFLINE_PERSIST_ROOTS.has("trash")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("freeze/unfreeze persistence (logout no-late-write guard)", () => {
|
||||||
|
const dummyClient = {
|
||||||
|
timestamp: Date.now(),
|
||||||
|
buster: "",
|
||||||
|
clientState: { mutations: [], queries: [] },
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Always leave persistence enabled so other tests/sessions persist normally.
|
||||||
|
unfreezeOfflinePersistence();
|
||||||
|
h.set.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT write to storage while frozen", async () => {
|
||||||
|
freezeOfflinePersistence();
|
||||||
|
await queryPersister.persistClient(dummyClient);
|
||||||
|
expect(h.set).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resumes writing to storage once unfrozen", async () => {
|
||||||
|
freezeOfflinePersistence();
|
||||||
|
unfreezeOfflinePersistence();
|
||||||
|
await queryPersister.persistClient(dummyClient);
|
||||||
|
expect(h.set).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { get, set, del } from "idb-keyval";
|
||||||
|
import { createAsyncStoragePersister } from "@tanstack/query-async-storage-persister";
|
||||||
|
|
||||||
|
// Structural subset of a TanStack Query we read when deciding what to persist.
|
||||||
|
// We avoid importing the branded `Query` class because the persist-client and
|
||||||
|
// react-query may resolve to different `@tanstack/query-core` copies, whose
|
||||||
|
// `Query` types are nominally incompatible (private brand). This structural
|
||||||
|
// shape stays assignable to whichever copy the persister expects.
|
||||||
|
type DehydratableQuery = {
|
||||||
|
state: { status: string };
|
||||||
|
queryKey: readonly unknown[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// idb-keyval key under which TanStack Query persists its dehydrated cache.
|
||||||
|
// Exported so the logout cache-clear logic deletes the exact same key (no
|
||||||
|
// magic-string drift between persist and purge).
|
||||||
|
export const OFFLINE_CACHE_KEY = "gitmost-rq-cache";
|
||||||
|
|
||||||
|
// IndexedDB-backed storage adapter for TanStack Query's async persister.
|
||||||
|
const idbStorage = {
|
||||||
|
getItem: (key: string) => get<string>(key).then((v) => v ?? null),
|
||||||
|
setItem: (key: string, value: string) => set(key, value),
|
||||||
|
removeItem: (key: string) => del(key),
|
||||||
|
};
|
||||||
|
|
||||||
|
const basePersister = createAsyncStoragePersister({
|
||||||
|
storage: idbStorage,
|
||||||
|
key: OFFLINE_CACHE_KEY,
|
||||||
|
throttleTime: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// When frozen, persistClient becomes a no-op so no new dehydrated snapshot is
|
||||||
|
// written to IndexedDB. This closes a logout data-leak race: clearing the cache
|
||||||
|
// (queryClient.clear()) fires `removed` cache events, each of which the persist
|
||||||
|
// subscription turns into a throttled persistClient call. The FIRST such call
|
||||||
|
// dehydrates a still-nearly-full snapshot and its async write can land AFTER the
|
||||||
|
// del() that clears the key, resurrecting the previous user's data (~180KB) in
|
||||||
|
// IndexedDB. Freezing before clear()/del() prevents any such rewrite. Re-enabled
|
||||||
|
// afterwards so the next (sign-in) session persists normally. See
|
||||||
|
// clear-offline-cache.ts.
|
||||||
|
let persistFrozen = false;
|
||||||
|
|
||||||
|
export function freezeOfflinePersistence(): void {
|
||||||
|
persistFrozen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unfreezeOfflinePersistence(): void {
|
||||||
|
persistFrozen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const queryPersister = {
|
||||||
|
persistClient: (persistedClient: Parameters<typeof basePersister.persistClient>[0]) =>
|
||||||
|
persistFrozen ? Promise.resolve() : basePersister.persistClient(persistedClient),
|
||||||
|
restoreClient: () => basePersister.restoreClient(),
|
||||||
|
removeClient: () => basePersister.removeClient(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only navigation/read query roots are persisted for offline reading.
|
||||||
|
// Volatile/auth queries (collab tokens, trash lists) are intentionally excluded.
|
||||||
|
//
|
||||||
|
// `currentUser` IS persisted: UserProvider gates the entire <Layout> subtree on
|
||||||
|
// useCurrentUser(), and offline the POST /api/users/me fails as a no-response
|
||||||
|
// network error. Without the persisted/hydrated user the gate blanked every
|
||||||
|
// authenticated route on an offline cold boot (#237/#238). It is the logged-in
|
||||||
|
// user's own profile (already mirrored to localStorage["currentUser"]), so
|
||||||
|
// persisting it to IndexedDB leaks nothing new while unlocking offline reads.
|
||||||
|
export const OFFLINE_PERSIST_ROOTS = new Set<string>([
|
||||||
|
"pages",
|
||||||
|
"sidebar-pages",
|
||||||
|
"root-sidebar-pages",
|
||||||
|
"breadcrumbs",
|
||||||
|
"comments",
|
||||||
|
"space",
|
||||||
|
"spaces",
|
||||||
|
"recent-changes",
|
||||||
|
"currentUser",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function shouldDehydrateOfflineQuery(query: DehydratableQuery): boolean {
|
||||||
|
return (
|
||||||
|
query.state.status === "success" &&
|
||||||
|
OFFLINE_PERSIST_ROOTS.has(String(query.queryKey?.[0]))
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Text, Group, UnstyledButton, Avatar, Tooltip } from "@mantine/core";
|
import { Text, Group, UnstyledButton, Avatar, Tooltip } from "@mantine/core";
|
||||||
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
|
||||||
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
|
import { AiAgentBadge } from "@/components/ui/ai-agent-badge.tsx";
|
||||||
import { formattedDate } from "@/lib/time";
|
import { formattedDate } from "@/lib/time";
|
||||||
import classes from "./css/history.module.css";
|
import classes from "./css/history.module.css";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
@@ -99,13 +99,12 @@ const HistoryItem = memo(function HistoryItem({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isAgentEdit && historyItem.agent && (
|
{isAgentEdit && (
|
||||||
<AgentAvatarStack
|
<AiAgentBadge
|
||||||
agent={historyItem.agent}
|
authorName={historyItem.lastUpdatedBy?.name}
|
||||||
launcher={historyItem.launcher}
|
|
||||||
aiChatId={historyItem.lastUpdatedAiChatId}
|
aiChatId={historyItem.lastUpdatedAiChatId}
|
||||||
// The history row owns the modal: close it when the stack deep-links
|
// The history row owns the modal: close it when the badge deep-links
|
||||||
// into the chat (the stack no longer reaches into page-history).
|
// into the chat (the badge no longer reaches into page-history).
|
||||||
onActivate={() => setHistoryModalOpen(false)}
|
onActivate={() => setHistoryModalOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
import type {
|
|
||||||
AgentInfo,
|
|
||||||
LauncherInfo,
|
|
||||||
} from "@/components/ui/agent-avatar-stack.tsx";
|
|
||||||
|
|
||||||
interface IPageHistoryUser {
|
interface IPageHistoryUser {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -29,9 +24,4 @@ export interface IPageHistory {
|
|||||||
// (when present) deep-links to the chat that produced the edit.
|
// (when present) deep-links to the chat that produced the edit.
|
||||||
lastUpdatedSource?: string;
|
lastUpdatedSource?: string;
|
||||||
lastUpdatedAiChatId?: string | null;
|
lastUpdatedAiChatId?: string | null;
|
||||||
// Server-normalized "agent avatar stack" provenance (#300), present only when
|
|
||||||
// lastUpdatedSource === "agent": `agent` is the front identity, `launcher` the
|
|
||||||
// human behind it (null for an external MCP agent).
|
|
||||||
agent?: AgentInfo | null;
|
|
||||||
launcher?: LauncherInfo | null;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
IconList,
|
IconList,
|
||||||
IconMarkdown,
|
IconMarkdown,
|
||||||
IconPrinter,
|
IconPrinter,
|
||||||
|
IconCloud,
|
||||||
|
IconCloudCheck,
|
||||||
IconStar,
|
IconStar,
|
||||||
IconStarFilled,
|
IconStarFilled,
|
||||||
IconTrash,
|
IconTrash,
|
||||||
@@ -39,6 +41,8 @@ import { Trans, useTranslation } from "react-i18next";
|
|||||||
import ExportModal from "@/components/common/export-modal";
|
import ExportModal from "@/components/common/export-modal";
|
||||||
import { htmlToMarkdown } from "@docmost/editor-ext";
|
import { htmlToMarkdown } from "@docmost/editor-ext";
|
||||||
import {
|
import {
|
||||||
|
isLocalSyncedAtom,
|
||||||
|
isRemoteSyncedAtom,
|
||||||
pageEditorAtom,
|
pageEditorAtom,
|
||||||
yjsConnectionStatusAtom,
|
yjsConnectionStatusAtom,
|
||||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
} from "@/features/editor/atoms/editor-atoms.ts";
|
||||||
@@ -411,14 +415,16 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
function ConnectionWarning() {
|
function ConnectionWarning() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const yjsConnectionStatus = useAtomValue(yjsConnectionStatusAtom);
|
const yjsConnectionStatus = useAtomValue(yjsConnectionStatusAtom);
|
||||||
|
const isLocalSynced = useAtomValue(isLocalSyncedAtom);
|
||||||
|
const isRemoteSynced = useAtomValue(isRemoteSyncedAtom);
|
||||||
const [showWarning, setShowWarning] = useState(false);
|
const [showWarning, setShowWarning] = useState(false);
|
||||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
const isDisconnected = ["disconnected", "connecting"].includes(
|
||||||
const isDisconnected = ["disconnected", "connecting"].includes(
|
yjsConnectionStatus,
|
||||||
yjsConnectionStatus,
|
);
|
||||||
);
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
if (isDisconnected) {
|
if (isDisconnected) {
|
||||||
if (!timeoutRef.current) {
|
if (!timeoutRef.current) {
|
||||||
timeoutRef.current = setTimeout(() => setShowWarning(true), 5000);
|
timeoutRef.current = setTimeout(() => setShowWarning(true), 5000);
|
||||||
@@ -430,7 +436,7 @@ function ConnectionWarning() {
|
|||||||
}
|
}
|
||||||
setShowWarning(false);
|
setShowWarning(false);
|
||||||
}
|
}
|
||||||
}, [yjsConnectionStatus]);
|
}, [isDisconnected]);
|
||||||
|
|
||||||
// Cleanup only on unmount
|
// Cleanup only on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -441,22 +447,59 @@ function ConnectionWarning() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!showWarning) return null;
|
// State (1): offline/disconnected — changes are kept locally. Preserve the
|
||||||
|
// existing >5s debounce before surfacing this state.
|
||||||
|
if (isDisconnected) {
|
||||||
|
if (!showWarning) return null;
|
||||||
|
|
||||||
|
const offlineLabel = t(
|
||||||
|
"Offline — changes are saved locally and will sync when you reconnect",
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Tooltip label={offlineLabel} openDelay={250} withArrow>
|
||||||
|
<ThemeIcon
|
||||||
|
variant="default"
|
||||||
|
c="red"
|
||||||
|
role="status"
|
||||||
|
aria-label={offlineLabel}
|
||||||
|
style={{ border: "none" }}
|
||||||
|
>
|
||||||
|
<IconWifiOff size={20} stroke={2} />
|
||||||
|
</ThemeIcon>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// State (2): connected but the remote replica is not fully caught up yet.
|
||||||
|
if (!isRemoteSynced || !isLocalSynced) {
|
||||||
|
const syncingLabel = t("Syncing changes…");
|
||||||
|
return (
|
||||||
|
<Tooltip label={syncingLabel} openDelay={250} withArrow>
|
||||||
|
<ThemeIcon
|
||||||
|
variant="default"
|
||||||
|
c="dimmed"
|
||||||
|
role="status"
|
||||||
|
aria-label={syncingLabel}
|
||||||
|
style={{ border: "none" }}
|
||||||
|
>
|
||||||
|
<IconCloud size={20} stroke={2} />
|
||||||
|
</ThemeIcon>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// State (3): fully synced — subtle confirmation indicator.
|
||||||
|
const syncedLabel = t("All changes synced");
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip label={syncedLabel} openDelay={250} withArrow>
|
||||||
label={t("Real-time editor connection lost. Retrying...")}
|
|
||||||
openDelay={250}
|
|
||||||
withArrow
|
|
||||||
>
|
|
||||||
<ThemeIcon
|
<ThemeIcon
|
||||||
variant="default"
|
variant="default"
|
||||||
c="red"
|
c="dimmed"
|
||||||
role="status"
|
role="status"
|
||||||
aria-label={t("Real-time editor connection lost. Retrying...")}
|
aria-label={syncedLabel}
|
||||||
style={{ border: "none" }}
|
style={{ border: "none" }}
|
||||||
>
|
>
|
||||||
<IconWifiOff size={20} stroke={2} />
|
<IconCloudCheck size={20} stroke={2} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
InfiniteData,
|
InfiniteData,
|
||||||
QueryKey,
|
QueryKey,
|
||||||
|
queryOptions,
|
||||||
useInfiniteQuery,
|
useInfiniteQuery,
|
||||||
UseInfiniteQueryResult,
|
UseInfiniteQueryResult,
|
||||||
useMutation,
|
useMutation,
|
||||||
@@ -42,12 +43,38 @@ import { treeModel } from "@/features/page/tree/model/tree-model";
|
|||||||
import { SpaceTreeNode } from "@/features/page/tree/types";
|
import { SpaceTreeNode } from "@/features/page/tree/types";
|
||||||
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
import { useQueryEmit } from "@/features/websocket/use-query-emit";
|
||||||
import { moveToTrashNotificationMessage } from "@/features/page/components/move-to-trash-notification";
|
import { moveToTrashNotificationMessage } from "@/features/page/components/move-to-trash-notification";
|
||||||
|
import { offlineMutationKeys } from "@/features/offline/offline-mutations";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centralized React Query key factories for page queries. The hooks below and
|
||||||
|
* the offline warm path (features/offline/make-offline.ts) share these so the
|
||||||
|
* runtime keys can never silently drift apart.
|
||||||
|
*/
|
||||||
|
export const pageKeys = {
|
||||||
|
detail: (idOrSlug: string) => ["pages", idOrSlug] as const,
|
||||||
|
sidebar: (data: unknown) => ["sidebar-pages", data] as const,
|
||||||
|
rootSidebar: (spaceId: string) => ["root-sidebar-pages", spaceId] as const,
|
||||||
|
breadcrumbs: (pageId: string) => ["breadcrumbs", pageId] as const,
|
||||||
|
recentChanges: (spaceId?: string) => ["recent-changes", spaceId] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared queryOptions for the sidebar-pages (ancestor children) query. Both
|
||||||
|
* fetchAllAncestorChildren and the offline warm path consume this so the key,
|
||||||
|
* queryFn and staleTime stay identical.
|
||||||
|
*/
|
||||||
|
export const sidebarPagesQueryOptions = (params: SidebarPagesParams) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: pageKeys.sidebar(params),
|
||||||
|
queryFn: () => getAllSidebarPages(params),
|
||||||
|
staleTime: 30 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
export function usePageQuery(
|
export function usePageQuery(
|
||||||
pageInput: Partial<IPageInput>,
|
pageInput: Partial<IPageInput>,
|
||||||
): UseQueryResult<IPage, Error> {
|
): UseQueryResult<IPage, Error> {
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ["pages", pageInput.pageId],
|
queryKey: pageKeys.detail(pageInput.pageId),
|
||||||
queryFn: () => getPageById(pageInput),
|
queryFn: () => getPageById(pageInput),
|
||||||
enabled: !!pageInput.pageId,
|
enabled: !!pageInput.pageId,
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
@@ -56,9 +83,9 @@ export function usePageQuery(
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (query.data) {
|
if (query.data) {
|
||||||
if (isValidUuid(pageInput.pageId)) {
|
if (isValidUuid(pageInput.pageId)) {
|
||||||
queryClient.setQueryData(["pages", query.data.slugId], query.data);
|
queryClient.setQueryData(pageKeys.detail(query.data.slugId), query.data);
|
||||||
} else {
|
} else {
|
||||||
queryClient.setQueryData(["pages", query.data.id], query.data);
|
queryClient.setQueryData(pageKeys.detail(query.data.id), query.data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [query.data]);
|
}, [query.data]);
|
||||||
@@ -69,6 +96,10 @@ export function usePageQuery(
|
|||||||
export function useCreatePageMutation() {
|
export function useCreatePageMutation() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return useMutation<IPage, Error, Partial<IPageInput>>({
|
return useMutation<IPage, Error, Partial<IPageInput>>({
|
||||||
|
// Stable key so a paused create restored from IndexedDB after an offline
|
||||||
|
// reload finds its default mutationFn (registerOfflineMutationDefaults) and
|
||||||
|
// is replayed by resumePausedMutations() on reconnect instead of being lost.
|
||||||
|
mutationKey: offlineMutationKeys.createPage,
|
||||||
mutationFn: (data) => createPage(data),
|
mutationFn: (data) => createPage(data),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
invalidateOnCreatePage(data);
|
invalidateOnCreatePage(data);
|
||||||
@@ -80,18 +111,20 @@ export function useCreatePageMutation() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function updatePageData(data: IPage) {
|
export function updatePageData(data: IPage) {
|
||||||
const pageBySlug = queryClient.getQueryData<IPage>(["pages", data.slugId]);
|
const pageBySlug = queryClient.getQueryData<IPage>(
|
||||||
const pageById = queryClient.getQueryData<IPage>(["pages", data.id]);
|
pageKeys.detail(data.slugId),
|
||||||
|
);
|
||||||
|
const pageById = queryClient.getQueryData<IPage>(pageKeys.detail(data.id));
|
||||||
|
|
||||||
if (pageBySlug) {
|
if (pageBySlug) {
|
||||||
queryClient.setQueryData(["pages", data.slugId], {
|
queryClient.setQueryData(pageKeys.detail(data.slugId), {
|
||||||
...pageBySlug,
|
...pageBySlug,
|
||||||
...data,
|
...data,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pageById) {
|
if (pageById) {
|
||||||
queryClient.setQueryData(["pages", data.id], { ...pageById, ...data });
|
queryClient.setQueryData(pageKeys.detail(data.id), { ...pageById, ...data });
|
||||||
}
|
}
|
||||||
|
|
||||||
invalidateOnUpdatePage(
|
invalidateOnUpdatePage(
|
||||||
@@ -145,11 +178,11 @@ export function useRemovePageMutation() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Stamp deletedAt so a re-visit shows the trash banner, not stale state.
|
// Stamp deletedAt so a re-visit shows the trash banner, not stale state.
|
||||||
const cached = queryClient.getQueryData<IPage>(["pages", pageId]);
|
const cached = queryClient.getQueryData<IPage>(pageKeys.detail(pageId));
|
||||||
if (cached) {
|
if (cached) {
|
||||||
const stamped = { ...cached, deletedAt: new Date() };
|
const stamped = { ...cached, deletedAt: new Date() };
|
||||||
queryClient.setQueryData(["pages", cached.id], stamped);
|
queryClient.setQueryData(pageKeys.detail(cached.id), stamped);
|
||||||
queryClient.setQueryData(["pages", cached.slugId], stamped);
|
queryClient.setQueryData(pageKeys.detail(cached.slugId), stamped);
|
||||||
}
|
}
|
||||||
|
|
||||||
invalidateOnDeletePage(pageId);
|
invalidateOnDeletePage(pageId);
|
||||||
@@ -188,6 +221,9 @@ export function useDeletePageMutation() {
|
|||||||
|
|
||||||
export function useMovePageMutation() {
|
export function useMovePageMutation() {
|
||||||
return useMutation<void, Error, IMovePage>({
|
return useMutation<void, Error, IMovePage>({
|
||||||
|
// Stable key so a paused move restored from IndexedDB after an offline
|
||||||
|
// reload finds its default mutationFn and is replayed on reconnect.
|
||||||
|
mutationKey: offlineMutationKeys.movePage,
|
||||||
mutationFn: (data) => movePage(data),
|
mutationFn: (data) => movePage(data),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -267,8 +303,11 @@ export function useRestorePageMutation() {
|
|||||||
// Replace would strip space/permissions/content and break the editor.
|
// Replace would strip space/permissions/content and break the editor.
|
||||||
const merge = (cached: IPage | undefined) =>
|
const merge = (cached: IPage | undefined) =>
|
||||||
cached ? { ...cached, ...restoredPage } : cached;
|
cached ? { ...cached, ...restoredPage } : cached;
|
||||||
queryClient.setQueryData<IPage>(["pages", restoredPage.id], merge);
|
queryClient.setQueryData<IPage>(pageKeys.detail(restoredPage.id), merge);
|
||||||
queryClient.setQueryData<IPage>(["pages", restoredPage.slugId], merge);
|
queryClient.setQueryData<IPage>(
|
||||||
|
pageKeys.detail(restoredPage.slugId),
|
||||||
|
merge,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
@@ -283,7 +322,7 @@ export function useGetSidebarPagesQuery(
|
|||||||
data: SidebarPagesParams | null,
|
data: SidebarPagesParams | null,
|
||||||
): UseInfiniteQueryResult<InfiniteData<IPagination<IPage>, unknown>> {
|
): UseInfiniteQueryResult<InfiniteData<IPagination<IPage>, unknown>> {
|
||||||
return useInfiniteQuery({
|
return useInfiniteQuery({
|
||||||
queryKey: ["sidebar-pages", data],
|
queryKey: pageKeys.sidebar(data),
|
||||||
enabled: !!data?.pageId || !!data?.spaceId,
|
enabled: !!data?.pageId || !!data?.spaceId,
|
||||||
queryFn: ({ pageParam }) =>
|
queryFn: ({ pageParam }) =>
|
||||||
getSidebarPages({ ...data, cursor: pageParam, limit: 100 }),
|
getSidebarPages({ ...data, cursor: pageParam, limit: 100 }),
|
||||||
@@ -294,7 +333,7 @@ export function useGetSidebarPagesQuery(
|
|||||||
|
|
||||||
export function useGetRootSidebarPagesQuery(data: SidebarPagesParams) {
|
export function useGetRootSidebarPagesQuery(data: SidebarPagesParams) {
|
||||||
return useInfiniteQuery({
|
return useInfiniteQuery({
|
||||||
queryKey: ["root-sidebar-pages", data.spaceId],
|
queryKey: pageKeys.rootSidebar(data.spaceId),
|
||||||
queryFn: async ({ pageParam }) => {
|
queryFn: async ({ pageParam }) => {
|
||||||
return getSidebarPages({
|
return getSidebarPages({
|
||||||
spaceId: data.spaceId,
|
spaceId: data.spaceId,
|
||||||
@@ -320,7 +359,7 @@ export function usePageBreadcrumbsQuery(
|
|||||||
pageId: string,
|
pageId: string,
|
||||||
): UseQueryResult<Partial<IPage[]>, Error> {
|
): UseQueryResult<Partial<IPage[]>, Error> {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["breadcrumbs", pageId],
|
queryKey: pageKeys.breadcrumbs(pageId),
|
||||||
queryFn: () => getPageBreadcrumbs(pageId),
|
queryFn: () => getPageBreadcrumbs(pageId),
|
||||||
enabled: !!pageId,
|
enabled: !!pageId,
|
||||||
});
|
});
|
||||||
@@ -332,10 +371,12 @@ export async function fetchAllAncestorChildren(
|
|||||||
// refresh (#159 #8), which must NOT receive the 30-min-cached children.
|
// refresh (#159 #8), which must NOT receive the 30-min-cached children.
|
||||||
opts?: { fresh?: boolean },
|
opts?: { fresh?: boolean },
|
||||||
) {
|
) {
|
||||||
// not using a hook here, so we can call it inside a useEffect hook
|
// not using a hook here, so we can call it inside a useEffect hook. Reuse the
|
||||||
|
// shared sidebarPagesQueryOptions (key + queryFn) so the offline warm path and
|
||||||
|
// this fetch never drift, but override staleTime for the `fresh` reconnect
|
||||||
|
// refresh (#159 #8), which must force a server refetch (staleTime 0).
|
||||||
const response = await queryClient.fetchQuery({
|
const response = await queryClient.fetchQuery({
|
||||||
queryKey: ["sidebar-pages", params],
|
...sidebarPagesQueryOptions(params),
|
||||||
queryFn: () => getAllSidebarPages(params),
|
|
||||||
staleTime: opts?.fresh ? 0 : 30 * 60 * 1000,
|
staleTime: opts?.fresh ? 0 : 30 * 60 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -345,7 +386,7 @@ export async function fetchAllAncestorChildren(
|
|||||||
|
|
||||||
export function useRecentChangesQuery(spaceId?: string) {
|
export function useRecentChangesQuery(spaceId?: string) {
|
||||||
return useInfiniteQuery({
|
return useInfiniteQuery({
|
||||||
queryKey: ["recent-changes", spaceId],
|
queryKey: pageKeys.recentChanges(spaceId),
|
||||||
queryFn: ({ pageParam }) =>
|
queryFn: ({ pageParam }) =>
|
||||||
getRecentChanges({ spaceId, cursor: pageParam, limit: 15 }),
|
getRecentChanges({ spaceId, cursor: pageParam, limit: 15 }),
|
||||||
initialPageParam: undefined as string | undefined,
|
initialPageParam: undefined as string | undefined,
|
||||||
@@ -416,12 +457,12 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
|
|||||||
|
|
||||||
let queryKey: QueryKey = null;
|
let queryKey: QueryKey = null;
|
||||||
if (data.parentPageId === null) {
|
if (data.parentPageId === null) {
|
||||||
queryKey = ["root-sidebar-pages", data.spaceId];
|
queryKey = pageKeys.rootSidebar(data.spaceId);
|
||||||
} else {
|
} else {
|
||||||
queryKey = [
|
queryKey = pageKeys.sidebar({
|
||||||
"sidebar-pages",
|
pageId: data.parentPageId,
|
||||||
{ pageId: data.parentPageId, spaceId: data.spaceId },
|
spaceId: data.spaceId,
|
||||||
];
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
//update all sidebar pages
|
//update all sidebar pages
|
||||||
@@ -481,7 +522,7 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
|
|||||||
|
|
||||||
//update root sidebar pages haschildern
|
//update root sidebar pages haschildern
|
||||||
const rootSideBarMatches = queryClient.getQueriesData({
|
const rootSideBarMatches = queryClient.getQueriesData({
|
||||||
queryKey: ["root-sidebar-pages", data.spaceId],
|
queryKey: pageKeys.rootSidebar(data.spaceId),
|
||||||
exact: false,
|
exact: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -505,7 +546,7 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
|
|||||||
|
|
||||||
//update recent changes
|
//update recent changes
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["recent-changes", data.spaceId],
|
queryKey: pageKeys.recentChanges(data.spaceId),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -519,9 +560,9 @@ export function invalidateOnUpdatePage(
|
|||||||
invalidatePageTree();
|
invalidatePageTree();
|
||||||
let queryKey: QueryKey = null;
|
let queryKey: QueryKey = null;
|
||||||
if (parentPageId === null) {
|
if (parentPageId === null) {
|
||||||
queryKey = ["root-sidebar-pages", spaceId];
|
queryKey = pageKeys.rootSidebar(spaceId);
|
||||||
} else {
|
} else {
|
||||||
queryKey = ["sidebar-pages", { pageId: parentPageId, spaceId: spaceId }];
|
queryKey = pageKeys.sidebar({ pageId: parentPageId, spaceId: spaceId });
|
||||||
}
|
}
|
||||||
//update all sidebar pages
|
//update all sidebar pages
|
||||||
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
|
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
|
||||||
@@ -544,7 +585,7 @@ export function invalidateOnUpdatePage(
|
|||||||
|
|
||||||
//update recent changes
|
//update recent changes
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["recent-changes", spaceId],
|
queryKey: pageKeys.recentChanges(spaceId),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -559,8 +600,8 @@ export function updateCacheOnMovePage(
|
|||||||
// Remove page from old parent's cache
|
// Remove page from old parent's cache
|
||||||
const oldQueryKey =
|
const oldQueryKey =
|
||||||
oldParentId === null
|
oldParentId === null
|
||||||
? ["root-sidebar-pages", spaceId]
|
? pageKeys.rootSidebar(spaceId)
|
||||||
: ["sidebar-pages", { pageId: oldParentId, spaceId }];
|
: pageKeys.sidebar({ pageId: oldParentId, spaceId });
|
||||||
|
|
||||||
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
|
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
|
||||||
oldQueryKey,
|
oldQueryKey,
|
||||||
@@ -580,7 +621,7 @@ export function updateCacheOnMovePage(
|
|||||||
if (oldParentId !== null) {
|
if (oldParentId !== null) {
|
||||||
const oldParentCache = queryClient.getQueryData<
|
const oldParentCache = queryClient.getQueryData<
|
||||||
InfiniteData<IPagination<IPage>>
|
InfiniteData<IPagination<IPage>>
|
||||||
>(["sidebar-pages", { pageId: oldParentId, spaceId }]);
|
>(pageKeys.sidebar({ pageId: oldParentId, spaceId }));
|
||||||
|
|
||||||
const remainingChildren =
|
const remainingChildren =
|
||||||
oldParentCache?.pages.flatMap((p) => p.items).length ?? 0;
|
oldParentCache?.pages.flatMap((p) => p.items).length ?? 0;
|
||||||
@@ -618,8 +659,8 @@ export function updateCacheOnMovePage(
|
|||||||
// Add page to new parent's cache
|
// Add page to new parent's cache
|
||||||
const newQueryKey =
|
const newQueryKey =
|
||||||
newParentId === null
|
newParentId === null
|
||||||
? ["root-sidebar-pages", spaceId]
|
? pageKeys.rootSidebar(spaceId)
|
||||||
: ["sidebar-pages", { pageId: newParentId, spaceId }];
|
: pageKeys.sidebar({ pageId: newParentId, spaceId });
|
||||||
|
|
||||||
queryClient.setQueryData<InfiniteData<IPagination<Partial<IPage>>>>(
|
queryClient.setQueryData<InfiniteData<IPagination<Partial<IPage>>>>(
|
||||||
newQueryKey,
|
newQueryKey,
|
||||||
|
|||||||
@@ -13,30 +13,20 @@ export type OpenMap = Record<string, boolean>;
|
|||||||
// `OpenMap | Promise<OpenMap>` and break the functional-updater setter below).
|
// `OpenMap | Promise<OpenMap>` and break the functional-updater setter below).
|
||||||
const openTreeNodesStorage = createJSONStorage<OpenMap>(() => localStorage);
|
const openTreeNodesStorage = createJSONStorage<OpenMap>(() => localStorage);
|
||||||
|
|
||||||
// Single source of truth for the open-map localStorage key prefix. Exported so
|
|
||||||
// the logout cache sweep (tree-data-atom.ts) removes keys by the SAME prefix
|
|
||||||
// used to write them — a rename here can never silently desync the cleanup.
|
|
||||||
export const OPEN_TREE_NODES_KEY_PREFIX = "openTreeNodes:";
|
|
||||||
|
|
||||||
// One persisted open/closed map per (workspace, user). Scoping the localStorage
|
// One persisted open/closed map per (workspace, user). Scoping the localStorage
|
||||||
// key prevents accounts that share a browser origin from leaking tree state.
|
// key prevents accounts that share a browser origin from leaking tree state.
|
||||||
// `getOnInit: true` reads localStorage synchronously at atom init (not on mount),
|
// `getOnInit: true` reads localStorage synchronously at atom init (not on mount),
|
||||||
// so the first render already has the saved state — no collapse-then-expand
|
// so the first render already has the saved state — no collapse-then-expand
|
||||||
// flicker on reload, and writes never run against an un-hydrated empty map.
|
// flicker on reload, and writes never run against an un-hydrated empty map.
|
||||||
const openTreeNodesFamily = atomFamily((scopeKey: string) =>
|
const openTreeNodesFamily = atomFamily((scopeKey: string) =>
|
||||||
atomWithStorage<OpenMap>(
|
atomWithStorage<OpenMap>(`openTreeNodes:${scopeKey}`, {}, openTreeNodesStorage, {
|
||||||
`${OPEN_TREE_NODES_KEY_PREFIX}${scopeKey}`,
|
getOnInit: true,
|
||||||
{},
|
}),
|
||||||
openTreeNodesStorage,
|
|
||||||
{ getOnInit: true },
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Resolve the storage scope from the current user. Fall back to "anon" for the
|
// Resolve the storage scope from the current user. Fall back to "anon" for the
|
||||||
// workspace/user parts when nothing is loaded yet (logged out / first paint).
|
// workspace/user parts when nothing is loaded yet (logged out / first paint).
|
||||||
// Shared by the open-map atom below and the persisted tree-data atom
|
const scopeKeyAtom = atom((get) => {
|
||||||
// (tree-data-atom.ts) so both caches are scoped identically.
|
|
||||||
export const scopeKeyAtom = atom((get) => {
|
|
||||||
const currentUser = get(currentUserAtom);
|
const currentUser = get(currentUserAtom);
|
||||||
const workspaceId = currentUser?.workspace?.id ?? "anon";
|
const workspaceId = currentUser?.workspace?.id ?? "anon";
|
||||||
const userId = currentUser?.user?.id ?? "anon";
|
const userId = currentUser?.user?.id ?? "anon";
|
||||||
|
|||||||
@@ -1,265 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
||||||
import type { SpaceTreeNode } from "@/features/page/tree/types";
|
|
||||||
import type { ICurrentUser } from "@/features/user/types/user.types";
|
|
||||||
|
|
||||||
// The persisted tree-data atom hydrates from localStorage ONCE, at family-atom
|
|
||||||
// creation (`getOnInit: true`). To exercise hydration deterministically each
|
|
||||||
// test imports a FRESH module instance (fresh atomFamily) after seeding the
|
|
||||||
// storage stub from vitest.setup.ts. jotai itself is externalized by vitest, so
|
|
||||||
// `createStore` can stay a static import — atoms are plain objects and any
|
|
||||||
// store works with any module instance.
|
|
||||||
import { createStore } from "jotai";
|
|
||||||
|
|
||||||
// Storage key for the default scope: no currentUser -> "anon:anon" (see
|
|
||||||
// scopeKeyAtom in open-tree-nodes-atom.ts) with the `v1` cache-shape version.
|
|
||||||
const ANON_KEY = "treeData:v1:anon:anon";
|
|
||||||
const DEBOUNCE_MS = 500;
|
|
||||||
|
|
||||||
async function freshImport() {
|
|
||||||
vi.resetModules();
|
|
||||||
const treeDataModule = await import("./tree-data-atom");
|
|
||||||
const userModule = await import(
|
|
||||||
"@/features/user/atoms/current-user-atom"
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
treeDataAtom: treeDataModule.treeDataAtom,
|
|
||||||
flushPendingTreeDataWrites: treeDataModule.flushPendingTreeDataWrites,
|
|
||||||
clearPersistedTreeCaches: treeDataModule.clearPersistedTreeCaches,
|
|
||||||
currentUserAtom: userModule.currentUserAtom,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function node(id: string): SpaceTreeNode {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
slugId: `slug-${id}`,
|
|
||||||
name: id,
|
|
||||||
position: "a0",
|
|
||||||
spaceId: "space-1",
|
|
||||||
parentPageId: null as unknown as string,
|
|
||||||
hasChildren: false,
|
|
||||||
children: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every persisted tree key currently in storage — asserting on the whole
|
|
||||||
// prefix (not one known key) catches writes that resurrect under ANY scope.
|
|
||||||
function persistedTreeDataKeys(): string[] {
|
|
||||||
const keys: string[] = [];
|
|
||||||
for (let i = 0; i < localStorage.length; i++) {
|
|
||||||
const key = localStorage.key(i);
|
|
||||||
if (key !== null && key.startsWith("treeData:v1:")) keys.push(key);
|
|
||||||
}
|
|
||||||
return keys;
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentUser(workspaceId: string, userId: string): ICurrentUser {
|
|
||||||
return {
|
|
||||||
user: { id: userId },
|
|
||||||
workspace: { id: workspaceId },
|
|
||||||
} as unknown as ICurrentUser;
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
localStorage.clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("treeDataAtom (localStorage-persisted)", () => {
|
|
||||||
it("reads [] from a fresh store with empty storage", async () => {
|
|
||||||
const { treeDataAtom } = await freshImport();
|
|
||||||
const store = createStore();
|
|
||||||
|
|
||||||
expect(store.get(treeDataAtom)).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("persists through the debounced setItem and hydrates a fresh module back", async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
const setItemSpy = vi.spyOn(localStorage, "setItem");
|
|
||||||
|
|
||||||
const { treeDataAtom } = await freshImport();
|
|
||||||
const store = createStore();
|
|
||||||
|
|
||||||
store.set(treeDataAtom, [node("a")]);
|
|
||||||
// Second write inside the debounce window — must coalesce into ONE flush
|
|
||||||
// carrying only the latest value.
|
|
||||||
vi.advanceTimersByTime(DEBOUNCE_MS / 2);
|
|
||||||
store.set(treeDataAtom, [node("a"), node("b")]);
|
|
||||||
|
|
||||||
// Nothing flushed yet: the write is trailing-debounced.
|
|
||||||
expect(localStorage.getItem(ANON_KEY)).toBeNull();
|
|
||||||
|
|
||||||
vi.advanceTimersByTime(DEBOUNCE_MS + 100);
|
|
||||||
|
|
||||||
expect(setItemSpy).toHaveBeenCalledTimes(1);
|
|
||||||
expect(JSON.parse(localStorage.getItem(ANON_KEY)!)).toEqual([
|
|
||||||
node("a"),
|
|
||||||
node("b"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// A fresh module (fresh atom family -> getOnInit re-reads storage) and a
|
|
||||||
// fresh store hydrate the persisted tree back — the reload scenario.
|
|
||||||
const second = await freshImport();
|
|
||||||
const store2 = createStore();
|
|
||||||
expect(store2.get(second.treeDataAtom)).toEqual([node("a"), node("b")]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reads [] (without throwing) when storage holds corrupted JSON", async () => {
|
|
||||||
localStorage.setItem(ANON_KEY, "{definitely not JSON!!!");
|
|
||||||
|
|
||||||
const { treeDataAtom } = await freshImport();
|
|
||||||
const store = createStore();
|
|
||||||
|
|
||||||
expect(store.get(treeDataAtom)).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reads [] when storage holds valid JSON of a non-array shape", async () => {
|
|
||||||
localStorage.setItem(ANON_KEY, JSON.stringify({ id: "not-a-tree" }));
|
|
||||||
|
|
||||||
const { treeDataAtom } = await freshImport();
|
|
||||||
const store = createStore();
|
|
||||||
|
|
||||||
expect(store.get(treeDataAtom)).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("supports functional-updater writes", async () => {
|
|
||||||
const { treeDataAtom } = await freshImport();
|
|
||||||
const store = createStore();
|
|
||||||
|
|
||||||
store.set(treeDataAtom, [node("a")]);
|
|
||||||
store.set(treeDataAtom, (prev) => [...prev, node("b")]);
|
|
||||||
|
|
||||||
expect(store.get(treeDataAtom).map((n) => n.id)).toEqual(["a", "b"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("isolates trees between (workspace, user) scopes", async () => {
|
|
||||||
const { treeDataAtom, currentUserAtom } = await freshImport();
|
|
||||||
const store = createStore();
|
|
||||||
|
|
||||||
store.set(currentUserAtom, currentUser("w1", "u1"));
|
|
||||||
store.set(treeDataAtom, [node("a")]);
|
|
||||||
expect(store.get(treeDataAtom).map((n) => n.id)).toEqual(["a"]);
|
|
||||||
|
|
||||||
// Another account on the same browser origin must NOT see u1's tree.
|
|
||||||
store.set(currentUserAtom, currentUser("w2", "u2"));
|
|
||||||
expect(store.get(treeDataAtom)).toEqual([]);
|
|
||||||
|
|
||||||
store.set(treeDataAtom, [node("b")]);
|
|
||||||
expect(store.get(treeDataAtom).map((n) => n.id)).toEqual(["b"]);
|
|
||||||
|
|
||||||
// Switching back resolves the original scope's tree untouched.
|
|
||||||
store.set(currentUserAtom, currentUser("w1", "u1"));
|
|
||||||
expect(store.get(treeDataAtom).map((n) => n.id)).toEqual(["a"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clearPersistedTreeCaches removes all tree keys and discards pending writes", async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
|
|
||||||
// Stale caches across scopes plus an UNRELATED key that must survive.
|
|
||||||
localStorage.setItem("treeData:v1:a:b", JSON.stringify([node("stale")]));
|
|
||||||
localStorage.setItem("openTreeNodes:a:b", JSON.stringify({ p1: true }));
|
|
||||||
localStorage.setItem("currentUser", JSON.stringify({ user: { id: "b" } }));
|
|
||||||
|
|
||||||
const { treeDataAtom, clearPersistedTreeCaches } = await freshImport();
|
|
||||||
const store = createStore();
|
|
||||||
|
|
||||||
// Queue a debounced write (not flushed yet) for the anon scope.
|
|
||||||
store.set(treeDataAtom, [node("pending")]);
|
|
||||||
expect(localStorage.getItem(ANON_KEY)).toBeNull();
|
|
||||||
|
|
||||||
clearPersistedTreeCaches();
|
|
||||||
|
|
||||||
// Both prefixed caches are swept; the unrelated key is untouched.
|
|
||||||
expect(localStorage.getItem("treeData:v1:a:b")).toBeNull();
|
|
||||||
expect(localStorage.getItem("openTreeNodes:a:b")).toBeNull();
|
|
||||||
expect(localStorage.getItem("currentUser")).toBe(
|
|
||||||
JSON.stringify({ user: { id: "b" } }),
|
|
||||||
);
|
|
||||||
|
|
||||||
// The queued write was DISCARDED, not merely delayed: the debounce timer
|
|
||||||
// firing later must not resurrect a tree key after logout.
|
|
||||||
vi.advanceTimersByTime(DEBOUNCE_MS + 100);
|
|
||||||
expect(localStorage.getItem(ANON_KEY)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("clearPersistedTreeCaches discards queued writes even when flushed DIRECTLY", async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
|
|
||||||
const { treeDataAtom, clearPersistedTreeCaches, flushPendingTreeDataWrites } =
|
|
||||||
await freshImport();
|
|
||||||
const store = createStore();
|
|
||||||
|
|
||||||
// Queue a debounced write, then clear. Calling the flush directly (not via
|
|
||||||
// the debounce timer) isolates the pending-queue discard from the timer
|
|
||||||
// cancel: if the queue survived, this flush would resurrect the key even
|
|
||||||
// though the timer never fired.
|
|
||||||
store.set(treeDataAtom, [node("pending")]);
|
|
||||||
clearPersistedTreeCaches();
|
|
||||||
flushPendingTreeDataWrites();
|
|
||||||
|
|
||||||
expect(localStorage.getItem(ANON_KEY)).toBeNull();
|
|
||||||
expect(persistedTreeDataKeys()).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("skips persisting a tree over the size cap and warns exactly once", async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
||||||
const setItemSpy = vi.spyOn(localStorage, "setItem");
|
|
||||||
|
|
||||||
const { treeDataAtom, flushPendingTreeDataWrites } = await freshImport();
|
|
||||||
const store = createStore();
|
|
||||||
|
|
||||||
// One node whose name alone serializes to > MAX_SERIALIZED_LENGTH (~4M).
|
|
||||||
const huge = node("big");
|
|
||||||
huge.name = "x".repeat(4_000_001);
|
|
||||||
|
|
||||||
store.set(treeDataAtom, [huge]);
|
|
||||||
vi.advanceTimersByTime(DEBOUNCE_MS + 100);
|
|
||||||
|
|
||||||
// The oversized serialization is skipped: the key is never written.
|
|
||||||
expect(localStorage.getItem(ANON_KEY)).toBeNull();
|
|
||||||
expect(setItemSpy).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
// Editing the still-oversized tree fires another debounced write, but the
|
|
||||||
// "too large" warn is gated by the once-flag — no per-tick console spam.
|
|
||||||
store.set(treeDataAtom, [huge, node("big2")]);
|
|
||||||
vi.advanceTimersByTime(DEBOUNCE_MS + 100);
|
|
||||||
flushPendingTreeDataWrites();
|
|
||||||
|
|
||||||
expect(localStorage.getItem(ANON_KEY)).toBeNull();
|
|
||||||
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
||||||
expect(warnSpy).toHaveBeenCalledWith(
|
|
||||||
"[tree] cached tree too large to persist; skipping",
|
|
||||||
ANON_KEY,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("disables persistence after clearPersistedTreeCaches: NEW writes never reach storage", async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
|
|
||||||
const { treeDataAtom, clearPersistedTreeCaches, flushPendingTreeDataWrites } =
|
|
||||||
await freshImport();
|
|
||||||
const store = createStore();
|
|
||||||
|
|
||||||
clearPersistedTreeCaches();
|
|
||||||
|
|
||||||
// The resurrection scenario: a websocket tree event lands while `await
|
|
||||||
// logout()` is still in flight, AFTER the sweep. The write must not be
|
|
||||||
// queued, must not arm a new debounce timer, and must not survive the
|
|
||||||
// beforeunload flush fired by the logout redirect.
|
|
||||||
store.set(treeDataAtom, [node("late")]);
|
|
||||||
|
|
||||||
vi.advanceTimersByTime(DEBOUNCE_MS + 100);
|
|
||||||
flushPendingTreeDataWrites(); // what the beforeunload handler runs
|
|
||||||
|
|
||||||
expect(persistedTreeDataKeys()).toEqual([]);
|
|
||||||
|
|
||||||
// Only PERSISTENCE is disabled: the in-memory atom keeps working, so the
|
|
||||||
// UI stays intact during the brief pre-redirect window.
|
|
||||||
expect(store.get(treeDataAtom).map((n) => n.id)).toEqual(["late"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,206 +1,8 @@
|
|||||||
import { atom } from "jotai";
|
import { atom } from "jotai";
|
||||||
import { atomFamily, atomWithStorage } from "jotai/utils";
|
|
||||||
import { SpaceTreeNode } from "@/features/page/tree/types";
|
import { SpaceTreeNode } from "@/features/page/tree/types";
|
||||||
import { appendNodeChildren } from "../utils";
|
import { appendNodeChildren } from "../utils";
|
||||||
import {
|
|
||||||
OPEN_TREE_NODES_KEY_PREFIX,
|
|
||||||
scopeKeyAtom,
|
|
||||||
} from "./open-tree-nodes-atom";
|
|
||||||
|
|
||||||
// The sidebar tree is persisted to localStorage so a page reload can paint the
|
export const treeDataAtom = atom<SpaceTreeNode[]>([]);
|
||||||
// last-known tree IMMEDIATELY (no blank sidebar while the root query runs) and
|
|
||||||
// then reconcile with the server in the background. localStorage is a BOOT
|
|
||||||
// CACHE only — the in-memory atom stays the source of truth while the app runs.
|
|
||||||
|
|
||||||
// Trailing-debounce machinery for the localStorage writes. The tree is
|
|
||||||
// rewritten on every lazy load / drag / socket event; serializing a large tree
|
|
||||||
// on each update would burn CPU and thrash the storage quota, so writes are
|
|
||||||
// coalesced (~500 ms per burst) and only the latest value per key is flushed.
|
|
||||||
const WRITE_DEBOUNCE_MS = 500;
|
|
||||||
|
|
||||||
// Single source of truth for the tree-cache localStorage key prefix. The `v1`
|
|
||||||
// segment versions the cached node shape (bump it when SpaceTreeNode changes
|
|
||||||
// incompatibly). Shared by the storage key construction below AND the logout
|
|
||||||
// sweep in clearPersistedTreeCaches() so the two can never drift apart.
|
|
||||||
export const TREE_DATA_KEY_PREFIX = "treeData:v1:";
|
|
||||||
|
|
||||||
// Size guard: skip persisting trees whose JSON exceeds ~4M chars. localStorage
|
|
||||||
// quota is typically ~5 MB per origin; a huge tree must not evict everything
|
|
||||||
// else or spam QuotaExceededError on every debounce tick.
|
|
||||||
const MAX_SERIALIZED_LENGTH = 4_000_000;
|
|
||||||
|
|
||||||
const pendingWrites = new Map<string, SpaceTreeNode[]>();
|
|
||||||
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
let writeFailureWarned = false;
|
|
||||||
|
|
||||||
// Persistence kill-switch, armed by clearPersistedTreeCaches(). Once set, the
|
|
||||||
// debounced setItem and the flush become no-ops so nothing can be written back
|
|
||||||
// to localStorage AFTER the logout sweep: a websocket tree event landing while
|
|
||||||
// `await logout()` is still in flight would otherwise re-queue a write that
|
|
||||||
// the `beforeunload` flush (fired by the redirect) silently resurrects.
|
|
||||||
// Intentionally never reset: every caller of clearPersistedTreeCaches()
|
|
||||||
// immediately navigates away with a full page load
|
|
||||||
// (window.location.replace/href), so this module instance is torn down anyway.
|
|
||||||
// Only PERSISTENCE stops — the in-memory atoms keep working, so the UI stays
|
|
||||||
// intact during the brief pre-redirect window.
|
|
||||||
let persistenceDisabled = false;
|
|
||||||
|
|
||||||
function writeNow(key: string, value: SpaceTreeNode[]): void {
|
|
||||||
try {
|
|
||||||
const serialized = JSON.stringify(value);
|
|
||||||
if (serialized.length > MAX_SERIALIZED_LENGTH) {
|
|
||||||
// Warn ONCE, like the quota branch below: a >4M-char tree re-serializes on
|
|
||||||
// every ~500ms debounce tick while it's edited, so an un-gated warn would
|
|
||||||
// spam the console on each flush.
|
|
||||||
if (!writeFailureWarned) {
|
|
||||||
writeFailureWarned = true;
|
|
||||||
console.warn("[tree] cached tree too large to persist; skipping", key);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
localStorage.setItem(key, serialized);
|
|
||||||
} catch (err) {
|
|
||||||
// QuotaExceededError, private mode, jsdom shims without working storage…
|
|
||||||
// The cache is best-effort: warn once, keep the in-memory tree working.
|
|
||||||
if (!writeFailureWarned) {
|
|
||||||
writeFailureWarned = true;
|
|
||||||
console.warn("[tree] failed to persist tree cache", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exported so tests can force the debounced write synchronously; production
|
|
||||||
// code must never need it (the beforeunload hook below covers reloads).
|
|
||||||
export function flushPendingTreeDataWrites(): void {
|
|
||||||
if (flushTimer !== null) {
|
|
||||||
clearTimeout(flushTimer);
|
|
||||||
flushTimer = null;
|
|
||||||
}
|
|
||||||
if (persistenceDisabled) {
|
|
||||||
// Belt-and-braces: after logout nothing may reach localStorage, even via
|
|
||||||
// the beforeunload flush racing the redirect. Drop anything queued.
|
|
||||||
pendingWrites.clear();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (const [key, value] of pendingWrites) {
|
|
||||||
writeNow(key, value);
|
|
||||||
}
|
|
||||||
pendingWrites.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Logout hygiene: the tree cache stores PAGE TITLES, so leaving it behind
|
|
||||||
// would keep them readable in localStorage on a shared machine after logout.
|
|
||||||
// Sweep by key prefix (not just the current scope) so stale scopes — old
|
|
||||||
// users, the `anon:anon` fallback — are purged too. Pending debounced writes
|
|
||||||
// are DISCARDED first (not flushed): a queued write firing after the sweep
|
|
||||||
// would silently resurrect a removed key.
|
|
||||||
export function clearPersistedTreeCaches(): void {
|
|
||||||
// Disable persistence FIRST so no write can be queued (or flushed) between
|
|
||||||
// the sweep below and the full-page navigation every caller performs next.
|
|
||||||
persistenceDisabled = true;
|
|
||||||
if (flushTimer !== null) {
|
|
||||||
clearTimeout(flushTimer);
|
|
||||||
flushTimer = null;
|
|
||||||
}
|
|
||||||
pendingWrites.clear();
|
|
||||||
try {
|
|
||||||
// Collect matching keys BEFORE removing: deleting while iterating
|
|
||||||
// `localStorage.key(i)` shifts the indices and skips entries.
|
|
||||||
const keysToRemove: string[] = [];
|
|
||||||
for (let i = 0; i < localStorage.length; i++) {
|
|
||||||
const key = localStorage.key(i);
|
|
||||||
if (
|
|
||||||
key !== null &&
|
|
||||||
(key.startsWith(TREE_DATA_KEY_PREFIX) ||
|
|
||||||
key.startsWith(OPEN_TREE_NODES_KEY_PREFIX))
|
|
||||||
) {
|
|
||||||
keysToRemove.push(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const key of keysToRemove) {
|
|
||||||
localStorage.removeItem(key);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Best-effort: disabled storage / jsdom shims must never break logout.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Flush the pending debounced write on unload so a reload right after a tree
|
|
||||||
// change doesn't lose the newest state (the debounce would otherwise eat it).
|
|
||||||
if (
|
|
||||||
typeof window !== "undefined" &&
|
|
||||||
typeof window.addEventListener === "function"
|
|
||||||
) {
|
|
||||||
window.addEventListener("beforeunload", flushPendingTreeDataWrites);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Custom sync storage for the tree cache. Deliberately NO `subscribe` key:
|
|
||||||
// cross-tab sync would REPLACE this tab's tree wholesale and clobber in-flight
|
|
||||||
// lazy loads; websockets already keep every open tab live. Each tab keeps its
|
|
||||||
// own in-memory tree — localStorage only seeds the next boot.
|
|
||||||
const treeDataStorage = {
|
|
||||||
getItem: (key: string, initialValue: SpaceTreeNode[]): SpaceTreeNode[] => {
|
|
||||||
// Defensive: jsdom test shims may lack methods, stored JSON may be
|
|
||||||
// corrupted or of a wrong shape. Any failure falls back to the empty tree.
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(key);
|
|
||||||
if (raw === null) return initialValue;
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
return Array.isArray(parsed) ? (parsed as SpaceTreeNode[]) : initialValue;
|
|
||||||
} catch {
|
|
||||||
return initialValue;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
setItem: (key: string, newValue: SpaceTreeNode[]): void => {
|
|
||||||
// After logout the cache must stay purged: neither queue the write nor arm
|
|
||||||
// a new flush timer (see persistenceDisabled above). The in-memory atom
|
|
||||||
// value is unaffected — only the localStorage mirror is frozen.
|
|
||||||
if (persistenceDisabled) return;
|
|
||||||
pendingWrites.set(key, newValue);
|
|
||||||
if (flushTimer !== null) clearTimeout(flushTimer);
|
|
||||||
flushTimer = setTimeout(flushPendingTreeDataWrites, WRITE_DEBOUNCE_MS);
|
|
||||||
},
|
|
||||||
removeItem: (key: string): void => {
|
|
||||||
pendingWrites.delete(key);
|
|
||||||
try {
|
|
||||||
localStorage.removeItem(key);
|
|
||||||
} catch {
|
|
||||||
/* best-effort cache — ignore */
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// One persisted tree per (workspace, user) — same scoping rationale as the
|
|
||||||
// open-map atom (accounts sharing a browser origin must not leak trees).
|
|
||||||
// `getOnInit: true` reads localStorage synchronously at atom init, so the very
|
|
||||||
// first render already has the cached tree — no blank-then-jump sidebar.
|
|
||||||
const treeDataFamily = atomFamily((scopeKey: string) =>
|
|
||||||
atomWithStorage<SpaceTreeNode[]>(
|
|
||||||
`${TREE_DATA_KEY_PREFIX}${scopeKey}`,
|
|
||||||
[],
|
|
||||||
treeDataStorage,
|
|
||||||
{ getOnInit: true },
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Public facade — same read value (SpaceTreeNode[]) and same setter shape
|
|
||||||
// (value OR functional updater) as the previous in-memory atom, transparently
|
|
||||||
// routed to the persisted tree of the current workspace/user.
|
|
||||||
export const treeDataAtom = atom(
|
|
||||||
(get) => get(treeDataFamily(get(scopeKeyAtom))),
|
|
||||||
(
|
|
||||||
get,
|
|
||||||
set,
|
|
||||||
update: SpaceTreeNode[] | ((prev: SpaceTreeNode[]) => SpaceTreeNode[]),
|
|
||||||
) => {
|
|
||||||
const target = treeDataFamily(get(scopeKeyAtom));
|
|
||||||
const next =
|
|
||||||
typeof update === "function"
|
|
||||||
? (update as (prev: SpaceTreeNode[]) => SpaceTreeNode[])(get(target))
|
|
||||||
: update;
|
|
||||||
set(target, next);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// Atom
|
// Atom
|
||||||
export const appendNodeChildrenAtom = atom(
|
export const appendNodeChildrenAtom = atom(
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
|
||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
|
||||||
|
// --- Mocks for the heavy / networked module graph ---------------------------
|
||||||
|
// NodeMenu pulls in query hooks, page services, websocket emit, i18n,
|
||||||
|
// notifications and three modal children. The F1 "make available offline"
|
||||||
|
// guarantee lives entirely inside handleMakeAvailableOffline, so we mock the
|
||||||
|
// two offline helpers + the collab-token hook + notifications and stub away
|
||||||
|
// everything else so the menu renders in isolation. matchMedia (read by
|
||||||
|
// MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||||
|
|
||||||
|
// vi.mock factories are hoisted above imports, so the shared spies they
|
||||||
|
// reference must be declared with vi.hoisted (hoisted as well).
|
||||||
|
const h = vi.hoisted(() => ({
|
||||||
|
makePageAvailableOffline: vi.fn(),
|
||||||
|
warmPageYdoc: vi.fn(),
|
||||||
|
notificationsShow: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/offline/make-offline", () => ({
|
||||||
|
makePageAvailableOffline: (...args: unknown[]) =>
|
||||||
|
h.makePageAvailableOffline(...args),
|
||||||
|
warmPageYdoc: (...args: unknown[]) => h.warmPageYdoc(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@mantine/notifications", () => ({
|
||||||
|
notifications: { show: (...args: unknown[]) => h.notificationsShow(...args) },
|
||||||
|
}));
|
||||||
|
|
||||||
|
// t is identity so assertions can match the real source strings by key.
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({ t: (key: string) => key }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-router-dom", () => ({
|
||||||
|
useParams: () => ({ spaceSlug: "space-slug" }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/auth/queries/auth-query.tsx", () => ({
|
||||||
|
useCollabToken: () => ({ data: { token: "collab-token" } }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/config.ts", () => ({
|
||||||
|
getCollaborationUrl: () => "wss://collab.example",
|
||||||
|
getAppUrl: () => "https://app.example",
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/page/tree/hooks/use-tree-mutation.ts", () => ({
|
||||||
|
useTreeMutation: () => ({ handleDelete: vi.fn() }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/websocket/use-query-emit.ts", () => ({
|
||||||
|
useQueryEmit: () => vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/favorite/queries/favorite-query", () => ({
|
||||||
|
useFavoriteIds: () => new Set<string>(),
|
||||||
|
useAddFavoriteMutation: () => ({ mutate: vi.fn() }),
|
||||||
|
useRemoveFavoriteMutation: () => ({ mutate: vi.fn() }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/page-embed/queries/page-embed-query", () => ({
|
||||||
|
useToggleTemplateMutation: () => ({ mutateAsync: vi.fn() }),
|
||||||
|
useToggleTemporaryMutation: () => ({ mutateAsync: vi.fn() }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/page/services/page-service.ts", () => ({
|
||||||
|
duplicatePage: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// The modal children drag in export / move / copy stacks we never exercise.
|
||||||
|
vi.mock("@/components/common/export-modal", () => ({ default: () => null }));
|
||||||
|
vi.mock("@/features/page/components/move-page-modal.tsx", () => ({
|
||||||
|
default: () => null,
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/page/components/copy-page-modal.tsx", () => ({
|
||||||
|
default: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { NodeMenu } from "./space-tree-node-menu";
|
||||||
|
import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||||
|
|
||||||
|
function node(): SpaceTreeNode {
|
||||||
|
return {
|
||||||
|
id: "page-1",
|
||||||
|
slugId: "slug-1",
|
||||||
|
name: "My Page",
|
||||||
|
icon: undefined,
|
||||||
|
position: "a0",
|
||||||
|
spaceId: "space-1",
|
||||||
|
parentPageId: null as unknown as string,
|
||||||
|
hasChildren: false,
|
||||||
|
children: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMenu() {
|
||||||
|
render(
|
||||||
|
<MantineProvider>
|
||||||
|
<NodeMenu node={node()} canEdit={true} />
|
||||||
|
</MantineProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open the menu (click the dots target) and click "Make available offline".
|
||||||
|
async function triggerMakeAvailableOffline() {
|
||||||
|
// Before opening, the only button is the menu target ActionIcon.
|
||||||
|
fireEvent.click(screen.getByRole("button"));
|
||||||
|
const item = await screen.findByText("Make available offline");
|
||||||
|
fireEvent.click(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The handler always fires a leading "Saving page for offline use..." toast and
|
||||||
|
// then the result/error toast — so the LAST show() call is the outcome we pin.
|
||||||
|
function lastShown(): { message?: string; color?: string } {
|
||||||
|
const calls = h.notificationsShow.mock.calls;
|
||||||
|
return calls[calls.length - 1]?.[0] ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
h.makePageAvailableOffline.mockReset();
|
||||||
|
h.warmPageYdoc.mockReset();
|
||||||
|
h.notificationsShow.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("NodeMenu — make available offline (F1 guarantee)", () => {
|
||||||
|
it("full success: read queries warmed AND ydoc synced → success toast with no error color", async () => {
|
||||||
|
h.makePageAvailableOffline.mockResolvedValue({ ok: true, failed: [] });
|
||||||
|
h.warmPageYdoc.mockResolvedValue(true);
|
||||||
|
|
||||||
|
renderMenu();
|
||||||
|
await triggerMakeAvailableOffline();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(lastShown().message).toBe("Page is now available offline");
|
||||||
|
});
|
||||||
|
// Success path: no red color (the gate `result.ok && ydocSynced` held).
|
||||||
|
expect(lastShown().color).toBeUndefined();
|
||||||
|
// warmPageYdoc was consulted with the page id, collab url and token.
|
||||||
|
expect(h.warmPageYdoc).toHaveBeenCalledWith(
|
||||||
|
"page-1",
|
||||||
|
"wss://collab.example",
|
||||||
|
"collab-token",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ydoc NOT synced: read queries ok but warmPageYdoc=false → RED toast naming 'editor'", async () => {
|
||||||
|
// F1: a page whose editor body never landed in IndexedDB must NOT be
|
||||||
|
// reported as available offline, even though every read query succeeded.
|
||||||
|
h.makePageAvailableOffline.mockResolvedValue({ ok: true, failed: [] });
|
||||||
|
h.warmPageYdoc.mockResolvedValue(false);
|
||||||
|
|
||||||
|
renderMenu();
|
||||||
|
await triggerMakeAvailableOffline();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(lastShown().color).toBe("red");
|
||||||
|
});
|
||||||
|
expect(lastShown().message).toContain("editor");
|
||||||
|
expect(lastShown().message).not.toBe("Page is now available offline");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("read-query failures: failed=['page','comments'] → RED toast naming the failed steps", async () => {
|
||||||
|
h.makePageAvailableOffline.mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
failed: ["page", "comments"],
|
||||||
|
});
|
||||||
|
h.warmPageYdoc.mockResolvedValue(true);
|
||||||
|
|
||||||
|
renderMenu();
|
||||||
|
await triggerMakeAvailableOffline();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(lastShown().color).toBe("red");
|
||||||
|
});
|
||||||
|
expect(lastShown().message).toContain("page");
|
||||||
|
expect(lastShown().message).toContain("comments");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("thrown error: rejection's response.data.message is extracted into the RED toast", async () => {
|
||||||
|
h.makePageAvailableOffline.mockRejectedValue({
|
||||||
|
response: { data: { message: "boom" } },
|
||||||
|
});
|
||||||
|
h.warmPageYdoc.mockResolvedValue(true);
|
||||||
|
|
||||||
|
renderMenu();
|
||||||
|
await triggerMakeAvailableOffline();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(lastShown().color).toBe("red");
|
||||||
|
});
|
||||||
|
expect(lastShown().message).toContain("boom");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,6 +7,7 @@ import { notifications } from "@mantine/notifications";
|
|||||||
import {
|
import {
|
||||||
IconArrowRight,
|
IconArrowRight,
|
||||||
IconClockHour4,
|
IconClockHour4,
|
||||||
|
IconCloudDownload,
|
||||||
IconCopy,
|
IconCopy,
|
||||||
IconDotsVertical,
|
IconDotsVertical,
|
||||||
IconFileExport,
|
IconFileExport,
|
||||||
@@ -35,6 +36,12 @@ import {
|
|||||||
useToggleTemplateMutation,
|
useToggleTemplateMutation,
|
||||||
useToggleTemporaryMutation,
|
useToggleTemporaryMutation,
|
||||||
} from "@/features/page-embed/queries/page-embed-query";
|
} from "@/features/page-embed/queries/page-embed-query";
|
||||||
|
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
||||||
|
import { getCollaborationUrl } from "@/lib/config.ts";
|
||||||
|
import {
|
||||||
|
makePageAvailableOffline,
|
||||||
|
warmPageYdoc,
|
||||||
|
} from "@/features/offline/make-offline";
|
||||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||||
import { treeModel } from "@/features/page/tree/model/tree-model";
|
import { treeModel } from "@/features/page/tree/model/tree-model";
|
||||||
import { pageToTreeNode } from "@/features/page/tree/utils";
|
import { pageToTreeNode } from "@/features/page/tree/utils";
|
||||||
@@ -72,6 +79,57 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
|
|||||||
const isTemplate = !!node.isTemplate;
|
const isTemplate = !!node.isTemplate;
|
||||||
const toggleTemporary = useToggleTemporaryMutation();
|
const toggleTemporary = useToggleTemporaryMutation();
|
||||||
const isTemporary = !!node.temporaryExpiresAt;
|
const isTemporary = !!node.temporaryExpiresAt;
|
||||||
|
const { data: collabQuery } = useCollabToken();
|
||||||
|
|
||||||
|
const handleMakeAvailableOffline = async () => {
|
||||||
|
notifications.show({ message: t("Saving page for offline use...") });
|
||||||
|
try {
|
||||||
|
// Prefetch read queries so they get persisted to IndexedDB. The result
|
||||||
|
// reports whether every warm step succeeded.
|
||||||
|
const result = await makePageAvailableOffline({
|
||||||
|
pageId: node.id,
|
||||||
|
spaceId: node.spaceId,
|
||||||
|
});
|
||||||
|
// Warm the page's Yjs document into IndexedDB. For a wiki the editor body
|
||||||
|
// IS the page, so this only truly succeeds when the doc actually synced;
|
||||||
|
// a timeout/failure here must NOT be reported as offline-available.
|
||||||
|
const ydocSynced = await warmPageYdoc(
|
||||||
|
node.id,
|
||||||
|
getCollaborationUrl(),
|
||||||
|
collabQuery?.token,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fold a failed editor warm into the failed-step set so it surfaces in the
|
||||||
|
// same error UI as the read-query failures (the editor body never landed
|
||||||
|
// in IndexedDB, so the page would open blank offline).
|
||||||
|
const failed = ydocSynced ? result.failed : [...result.failed, "editor"];
|
||||||
|
|
||||||
|
if (result.ok && ydocSynced) {
|
||||||
|
notifications.show({ message: t("Page is now available offline") });
|
||||||
|
} else {
|
||||||
|
// Partial warm — the page may still be partly usable offline, but some
|
||||||
|
// queries (or the editor body) failed to cache, so surface it as an
|
||||||
|
// error rather than a silent success. Name the failed step(s) (AGENTS.md:
|
||||||
|
// errors must be specific, never a bare generic string).
|
||||||
|
notifications.show({
|
||||||
|
message: `${t("Failed to make page available offline")}: ${failed.join(", ")}`,
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// makePageAvailableOffline no longer throws, but warmPageYdoc and other
|
||||||
|
// unexpected failures stay guarded here. Log the raw error and surface the
|
||||||
|
// real cause to the user instead of a bare generic string (AGENTS.md).
|
||||||
|
console.error("handleMakeAvailableOffline failed", err);
|
||||||
|
const reason =
|
||||||
|
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||||
|
?.message ?? (err instanceof Error ? err.message : String(err));
|
||||||
|
notifications.show({
|
||||||
|
message: `${t("Failed to make page available offline")}: ${reason}`,
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleToggleTemplate = async () => {
|
const handleToggleTemplate = async () => {
|
||||||
const next = !isTemplate;
|
const next = !isTemplate;
|
||||||
@@ -228,6 +286,17 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
|
|||||||
{t("Export")}
|
{t("Export")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconCloudDownload size={16} />}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
handleMakeAvailableOffline();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("Make available offline")}
|
||||||
|
</Menu.Item>
|
||||||
|
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<>
|
<>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
|
|||||||
@@ -1,222 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
||||||
import { createRef } from "react";
|
|
||||||
import { render, act, waitFor, cleanup } from "@testing-library/react";
|
|
||||||
|
|
||||||
// --- Mocks for the heavy / networked module graph ---------------------------
|
|
||||||
// Same isolation strategy as space-tree.expand-all.test.tsx: everything that
|
|
||||||
// would otherwise need a real server / router / DnD stack is mocked. Here we
|
|
||||||
// additionally CAPTURE the DocTree props (onToggle + data) so the test can
|
|
||||||
// drive a lazy-load expand exactly as a row click would, and we control
|
|
||||||
// fetchAllAncestorChildren to assert the fresh fetch happens.
|
|
||||||
|
|
||||||
const fetchAllAncestorChildrenMock = vi.fn();
|
|
||||||
|
|
||||||
// Holder mutated by the DocTree stub each render so the test can read the
|
|
||||||
// latest tree it was handed and invoke its onToggle callback.
|
|
||||||
const docTree: {
|
|
||||||
onToggle?: (id: string, isOpen: boolean) => void | Promise<void>;
|
|
||||||
data: unknown[];
|
|
||||||
} = { data: [] };
|
|
||||||
|
|
||||||
vi.mock("@/features/page/services/page-service.ts", () => ({
|
|
||||||
getSpaceTree: vi.fn(),
|
|
||||||
getPageBreadcrumbs: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/features/page/queries/page-query.ts", () => ({
|
|
||||||
// No root pages and no further pages — the server data-load effect stays
|
|
||||||
// inert (isDataLoaded never flips), so refreshOpenBranches never runs and the
|
|
||||||
// test exercises ONLY the boot-prune + handleToggle lazy-load path against
|
|
||||||
// the hydrated cache we seed into the atom below.
|
|
||||||
useGetRootSidebarPagesQuery: () => ({
|
|
||||||
data: undefined,
|
|
||||||
hasNextPage: false,
|
|
||||||
fetchNextPage: vi.fn(),
|
|
||||||
isFetching: false,
|
|
||||||
}),
|
|
||||||
usePageQuery: () => ({ data: undefined }),
|
|
||||||
fetchAllAncestorChildren: (...args: unknown[]) =>
|
|
||||||
fetchAllAncestorChildrenMock(...args),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/features/page/tree/hooks/use-tree-mutation.ts", () => ({
|
|
||||||
useTreeMutation: () => ({ handleMove: vi.fn() }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@mantine/notifications", () => ({
|
|
||||||
notifications: { show: vi.fn() },
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("react-i18next", () => ({
|
|
||||||
useTranslation: () => ({ t: (key: string) => key }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("react-router-dom", () => ({
|
|
||||||
useParams: () => ({ pageSlug: undefined }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lib", () => ({
|
|
||||||
extractPageSlugId: () => undefined,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/lib/config.ts", () => ({
|
|
||||||
isCompactPageTreeEnabled: () => false,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Capture the props DocTree is rendered with instead of rendering anything.
|
|
||||||
vi.mock("./doc-tree", () => ({
|
|
||||||
DocTree: (props: { onToggle: (id: string, isOpen: boolean) => void; data: unknown[] }) => {
|
|
||||||
docTree.onToggle = props.onToggle;
|
|
||||||
docTree.data = props.data;
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
ROW_HEIGHT_COMPACT: 28,
|
|
||||||
ROW_HEIGHT_STANDARD: 32,
|
|
||||||
}));
|
|
||||||
vi.mock("./space-tree-row", () => ({
|
|
||||||
SpaceTreeRow: () => null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@mantine/core", () => ({
|
|
||||||
Text: ({ children }: { children?: unknown }) => children ?? null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// In-memory open-map (the real one is localStorage-backed and crashes under the
|
|
||||||
// jsdom shim). Empty at start of each test -> every branch is COLLAPSED, which
|
|
||||||
// is exactly the state we need to prove the boot-prune. `scopeKeyAtom` is
|
|
||||||
// re-exported because the persisted tree-data atom resolves its scope through it.
|
|
||||||
vi.mock("@/features/page/tree/atoms/open-tree-nodes-atom.ts", async () => {
|
|
||||||
const { atom } = await import("jotai");
|
|
||||||
type OpenMap = Record<string, boolean>;
|
|
||||||
const base = atom<OpenMap>({});
|
|
||||||
const openTreeNodesAtom = atom(
|
|
||||||
(get) => get(base),
|
|
||||||
(get, set, update: OpenMap | ((prev: OpenMap) => OpenMap)) => {
|
|
||||||
const next =
|
|
||||||
typeof update === "function"
|
|
||||||
? (update as (prev: OpenMap) => OpenMap)(get(base))
|
|
||||||
: update;
|
|
||||||
set(base, next);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const scopeKeyAtom = atom(() => "test-workspace:test-user");
|
|
||||||
return { openTreeNodesAtom, scopeKeyAtom };
|
|
||||||
});
|
|
||||||
|
|
||||||
import SpaceTree, { SpaceTreeApi } from "./space-tree";
|
|
||||||
import {
|
|
||||||
treeDataAtom,
|
|
||||||
flushPendingTreeDataWrites,
|
|
||||||
} from "@/features/page/tree/atoms/tree-data-atom.ts";
|
|
||||||
import { createStore, Provider } from "jotai";
|
|
||||||
import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
|
||||||
|
|
||||||
// The scopeKeyAtom mock resolves to this fixed scope, so the persisted
|
|
||||||
// tree-data atom hydrates from exactly this localStorage key at mount
|
|
||||||
// (getOnInit + atomWithStorage's onMount both read it).
|
|
||||||
const CACHE_KEY = "treeData:v1:test-workspace:test-user";
|
|
||||||
|
|
||||||
function child(
|
|
||||||
id: string,
|
|
||||||
parentPageId: string,
|
|
||||||
hasChildren = false,
|
|
||||||
): SpaceTreeNode {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
slugId: `slug-${id}`,
|
|
||||||
name: id,
|
|
||||||
position: "a0",
|
|
||||||
spaceId: "space-1",
|
|
||||||
parentPageId,
|
|
||||||
hasChildren,
|
|
||||||
children: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// A hydrated boot cache: a COLLAPSED branch (not in the open-map) that still
|
|
||||||
// carries a stale cached child — the exact shape a previous session left behind
|
|
||||||
// after the branch was expanded then collapsed then persisted.
|
|
||||||
function cachedTreeWithCollapsedBranch(): SpaceTreeNode[] {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
id: "branch",
|
|
||||||
slugId: "slug-branch",
|
|
||||||
name: "branch",
|
|
||||||
position: "a0",
|
|
||||||
spaceId: "space-1",
|
|
||||||
parentPageId: null as unknown as string,
|
|
||||||
hasChildren: true,
|
|
||||||
children: [child("stale", "branch")],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
fetchAllAncestorChildrenMock.mockReset();
|
|
||||||
docTree.onToggle = undefined;
|
|
||||||
docTree.data = [];
|
|
||||||
// Flush any pending debounced write from a previous test before clearing.
|
|
||||||
flushPendingTreeDataWrites();
|
|
||||||
try {
|
|
||||||
localStorage.clear?.();
|
|
||||||
} catch {
|
|
||||||
/* fresh store per test isolates state */
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("SpaceTree boot-cache prune (#159 #8 stale collapsed children)", () => {
|
|
||||||
it("drops a collapsed cached branch's children on boot and fetches fresh on first expand", async () => {
|
|
||||||
// Server returns FRESH children on the lazy-load: the stale cached child is
|
|
||||||
// gone, a renamed/new one takes its place.
|
|
||||||
fetchAllAncestorChildrenMock.mockResolvedValue([child("fresh", "branch")]);
|
|
||||||
|
|
||||||
// Simulate the localStorage-hydrated boot cache: seed the persisted key
|
|
||||||
// BEFORE mount so the atom hydrates it (store.set would be clobbered by
|
|
||||||
// atomWithStorage's onMount re-reading storage — this is the real path).
|
|
||||||
localStorage.setItem(
|
|
||||||
CACHE_KEY,
|
|
||||||
JSON.stringify(cachedTreeWithCollapsedBranch()),
|
|
||||||
);
|
|
||||||
|
|
||||||
const store = createStore();
|
|
||||||
const ref = createRef<SpaceTreeApi>();
|
|
||||||
render(
|
|
||||||
<Provider store={store}>
|
|
||||||
<SpaceTree ref={ref} spaceId="space-1" readOnly={false} />
|
|
||||||
</Provider>,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Boot-prune ran at mount: the COLLAPSED branch's cached children were
|
|
||||||
// dropped to the unloaded shape ([]), so the stale child is no longer there.
|
|
||||||
const branchAfterBoot = docTree.data.find(
|
|
||||||
(n) => (n as SpaceTreeNode).id === "branch",
|
|
||||||
) as SpaceTreeNode;
|
|
||||||
expect(branchAfterBoot.children).toEqual([]);
|
|
||||||
expect(branchAfterBoot.hasChildren).toBe(true);
|
|
||||||
|
|
||||||
// First expand of the collapsed branch after boot must lazy-load fresh
|
|
||||||
// children (before this fix the cached children were kept and the fetch
|
|
||||||
// was skipped, showing stale data).
|
|
||||||
await act(async () => {
|
|
||||||
await docTree.onToggle!("branch", true);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(fetchAllAncestorChildrenMock).toHaveBeenCalledTimes(1);
|
|
||||||
expect(fetchAllAncestorChildrenMock).toHaveBeenCalledWith({
|
|
||||||
pageId: "branch",
|
|
||||||
spaceId: "space-1",
|
|
||||||
});
|
|
||||||
|
|
||||||
// The fresh children replaced the stale cache in the live tree.
|
|
||||||
await waitFor(() => {
|
|
||||||
const branch = store
|
|
||||||
.get(treeDataAtom)
|
|
||||||
.find((n) => n.id === "branch")!;
|
|
||||||
expect(branch.children.map((c) => c.id)).toEqual(["fresh"]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -71,8 +71,7 @@ vi.mock("@mantine/core", () => ({
|
|||||||
// getOnInit), which crashes under jsdom's localStorage shim here. Swap in a
|
// getOnInit), which crashes under jsdom's localStorage shim here. Swap in a
|
||||||
// plain in-memory atom with the same read value (OpenMap) and the same setter
|
// plain in-memory atom with the same read value (OpenMap) and the same setter
|
||||||
// shape (value OR functional updater) so the component's open-state logic runs
|
// shape (value OR functional updater) so the component's open-state logic runs
|
||||||
// unchanged while staying inside the test store. `scopeKeyAtom` is also
|
// unchanged while staying inside the test store.
|
||||||
// re-exported (the real module exports it for the persisted tree-data atom).
|
|
||||||
vi.mock("@/features/page/tree/atoms/open-tree-nodes-atom.ts", async () => {
|
vi.mock("@/features/page/tree/atoms/open-tree-nodes-atom.ts", async () => {
|
||||||
const { atom } = await import("jotai");
|
const { atom } = await import("jotai");
|
||||||
type OpenMap = Record<string, boolean>;
|
type OpenMap = Record<string, boolean>;
|
||||||
@@ -87,17 +86,11 @@ vi.mock("@/features/page/tree/atoms/open-tree-nodes-atom.ts", async () => {
|
|||||||
set(base, next);
|
set(base, next);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
// Fixed scope key: the tree-data atom family resolves through this, so all
|
return { openTreeNodesAtom };
|
||||||
// tests read/write the same (empty at start of each test) storage key.
|
|
||||||
const scopeKeyAtom = atom(() => "test-workspace:test-user");
|
|
||||||
return { openTreeNodesAtom, scopeKeyAtom };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
import SpaceTree, { SpaceTreeApi } from "./space-tree";
|
import SpaceTree, { SpaceTreeApi } from "./space-tree";
|
||||||
import {
|
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||||
treeDataAtom,
|
|
||||||
flushPendingTreeDataWrites,
|
|
||||||
} from "@/features/page/tree/atoms/tree-data-atom.ts";
|
|
||||||
import { openTreeNodesAtom } from "@/features/page/tree/atoms/open-tree-nodes-atom.ts";
|
import { openTreeNodesAtom } from "@/features/page/tree/atoms/open-tree-nodes-atom.ts";
|
||||||
import { createStore, Provider } from "jotai";
|
import { createStore, Provider } from "jotai";
|
||||||
import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||||
@@ -141,10 +134,6 @@ function renderTree(store: ReturnType<typeof createStore>) {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
getSpaceTreeMock.mockReset();
|
getSpaceTreeMock.mockReset();
|
||||||
notificationsShowMock.mockReset();
|
notificationsShowMock.mockReset();
|
||||||
// The tree-data atom persists via a ~500 ms trailing debounce; flush it NOW
|
|
||||||
// (cancelling the timer) so a previous test's pending write can't land in
|
|
||||||
// storage mid-test after the clear below.
|
|
||||||
flushPendingTreeDataWrites();
|
|
||||||
// jsdom's localStorage shim here lacks `clear`; guard it. Each test uses a
|
// jsdom's localStorage shim here lacks `clear`; guard it. Each test uses a
|
||||||
// fresh jotai store anyway, so cross-test open-state never leaks.
|
// fresh jotai store anyway, so cross-test open-state never leaks.
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ import {
|
|||||||
openBranches,
|
openBranches,
|
||||||
closeIds,
|
closeIds,
|
||||||
loadedOpenBranchIds,
|
loadedOpenBranchIds,
|
||||||
pruneCollapsedChildren,
|
|
||||||
} from "@/features/page/tree/utils/utils.ts";
|
} from "@/features/page/tree/utils/utils.ts";
|
||||||
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||||
import { treeModel } from "@/features/page/tree/model/tree-model";
|
import { treeModel } from "@/features/page/tree/model/tree-model";
|
||||||
@@ -200,81 +199,45 @@ const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(function SpaceTree(
|
|||||||
const openIdsRef = useRef(openIds);
|
const openIdsRef = useRef(openIds);
|
||||||
openIdsRef.current = openIds;
|
openIdsRef.current = openIds;
|
||||||
|
|
||||||
// Boot-cache hygiene (#159 #8): the localStorage-hydrated tree carries the
|
// Reconnect refresh (#159 #8): on a socket reconnect, re-fetch and reconcile
|
||||||
// children of every branch ever expanded, including ones now COLLAPSED. Their
|
// the children of every currently-open, already-loaded branch of THIS space,
|
||||||
// first expand would skip the lazy-load and render stale children (a
|
|
||||||
// rename/move/delete missed while offline). Drop the cached children of every
|
|
||||||
// COLLAPSED branch ONCE at mount so its first expand fetches fresh via
|
|
||||||
// handleToggle — exactly as it did before the tree was cached. OPEN branches
|
|
||||||
// keep their children and are refreshed by refreshOpenBranches instead, so
|
|
||||||
// this runs before any expand and never double-fetches an open branch.
|
|
||||||
const prunedBootCacheRef = useRef(false);
|
|
||||||
useEffect(() => {
|
|
||||||
if (prunedBootCacheRef.current) return;
|
|
||||||
prunedBootCacheRef.current = true;
|
|
||||||
setData((prev) => pruneCollapsedChildren(prev, openIdsRef.current));
|
|
||||||
}, [setData]);
|
|
||||||
|
|
||||||
// Re-fetch and reconcile the children of every currently-open, already-loaded
|
|
||||||
// branch of THIS space. Shared by the socket reconnect handler and the
|
|
||||||
// post-load cache refresh below. The ROOT level is reconciled separately by
|
|
||||||
// the root-query refetch + mergeRootTrees; an UNLOADED branch is skipped
|
|
||||||
// (lazy-load fetches it fresh on expand). Reads refs so it always sees the
|
|
||||||
// latest tree/open-state/space without re-creating the callback.
|
|
||||||
const refreshOpenBranches = useCallback(async () => {
|
|
||||||
const effectSpaceId = spaceIdRef.current;
|
|
||||||
const branchIds = loadedOpenBranchIds(
|
|
||||||
dataRef.current.filter((n) => n?.spaceId === effectSpaceId),
|
|
||||||
openIdsRef.current,
|
|
||||||
);
|
|
||||||
if (branchIds.length === 0) return;
|
|
||||||
for (const id of branchIds) {
|
|
||||||
try {
|
|
||||||
// `fresh: true` bypasses the 30-min sidebar-pages cache so the
|
|
||||||
// reconcile sees the server's CURRENT children (handler-order
|
|
||||||
// independent — no reliance on the global reconnect invalidation).
|
|
||||||
const fresh = await fetchAllAncestorChildren(
|
|
||||||
{ pageId: id, spaceId: effectSpaceId },
|
|
||||||
{ fresh: true },
|
|
||||||
);
|
|
||||||
if (spaceIdRef.current !== effectSpaceId) return; // space switched
|
|
||||||
setData((prev) => treeModel.reconcileChildren(prev, id, fresh));
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[tree] open branch refresh failed", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [setData]);
|
|
||||||
|
|
||||||
// Reconnect refresh (#159 #8): on a socket reconnect, refresh open branches
|
|
||||||
// so a move/rename/delete that happened INSIDE a loaded branch while events
|
// so a move/rename/delete that happened INSIDE a loaded branch while events
|
||||||
// were missed (laptop sleep / wifi gap) is reflected instead of left stale.
|
// were missed (laptop sleep / wifi gap) is reflected instead of left stale.
|
||||||
// No first-connect guard is needed: space-tree usually mounts AFTER the
|
// The ROOT level is reconciled separately by the root-query refetch +
|
||||||
// initial connect, so every `connect` it sees is a reconnect; the rare
|
// mergeRootTrees; an UNLOADED branch is skipped (lazy-load fetches it fresh on
|
||||||
|
// expand). No first-connect guard is needed: space-tree usually mounts AFTER
|
||||||
|
// the initial connect, so every `connect` it sees is a reconnect; the rare
|
||||||
// initial-connect case has an empty tree, so the refresh is a harmless no-op.
|
// initial-connect case has an empty tree, so the refresh is a harmless no-op.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!socket) return;
|
if (!socket) return;
|
||||||
const onConnect = () => {
|
const onConnect = async () => {
|
||||||
refreshOpenBranches();
|
const effectSpaceId = spaceIdRef.current;
|
||||||
|
const branchIds = loadedOpenBranchIds(
|
||||||
|
dataRef.current.filter((n) => n?.spaceId === effectSpaceId),
|
||||||
|
openIdsRef.current,
|
||||||
|
);
|
||||||
|
if (branchIds.length === 0) return;
|
||||||
|
for (const id of branchIds) {
|
||||||
|
try {
|
||||||
|
// `fresh: true` bypasses the 30-min sidebar-pages cache so the
|
||||||
|
// reconcile sees the server's CURRENT children (handler-order
|
||||||
|
// independent — no reliance on the global reconnect invalidation).
|
||||||
|
const fresh = await fetchAllAncestorChildren(
|
||||||
|
{ pageId: id, spaceId: effectSpaceId },
|
||||||
|
{ fresh: true },
|
||||||
|
);
|
||||||
|
if (spaceIdRef.current !== effectSpaceId) return; // space switched
|
||||||
|
setData((prev) => treeModel.reconcileChildren(prev, id, fresh));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[tree] reconnect branch refresh failed", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
socket.on("connect", onConnect);
|
socket.on("connect", onConnect);
|
||||||
return () => {
|
return () => {
|
||||||
socket.off("connect", onConnect);
|
socket.off("connect", onConnect);
|
||||||
};
|
};
|
||||||
}, [socket, refreshOpenBranches]);
|
}, [socket, setData]);
|
||||||
|
|
||||||
// Post-load cache refresh: the sidebar paints instantly from the
|
|
||||||
// localStorage-cached tree, so children of open branches may be stale. Once
|
|
||||||
// the server root set has been merged for this space (isDataLoaded flips
|
|
||||||
// true), refresh every open, already-loaded branch ONCE per space per mount.
|
|
||||||
// dataRef.current is already up to date here: refs are assigned during
|
|
||||||
// render, and this effect runs after the merge-triggered re-render commit.
|
|
||||||
const refreshedSpacesRef = useRef<Set<string>>(new Set());
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isDataLoaded) return;
|
|
||||||
if (refreshedSpacesRef.current.has(spaceId)) return;
|
|
||||||
refreshedSpacesRef.current.add(spaceId);
|
|
||||||
refreshOpenBranches();
|
|
||||||
}, [isDataLoaded, spaceId, refreshOpenBranches]);
|
|
||||||
|
|
||||||
const handleToggle = useCallback(
|
const handleToggle = useCallback(
|
||||||
async (id: string, isOpen: boolean) => {
|
async (id: string, isOpen: boolean) => {
|
||||||
@@ -370,17 +333,12 @@ const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(function SpaceTree(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classes.treeContainer}>
|
<div className={classes.treeContainer}>
|
||||||
{/* "No pages yet" only after the SERVER confirmed the space is empty —
|
|
||||||
never while just the localStorage cache is empty. */}
|
|
||||||
{isDataLoaded && filteredData.length === 0 && (
|
{isDataLoaded && filteredData.length === 0 && (
|
||||||
<Text size="xs" c="dimmed" py="xs" px="sm">
|
<Text size="xs" c="dimmed" py="xs" px="sm">
|
||||||
{t("No pages yet")}
|
{t("No pages yet")}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{/* Cache-first paint: render as soon as ANY data exists (synchronous
|
{isDataLoaded && filteredData.length > 0 && (
|
||||||
localStorage hydration) instead of waiting for the server round-trip;
|
|
||||||
the background merge/refresh reconciles it afterwards. */}
|
|
||||||
{filteredData.length > 0 && (
|
|
||||||
<DocTree<SpaceTreeNode>
|
<DocTree<SpaceTreeNode>
|
||||||
data={filteredData}
|
data={filteredData}
|
||||||
openIds={openIds}
|
openIds={openIds}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
useCreatePageMutation,
|
useCreatePageMutation,
|
||||||
useRemovePageMutation,
|
useRemovePageMutation,
|
||||||
useMovePageMutation,
|
useMovePageMutation,
|
||||||
useUpdatePageMutation,
|
|
||||||
updateCacheOnMovePage,
|
updateCacheOnMovePage,
|
||||||
} from "@/features/page/queries/page-query.ts";
|
} from "@/features/page/queries/page-query.ts";
|
||||||
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||||
@@ -27,7 +26,6 @@ export type UseTreeMutation = {
|
|||||||
parentId: string | null,
|
parentId: string | null,
|
||||||
opts?: { temporary?: boolean },
|
opts?: { temporary?: boolean },
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
handleRename: (id: string, name: string) => Promise<void>;
|
|
||||||
handleDelete: (id: string) => Promise<void>;
|
handleDelete: (id: string) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -39,7 +37,6 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
|||||||
// children) and then immediately invokes a handler.
|
// children) and then immediately invokes a handler.
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
const createPageMutation = useCreatePageMutation();
|
const createPageMutation = useCreatePageMutation();
|
||||||
const updatePageMutation = useUpdatePageMutation();
|
|
||||||
const removePageMutation = useRemovePageMutation();
|
const removePageMutation = useRemovePageMutation();
|
||||||
const movePageMutation = useMovePageMutation();
|
const movePageMutation = useMovePageMutation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -205,20 +202,6 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
|||||||
[spaceId, createPageMutation, setData, store, navigate, spaceSlug],
|
[spaceId, createPageMutation, setData, store, navigate, spaceSlug],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleRename = useCallback(
|
|
||||||
async (id: string, name: string) => {
|
|
||||||
setData((prev) =>
|
|
||||||
treeModel.update(prev, id, { name } as Partial<SpaceTreeNode>),
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
await updatePageMutation.mutateAsync({ pageId: id, title: name });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error updating page title:", error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[updatePageMutation, setData],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDelete = useCallback(
|
const handleDelete = useCallback(
|
||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
const node = treeModel.find(
|
const node = treeModel.find(
|
||||||
@@ -264,7 +247,7 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
|||||||
[removePageMutation, setData, store, pageSlug, navigate, spaceSlug],
|
[removePageMutation, setData, store, pageSlug, navigate, spaceSlug],
|
||||||
);
|
);
|
||||||
|
|
||||||
return { handleMove, handleCreate, handleRename, handleDelete };
|
return { handleMove, handleCreate, handleDelete };
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPageInNode(node: SpaceTreeNode, pageSlug: string): boolean {
|
function isPageInNode(node: SpaceTreeNode, pageSlug: string): boolean {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
closeIds,
|
closeIds,
|
||||||
mergeRootTrees,
|
mergeRootTrees,
|
||||||
loadedOpenBranchIds,
|
loadedOpenBranchIds,
|
||||||
pruneCollapsedChildren,
|
|
||||||
sortPositionKeys,
|
sortPositionKeys,
|
||||||
pageToTreeNode,
|
pageToTreeNode,
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
@@ -439,62 +438,3 @@ describe("loadedOpenBranchIds (#159 #8 reconnect refresh targets)", () => {
|
|||||||
expect(ids.sort()).toEqual(["a", "a1"]);
|
expect(ids.sort()).toEqual(["a", "a1"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("pruneCollapsedChildren", () => {
|
|
||||||
// Signature: pruneCollapsedChildren(tree: SpaceTreeNode[], openIds:
|
|
||||||
// ReadonlySet<string>): SpaceTreeNode[]. Collapsed nodes (id NOT in openIds)
|
|
||||||
// are reset to `children: []` (hasChildren untouched); open nodes keep their
|
|
||||||
// children but are recursed into so a collapsed branch nested under an open
|
|
||||||
// one is still pruned.
|
|
||||||
//
|
|
||||||
// Fixture:
|
|
||||||
// open "p" (in openIds, hasChildren)
|
|
||||||
// └─ collapsed "c" (NOT in openIds) with STALE child "g"
|
|
||||||
// collapsed "t" (NOT in openIds) with child "t1"
|
|
||||||
// Only "p" is open.
|
|
||||||
function fixture() {
|
|
||||||
const grandchild = treeNode("g"); // stale, cached under the collapsed child
|
|
||||||
const collapsedChild = treeNode("c", [grandchild]);
|
|
||||||
const openParent = treeNode("p", [collapsedChild]);
|
|
||||||
const topCollapsed = treeNode("t", [treeNode("t1")]);
|
|
||||||
return { openParent, collapsedChild, topCollapsed };
|
|
||||||
}
|
|
||||||
|
|
||||||
it("keeps an OPEN parent's children and recurses to prune a nested collapsed branch; prunes a top-level collapsed node", () => {
|
|
||||||
const { openParent, topCollapsed } = fixture();
|
|
||||||
const tree = [openParent, topCollapsed];
|
|
||||||
const result = pruneCollapsedChildren(tree, new Set(["p"]));
|
|
||||||
|
|
||||||
// (a) OPEN parent keeps its children (not cleared) and hasChildren stays true.
|
|
||||||
const p = result[0];
|
|
||||||
expect(p.id).toBe("p");
|
|
||||||
expect(p.hasChildren).toBe(true);
|
|
||||||
expect(p.children).toHaveLength(1);
|
|
||||||
|
|
||||||
// (b) The nested COLLAPSED child under the open parent is pruned to
|
|
||||||
// `children: []` by the recursion, with hasChildren preserved. This is the
|
|
||||||
// open-keep + recurse branch that F1's empty-open-set fixture never hits.
|
|
||||||
const c = p.children[0];
|
|
||||||
expect(c.id).toBe("c");
|
|
||||||
expect(c.children).toEqual([]);
|
|
||||||
expect(c.hasChildren).toBe(true);
|
|
||||||
|
|
||||||
// (c) The top-level collapsed node is pruned to `children: []`, hasChildren kept.
|
|
||||||
const t = result[1];
|
|
||||||
expect(t.id).toBe("t");
|
|
||||||
expect(t.children).toEqual([]);
|
|
||||||
expect(t.hasChildren).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not mutate the input tree (returns fresh nodes)", () => {
|
|
||||||
const { openParent, collapsedChild, topCollapsed } = fixture();
|
|
||||||
const tree = [openParent, topCollapsed];
|
|
||||||
pruneCollapsedChildren(tree, new Set(["p"]));
|
|
||||||
|
|
||||||
// Originals are untouched: the collapsed child still carries its stale grandchild.
|
|
||||||
expect(collapsedChild.children).toHaveLength(1);
|
|
||||||
expect(collapsedChild.children[0].id).toBe("g");
|
|
||||||
expect(openParent.children[0]).toBe(collapsedChild);
|
|
||||||
expect(topCollapsed.children).toHaveLength(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -293,41 +293,6 @@ export function loadedOpenBranchIds(
|
|||||||
return ids;
|
return ids;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Boot-cache hygiene (#159 #8): the persisted tree keeps the children of EVERY
|
|
||||||
* branch ever expanded — collapsing a branch never prunes them. So on reload a
|
|
||||||
* COLLAPSED branch hydrates with its old cached children, and `handleToggle`
|
|
||||||
* skips the lazy-load on first expand (children already present) → it shows
|
|
||||||
* STALE children (renamed / moved / deleted while the user was offline) with no
|
|
||||||
* reconcile. `refreshOpenBranches` only refreshes OPEN branches, so collapsed
|
|
||||||
* ones slip through.
|
|
||||||
*
|
|
||||||
* Fix: drop the cached children of every node NOT in the persisted open-set,
|
|
||||||
* resetting it to the canonical UNLOADED shape (`children: []`, `hasChildren`
|
|
||||||
* untouched — see pageToTreeNode). Its first expand then lazy-loads fresh, just
|
|
||||||
* as it did before the tree was cached to localStorage. OPEN branches keep
|
|
||||||
* their children (refreshOpenBranches reconciles those, so they must not be
|
|
||||||
* dropped here) and are recursed into so a collapsed branch nested under an
|
|
||||||
* open one is pruned too.
|
|
||||||
*/
|
|
||||||
export function pruneCollapsedChildren(
|
|
||||||
tree: SpaceTreeNode[],
|
|
||||||
openIds: ReadonlySet<string>,
|
|
||||||
): SpaceTreeNode[] {
|
|
||||||
return tree.map((node) => {
|
|
||||||
const hasLoadedChildren = !!node.children && node.children.length > 0;
|
|
||||||
if (!openIds.has(node.id)) {
|
|
||||||
// Collapsed: drop the whole cached subtree so it reads as unloaded.
|
|
||||||
return hasLoadedChildren ? { ...node, children: [] } : node;
|
|
||||||
}
|
|
||||||
// Open: keep it, but recurse into its children (a nested collapsed branch
|
|
||||||
// must still be pruned).
|
|
||||||
return hasLoadedChildren
|
|
||||||
? { ...node, children: pruneCollapsedChildren(node.children, openIds) }
|
|
||||||
: node;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect every node id in the tree (roots, branches, leaves). Used by
|
// Collect every node id in the tree (roots, branches, leaves). Used by
|
||||||
// collapseAll to clear the open-state map for all current-space nodes.
|
// collapseAll to clear the open-state map for all current-space nodes.
|
||||||
export function collectAllIds(nodes: SpaceTreeNode[]): string[] {
|
export function collectAllIds(nodes: SpaceTreeNode[]): string[] {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
keepPreviousData,
|
keepPreviousData,
|
||||||
|
queryOptions,
|
||||||
useInfiniteQuery,
|
useInfiniteQuery,
|
||||||
useMutation,
|
useMutation,
|
||||||
useQuery,
|
useQuery,
|
||||||
@@ -31,11 +32,37 @@ import { getRecentChanges } from "@/features/page/services/page-service.ts";
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { validate as isValidUuid } from "uuid";
|
import { validate as isValidUuid } from "uuid";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centralized React Query key factories for space queries. The hooks below and
|
||||||
|
* the offline warm path (features/offline/make-offline.ts) share these so the
|
||||||
|
* runtime keys can never silently drift apart.
|
||||||
|
*/
|
||||||
|
export const spaceKeys = {
|
||||||
|
detail: (idOrSlug: string) => ["space", idOrSlug] as const,
|
||||||
|
list: (params?: QueryParams) => ["spaces", params] as const,
|
||||||
|
members: (spaceId: string, query?: string) =>
|
||||||
|
["spaceMembers", spaceId, query] as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared queryOptions for fetching a space by id/slug. Both
|
||||||
|
* useGetSpaceBySlugQuery and the offline warm path consume this so the key,
|
||||||
|
* queryFn and staleTime stay identical. (`enabled` is intentionally omitted —
|
||||||
|
* prefetchQuery ignores it anyway and the warm path always passes a real id;
|
||||||
|
* the hook reapplies `enabled` itself.)
|
||||||
|
*/
|
||||||
|
export const spaceByIdQueryOptions = (spaceId: string) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: spaceKeys.detail(spaceId),
|
||||||
|
queryFn: () => getSpaceById(spaceId),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
export function useGetSpacesQuery(
|
export function useGetSpacesQuery(
|
||||||
params?: QueryParams,
|
params?: QueryParams,
|
||||||
): UseQueryResult<IPagination<ISpace>, Error> {
|
): UseQueryResult<IPagination<ISpace>, Error> {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["spaces", params],
|
queryKey: spaceKeys.list(params),
|
||||||
queryFn: () => getSpaces(params),
|
queryFn: () => getSpaces(params),
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
refetchOnMount: true,
|
refetchOnMount: true,
|
||||||
@@ -44,16 +71,16 @@ export function useGetSpacesQuery(
|
|||||||
|
|
||||||
export function useSpaceQuery(spaceId: string): UseQueryResult<ISpace, Error> {
|
export function useSpaceQuery(spaceId: string): UseQueryResult<ISpace, Error> {
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ["space", spaceId],
|
queryKey: spaceKeys.detail(spaceId),
|
||||||
queryFn: () => getSpaceById(spaceId),
|
queryFn: () => getSpaceById(spaceId),
|
||||||
enabled: !!spaceId,
|
enabled: !!spaceId,
|
||||||
});
|
});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (query.data) {
|
if (query.data) {
|
||||||
if (isValidUuid(spaceId)) {
|
if (isValidUuid(spaceId)) {
|
||||||
queryClient.setQueryData(["space", query.data.slug], query.data);
|
queryClient.setQueryData(spaceKeys.detail(query.data.slug), query.data);
|
||||||
} else {
|
} else {
|
||||||
queryClient.setQueryData(["space", query.data.id], query.data);
|
queryClient.setQueryData(spaceKeys.detail(query.data.id), query.data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [query.data]);
|
}, [query.data]);
|
||||||
@@ -62,8 +89,11 @@ export function useSpaceQuery(spaceId: string): UseQueryResult<ISpace, Error> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const prefetchSpace = (spaceSlug: string, spaceId?: string) => {
|
export const prefetchSpace = (spaceSlug: string, spaceId?: string) => {
|
||||||
|
// Note: intentionally NOT using spaceByIdQueryOptions here — that factory sets
|
||||||
|
// a 5min staleTime which would let this prefetch skip fetching fresh data;
|
||||||
|
// prefetchSpace must always refetch (default staleTime: 0).
|
||||||
queryClient.prefetchQuery({
|
queryClient.prefetchQuery({
|
||||||
queryKey: ["space", spaceSlug],
|
queryKey: spaceKeys.detail(spaceSlug),
|
||||||
queryFn: () => getSpaceById(spaceSlug),
|
queryFn: () => getSpaceById(spaceSlug),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -100,10 +130,8 @@ export function useGetSpaceBySlugQuery(
|
|||||||
spaceId: string,
|
spaceId: string,
|
||||||
): UseQueryResult<ISpace, Error> {
|
): UseQueryResult<ISpace, Error> {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["space", spaceId],
|
...spaceByIdQueryOptions(spaceId),
|
||||||
queryFn: () => getSpaceById(spaceId),
|
|
||||||
enabled: !!spaceId,
|
enabled: !!spaceId,
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,14 +144,16 @@ export function useUpdateSpaceMutation() {
|
|||||||
onSuccess: (data, variables) => {
|
onSuccess: (data, variables) => {
|
||||||
notifications.show({ message: t("Space updated successfully") });
|
notifications.show({ message: t("Space updated successfully") });
|
||||||
|
|
||||||
const space = queryClient.getQueryData([
|
const space = queryClient.getQueryData(
|
||||||
"space",
|
spaceKeys.detail(variables.spaceId),
|
||||||
variables.spaceId,
|
) as ISpace;
|
||||||
]) as ISpace;
|
|
||||||
if (space) {
|
if (space) {
|
||||||
const updatedSpace = { ...space, ...data };
|
const updatedSpace = { ...space, ...data };
|
||||||
queryClient.setQueryData(["space", variables.spaceId], updatedSpace);
|
queryClient.setQueryData(
|
||||||
queryClient.setQueryData(["space", data.slug], updatedSpace);
|
spaceKeys.detail(variables.spaceId),
|
||||||
|
updatedSpace,
|
||||||
|
);
|
||||||
|
queryClient.setQueryData(spaceKeys.detail(data.slug), updatedSpace);
|
||||||
}
|
}
|
||||||
|
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
@@ -148,7 +178,7 @@ export function useDeleteSpaceMutation() {
|
|||||||
|
|
||||||
if (variables.slug) {
|
if (variables.slug) {
|
||||||
queryClient.removeQueries({
|
queryClient.removeQueries({
|
||||||
queryKey: ["space", variables.slug],
|
queryKey: spaceKeys.detail(variables.slug),
|
||||||
exact: true,
|
exact: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -156,7 +186,7 @@ export function useDeleteSpaceMutation() {
|
|||||||
// Remove space-specific queries
|
// Remove space-specific queries
|
||||||
if (variables.id) {
|
if (variables.id) {
|
||||||
queryClient.removeQueries({
|
queryClient.removeQueries({
|
||||||
queryKey: ["space", variables.id],
|
queryKey: spaceKeys.detail(variables.id),
|
||||||
exact: true,
|
exact: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -196,7 +226,7 @@ export function useSpaceMembersInfiniteQuery(
|
|||||||
query?: string,
|
query?: string,
|
||||||
) {
|
) {
|
||||||
return useInfiniteQuery({
|
return useInfiniteQuery({
|
||||||
queryKey: ["spaceMembers", spaceId, query],
|
queryKey: spaceKeys.members(spaceId, query),
|
||||||
queryFn: ({ pageParam }) =>
|
queryFn: ({ pageParam }) =>
|
||||||
getSpaceMembers(spaceId, { cursor: pageParam, limit: 50, query }),
|
getSpaceMembers(spaceId, { cursor: pageParam, limit: 50, query }),
|
||||||
enabled: !!spaceId,
|
enabled: !!spaceId,
|
||||||
|
|||||||
@@ -2,9 +2,19 @@ import { useQuery, UseQueryResult } from "@tanstack/react-query";
|
|||||||
import { getMyInfo } from "@/features/user/services/user-service";
|
import { getMyInfo } from "@/features/user/services/user-service";
|
||||||
import { ICurrentUser } from "@/features/user/types/user.types";
|
import { ICurrentUser } from "@/features/user/types/user.types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centralized React Query key factory for current-user queries. This hook and
|
||||||
|
* the offline warm path (features/offline/make-offline.ts) share it so the
|
||||||
|
* runtime key can never silently drift, and the OFFLINE_PERSIST_ROOTS guard
|
||||||
|
* test can assert the persisted "currentUser" root maps to a real factory.
|
||||||
|
*/
|
||||||
|
export const userKeys = {
|
||||||
|
currentUser: () => ["currentUser"] as const,
|
||||||
|
};
|
||||||
|
|
||||||
export default function useCurrentUser(): UseQueryResult<ICurrentUser> {
|
export default function useCurrentUser(): UseQueryResult<ICurrentUser> {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["currentUser"],
|
queryKey: userKeys.currentUser(),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
return await getMyInfo();
|
return await getMyInfo();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import { HelmetProvider } from "react-helmet-async";
|
||||||
|
|
||||||
|
// Control useCurrentUser per test; stub the rest of UserProvider's network/
|
||||||
|
// socket dependencies so we only exercise its render-gating logic.
|
||||||
|
const h = vi.hoisted(() => ({ useCurrentUser: vi.fn() }));
|
||||||
|
|
||||||
|
vi.mock("@/features/user/hooks/use-current-user", () => ({
|
||||||
|
default: h.useCurrentUser,
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/auth/queries/auth-query.tsx", () => ({
|
||||||
|
useCollabToken: () => ({ data: undefined }),
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/websocket/use-query-subscription.ts", () => ({
|
||||||
|
useQuerySubscription: () => {},
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/websocket/use-tree-socket.ts", () => ({
|
||||||
|
useTreeSocket: () => {},
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/notification/hooks/use-notification-socket.ts", () => ({
|
||||||
|
useNotificationSocket: () => {},
|
||||||
|
}));
|
||||||
|
vi.mock("@/main.tsx", () => ({ queryClient: {} }));
|
||||||
|
vi.mock("@/features/user/connect-resync.ts", () => ({
|
||||||
|
makeConnectHandler: () => () => {},
|
||||||
|
}));
|
||||||
|
vi.mock("socket.io-client", () => ({
|
||||||
|
io: () => ({ on: vi.fn(), disconnect: vi.fn() }),
|
||||||
|
}));
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (k: string) => k,
|
||||||
|
i18n: {
|
||||||
|
changeLanguage: vi.fn(),
|
||||||
|
language: "en-US",
|
||||||
|
resolvedLanguage: "en-US",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { UserProvider } from "./user-provider";
|
||||||
|
|
||||||
|
const networkError = { message: "Network Error" }; // axios network error: no `response`
|
||||||
|
|
||||||
|
function renderProvider() {
|
||||||
|
return render(
|
||||||
|
<HelmetProvider>
|
||||||
|
<MemoryRouter>
|
||||||
|
<MantineProvider>
|
||||||
|
<UserProvider>
|
||||||
|
<div data-testid="app-child">app content</div>
|
||||||
|
</UserProvider>
|
||||||
|
</MantineProvider>
|
||||||
|
</MemoryRouter>
|
||||||
|
</HelmetProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
h.useCurrentUser.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("UserProvider offline render-gating", () => {
|
||||||
|
it("renders the app (cached children) when useCurrentUser errors offline but a cached user exists", () => {
|
||||||
|
// Offline reload: the persisted ['currentUser'] cache hydrates `data`, but
|
||||||
|
// the background POST /api/users/me refetch fails as a network error.
|
||||||
|
h.useCurrentUser.mockReturnValue({
|
||||||
|
data: {
|
||||||
|
user: { id: "u1", locale: "en" },
|
||||||
|
workspace: { id: "w1" },
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
error: networkError,
|
||||||
|
isError: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderProvider();
|
||||||
|
|
||||||
|
// The cached app must render — NOT a blank fragment (#237/#238).
|
||||||
|
expect(screen.getByTestId("app-child")).toBeDefined();
|
||||||
|
expect(screen.queryByText("You're offline")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the offline fallback (not a blank fragment) when erroring with no cached user", () => {
|
||||||
|
h.useCurrentUser.mockReturnValue({
|
||||||
|
data: undefined,
|
||||||
|
isLoading: false,
|
||||||
|
error: networkError,
|
||||||
|
isError: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { container } = renderProvider();
|
||||||
|
|
||||||
|
// Previously this returned `<></>` — a blank white screen. Now it must show
|
||||||
|
// an explicit offline fallback.
|
||||||
|
expect(screen.getByText("You're offline")).toBeDefined();
|
||||||
|
expect(screen.queryByTestId("app-child")).toBeNull();
|
||||||
|
expect(container.textContent?.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the app normally on a successful currentUser load", () => {
|
||||||
|
h.useCurrentUser.mockReturnValue({
|
||||||
|
data: {
|
||||||
|
user: { id: "u1", locale: "en" },
|
||||||
|
workspace: { id: "w1" },
|
||||||
|
},
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
isError: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderProvider();
|
||||||
|
expect(screen.getByTestId("app-child")).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,6 +11,7 @@ import { useTreeSocket } from "@/features/websocket/use-tree-socket.ts";
|
|||||||
import { useNotificationSocket } from "@/features/notification/hooks/use-notification-socket.ts";
|
import { useNotificationSocket } from "@/features/notification/hooks/use-notification-socket.ts";
|
||||||
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
|
||||||
import { Error404 } from "@/components/ui/error-404.tsx";
|
import { Error404 } from "@/components/ui/error-404.tsx";
|
||||||
|
import { OfflineFallback } from "@/features/offline/offline-fallback.tsx";
|
||||||
import { queryClient } from "@/main.tsx";
|
import { queryClient } from "@/main.tsx";
|
||||||
import { makeConnectHandler } from "@/features/user/connect-resync.ts";
|
import { makeConnectHandler } from "@/features/user/connect-resync.ts";
|
||||||
|
|
||||||
@@ -70,14 +71,30 @@ export function UserProvider({ children }: React.PropsWithChildren) {
|
|||||||
document.documentElement.lang = i18n.resolvedLanguage || i18n.language || "en-US";
|
document.documentElement.lang = i18n.resolvedLanguage || i18n.language || "en-US";
|
||||||
}, [i18n.language, i18n.resolvedLanguage]);
|
}, [i18n.language, i18n.resolvedLanguage]);
|
||||||
|
|
||||||
if (isLoading) return <></>;
|
// First load with no cached user yet: render nothing briefly while the
|
||||||
|
// persisted ['currentUser'] cache hydrates (avoids flashing the offline
|
||||||
|
// fallback before restore). Once we have a user we render the app even if a
|
||||||
|
// refetch is still in flight.
|
||||||
|
if (isLoading && !data) return <></>;
|
||||||
|
|
||||||
if (isError && error?.["response"]?.status === 404) {
|
if (isError && error?.["response"]?.status === 404) {
|
||||||
return <Error404 />;
|
return <Error404 />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// We have a (possibly cached/stale) user — render the app. Offline, the
|
||||||
|
// POST /api/users/me refetch fails as a network error, but the persisted/
|
||||||
|
// hydrated user is enough to render the cached UI. Previously `if (error)
|
||||||
|
// return <></>` blanked every authenticated route on an offline reload even
|
||||||
|
// though the cached data was present (#237/#238).
|
||||||
|
if (data) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No user AND an error (offline cold boot of a page never warmed for offline,
|
||||||
|
// or no persisted cache to restore): show an explicit offline fallback rather
|
||||||
|
// than a blank white screen.
|
||||||
if (error) {
|
if (error) {
|
||||||
return <></>;
|
return <OfflineFallback />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import axios, { AxiosInstance } from "axios";
|
import axios, { AxiosInstance } from "axios";
|
||||||
import APP_ROUTE from "@/lib/app-route.ts";
|
import APP_ROUTE from "@/lib/app-route.ts";
|
||||||
import { isCloud } from "@/lib/config.ts";
|
import { isCloud } from "@/lib/config.ts";
|
||||||
import { clearPersistedTreeCaches } from "@/features/page/tree/atoms/tree-data-atom";
|
|
||||||
|
|
||||||
const api: AxiosInstance = axios.create({
|
const api: AxiosInstance = axios.create({
|
||||||
baseURL: "/api",
|
baseURL: "/api",
|
||||||
@@ -72,12 +71,6 @@ function redirectToLogin() {
|
|||||||
"/invites",
|
"/invites",
|
||||||
];
|
];
|
||||||
if (!exemptPaths.some((path) => window.location.pathname.startsWith(path))) {
|
if (!exemptPaths.some((path) => window.location.pathname.startsWith(path))) {
|
||||||
// Forced logout (401 / expired session) must purge the persisted sidebar
|
|
||||||
// tree caches too: they contain page titles, and on a shared machine most
|
|
||||||
// sessions end via cookie expiry — not the logout button — so this is the
|
|
||||||
// only cleanup that runs on that path. It also disables further cache
|
|
||||||
// persistence until the full page load below.
|
|
||||||
clearPersistedTreeCaches();
|
|
||||||
const redirectTo = window.location.pathname;
|
const redirectTo = window.location.pathname;
|
||||||
if (redirectTo === APP_ROUTE.HOME) {
|
if (redirectTo === APP_ROUTE.HOME) {
|
||||||
window.location.href = APP_ROUTE.AUTH.LOGIN;
|
window.location.href = APP_ROUTE.AUTH.LOGIN;
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import { MantineProvider } from "@mantine/core";
|
|||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import { ModalsProvider } from "@mantine/modals";
|
import { ModalsProvider } from "@mantine/modals";
|
||||||
import { Notifications } from "@mantine/notifications";
|
import { Notifications } from "@mantine/notifications";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, onlineManager } from "@tanstack/react-query";
|
||||||
|
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
|
||||||
import { HelmetProvider } from "react-helmet-async";
|
import { HelmetProvider } from "react-helmet-async";
|
||||||
import "./i18n";
|
import "./i18n";
|
||||||
import { PostHogProvider } from "posthog-js/react";
|
import { PostHogProvider } from "posthog-js/react";
|
||||||
@@ -21,6 +22,13 @@ import {
|
|||||||
isCloud,
|
isCloud,
|
||||||
isPostHogEnabled,
|
isPostHogEnabled,
|
||||||
} from "@/lib/config.ts";
|
} from "@/lib/config.ts";
|
||||||
|
import {
|
||||||
|
queryPersister,
|
||||||
|
shouldDehydrateOfflineQuery,
|
||||||
|
} from "@/features/offline/query-persister";
|
||||||
|
import { registerOfflineMutationDefaults } from "@/features/offline/offline-mutations";
|
||||||
|
import { PwaUpdatePrompt } from "@/pwa/pwa-update-prompt";
|
||||||
|
import { isCapacitorNativePlatform } from "@/pwa/is-capacitor";
|
||||||
import posthog from "posthog-js";
|
import posthog from "posthog-js";
|
||||||
|
|
||||||
export const queryClient = new QueryClient({
|
export const queryClient = new QueryClient({
|
||||||
@@ -30,10 +38,30 @@ export const queryClient = new QueryClient({
|
|||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
retry: false,
|
retry: false,
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
|
// Keep cached read data around long enough to be persisted/restored for offline use.
|
||||||
|
gcTime: 1000 * 60 * 60 * 24,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Register default mutationFns for the offline-relevant structural mutations so
|
||||||
|
// a paused mutation restored from IndexedDB after an offline reload still has a
|
||||||
|
// mutationFn and is replayed by resumePausedMutations() on reconnect (instead
|
||||||
|
// of silently no-op'ing and dropping the offline create/move/comment). MUST run
|
||||||
|
// before any resumePausedMutations() so rehydrated paused mutations have a fn.
|
||||||
|
registerOfflineMutationDefaults(queryClient);
|
||||||
|
|
||||||
|
// Seed TanStack Query's onlineManager from the REAL connectivity state at boot.
|
||||||
|
// It defaults to `online: true` and only flips on window online/offline events,
|
||||||
|
// so a tab that COLD-BOOTS offline would wrongly believe it is online: paused
|
||||||
|
// mutations restored from IndexedDB would never get a later offline->online
|
||||||
|
// transition to trigger their replay, and the offline UI affordances could not
|
||||||
|
// tell they are offline. Seeding here makes the first real `online` event a true
|
||||||
|
// transition that auto-resumes the rehydrated paused mutations (#120 data loss).
|
||||||
|
if (typeof navigator !== "undefined" && "onLine" in navigator) {
|
||||||
|
onlineManager.setOnline(navigator.onLine);
|
||||||
|
}
|
||||||
|
|
||||||
if (isCloud() && isPostHogEnabled) {
|
if (isCloud() && isPostHogEnabled) {
|
||||||
posthog.init(getPostHogKey(), {
|
posthog.init(getPostHogKey(), {
|
||||||
api_host: getPostHogHost(),
|
api_host: getPostHogHost(),
|
||||||
@@ -50,15 +78,44 @@ root.render(
|
|||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
|
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
|
||||||
<ModalsProvider>
|
<ModalsProvider>
|
||||||
<QueryClientProvider client={queryClient}>
|
<PersistQueryClientProvider
|
||||||
|
client={queryClient}
|
||||||
|
persistOptions={{
|
||||||
|
persister: queryPersister,
|
||||||
|
maxAge: 1000 * 60 * 60 * 24,
|
||||||
|
buster: APP_VERSION,
|
||||||
|
dehydrateOptions: {
|
||||||
|
shouldDehydrateQuery: shouldDehydrateOfflineQuery,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
// After the persister finishes rehydrating, replay any paused
|
||||||
|
// mutations restored from IndexedDB. If we are back online this fires
|
||||||
|
// them immediately; if still offline they stay paused and TanStack's
|
||||||
|
// onlineManager auto-resumes them on the next online transition (which
|
||||||
|
// is now a true transition thanks to the onlineManager seeding above).
|
||||||
|
// Without this, a paused mutation persisted while offline and then
|
||||||
|
// reloaded would never resume and the user's work would be lost (#120).
|
||||||
|
onSuccess={() => {
|
||||||
|
queryClient.resumePausedMutations();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Notifications position="bottom-center" limit={3} zIndex={10000} />
|
<Notifications position="bottom-center" limit={3} zIndex={10000} />
|
||||||
|
{/* Skip SW registration inside the Capacitor native WebView — the
|
||||||
|
native shell serves assets itself; a browser SW would conflict. */}
|
||||||
|
{!isCapacitorNativePlatform() && <PwaUpdatePrompt />}
|
||||||
<HelmetProvider>
|
<HelmetProvider>
|
||||||
<PostHogProvider client={posthog}>
|
<PostHogProvider client={posthog}>
|
||||||
<App />
|
<App />
|
||||||
</PostHogProvider>
|
</PostHogProvider>
|
||||||
</HelmetProvider>
|
</HelmetProvider>
|
||||||
</QueryClientProvider>
|
</PersistQueryClientProvider>
|
||||||
</ModalsProvider>
|
</ModalsProvider>
|
||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
</BrowserRouter>,
|
</BrowserRouter>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Service worker registration is owned by <PwaUpdatePrompt /> above (via
|
||||||
|
// vite-plugin-pwa's useRegisterSW: Workbox precache + prompt-based updates,
|
||||||
|
// and skipped inside the Capacitor native WebView). The earlier hand-written
|
||||||
|
// /sw.js registration from the mobile bootstrap was removed here to avoid a
|
||||||
|
// double registration / competing service worker.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts"
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { EmptyState } from "@/components/ui/empty-state.tsx";
|
import { EmptyState } from "@/components/ui/empty-state.tsx";
|
||||||
|
import { OfflineFallback } from "@/features/offline/offline-fallback.tsx";
|
||||||
import { IconAlertTriangle, IconFileOff } from "@tabler/icons-react";
|
import { IconAlertTriangle, IconFileOff } from "@tabler/icons-react";
|
||||||
import { Button } from "@mantine/core";
|
import { Button } from "@mantine/core";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
@@ -62,7 +63,19 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isError || !page) {
|
if (isError || !page) {
|
||||||
if ([401, 403, 404].includes(error?.["status"])) {
|
// An offline fetch of a page that was never saved for offline use yields a
|
||||||
|
// network error with NO HTTP status (status is undefined), which would
|
||||||
|
// otherwise fall through to the generic "Error fetching page data." state.
|
||||||
|
// When we are offline (or the failure is a network error with no status),
|
||||||
|
// show the dedicated "You're offline — this page isn't saved for offline"
|
||||||
|
// fallback instead, so the user understands why the page won't load.
|
||||||
|
const httpStatus = error?.["status"];
|
||||||
|
const isOffline =
|
||||||
|
typeof navigator !== "undefined" && navigator.onLine === false;
|
||||||
|
if (isOffline || (isError && httpStatus == null)) {
|
||||||
|
return <OfflineFallback />;
|
||||||
|
}
|
||||||
|
if ([401, 403, 404].includes(httpStatus)) {
|
||||||
return (
|
return (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={IconFileOff}
|
icon={IconFileOff}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { describe, it, expect, afterEach } from "vitest";
|
||||||
|
import { isCapacitorNativePlatform } from "./is-capacitor";
|
||||||
|
|
||||||
|
describe("isCapacitorNativePlatform", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
// Keep tests isolated from each other and from the rest of the suite.
|
||||||
|
delete (globalThis as any).Capacitor;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when Capacitor is undefined", () => {
|
||||||
|
expect(isCapacitorNativePlatform()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses isNativePlatform() when it is a function", () => {
|
||||||
|
(globalThis as any).Capacitor = { isNativePlatform: () => true };
|
||||||
|
expect(isCapacitorNativePlatform()).toBe(true);
|
||||||
|
|
||||||
|
(globalThis as any).Capacitor = { isNativePlatform: () => false };
|
||||||
|
expect(isCapacitorNativePlatform()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the boolean property when isNativePlatform is not a function", () => {
|
||||||
|
(globalThis as any).Capacitor = { isNativePlatform: true };
|
||||||
|
expect(isCapacitorNativePlatform()).toBe(true);
|
||||||
|
|
||||||
|
(globalThis as any).Capacitor = { isNativePlatform: false };
|
||||||
|
expect(isCapacitorNativePlatform()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when reading Capacitor throws (try/catch)", () => {
|
||||||
|
Object.defineProperty(globalThis, "Capacitor", {
|
||||||
|
configurable: true,
|
||||||
|
get() {
|
||||||
|
throw new Error("boom");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(isCapacitorNativePlatform()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* Detects whether the client is running inside a Capacitor native WebView
|
||||||
|
* (native iOS/Android shell from the feature/mobile-app-bootstrap branch).
|
||||||
|
*
|
||||||
|
* This is a pure runtime check against the global `Capacitor` object that the
|
||||||
|
* native bridge injects — no `@capacitor/*` dependency is added. On the plain
|
||||||
|
* browser / installed-PWA path `window.Capacitor` is undefined, so this returns
|
||||||
|
* false and the Workbox service worker registers normally.
|
||||||
|
*
|
||||||
|
* Inside the native WebView the SW must NOT register: it would layer a redundant
|
||||||
|
* (and conflicting) cache over Capacitor's own asset serving and interfere with
|
||||||
|
* the native auth/CORS flow.
|
||||||
|
*/
|
||||||
|
export function isCapacitorNativePlatform(): boolean {
|
||||||
|
try {
|
||||||
|
const cap = (globalThis as any)?.Capacitor;
|
||||||
|
return !!(cap && typeof cap.isNativePlatform === "function"
|
||||||
|
? cap.isNativePlatform()
|
||||||
|
: cap?.isNativePlatform);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { Button } from "@mantine/core";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useRegisterSW } from "virtual:pwa-register/react";
|
||||||
|
|
||||||
|
// Stable notification id so we can show/hide a single update prompt.
|
||||||
|
const UPDATE_NOTIFICATION_ID = "pwa-update-available";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listens for a waiting service worker and surfaces a Mantine notification
|
||||||
|
* prompting the user to reload into the new version.
|
||||||
|
*
|
||||||
|
* Must be mounted inside the Mantine provider subtree (Notifications must be
|
||||||
|
* available). Renders nothing itself.
|
||||||
|
*/
|
||||||
|
export function PwaUpdatePrompt() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const {
|
||||||
|
needRefresh: [needRefresh],
|
||||||
|
updateServiceWorker,
|
||||||
|
} = useRegisterSW({
|
||||||
|
onRegisterError(error) {
|
||||||
|
// Best-effort: a failed registration must not break the app.
|
||||||
|
console.error("Service worker registration error:", error);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!needRefresh) return;
|
||||||
|
|
||||||
|
notifications.show({
|
||||||
|
id: UPDATE_NOTIFICATION_ID,
|
||||||
|
title: t("Update available"),
|
||||||
|
message: (
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
mt="xs"
|
||||||
|
onClick={() => updateServiceWorker(true)}
|
||||||
|
>
|
||||||
|
{t("Reload")}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
autoClose: false,
|
||||||
|
withCloseButton: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Hide the notification when the prompt is no longer needed / on cleanup.
|
||||||
|
return () => {
|
||||||
|
notifications.hide(UPDATE_NOTIFICATION_ID);
|
||||||
|
};
|
||||||
|
}, [needRefresh, t, updateServiceWorker]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PwaUpdatePrompt;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { isApiPath, isCollabOrSocketPath } from "./sw-strategy";
|
||||||
|
|
||||||
|
describe("isApiPath", () => {
|
||||||
|
it("matches the /api segment and its subtree", () => {
|
||||||
|
expect(isApiPath("/api")).toBe(true);
|
||||||
|
expect(isApiPath("/api/")).toBe(true);
|
||||||
|
expect(isApiPath("/api/pages")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not over-match sibling paths", () => {
|
||||||
|
expect(isApiPath("/apidocs")).toBe(false);
|
||||||
|
expect(isApiPath("/apixyz")).toBe(false);
|
||||||
|
expect(isApiPath("/")).toBe(false);
|
||||||
|
expect(isApiPath("/pages")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isCollabOrSocketPath", () => {
|
||||||
|
it("matches the /collab and /socket.io segments and their subtrees", () => {
|
||||||
|
expect(isCollabOrSocketPath("/collab")).toBe(true);
|
||||||
|
expect(isCollabOrSocketPath("/collab/x")).toBe(true);
|
||||||
|
expect(isCollabOrSocketPath("/socket.io")).toBe(true);
|
||||||
|
expect(isCollabOrSocketPath("/socket.io/abc")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not over-match sibling paths", () => {
|
||||||
|
expect(isCollabOrSocketPath("/collaborators")).toBe(false);
|
||||||
|
expect(isCollabOrSocketPath("/collabx")).toBe(false);
|
||||||
|
expect(isCollabOrSocketPath("/socket.iox")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/**
|
||||||
|
* Canonical service-worker routing predicates.
|
||||||
|
*
|
||||||
|
* IMPORTANT: With vite-plugin-pwa using Workbox `generateSW`, the
|
||||||
|
* `runtimeCaching[].urlPattern` functions are serialized standalone into the
|
||||||
|
* generated service worker and CANNOT reference imported symbols. The matching
|
||||||
|
* logic is therefore duplicated as inline regex literals in
|
||||||
|
* apps/client/vite.config.ts. This module is the testable source of truth, and
|
||||||
|
* the two MUST be kept in sync. This duplication is intentional and is the
|
||||||
|
* documented Workbox limitation.
|
||||||
|
*
|
||||||
|
* Matching is anchored to a path SEGMENT boundary (`^/<seg>(/|$)`) so that
|
||||||
|
* sibling paths like `/apidocs`, `/collaborators`, `/socket.iox` are NOT
|
||||||
|
* wrongly treated as API/realtime traffic.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when `pathname` is the `/api` segment or anything beneath it.
|
||||||
|
* `/api` and `/api/...` -> true; `/apidocs`, `/apixyz` -> false.
|
||||||
|
*/
|
||||||
|
export function isApiPath(pathname: string): boolean {
|
||||||
|
return /^\/api(\/|$)/.test(pathname);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when `pathname` is the `/collab` or `/socket.io` segment (or beneath it).
|
||||||
|
* `/collab`, `/collab/x`, `/socket.io`, `/socket.io/abc` -> true;
|
||||||
|
* `/collaborators`, `/collabx`, `/socket.iox` -> false.
|
||||||
|
*/
|
||||||
|
export function isCollabOrSocketPath(pathname: string): boolean {
|
||||||
|
return /^\/(collab|socket\.io)(\/|$)/.test(pathname);
|
||||||
|
}
|
||||||
Vendored
+2
@@ -1,2 +1,4 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
/// <reference types="vite-plugin-pwa/react" />
|
||||||
|
/// <reference types="vite-plugin-pwa/info" />
|
||||||
declare const APP_VERSION: string
|
declare const APP_VERSION: string
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { defineConfig, loadEnv } from "vite";
|
import { defineConfig, loadEnv } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { VitePWA } from "vite-plugin-pwa";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
|
|
||||||
@@ -53,7 +54,55 @@ export default defineConfig(({ mode }) => {
|
|||||||
},
|
},
|
||||||
APP_VERSION: JSON.stringify(resolveAppVersion(envPath)),
|
APP_VERSION: JSON.stringify(resolveAppVersion(envPath)),
|
||||||
},
|
},
|
||||||
plugins: [react()],
|
plugins: [
|
||||||
|
react(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: "prompt",
|
||||||
|
injectRegister: null,
|
||||||
|
strategies: "generateSW",
|
||||||
|
manifest: false,
|
||||||
|
workbox: {
|
||||||
|
globPatterns: ["**/*.{js,css,html,svg,png,ico,woff2,json}"],
|
||||||
|
navigateFallback: "index.html",
|
||||||
|
// Segment-anchored (`^/<seg>(/|$)`) so navigation requests to these
|
||||||
|
// segments are consistently excluded from the SPA fallback, mirroring
|
||||||
|
// the runtimeCaching urlPattern regexes below.
|
||||||
|
//
|
||||||
|
// `/share`, `/mcp`, `/l`, and `/robots.txt` mirror the server
|
||||||
|
// static-serve exclude list (apps/server/src/main.ts setGlobalPrefix
|
||||||
|
// `exclude`): robots.txt, the SEO/OG/analytics-injected public share
|
||||||
|
// HTML, the embedded MCP endpoint, and the `l/:alias` vanity short-link
|
||||||
|
// (a server 302 to a share page) are served by server controllers, so
|
||||||
|
// the SW must never shadow them with the precached index.html app shell.
|
||||||
|
// For `/l/:alias` the client router has NO matching route, so serving
|
||||||
|
// the app shell would dead-end on Error404 and break the public link;
|
||||||
|
// it must reach the server to perform the redirect.
|
||||||
|
navigateFallbackDenylist: [
|
||||||
|
/^\/api(\/|$)/,
|
||||||
|
/^\/collab(\/|$)/,
|
||||||
|
/^\/socket\.io(\/|$)/,
|
||||||
|
/^\/share(\/|$)/,
|
||||||
|
/^\/mcp(\/|$)/,
|
||||||
|
/^\/l(\/|$)/,
|
||||||
|
/^\/robots\.txt$/,
|
||||||
|
],
|
||||||
|
cleanupOutdatedCaches: true,
|
||||||
|
clientsClaim: true,
|
||||||
|
// The urlPattern regexes below mirror apps/client/src/pwa/sw-strategy.ts
|
||||||
|
// and MUST be kept in sync with it. Workbox `generateSW` serializes these
|
||||||
|
// functions standalone into the generated service worker, so they cannot
|
||||||
|
// import the module — the matching logic is intentionally duplicated as
|
||||||
|
// self-contained inline regex literals anchored to a path segment boundary.
|
||||||
|
runtimeCaching: [
|
||||||
|
{ urlPattern: ({ url }) => /^\/(collab|socket\.io)(\/|$)/.test(url.pathname), handler: "NetworkOnly" },
|
||||||
|
// All /api stays network-only; offline reads come from the persisted
|
||||||
|
// React Query cache (IndexedDB) + y-indexeddb, not the SW HTTP cache.
|
||||||
|
{ urlPattern: ({ url }) => /^\/api(\/|$)/.test(url.pathname), handler: "NetworkOnly" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
devOptions: { enabled: false },
|
||||||
|
}),
|
||||||
|
],
|
||||||
build: {
|
build: {
|
||||||
rolldownOptions: {
|
rolldownOptions: {
|
||||||
output: {
|
output: {
|
||||||
|
|||||||
@@ -64,6 +64,7 @@
|
|||||||
"@nestjs/platform-fastify": "^11.1.19",
|
"@nestjs/platform-fastify": "^11.1.19",
|
||||||
"@nestjs/platform-socket.io": "^11.1.19",
|
"@nestjs/platform-socket.io": "^11.1.19",
|
||||||
"@nestjs/schedule": "^6.1.3",
|
"@nestjs/schedule": "^6.1.3",
|
||||||
|
"@nestjs/swagger": "^11.2.0",
|
||||||
"@nestjs/terminus": "^11.1.1",
|
"@nestjs/terminus": "^11.1.1",
|
||||||
"@nestjs/throttler": "^6.5.0",
|
"@nestjs/throttler": "^6.5.0",
|
||||||
"@nestjs/websockets": "^11.1.19",
|
"@nestjs/websockets": "^11.1.19",
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ import { CollabWsAdapter } from './adapter/collab-ws.adapter';
|
|||||||
import {
|
import {
|
||||||
CollaborationHandler,
|
CollaborationHandler,
|
||||||
CollabEventHandlers,
|
CollabEventHandlers,
|
||||||
|
writeTitleFragment,
|
||||||
} from './collaboration.handler';
|
} from './collaboration.handler';
|
||||||
|
import { User } from '@docmost/db/types/entity.types';
|
||||||
|
import * as Y from 'yjs';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CollaborationGateway {
|
export class CollaborationGateway {
|
||||||
@@ -155,6 +158,70 @@ export class CollaborationGateway {
|
|||||||
return this.hocuspocus.openDirectConnection(documentName, context);
|
return this.hocuspocus.openDirectConnection(documentName, context);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write a new page title INTO the page's Yjs 'title' fragment, Redis-INDEPENDENT.
|
||||||
|
*
|
||||||
|
* Unlike the Redis-routed `handleYjsEvent` path — which routes through
|
||||||
|
* `redisSync?.handleEvent` and SILENTLY no-ops when Redis is disabled
|
||||||
|
* (COLLAB_DISABLE_REDIS=true → redisSync === null) — this goes straight
|
||||||
|
* through the local Hocuspocus `openDirectConnection`. The title sync
|
||||||
|
* therefore works in BOTH single-process (no Redis) and Redis-clustered
|
||||||
|
* deployments.
|
||||||
|
*
|
||||||
|
* openDirectConnection loads the doc from persistence when no editor is
|
||||||
|
* connected, so this works whether or not an editor is currently open: the
|
||||||
|
* clear+reseed lands on the loaded doc and is persisted by onStoreDocument.
|
||||||
|
*
|
||||||
|
* Provenance: when the caller is the agent, the actor/aiChatId are threaded
|
||||||
|
* into the connection `context` so onStoreDocument sees `context.actor ===
|
||||||
|
* 'agent'` for the resulting title store (mirrors the body/REST path). The
|
||||||
|
* resulting title store is usually a no-op anyway — PageService already wrote
|
||||||
|
* the same title to the page.title column, so onStoreDocument's
|
||||||
|
* `titleText !== page.title` guard skips the column write — but we wire the
|
||||||
|
* context for correctness regardless.
|
||||||
|
*/
|
||||||
|
async writePageTitle(
|
||||||
|
pageId: string,
|
||||||
|
title: string,
|
||||||
|
context?: { user?: User; actor?: string; aiChatId?: string },
|
||||||
|
): Promise<void> {
|
||||||
|
const documentName = `page.${pageId}`;
|
||||||
|
const connection = await this.hocuspocus.openDirectConnection(
|
||||||
|
documentName,
|
||||||
|
context ?? {},
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
// Write the new title into the in-memory 'title' fragment AND capture the
|
||||||
|
// resulting full doc state so we can persist it directly below.
|
||||||
|
let ydocState: Buffer | null = null;
|
||||||
|
await connection.transact((doc) => {
|
||||||
|
writeTitleFragment(doc, title);
|
||||||
|
ydocState = Buffer.from(Y.encodeStateAsUpdate(doc));
|
||||||
|
});
|
||||||
|
|
||||||
|
// F1 (variant C): persist the 'title' fragment to `page.ydoc` DIRECTLY,
|
||||||
|
// bypassing onStoreDocument. PageService.update already wrote the new title
|
||||||
|
// to the page.title COLUMN before calling this, so onStoreDocument's no-op
|
||||||
|
// fast-path (titleText === column) would NOT persist the in-memory fragment
|
||||||
|
// on disconnect — leaving the stored ydoc with the OLD title, which a later
|
||||||
|
// body edit would then revert the column back to. Writing the ydoc here
|
||||||
|
// makes BOTH column and persisted fragment consistent (NEW = NEW).
|
||||||
|
//
|
||||||
|
// Safe with or without a live editor: the write is idempotent and carries
|
||||||
|
// no tree snapshot (no double broadcast); when an editor is connected, the
|
||||||
|
// normal onStoreDocument flow still persists the (superset) state later and
|
||||||
|
// the live clients receive the title change through the transact above.
|
||||||
|
if (ydocState) {
|
||||||
|
await this.persistenceExtension.persistTitleFragmentYdoc(
|
||||||
|
pageId,
|
||||||
|
ydocState,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await connection.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
*Can be used before calling openDirectConnection directly
|
*Can be used before calling openDirectConnection directly
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import * as Y from 'yjs';
|
||||||
|
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||||
|
import { writeTitleFragment } from './collaboration.handler';
|
||||||
|
import { CollaborationGateway } from './collaboration.gateway';
|
||||||
|
import {
|
||||||
|
buildTitleSeedYdoc,
|
||||||
|
jsonToText,
|
||||||
|
tiptapExtensions,
|
||||||
|
} from './collaboration.util';
|
||||||
|
|
||||||
|
// Read the plain text held in the doc's 'title' XmlFragment, the same way
|
||||||
|
// PersistenceExtension.onStoreDocument extracts it before writing page.title.
|
||||||
|
const readTitleText = (doc: Y.Doc): string => {
|
||||||
|
const titleJson = TiptapTransformer.fromYdoc(doc, 'title');
|
||||||
|
return titleJson ? jsonToText(titleJson).trim() : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('writeTitleFragment — the clear+seed title write (Bug 1)', () => {
|
||||||
|
it('replaces an OLD title fragment with EXACTLY the new title (no duplication)', () => {
|
||||||
|
// Seed the doc's 'title' fragment with an OLD title, like a real page.
|
||||||
|
const doc = new Y.Doc();
|
||||||
|
Y.applyUpdate(doc, Y.encodeStateAsUpdate(buildTitleSeedYdoc('Old Title')));
|
||||||
|
expect(readTitleText(doc)).toBe('Old Title');
|
||||||
|
|
||||||
|
writeTitleFragment(doc, 'New Title');
|
||||||
|
|
||||||
|
// The fragment must contain EXACTLY the new title — not "Old TitleNew Title"
|
||||||
|
// (append) or "New TitleNew Title" (duplication). A single heading node.
|
||||||
|
expect(readTitleText(doc)).toBe('New Title');
|
||||||
|
|
||||||
|
const titleJson = TiptapTransformer.fromYdoc(doc, 'title') as any;
|
||||||
|
expect(titleJson.content).toHaveLength(1);
|
||||||
|
expect(titleJson.content[0].type).toBe('heading');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('seeds the title fragment when it started empty', () => {
|
||||||
|
const doc = new Y.Doc();
|
||||||
|
// Force the 'title' fragment to exist but be empty.
|
||||||
|
doc.getXmlFragment('title');
|
||||||
|
expect(readTitleText(doc)).toBe('');
|
||||||
|
|
||||||
|
writeTitleFragment(doc, 'First Title');
|
||||||
|
|
||||||
|
expect(readTitleText(doc)).toBe('First Title');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not corrupt the body when rewriting the title', () => {
|
||||||
|
// A doc with both a body and an old title; the body must survive untouched.
|
||||||
|
const doc = new Y.Doc();
|
||||||
|
const bodyDoc = TiptapTransformer.toYdoc(
|
||||||
|
{
|
||||||
|
type: 'doc',
|
||||||
|
content: [
|
||||||
|
{ type: 'paragraph', content: [{ type: 'text', text: 'body text' }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'default',
|
||||||
|
tiptapExtensions,
|
||||||
|
);
|
||||||
|
Y.applyUpdate(doc, Y.encodeStateAsUpdate(bodyDoc));
|
||||||
|
Y.applyUpdate(doc, Y.encodeStateAsUpdate(buildTitleSeedYdoc('Old')));
|
||||||
|
|
||||||
|
writeTitleFragment(doc, 'New');
|
||||||
|
|
||||||
|
expect(readTitleText(doc)).toBe('New');
|
||||||
|
const bodyJson = TiptapTransformer.fromYdoc(doc, 'default');
|
||||||
|
expect(jsonToText(bodyJson)).toContain('body text');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CollaborationGateway.writePageTitle — Redis-independent path', () => {
|
||||||
|
// Build a gateway with only its hocuspocus.openDirectConnection stubbed; the
|
||||||
|
// method must drive the clear+seed through that direct connection (NOT through
|
||||||
|
// redisSync), so the title write survives COLLAB_DISABLE_REDIS.
|
||||||
|
const makeGateway = (doc: Y.Doc) => {
|
||||||
|
const disconnect = jest.fn().mockResolvedValue(undefined);
|
||||||
|
const transact = jest.fn(async (fn: (d: Y.Doc) => void) => {
|
||||||
|
fn(doc);
|
||||||
|
});
|
||||||
|
const openDirectConnection = jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ transact, disconnect });
|
||||||
|
|
||||||
|
const gateway = Object.create(CollaborationGateway.prototype);
|
||||||
|
// redisSync is intentionally null — this is the no-Redis scenario.
|
||||||
|
gateway.redisSync = null;
|
||||||
|
gateway.hocuspocus = { openDirectConnection } as any;
|
||||||
|
// F1 (variant C): writePageTitle persists the 'title' fragment directly so a
|
||||||
|
// later body edit can't revert the rename (see title-rename-durability.spec).
|
||||||
|
const persistTitleFragmentYdoc = jest.fn().mockResolvedValue(undefined);
|
||||||
|
gateway.persistenceExtension = { persistTitleFragmentYdoc } as any;
|
||||||
|
|
||||||
|
return {
|
||||||
|
gateway,
|
||||||
|
openDirectConnection,
|
||||||
|
transact,
|
||||||
|
disconnect,
|
||||||
|
persistTitleFragmentYdoc,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
it('writes the new title via openDirectConnection and disconnects', async () => {
|
||||||
|
const doc = new Y.Doc();
|
||||||
|
Y.applyUpdate(doc, Y.encodeStateAsUpdate(buildTitleSeedYdoc('Old Title')));
|
||||||
|
|
||||||
|
const { gateway, openDirectConnection, disconnect, persistTitleFragmentYdoc } =
|
||||||
|
makeGateway(doc);
|
||||||
|
|
||||||
|
await gateway.writePageTitle('page-1', 'New Title', { user: { id: 'u1' } });
|
||||||
|
|
||||||
|
expect(openDirectConnection).toHaveBeenCalledWith(
|
||||||
|
'page.page-1',
|
||||||
|
expect.objectContaining({ user: { id: 'u1' } }),
|
||||||
|
);
|
||||||
|
expect(readTitleText(doc)).toBe('New Title');
|
||||||
|
// The renamed fragment is persisted directly to page.ydoc (F1 variant C).
|
||||||
|
expect(persistTitleFragmentYdoc).toHaveBeenCalledWith(
|
||||||
|
'page-1',
|
||||||
|
expect.any(Buffer),
|
||||||
|
);
|
||||||
|
expect(disconnect).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('threads agent provenance into the connection context', async () => {
|
||||||
|
const doc = new Y.Doc();
|
||||||
|
const { gateway, openDirectConnection } = makeGateway(doc);
|
||||||
|
|
||||||
|
await gateway.writePageTitle('page-1', 'Agent Title', {
|
||||||
|
user: { id: 'u1' },
|
||||||
|
actor: 'agent',
|
||||||
|
aiChatId: 'chat-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(openDirectConnection).toHaveBeenCalledWith(
|
||||||
|
'page.page-1',
|
||||||
|
expect.objectContaining({ actor: 'agent', aiChatId: 'chat-1' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disconnects even when the transaction throws', async () => {
|
||||||
|
const disconnect = jest.fn().mockResolvedValue(undefined);
|
||||||
|
const openDirectConnection = jest.fn().mockResolvedValue({
|
||||||
|
transact: jest.fn().mockRejectedValue(new Error('boom')),
|
||||||
|
disconnect,
|
||||||
|
});
|
||||||
|
const gateway = Object.create(CollaborationGateway.prototype);
|
||||||
|
gateway.redisSync = null;
|
||||||
|
gateway.hocuspocus = { openDirectConnection } as any;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
gateway.writePageTitle('page-1', 'X', {}),
|
||||||
|
).rejects.toThrow('boom');
|
||||||
|
expect(disconnect).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
|
|||||||
import { Hocuspocus, Document } from '@hocuspocus/server';
|
import { Hocuspocus, Document } from '@hocuspocus/server';
|
||||||
import { TiptapTransformer } from '@hocuspocus/transformer';
|
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||||
import {
|
import {
|
||||||
|
buildTitleSeedYdoc,
|
||||||
prosemirrorNodeToYElement,
|
prosemirrorNodeToYElement,
|
||||||
tiptapExtensions,
|
tiptapExtensions,
|
||||||
} from './collaboration.util';
|
} from './collaboration.util';
|
||||||
@@ -13,6 +14,35 @@ export type CollabEventHandlers = ReturnType<
|
|||||||
CollaborationHandler['getHandlers']
|
CollaborationHandler['getHandlers']
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear+reseed the 'title' XmlFragment of `doc` so it holds EXACTLY `title`.
|
||||||
|
*
|
||||||
|
* Used by the gateway's direct `writePageTitle` method to write a new page
|
||||||
|
* title INTO the page's Yjs 'title' fragment. The title lives in the same
|
||||||
|
* Y.Doc as the body; onStoreDocument extracts it on every save, so a REST/MCP
|
||||||
|
* rename that only updated the page.title DB column would be reverted on the
|
||||||
|
* next collaborative save unless the Yjs 'title' fragment is kept in sync.
|
||||||
|
* The whole fragment is replaced (no merge/append),
|
||||||
|
* mirroring the 'replace' body path: the new title fully supersedes the old.
|
||||||
|
*
|
||||||
|
* DELIBERATE TRADE-OFF: because this does a FULL clear+replace of the 'title'
|
||||||
|
* fragment, a REST/MCP rename arriving while a user is actively editing the
|
||||||
|
* title in an open editor WILL overwrite that in-progress edit. This is
|
||||||
|
* acceptable — the title is a short, rarely-concurrently-edited field — and is
|
||||||
|
* preferable to leaving a stale Yjs title that onStoreDocument would revert the
|
||||||
|
* DB column to on the next save.
|
||||||
|
*/
|
||||||
|
export function writeTitleFragment(doc: Y.Doc, title: string): void {
|
||||||
|
const titleFragment = doc.getXmlFragment('title');
|
||||||
|
|
||||||
|
if (titleFragment.length > 0) {
|
||||||
|
titleFragment.delete(0, titleFragment.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTitleDoc = buildTitleSeedYdoc(title);
|
||||||
|
Y.applyUpdate(doc, Y.encodeStateAsUpdate(newTitleDoc));
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CollaborationHandler {
|
export class CollaborationHandler {
|
||||||
private readonly logger = new Logger(CollaborationHandler.name);
|
private readonly logger = new Logger(CollaborationHandler.name);
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import * as Y from 'yjs';
|
import * as Y from 'yjs';
|
||||||
|
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||||
import {
|
import {
|
||||||
getPageId,
|
getPageId,
|
||||||
isEmptyParagraphDoc,
|
isEmptyParagraphDoc,
|
||||||
jsonToNode,
|
jsonToNode,
|
||||||
prosemirrorNodeToYElement,
|
prosemirrorNodeToYElement,
|
||||||
|
buildTitleSeedYdoc,
|
||||||
|
jsonToText,
|
||||||
} from './collaboration.util';
|
} from './collaboration.util';
|
||||||
import { Node } from '@tiptap/pm/model';
|
import { Node } from '@tiptap/pm/model';
|
||||||
|
|
||||||
@@ -241,3 +244,43 @@ describe('prosemirrorNodeToYElement', () => {
|
|||||||
expect(element.get(1).get(0).toString()).toBe('two');
|
expect(element.get(1).get(0).toString()).toBe('two');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('buildTitleSeedYdoc', () => {
|
||||||
|
it('builds a level-1 heading carrying the title text', () => {
|
||||||
|
const doc = buildTitleSeedYdoc('Hello World');
|
||||||
|
const json: any = TiptapTransformer.fromYdoc(doc, 'title');
|
||||||
|
|
||||||
|
const first = json.content?.[0];
|
||||||
|
expect(first.type).toBe('heading');
|
||||||
|
expect(first.attrs.level).toBe(1);
|
||||||
|
expect(jsonToText(json).trim()).toBe('Hello World');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces a non-empty title fragment for a non-empty title', () => {
|
||||||
|
const doc = buildTitleSeedYdoc('Some Title');
|
||||||
|
expect(doc.get('title', Y.XmlFragment).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces a heading with no text child for an empty title', () => {
|
||||||
|
const doc = buildTitleSeedYdoc('');
|
||||||
|
const json: any = TiptapTransformer.fromYdoc(doc, 'title');
|
||||||
|
|
||||||
|
const first = json.content?.[0];
|
||||||
|
expect(first.type).toBe('heading');
|
||||||
|
// No text content for an empty title.
|
||||||
|
expect(first.content ?? []).toHaveLength(0);
|
||||||
|
expect(jsonToText(json).trim()).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips a title through build -> extract -> build -> extract', () => {
|
||||||
|
const title = 'Round Trip Title';
|
||||||
|
const doc1 = buildTitleSeedYdoc(title);
|
||||||
|
const text1 = jsonToText(TiptapTransformer.fromYdoc(doc1, 'title')).trim();
|
||||||
|
|
||||||
|
const doc2 = buildTitleSeedYdoc(text1);
|
||||||
|
const text2 = jsonToText(TiptapTransformer.fromYdoc(doc2, 'title')).trim();
|
||||||
|
|
||||||
|
expect(text1).toBe(title);
|
||||||
|
expect(text2).toBe(text1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
|
|||||||
import { Node, Schema } from '@tiptap/pm/model';
|
import { Node, Schema } from '@tiptap/pm/model';
|
||||||
import * as Y from 'yjs';
|
import * as Y from 'yjs';
|
||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { TiptapTransformer } from '@hocuspocus/transformer';
|
||||||
|
|
||||||
export const tiptapExtensions = [
|
export const tiptapExtensions = [
|
||||||
StarterKit.configure({
|
StarterKit.configure({
|
||||||
@@ -145,6 +146,34 @@ export function jsonToText(tiptapJson: JSONContent) {
|
|||||||
return generateText(tiptapJson, tiptapExtensions);
|
return generateText(tiptapJson, tiptapExtensions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a standalone Y.Doc that holds ONLY the page title, in a dedicated Yjs
|
||||||
|
* fragment named exactly 'title' (the collaborative title-editor contract with
|
||||||
|
* the client). The ProseMirror shape is a doc with a single level-1 heading
|
||||||
|
* whose text is the title (empty title => heading with no text child).
|
||||||
|
*
|
||||||
|
* The encoded state of the returned doc can be merged into a body doc via
|
||||||
|
* `Y.applyUpdate(doc, Y.encodeStateAsUpdate(titleSeed))` to seed the title
|
||||||
|
* fragment for legacy pages. Seeding MUST be guarded by an emptiness check on
|
||||||
|
* the existing 'title' fragment to avoid the Yjs duplication trap.
|
||||||
|
*/
|
||||||
|
export function buildTitleSeedYdoc(title: string): Y.Doc {
|
||||||
|
return TiptapTransformer.toYdoc(
|
||||||
|
{
|
||||||
|
type: 'doc',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'heading',
|
||||||
|
attrs: { level: 1 },
|
||||||
|
content: title ? [{ type: 'text', text: title }] : [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'title',
|
||||||
|
tiptapExtensions,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function jsonToNode(tiptapJson: JSONContent) {
|
export function jsonToNode(tiptapJson: JSONContent) {
|
||||||
const schema = getSchema(tiptapExtensions);
|
const schema = getSchema(tiptapExtensions);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
export const HISTORY_INTERVAL = 5 * 60 * 1000;
|
export const HISTORY_INTERVAL = 5 * 60 * 1000;
|
||||||
export const HISTORY_FAST_INTERVAL = 60 * 1000;
|
export const HISTORY_FAST_INTERVAL = 60 * 1000;
|
||||||
export const HISTORY_FAST_THRESHOLD = 5 * 60 * 1000;
|
export const HISTORY_FAST_THRESHOLD = 5 * 60 * 1000;
|
||||||
|
|
||||||
|
// Redis pub/sub channel that bridges a PAGE_UPDATED tree snapshot (a title/icon
|
||||||
|
// rename) from the standalone collab process to the API process, which is the
|
||||||
|
// single broadcast authority. Imported by both halves of the bridge:
|
||||||
|
// PageTreeBridgePublisher (collab process) and PageTreeBridgeSubscriber (API process).
|
||||||
|
export const COLLAB_TREE_UPDATE_CHANNEL = 'collab:tree-update';
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user