Compare commits

..

25 Commits

Author SHA1 Message Date
agent_coder cd3f2b7166 Merge branch 'develop' into feature/offline-sync
# Conflicts:
#	apps/client/src/features/editor/page-editor.tsx
#	apps/server/src/collaboration/extensions/persistence.extension.ts
2026-07-02 15:15:36 +03:00
claude code agent 227 0eecdcba23 test(collab): cover the title-change ⋈ empty-guard intersection (F1)
The develop rebase merged the #120 title-only-change branch and the
#248/#251 store-side empty-guard into one onStoreDocument. The existing 14
tests exercise each only in isolation (empty-guard tests send no title
fragment; title tests send a non-empty body), so none reached the
empty-guard's blocking branch with titleChanged===true. Add two paired
regression tests on that exact junction:

- empty body + a changed non-empty title over non-empty persisted content,
  no intentional-clear → the empty-guard blocks the WHOLE store, dropping
  the simultaneous rename too (updatePage not called); the rich content and
  old title survive.
- the same doc with a deliberate clear armed via the real stateless
  transport → the empty body is allowed and the rename rides along on the
  same body-path updatePage (title + empty content persisted).

The pair makes Test 1 non-vacuous: same doc, only the clear differs, and
Test 2 proves updatePage IS reachable — so Test 1's "not called" is the
guard blocking, not an unreached path. Test-only; no production change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 03:16:40 +03:00
claude code agent 227 03314d747f Merge remote-tracking branch 'gitea/develop' into HEAD
# Conflicts:
#	CHANGELOG.md
#	apps/server/src/collaboration/extensions/persistence.extension.ts
2026-06-30 02:36:02 +03:00
claude code agent 227 dd186406b6 Merge remote-tracking branch 'gitea/develop' into HEAD
# Conflicts:
#	CHANGELOG.md
#	apps/server/src/integrations/environment/environment.service.spec.ts
#	apps/server/src/integrations/environment/environment.service.ts
2026-06-29 18:49:06 +03:00
claude code agent 227 47f37072ab test(offline): rename success case to match its assertion (F5)
The success path calls notifications.show without a color (Mantine default,
not green), and the test asserts color is undefined — rename the case from
'GREEN success toast' to 'success toast with no error color'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 17:02:44 +03:00
claude code agent 227 5eb92f2cef test(offline): cover the make-available-offline handler's F1 toast gating (F4)
Add space-tree-node-menu.test.tsx driving handleMakeAvailableOffline through
the menu: full success → green 'available offline' toast; ydoc not synced
(result.ok but !didSync) → red toast naming 'editor' (the F1 guarantee a
non-warmed page is not reported available); read-query failures → red toast
listing labels; thrown error → extracted reason. Mutation-checked (inverted
gate flips two cases).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 16:09:21 +03:00
claude code agent 227 57b77c35e5 fix(offline): report editor-warm failures honestly; cover failure labels; align docs (F1-F3)
F1: warmPageYdoc now returns didSync (true only on the real 'synced' event,
    false on the 8s timeout / error, which are now logged). The tree menu gates
    the 'available offline' success toast on result.ok AND didSync, and pushes
    'editor' into the failed set otherwise — so a page whose Yjs body never
    warmed is no longer reported as fully offline-available.
F2: tests for the page/space/currentUser failure labels and the didSync
    true/false paths.
F3: correct the mobile-app-plan / mobile-bootstrap present-state sections to
    match shipped code (Capacitor, CORS allowlist, Swagger, offline reading,
    /l in the SW denylist).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 14:31:10 +03:00
claude code agent 227 411c05a9d6 fix(client): add /l vanity route to SW denylist; name failed offline steps (F2, F3)
F2: navigateFallbackDenylist was missing the server's `l/:alias` vanity
short-link, so a top-nav to /l/<alias> after SW registration got the
index.html app shell (which has no /l route) and dead-ended on Error404
instead of the server's 302 redirect. Add /^\/l(\/|$)/ mirroring main.ts.

F3: the partial-failure branch of "make page available offline" showed a
bare generic toast; include result.failed step labels in the message per
AGENTS.md (errors must be specific), matching the catch-branch below it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 00:40:42 +03:00
claude code agent 227 e8805b39c8 fix(collab): persist renamed title fragment to page.ydoc (F1, variant C)
A REST/MCP/agent rename (no live editor) wrote the new title to the
page.title column, then writePageTitle loaded the ydoc (fragment still
OLD) and set it to NEW only in memory. On disconnect onStoreDocument saw
titleText(NEW) === column(NEW), took the no-op fast-path, and never
persisted the in-memory fragment — so page.ydoc kept the OLD title and a
later body edit silently reverted the column back to OLD.

writePageTitle now persists the 'title' fragment to page.ydoc DIRECTLY
(PersistenceExtension.persistTitleFragmentYdoc) after the transact,
bypassing the no-op onStoreDocument. The write carries no treeUpdate, so
the tree WS/redis listeners do not re-broadcast (no double broadcast),
and it is idempotent/lock-free so it is safe whether or not a live editor
is connected. Adds a persist-then-reload-then-edit-body regression test
that fails on the old no-op behaviour and passes after the fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 00:40:34 +03:00
claude code agent 227 67a3663fc5 fix(offline): resume rehydrated paused mutations, stop logout cache leak, offline affordances (PR #120 QA)
Address six QA findings on the offline-sync feature:

1. HIGH — silent data loss: a paused mutation persisted to IndexedDB and
   reloaded while still offline never resumed on reconnect. Seed TanStack
   onlineManager from navigator.onLine at boot (it defaults to online:true and
   only flips on events, so a cold-boot-offline tab wrongly believed it was
   online and never got a true online transition), and call
   resumePausedMutations() in PersistQueryClientProvider onSuccess after the
   persister rehydrates (defaults are registered before, so the restored
   mutation has a mutationFn). New offline-resume.test.ts reproduces the full
   persist -> reload -> reconnect path.

2. MEDIUM (security) — logout did not durably clear gitmost-rq-cache: the
   throttled persister re-wrote the key ~1s after del() with the still-in-memory
   snapshot, resurrecting the previous user's data. Freeze the persister
   (persistClient becomes a no-op) before clearing/deleting so neither the
   clear()-triggered nor any in-flight write can repopulate the key; re-enable
   afterwards for the next sign-in session.

3. MEDIUM (UX) — offline create spun forever: the create-note button awaited a
   mutateAsync that stays pending while paused. Detect offline, fire-and-forget
   the (queued) mutation, show a "saved offline" notice, and gate the spinner on
   !isPaused so it no longer hangs.

4. LOW — an uncached page opened offline showed the generic "Error fetching page
   data." instead of the offline fallback (offline fetch yields no HTTP status).
   Render OfflineFallback when navigator is offline or the error has no status.

5. LOW — logout teardown threw "Cannot read properties of null (reading
   'settings')" in full-editor.tsx: optional-chain the (transiently null) user.

6. Tab title "Untitled": investigated — the tab-title derivation in page.tsx is
   byte-identical to develop and already reads page.title from REST/cache (the
   recommended source); live edits keep it in sync via updatePageData. Not a
   tab-title-derivation regression introduced by this PR; no change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 17:51:01 +03:00
claude code agent 227 2cf30c7690 fix(offline): address PR #120 review (comment 2513)
CHANGELOG: stop presenting the service-worker API cache as an active offline
store (/api is NetworkOnly) — describe it as a defensive purge of the legacy
api-get-cache from older clients; add an explicit upgrade note that the new CORS
allowlist rejects previously-allowed cross-domain REST clients until their origin
is added to CORS_ALLOWED_ORIGINS.

test(offline): cover make-offline ancestor-walk + dedup — a real-ancestor case
exercising the ancestorId===pageId guard (page warmed once), the dedup of
repeated tree failures into a single "tree" label, and the "breadcrumbs" label
when the breadcrumbs lookup rejects.

test(auth): cover clearOfflineCache in handleLogout — purged exactly once before
window.location.replace, and a thrown purge error does not block the redirect.

conventions: use pageKeys.detail() instead of raw ["pages", …] literals in
title-editor and use-page-collab-providers.

cleanup: remove the dead emit() in title-editor (the gateway ignores it; the
cross-user tree refresh is server-side via the Yjs title fragment); drop the
trivial Array.isArray(tiptapExtensions) test (schema is exercised transitively).

refactor: extract the shared page.<id> Yjs doc-name convention into
pageYdocName()/PAGE_YDOC_NAME_PREFIX so the editor providers, offline warm, and
offline purge can no longer drift apart.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:26:24 +03:00
a ca26af9e9d fix(offline): address PR #120 review (cross-user leak, Yjs title dup, i18n, docs, guards)
Security:
- Clear the offline IndexedDB cache on sign-in (not only logout) so a previous
  user's persisted query cache and Yjs page bodies cannot leak to the next user
  on a shared device when the prior session ended without an explicit logout.

Regressions:
- Remove the double Yjs title write from the AI title-generation path: the title
  editor is bound to the Yjs `title` fragment and the server REST update reseeds
  it, so the local setContent raced that reseed and doubled/garbled the title.

Conventions / i18n / docs:
- Remove the unused showAiMenuAtom.
- Register the 3 offline-fallback strings in en-US and ru-RU.
- Fix the 5 broken links to the nonexistent docs/offline-sync-plan.md.

Stability / simplification:
- warmInfiniteAll now reports truncation (returns false) when it hits maxPages
  with a cursor still pending instead of silently succeeding.
- space-tree make-offline catch logs the raw error and surfaces the real cause.
- Move the Offline/Mobile/CORS CHANGELOG entries from the released 0.93.0 section
  into [Unreleased] (CORS is a documented breaking change).
- Drop the pass-through sync-flag forwarders in use-page-collab-providers; set the
  atoms directly.
- Collapse the three isSwaggerEnabled true-cases into it.each.

Tests / architecture:
- Extract collabTokenNeedsRefresh (pure) and cover all four token states.
- Extract shouldPropagateTitleChange and cover the collab-origin skip; add a
  TitleEditor render test for the static-h1 vs collaborative-editor switch.
- Add a use-auth test asserting the sign-in cache purge runs before login.
- Add an OFFLINE_PERSIST_ROOTS guard test asserting every persisted root maps to
  an exported query-key factory; route make-offline's currentUser warm through a
  new userKeys factory.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:15:50 +03:00
a 3d6f48c3bd fix(offline): stop offline white-screen and replay paused structural mutations
Fixes the offline-sync defects QA found on PR #120 (#237/#238/#220).

Blank-shell / white-screen on offline reload (HIGH):
- auth-query.tsx: the useCollabToken retry predicate read
  `error.response.status` unguarded. Offline the collab-token POST rejects as
  an axios NETWORK error (isAxiosError true, response undefined), so `.status`
  threw an uncaught TypeError in the React Query retryer BEFORE React mounted,
  white-screening every route. Extracted the predicate as `collabTokenRetry`
  and guarded it with optional chaining (`error.response?.status === 404`).
- user-provider.tsx: gated the whole <Layout> on useCurrentUser() and returned
  a bare `<></>` on any error, blanking every authenticated route offline even
  when cached data existed. Now renders the cached app when a (stale) user is
  present and an explicit OfflineFallback when there is no user to fall back on.
- query-persister.ts / make-offline.ts: persist and warm the ['currentUser']
  query so the auth gate can hydrate offline (pinned pages now survive relaunch).

Offline structural create/move/comment silently lost on reload (HIGH):
- offline-mutations.ts: register setMutationDefaults (default mutationFns) for
  stable mutation keys and tag useCreatePageMutation / useMovePageMutation /
  useCreateCommentMutation with those keys. A paused mutation dehydrated to
  IndexedDB while offline now has a mutationFn after reload, so
  resumePausedMutations() replays it on reconnect instead of no-op'ing.

Tests (client vitest): collabTokenRetry no longer throws on a no-response
network error; UserProvider renders cached children / the offline fallback (not
a blank fragment) on a network error; a rehydrated paused create/move is
replayable via resumePausedMutations; currentUser persist-root coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 15:15:50 +03:00
claude code agent 227 2f5b520af2 chore(offline-sync): tighten SW denylist, drop dead /api cache + http localhost CORS
- Service worker (vite-plugin-pwa/Workbox): add /share/, /mcp, and /robots.txt
  to navigateFallbackDenylist so the SPA app-shell never shadows those
  server-rendered routes (they mirror the server static-serve exclude list — the
  share SEO/OG HTML, the MCP endpoint, and robots.txt must come from the server).
- Remove the dead /api GET NetworkFirst Workbox rule (api-get-cache): offline
  reads are served by the persisted TanStack Query cache (IndexedDB) + y-indexeddb,
  never by an SW HTTP cache, so caching GET /api only risked stale responses. All
  /api is now NetworkOnly. clearOfflineCache still deletes any legacy api-get-cache
  defensively (comment updated to note it is no longer created).
- CORS: drop the cleartext 'http://localhost' native-WebView origin. The Capacitor
  shell uses the secure scheme (capacitor.config cleartext:false, default Android
  scheme https, iOS hosted via CAP_SERVER_URL), so no native client uses it;
  allowing it only widened the credentialed-CORS surface. Keeps capacitor://,
  ionic://, and https://localhost.
- docs/mobile-bootstrap.md: replace the inaccurate 'hand-rolled service worker'
  description with the real Workbox generateSW setup (prompt registration via
  virtual:pwa-register, production-only, denylist, NetworkOnly, RQ/y-indexeddb
  offline reads) and drop http://localhost from the CORS origins list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:15:50 +03:00
claude code agent 227 655970dd49 refactor(offline-sync): share query keys/options between hooks and offline warm
The 'Make available offline' warm path re-typed React Query key literals and
re-declared queryKey+queryFn pairs that the feature hooks already owned, so the
two could silently drift (a hook key change would leave the warm cache under a
stale key). Centralize them so there is one source:

- Add pageKeys (page-query.ts) and spaceKeys (space-query.ts) key factories and
  route the inline key literals through them. Partial-match keys and 2-element
  spaceMembers invalidations are deliberately left inline so their effective key
  VALUE (and invalidation breadth) is unchanged.
- Add queryOptions factories sidebarPagesQueryOptions and spaceByIdQueryOptions,
  consumed by both the hooks (fetchAllAncestorChildren, useGetSpaceBySlugQuery)
  and the warm path. Comments reuse the existing RQ_KEY factory.

The warm path also stops silently succeeding: warmInfiniteAll returns a boolean
and logs failures; makePageAvailableOffline is best-effort (never throws) and
returns { ok, failed[] }, recording each failed step by label; the tree menu
caller now shows a success or error toast from result.ok. Removed the unused
slugId/parentPageId params from the offline params type.

This is a behavior-preserving centralization: effective query keys, queryFns,
staleTime and enabled are unchanged for every hook.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:15:50 +03:00
claude code agent 227 7ceef2bae6 fix(offline-sync): harden collab auth-failure handler, drop dead sync state
The onAuthenticationFailed handler is created once per page (effect keyed on
pageId), so it closed over the initial collab token and decoded a STALE value
after a refetch. Worse, jwtDecode(undefined) throws, so when the token had not
loaded (or the request failed) the handler crashed before it could refetch and
reconnect — leaving the editor stuck disconnected.

Mirror the latest token into a ref the handler reads live, and guard the decode:
a missing or malformed token is treated as 'needs refresh' so it refetches and
reconnects instead of throwing. A valid, unexpired token still early-returns.

Also remove two local useState sync flags (isLocalSynced/isRemoteSynced) that
were set but never read — the header indicator consumes the Jotai atoms, and the
hook's return values were never destructured by any caller. The setter wrappers
now drive only the atoms.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:15:50 +03:00
claude code agent 227 77aa9443e9 fix(offline-sync): bridge collaborative tree updates across processes via Redis
In 2-process deployments (COLLAB_URL set) the standalone collab process runs
Hocuspocus onStoreDocument, which emits PAGE_UPDATED with a treeUpdate snapshot
on a collaborative rename. But CollabAppModule has no WsModule, so PageWsListener
(the broadcaster) only exists in the API process — the collab-originated tree
update never reached clients, and other users' sidebars/breadcrumbs went stale.

Bridge it over Redis pub/sub with the API process as the single broadcast
authority:

- PageTreeBridgePublisher (registered ONLY in CollabAppModule) listens for
  PAGE_UPDATED and, when a treeUpdate snapshot is present, publishes it to the
  collab:tree-update channel. Gated exactly like PageWsListener so content-only
  saves never publish noise.
- PageTreeBridgeSubscriber (registered in WsModule, API process) subscribes on a
  dedicated duplicated connection and re-broadcasts each snapshot through
  WsTreeService.broadcastPageUpdated — the same restriction-aware emitTreeEvent
  path, so authorization is preserved.

Double-broadcast is prevented by module placement: the publisher lives only in
the standalone collab process's root module, so in single-process mode it is
never loaded and the local PageWsListener stays the sole broadcaster.

The bridge is optional and fail-safe: publish errors, malformed payloads,
broadcast rejections, an unlistened 'error' on the subscriber connection, and a
subscribe() failure at boot are all caught and logged, never crashing or blocking
the process. NOTE: assumes a single API broadcaster; horizontal API scaling would
need a consumer-group/leader-election instead of fan-out pub/sub.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:15:50 +03:00
claude code agent 227 1ac9a8df98 fix(offline-sync): make legacy ydoc self-heal atomic and crash-safe
onLoadDocument rebuilds a legacy page (page.content, no page.ydoc) into a Yjs
doc and seeds its 'title' fragment from the page.title column. Both
TiptapTransformer.toYdoc and buildTitleSeedYdoc mint fresh Yjs client-ids on
every call, so the heal must run exactly once per page. Three holes let it run
twice (or lose a write):

- Duplication trap: the initial page read took no row lock, so two processes
  (the API process via openDirectConnection and the standalone collab process)
  could both observe ydoc IS NULL and each rebuild with different client-ids; a
  long-offline client merging an earlier rebuild then duplicates all content.
- Lost-update: persistYdoc wrote updatePage({ydoc}) outside any transaction, so
  it could clobber a concurrent onStoreDocument write (which does take a lock).
- Swallowed write errors: a failed heal-persist was logged but the unpersisted
  fresh-client-id doc was returned anyway, silently re-arming the trap.

Fix: the heal now runs in healUnderLock, which re-reads the row FOR UPDATE inside
one transaction and re-validates under the lock — if ydoc is now present it
adopts it (no rebuild, no write), otherwise it rebuilds, seeds, and persists the
ydoc in the SAME transaction. The healthy hot path still loads with no lock and
no write. Failure handling surfaces instead of hiding: a rebuild-persist failure
refuses the load (re-throw + error log) so an unpersisted rebuild is never handed
out, while a seed-only persist failure serves the existing healthy ydoc without
the unpersisted seed (non-fatal). Removed the non-transactional persistYdoc.

Deliberately does NOT use a fixed clientID: identical client-ids across docs
built from differing content violate Yjs per-actor uniqueness and corrupt worse
than the trap; serialization under the row lock is the correct fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:15:50 +03:00
claude code agent 227 8cfc4c3c40 fix(offline-sync): keep page titles in sync between REST and Yjs
Title now lives in the page's Yjs 'title' fragment, but two paths corrupted it:

- Rename-revert: a REST/MCP title change wrote only the page.title column,
  never the Yjs fragment, so the next editor open replayed the stale Yjs title
  and reverted the rename. PageService.update now mirrors the new title into the
  Yjs 'title' fragment via CollaborationGateway.writePageTitle, which goes
  through openDirectConnection directly (Redis-independent: works with
  COLLAB_DISABLE_REDIS and in single-process deployments, unlike the
  Redis-routed handleYjsEvent path). The write is best-effort: a Yjs failure is
  logged and never rolls back the committed column write. Agent provenance
  (actor/aiChatId) is threaded into the store context.

- Untitled-on-open: an empty/just-initialized 'title' fragment clobbered a
  non-empty page.title to '' on open. onStoreDocument now treats the title as
  changed only when the extracted text is non-empty, covering both the
  title-only and body+title save branches. Empty-retitling via collab is
  intentionally impossible; the REST DTO is the place to enforce non-empty.

writeTitleFragment does a full clear+seed of the 'title' fragment (no
duplication/concatenation) and leaves the body fragment intact. Removed the dead
useTreeMutation.handleRename path. Adds unit tests for writeTitleFragment, the
gateway write, the anti-empty-clobber guard, and agent provenance.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:15:50 +03:00
claude_code 85ad697cd4 fix(offline,server,docs): apply PR #116 review findings to offline-sync
Carries the still-applicable findings from the PR #116 review into PR #120,
since #120 includes the mobile-bootstrap commit. CORS hardening (removing the
unconditional localhost/capacitor origins) is intentionally left out of scope.

Service worker routing (latent bug fix + testability):
- vite.config.ts: anchor Workbox path matching to a segment boundary
  (^/<seg>(/|$)) instead of startsWith, so siblings like /apidocs,
  /collaborators, /socket.iox are no longer mis-routed as API/realtime and
  forced NetworkOnly; align navigateFallbackDenylist with the same anchors.
- new apps/client/src/pwa/sw-strategy.ts holds the canonical predicates
  (isApiPath, isCollabOrSocketPath) + unit tests; the vite.config regexes
  mirror it inline (Workbox generateSW serializes urlPattern fns standalone,
  so they cannot import the module).

Server CORS (R1 extraction + coverage):
- extract buildCorsAllowlist / isOriginAllowed into cors.util.ts with unit
  tests (evil-origin rejected, WebView/no-Origin allowed); main.ts rewired to
  use them with byte-for-byte identical behavior.

Privacy — clear offline cache on logout:
- new clear-offline-cache.ts purges the persisted query cache
  (idb-keyval gitmost-rq-cache), the Yjs page.* IndexedDB databases, and the
  service-worker api-get-cache; wired into handleLogout (best-effort, before
  the redirect) so a previous user's private data does not linger locally.

Conventions & docs:
- prettier fixes on main.ts and login.dto.ts.
- CHANGELOG: document offline reading, returnToken opt-in, optional Swagger,
  new env vars, logout cache-clear, and the CORS open->allowlist breaking
  change.
- docs/mobile-app-plan.md: correct the now-false §2.4 claims and update the
  §12 checklist (native cap add ios left unchecked — generated locally,
  gitignored).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:15:50 +03:00
claude_code ccc5e97000 test(server): port missing returnToken/env edge cases from #116
PR #120 rewrote auth.controller.spec.ts and environment.service.spec.ts in a
leaner style but dropped several edge cases that PR #116 covered. Port the
gaps so the server coverage matches the original review intent:

- auth.controller: returnToken=false must behave like the omitted case
  (no token in the response body, cookie still set) — guards an
  `!== undefined`-style regression.
- environment.getCorsAllowedOrigins: empty string -> [], single origin,
  and leading/trailing/duplicate commas with spaces -> trimmed list.
- environment.isSwaggerEnabled: mixed-case "True" -> true; "false"/""/"1"
  -> false.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:15:50 +03:00
claude_code df02f2d672 test(offline): add reviewer-requested coverage for offline-sync core logic
Adds the unit tests called out in the PR #120 review (test-coverage
aspect). No production logic changes — the only non-test edit is exporting
the already-injectable warmInfiniteAll helper so it can be unit tested.

Server (Jest):
- persistence.extension.spec.ts: onStoreDocument classification matrix
  (no-op / title-only / body+title / body-only), onLoadDocument seed +
  persist gating (early-return, page-null, ydoc seed, already-seeded
  no-persist, legacy content->ydoc), and seedTitleFragment 4-branch guard.
- collaboration.util.spec.ts: buildTitleSeedYdoc round-trip.
- environment.service.spec.ts: getCorsAllowedOrigins / isSwaggerEnabled.
- auth.controller.spec.ts: login returnToken opt-in branch.

Client (Vitest):
- query-persister.test.ts: shouldDehydrateOfflineQuery status + allowlist
  gates and OFFLINE_PERSIST_ROOTS membership.
- is-capacitor.test.ts: isCapacitorNativePlatform platform detection.
- make-offline.test.ts: warmInfiniteAll cursor walk / maxPages / error
  swallow, and warmPageYdoc settle-once + timeout + teardown.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:15:50 +03:00
claude_code 7ac7fcba2d chore(pwa): reconcile dual service worker after mobile-app-bootstrap merge
The mobile bootstrap shipped a hand-written public/sw.js plus a manual
navigator.serviceWorker.register('/sw.js') in main.tsx. The offline-sync
Workbox SW (vite-plugin-pwa, generateSW) functionally supersedes it
(NetworkOnly for /api,/collab,/socket.io, navigateFallback to the app shell,
runtime caching) and adds precache + prompt-based updates, so:

- Remove the hand-written apps/client/public/sw.js.
- Remove the manual SW registration block from main.tsx; registration is now
  owned by <PwaUpdatePrompt/> via useRegisterSW (skipped in Capacitor native).
- Regenerate pnpm-lock.yaml for the merged Capacitor + @nestjs/swagger deps.

Kept from mobile-app-bootstrap: the richer manifest.json (offline-sync uses
manifest:false), capacitor.config.ts, the apple-touch-icon, and all server
mobile-auth/CORS/Swagger changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:15:50 +03:00
claude_code caeb555039 feat(mobile): bootstrap mobile app (PWA + Capacitor + backend auth/CORS)
Implements the §12 bootstrap from docs/mobile-app-plan.md.

Backend (§6):
- auth: optional returnToken flag on login returns the JWT in the body
  (data.authToken) for native Keychain/Keystore + Bearer; web cookie flow
  unchanged.
- main.ts: explicit CORS allowlist (APP_URL + CORS_ALLOWED_ORIGINS env +
  Capacitor WebView origins), credentials enabled, replaces open enableCors().
- optional OpenAPI/Swagger at /api/docs behind SWAGGER_ENABLED.
- env: CORS_ALLOWED_ORIGINS, SWAGGER_ENABLED, CAP_SERVER_URL.

PWA:
- manifest metadata, hand-rolled service worker (network-first nav, SWR
  assets, never intercepts /api,/socket.io,/collab), prod-only registration,
  apple-touch-icon.

Capacitor:
- capacitor.config.ts (webDir apps/client/dist; iOS via CAP_SERVER_URL to
  avoid bundling the AGPL client in the .ipa, see plan §9), cap:* scripts,
  deps, .gitignore for native dirs.
- docs/mobile-bootstrap.md documenting what is done and the remaining manual
  steps (cap add ios/android, APNs/FCM, stores).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:15:50 +03:00
claude_code e05495ba4f feat(offline): PWA shell, Yjs-backed titles, and offline read cache (M0–M2)
Implements docs/offline-sync-plan.md milestones M0–M2.

M0 (PWA shell):
- Add vite-plugin-pwa (generateSW, registerType: 'prompt', manifest:false);
  NetworkOnly for /api,/collab,/socket.io, NetworkFirst for GET /api,
  navigateFallback to index.html.
- Register SW via useRegisterSW with a Mantine update prompt; skip
  registration inside Capacitor native WebView (is-capacitor guard).

M1 (harden CRDT body + title into Yjs):
- Lift the per-page Y.Doc/Hocuspocus providers into a shared hook+context so
  body and title editors share one doc.
- Move the page title into a dedicated 'title' Yjs fragment (CRDT, offline-
  tolerant); drop the REST title save. Server persists the title fragment to
  page.title and seeds it for legacy pages (empty-fragment guard); a collab
  rename emits a treeUpdate so other users' tree/breadcrumbs refresh.
- Persist the rebuilt ydoc on the content->ydoc path to neutralize the Yjs
  duplication trap. Add a 3-state sync indicator.

M2 (offline read/navigation):
- Persist React Query to IndexedDB (idb-keyval persister, version buster,
  selected roots only).
- "Make available offline" action warms page, space, tree (root+ancestors+
  children) and comments under exact hook keys, plus the page ydoc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:15:49 +03:00
193 changed files with 8172 additions and 8684 deletions
+16 -15
View File
@@ -92,6 +92,19 @@ IFRAME_EMBED_ALLOWED=false
# Example: https://intranet.example.com,https://portal.example.com
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)
DEBUG_MODE=false
@@ -173,21 +186,9 @@ MCP_DOCMOST_PASSWORD=
# Keep-alive recycle window (ms) for streaming chat/agent AI + external-MCP calls.
# A pooled connection idle longer than this is closed instead of reused, so a
# NAT / egress firewall / reverse proxy that silently drops idle connections
# cannot poison a reused socket into a PRE-RESPONSE `read ECONNRESET`. Kept under
# common ~5s upstream/middlebox idle cutoffs so undici recycles the socket before
# the network kills it (fewer resets), while still reusing within a burst of
# back-to-back calls. Lower it further if your egress drops idle connections even
# faster. Default 4000 (4 s).
# AI_STREAM_KEEPALIVE_MS=4000
# Number of PRE-RESPONSE connection retries for streaming chat/agent AI calls: a
# reset/timeout BEFORE any response byte (e.g. `read ECONNRESET` on a stale pooled
# socket) is retried on a fresh connection with jittered exponential backoff.
# Total attempts = value + 1, so the default 4 gives 5 attempts — headroom to
# absorb a short BURST of upstream resets without exhausting the budget. Safe to
# retry: a started stream is never replayed, only a connect that never responded.
# 0 disables the retry. Default 4.
# AI_STREAM_PRE_RESPONSE_RETRIES=4
# cannot poison a reused socket into a PRE-RESPONSE `read ECONNRESET`. Lower it if
# your egress drops idle connections faster than ~10s. Default 10000 (10 s).
# AI_STREAM_KEEPALIVE_MS=10000
# Silence timeout (ms) for EXTERNAL-MCP transport ONLY (not the chat provider).
# Tighter than AI_STREAM_TIMEOUT_MS so a byte-silent/hung MCP server is broken in
+5
View File
@@ -49,3 +49,8 @@ lerna-debug.log*
# Self-hosted VAD / onnxruntime-web assets (copied from node_modules at dev/build time)
apps/client/public/vad/
# Capacitor native platform projects (generated locally via 'npx cap add ios|android')
/ios
/android
.capacitor
+21 -32
View File
@@ -72,10 +72,7 @@ git log -1 --format='Author: %an <%ae>%nCommitter: %cn <%ce>'
### 4. Push and PR to develop
PRs always target `develop`. Two different mechanisms are involved: **pushing
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
PRs always target `develop`. The `claude_code` password lives in the macOS
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
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
```
The PR is opened through the **Gitea MCP** (server `gitea`), not `curl`/`tea`
the MCP authenticates in-process, so no keychain lookup or Basic-Auth is needed.
Call `pull_request_write` with:
The PR is created via the Gitea REST API (Basic Auth as `claude_code`):
- `method: "create"`
- `owner: "vvzvlad"`, `repo: "gitmost"`
- `base: "develop"`, `head: "<branch>"`
- `title`, `body` — in the body: what was done, what is out of scope,
verification results (tsc/lint/tests).
```bash
curl -s -X POST \
-u "claude_code:$(security find-generic-password -s gitea-claude-code -w)" \
-H "Content-Type: application/json" \
-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`,
`pull_request_read` (`get`, `get_diff`, `get_files`, `get_status`),
`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`.
`base: develop`, `head: <branch>`. In the PR body: what was done, what is out
of scope, verification results (tsc/lint/tests).
> 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
@@ -161,25 +152,23 @@ below.
| Agent user (Gitea/git) | `claude_code` |
| Agent email | `claude_code@vvzvlad.xyz` |
| 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` |
| `origin` | GitHub mirror `vvzvlad/gitmost`**do not push**, updated by the owner's CI |
| `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
`issue_write` with:
Issues are filed with the official Gitea CLI `tea`, already logged in as
`claude_code` (`tea logins list` shows the `gitea` login as default):
- `method: "create"`
- `owner: "vvzvlad"`, `repo: "gitmost"`
- `title`, `body`
- `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>]`.
```bash
tea issues create --repo vvzvlad/gitmost --labels feature \
--title '<title>' --description "$(cat body.md)"
```
Read issues with `list_issues`, `issue_read`, or `search_issues`. The MCP is
authenticated in-process, so no `tea`/`curl` and no keychain lookup are needed.
> Gotcha (tea 0.14.1): the issue body flag is `--description`/`-d`, **not**
> `--body` — passing `--body` fails with `flag provided but not defined: -body`.
---
+25 -70
View File
@@ -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
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
centered horizontally by default in modern browsers (CSS `:has()`), falling
back to start-aligned rows in browsers without support. Unlike the float
modes, text does not wrap around inline images. The mode round-trips
images as a row that wraps onto the next line on narrow screens. 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
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
are RAM-only, bound to the instance that created them. Tunable via five
`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
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.
The mark is preserved losslessly through Markdown export/import (as a raw
`<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
@@ -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
size-cap, redirect and path-traversal guards. The `AI_AGENT_ROLES_CATALOG_URL`
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
@@ -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
write through the guard, so genuinely emptying a page is persisted while
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
+3 -6
View File
@@ -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).
-**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.
-**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
@@ -187,17 +187,14 @@ start the new migrations apply on top of your existing schema (`CREATE EXTENSION
- Spaces
- Permissions management
- Groups
- Comments (with resolve / re-open and hover tooltips showing the comment text)
- Comments (with resolve / re-open)
- Page history
- Search
- File attachments
- Embeds (Airtable, Loom, Miro and more)
- Translations (10+ languages)
- 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
- 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
- AI agent chat over your wiki (read + write, RAG search, external MCP / web access)
### Screenshots
+3 -7
View File
@@ -105,7 +105,7 @@ real-time-коллаборации Docmost, поэтому запись нико
-**Шаблоны страниц** — пометить страницу шаблоном и вставлять её содержимое живой ссылкой в другие страницы; правки шаблона распространяются на все места вставки (whole-page-транслюзия поверх существующих synced-блоков).
-**AI-ассистент на публичных шарах** — анонимный зритель расшаренной страницы может спросить AI-агента, который ищет строго по дереву этой шары (read-only, share-scoped поиск), за тумблером воркспейса.
-**Сноски** — сноски академического вида: нумерованная ссылка-надстрочник прямо в тексте (читается на месте во всплывающем окне по наведению), а текст сноски живёт реальным редактируемым блоком внизу страницы; авто-нумерация, безопасна для совместного редактирования, переживает экспорт/импорт Markdown и доступна AI-агенту / MCP.
-**Временные заметки**создайте временную заметку, и она автоматически уедет в корзину по истечении настраиваемого срока жизни (по умолчанию 24 ч); создать такую можно в один клик с домашнего экрана, с обзора любого пространства или из сайдбара пространства.
-**Временные заметки**пометьте заметку временной, и она автоматически уедет в корзину по истечении настраиваемого срока жизни воркспейса (по умолчанию 24 ч), если её предварительно не сделать постоянной; создать такую можно в один клик с домашнего экрана, с обзора любого пространства или из сайдбара пространства, а на открытой заметке есть баннер «Сделать постоянной».
### В процессе
@@ -174,18 +174,14 @@ dump/restore, существующий каталог данных переис
- Пространства (Spaces)
- Управление правами доступа
- Группы
- Комментарии (с резолвом / переоткрытием и всплывающими подсказками с текстом комментария при наведении)
- Комментарии (с резолвом / переоткрытием)
- История страниц
- Поиск
- Вложения файлов
- Встраивания (Airtable, Loom, Miro и другие)
- Переводы (10+ языков)
- Встроенный MCP-сервер (`/mcp`)
- Чат с AI-агентом по вики (чтение + запись, RAG-поиск, внешние MCP / доступ в интернет); окно чата закрепляется в боковом меню, а агент узнаёт о ваших правках страницы между ходами
- Кнопки код-блока оверлеем, селектор языка появляется при наведении
- Кнопка «Ударение» (U+0301) в bubble-меню
- Позиция чтения (прокрутка) восстанавливается после перезагрузки
- Slash-меню терпимо к неправильной раскладке (ЙЦУКЕН↔QWERTY)
- Чат с AI-агентом по вики (чтение + запись, RAG-поиск, внешние MCP / доступ в интернет)
### Скриншоты
+1
View File
@@ -10,6 +10,7 @@
<meta name="theme-color" content="#0E1117" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#f6f7f9" media="(prefers-color-scheme: light)" />
<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="apple-touch-fullscreen" content="yes" />
<meta name="apple-mobile-web-app-title" content="Gitmost" />
+4
View File
@@ -33,7 +33,9 @@
"@slidoapp/emoji-mart-data": "1.2.4",
"@slidoapp/emoji-mart-react": "1.1.5",
"@tabler/icons-react": "3.40.0",
"@tanstack/query-async-storage-persister": "5.90.17",
"@tanstack/react-query": "5.90.17",
"@tanstack/react-query-persist-client": "5.90.17",
"@tanstack/react-virtual": "3.13.24",
"ai": "6.0.207",
"alfaaz": "1.1.0",
@@ -45,6 +47,7 @@
"highlightjs-sap-abap": "0.3.0",
"i18next": "25.10.1",
"i18next-http-backend": "3.0.6",
"idb-keyval": "6.2.5",
"jotai": "2.18.1",
"jotai-optics": "0.4.0",
"js-cookie": "3.0.7",
@@ -95,6 +98,7 @@
"typescript": "5.9.3",
"typescript-eslint": "8.57.1",
"vite": "8.0.5",
"vite-plugin-pwa": "1.3.0",
"vitest": "4.1.6"
}
}
@@ -471,6 +471,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": "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",
"Add headings (H1, H2, H3) to generate a table of contents.": "Add headings (H1, H2, H3) to generate a table of contents.",
"Share": "Share",
@@ -1222,8 +1234,8 @@
"Commented": "Commented",
"Resolved comment": "Resolved comment",
"Ran tool {{name}}": "Ran tool {{name}}",
"AI agent «{{role}}» on behalf of {{person}}": "AI agent «{{role}}» on behalf of {{person}}",
"AI agent {{name}}": "AI agent {{name}}",
"AI-agent": "AI-agent",
"Edited by AI agent on behalf of {{name}}": "Edited by AI agent on behalf of {{name}}",
"Endpoints": "Endpoints",
"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.",
@@ -1373,10 +1385,5 @@
"Updated to the latest version": "Updated to the latest version",
"This role is no longer in the catalog": "This role is no longer in the catalog",
"This language is no longer available in the catalog": "This language is no longer available in the catalog",
"Connecting… (read-only)": "Connecting… (read-only)",
"Apply": "Apply",
"Applied": "Applied",
"Suggestion applied": "Suggestion applied",
"Failed to apply suggestion": "Failed to apply suggestion",
"The commented text changed since this suggestion was made; it was not applied.": "The commented text changed since this suggestion was made; it was not applied."
"Connecting… (read-only)": "Connecting… (read-only)"
}
@@ -476,6 +476,18 @@
"Move page": "Переместить страницу",
"Move page to a different space.": "Переместите страницу в другое пространство.",
"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": "Оглавление",
"Add headings (H1, H2, H3) to generate a table of contents.": "Добавьте заголовки (H1, H2, H3), чтобы создать оглавление.",
"Share": "Поделиться",
@@ -724,8 +736,7 @@
"Shown as used / total in the chat header. Leave empty to hide the limit.": "Показывается в шапке чата как использовано / всего. Пусто — лимит скрыт.",
"Delete this chat?": "Удалить этот чат?",
"Deleted successfully": "Успешно удалено",
"AI agent «{{role}}» on behalf of {{person}}": "AI-агент «{{role}}» от имени {{person}}",
"AI agent {{name}}": "AI-агент {{name}}",
"Edited by AI agent on behalf of {{name}}": "Отредактировано AI-агентом от имени {{name}}",
"Failed to delete chat": "Не удалось удалить чат",
"Failed to rename chat": "Не удалось переименовать чат",
"Failed": "Ошибка",
@@ -1229,10 +1240,5 @@
"Updated to the latest version": "Обновлено до последней версии",
"This role is no longer in the catalog": "Эта роль больше не представлена в каталоге",
"This language is no longer available in the catalog": "Этот язык больше не доступен в каталоге",
"Connecting… (read-only)": "Подключение… (только чтение)",
"Apply": "Применить",
"Applied": "Применено",
"Suggestion applied": "Предложение применено",
"Failed to apply suggestion": "Не удалось применить предложение",
"The commented text changed since this suggestion was made; it was not applied.": "Прокомментированный текст изменился после создания предложения; оно не было применено."
"Connecting… (read-only)": "Подключение… (только чтение)"
}
+9 -20
View File
@@ -1,30 +1,19 @@
{
"id": "/",
"name": "Gitmost",
"short_name": "Gitmost",
"description": "Gitmost - open-source collaborative documentation and knowledge base.",
"lang": "en",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#0E1117",
"theme_color": "#0E1117",
"icons": [
{
"src": "icons/favicon-16x16.png",
"type": "image/png",
"sizes": "16x16"
},
{
"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"
}
{ "src": "icons/favicon-16x16.png", "type": "image/png", "sizes": "16x16" },
{ "src": "icons/favicon-32x32.png", "type": "image/png", "sizes": "32x32" },
{ "src": "icons/app-icon-192x192.png", "type": "image/png", "sizes": "192x192", "purpose": "any" },
{ "src": "icons/app-icon-512x512.png", "type": "image/png", "sizes": "512x512", "purpose": "any" }
]
}
@@ -14,22 +14,6 @@ import { notifications } from "@mantine/notifications";
import { exportSpace } from "@/features/space/services/space-service";
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 {
id: string;
type: "space" | "page";
@@ -68,9 +52,8 @@ export default function ExportModal({
});
onClose();
} catch (err) {
const message = await extractExportError(err);
notifications.show({
message: t("Export failed") + (message ? `: ${message}` : ""),
message: "Export failed:" + err.response?.data.message,
color: "red",
});
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 { useAtom } from "jotai";
import {
NAVBAR_COLLAPSE_BREAKPOINT,
desktopSidebarAtom,
mobileSidebarAtom,
} from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
@@ -54,13 +53,7 @@ export function AppHeader() {
aria-label={t("Sidebar toggle")}
opened={mobileOpened}
onClick={toggleMobile}
// Must match the AppShell navbar breakpoint (md). The navbar
// 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}
hiddenFrom="sm"
size="sm"
/>
</Tooltip>
@@ -70,7 +63,7 @@ export function AppHeader() {
aria-label={t("Sidebar toggle")}
opened={desktopOpened}
onClick={toggleDesktop}
visibleFrom={NAVBAR_COLLAPSE_BREAKPOINT}
visibleFrom="sm"
size="sm"
/>
</Tooltip>
@@ -6,7 +6,6 @@ import SettingsSidebar from "@/components/settings/settings-sidebar.tsx";
import { useAtom } from "jotai";
import {
APP_NAVBAR_ID,
NAVBAR_COLLAPSE_BREAKPOINT,
asideStateAtom,
desktopSidebarAtom,
mobileSidebarAtom,
@@ -89,13 +88,7 @@ export default function GlobalAppShell({
header={{ height: 45 }}
navbar={{
width: isSpaceRoute ? sidebarWidth : 300,
// `md` (not `sm`): below 992px the fixed ~300px sidebar leaves too little
// 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,
breakpoint: "sm",
collapsed: {
mobile: !mobileOpened,
desktop: !desktopOpened,
@@ -104,7 +97,7 @@ export default function GlobalAppShell({
aside={
isPageRoute && {
width: 420,
breakpoint: "md",
breakpoint: "sm",
collapsed: { mobile: !isAsideOpen, desktop: !isAsideOpen },
}
}
@@ -7,13 +7,6 @@ import { atom } from "jotai";
// would create a shell -> chat-window -> shell import cycle).
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 desktopSidebarAtom = atomWithWebStorage<boolean>(
@@ -1,183 +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 } from "./agent-avatar-stack";
import { avatarStyle } from "@/lib/avatar-palette";
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>;
// The DOM normalizes an inline hex `background-color` to `rgb(...)`. Push the
// expected color through the same CSSOM path so the comparison stays exact and
// non-vacuous (an empty string — i.e. no inline background, as in the pre-fix
// Avatar approach — can never match a real color). NOTE: jsdom's CSSOM does not
// round-trip a `linear-gradient` in the `background` shorthand, which is why the
// glyph carries an explicit solid `background-color` we assert on here.
function normalizeColor(value: string): string {
const probe = document.createElement("div");
probe.style.backgroundColor = value;
return probe.style.backgroundColor;
}
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("AgentAvatarStack", () => {
it("internal chat WITH role: emoji glyph + human launcher badge in front", () => {
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("emoji glyph applies its per-agent gradient as an inline DOM background", () => {
// Pins the actual fix: the hashed gradient must reach the DOM as an inline
// `background` on the glyph Box. The pre-fix `Avatar variant="filled"` set no
// inline background (Mantine's --avatar-bg overrode it), so this fails there.
const agent = { name: "Researcher", emoji: "🔬", avatarUrl: null };
const { container } = renderStack({
agent,
launcher: { name: "Alice", avatarUrl: null },
aiChatId: "chat-1",
});
const glyph = container.querySelector<HTMLElement>(
'[data-testid="agent-glyph"]',
);
expect(glyph).not.toBeNull();
const expected = normalizeColor(avatarStyle(agent.name).bg);
// Non-vacuous: the pre-fix Avatar set no inline background at all.
expect(expected).not.toBe("");
expect(glyph!.style.backgroundColor).toBe(expected);
// (The gradient overlay is a browser-only enhancement — jsdom's CSSOM does
// not round-trip linear-gradient — so its stops/angle are covered by the
// avatarStyle unit tests above, not asserted on the DOM here.)
});
it("agents with distinct styles reach the DOM as distinct backgrounds", () => {
// "Researcher" and "Нарратор" hash to different palette entries, so their
// applied DOM backgrounds must differ — pins "distinct colors reach the DOM".
expect(avatarStyle("Researcher").bg).not.toBe(avatarStyle("Нарратор").bg);
const a = renderStack({
agent: { name: "Researcher", emoji: "🔬", avatarUrl: null },
launcher: null,
aiChatId: null,
});
const b = renderStack({
agent: { name: "Нарратор", emoji: "📖", avatarUrl: null },
launcher: null,
aiChatId: null,
});
const glyphA = a.container.querySelector<HTMLElement>(
'[data-testid="agent-glyph"]',
);
const glyphB = b.container.querySelector<HTMLElement>(
'[data-testid="agent-glyph"]',
);
expect(glyphA!.style.backgroundColor).not.toBe("");
// Different base colors reach the DOM (the serialized rgb values differ).
expect(glyphA!.style.backgroundColor).not.toBe(glyphB!.style.backgroundColor);
});
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 only, NO human launcher badge", () => {
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,234 +0,0 @@
import { 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 { avatarStyle, avatarBackgroundCss } from "@/lib/avatar-palette";
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 — it
// sits as a small badge over that corner (above the glyph) and stays fully visible.
const LAUNCHER_OVERHANG = 8;
/**
* 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 per-agent gradient circle.
* 3. otherwise -> the IconSparkles glyph on a per-agent gradient circle.
*/
function AgentGlyph({ agent }: { agent: AgentInfo }) {
if (agent.avatarUrl) {
return (
<CustomAvatar
size={GLYPH_SIZE}
avatarUrl={agent.avatarUrl}
name={agent.name}
/>
);
}
// Emoji/sparkles glyph on a per-agent gradient circle (color, gradient partner
// and split angle all hashed from the agent name via avatarStyle — see
// @/lib/avatar-palette). Rendered as a plain Box, NOT a Mantine
// `Avatar variant="filled"` — Mantine's `--avatar-bg` overrode the background
// (every agent fell back to the theme's violet). The foreground (the sparkles
// icon) uses the ring's WCAG-checked readable text color.
const style = avatarStyle(agent.name);
return (
<Box
data-testid="agent-glyph"
style={{
width: GLYPH_SIZE,
height: GLYPH_SIZE,
borderRadius: "50%",
// Solid base color is the fallback (and the testable value); the gradient
// paints over it in browsers that support it.
backgroundColor: style.bg,
backgroundImage: avatarBackgroundCss(style),
color:
style.text === "white"
? "var(--mantine-color-white)"
: "var(--mantine-color-black)",
display: "flex",
alignItems: "center",
justifyContent: "center",
lineHeight: 1,
}}
>
{agent.emoji ? (
<span style={{ fontSize: Math.round(GLYPH_SIZE * 0.5) }} aria-hidden>
{agent.emoji}
</span>
) : (
<IconSparkles size={Math.round(GLYPH_SIZE * 0.55)} stroke={2} />
)}
</Box>
);
}
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, and — for an internal AI
* chat — the HUMAN who launched it as a smaller avatar badge on top, overhanging
* the glyph's top-right corner in FRONT (zIndex 2 > the glyph's zIndex 1) so the
* launcher stays fully visible rather than being half-hidden behind the glyph.
* 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 && (
// Launcher badge sits ABOVE the agent glyph (zIndex) at the top-right so
// it is fully visible, not half-hidden behind the agent circle.
<Box pos="absolute" top={0} right={0} style={{ zIndex: 2 }}>
<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;
@@ -1,14 +1,7 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
// Spy on the markdown renderer so we can assert it is NOT called while the block
// is collapsed (the #302 fix) and IS called once on expand. The count/fallback
// tests don't depend on real markdown, so a light stub is safe.
vi.mock("@/features/ai-chat/utils/markdown.ts", () => ({
renderChatMarkdown: vi.fn((md: string) => `<p>${md}</p>`),
}));
// Stub react-i18next so `t` returns the key with `{{count}}` interpolated. This
// keeps the assertions on the component's OWN count logic (authoritative vs
// estimate) rather than on translation, and mirrors the t-mock pattern used by
@@ -24,7 +17,6 @@ vi.mock("react-i18next", () => ({
import ReasoningBlock from "./reasoning-block";
import { estimateTokens } from "@/features/ai-chat/utils/count-stream-tokens.ts";
import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
@@ -70,18 +62,4 @@ describe("ReasoningBlock", () => {
// either way the text is present in the document.
expect(screen.getByText(/reasoning/)).toBeDefined();
});
it("does not parse the reasoning markdown while collapsed; parses on expand (#302)", () => {
const renderSpy = vi.mocked(renderChatMarkdown);
renderSpy.mockClear();
renderBlock({ text: "**bold** reasoning", tokens: 5 });
// Collapsed is the default. The expensive markdown parse (marked + DOMPurify)
// must NOT run for the hidden body — that O(n^2) re-parse on every streamed
// delta is exactly what froze the chat (#302). The collapsed body shows the
// cheap raw-text fallback instead.
expect(renderSpy).not.toHaveBeenCalled();
// Expanding parses the current text exactly once (a user-initiated click).
fireEvent.click(screen.getByRole("button"));
expect(renderSpy).toHaveBeenCalledTimes(1);
});
});
@@ -34,19 +34,15 @@ function ReasoningBlock({ text, tokens }: ReasoningBlockProps) {
// Authoritative count wins; otherwise estimate live from the streamed text.
const count = tokens && tokens > 0 ? tokens : estimateTokens(text);
const trimmed = text.trim();
// Parse the reasoning markdown ONLY while the block is expanded. Collapsed is the
// default and the common case during a long "thinking" stream: reasoning text
// streams in and grows with every throttled delta (~20Hz), so a `[trimmed]`-only
// memo re-parses the whole, ever-growing text (marked + DOMPurify) on every delta
// — an O(n²) storm that pins the main thread and freezes the chat, all for a block
// the user isn't even looking at (the html is only shown inside <Collapse in={open}>
// below). Gating on `open` skips that hidden parsing entirely; expanding parses the
// current text once (an instant, user-initiated click), and further streaming while
// open is the normal per-delta append render, like the answer.
// Memoize the markdown render so toggling `open` (or a parent re-render caused
// by an unrelated streamed delta) does not re-parse the reasoning text; it
// recomputes only when the reasoning text itself changes (while it streams in).
// collapseBlankLines collapses the blank-line gaps the model emits between every
// list item / paragraph so the reasoning renders compactly (tight lists, joined
// paragraphs) — ONLY here, not in the normal answer.
const html = useMemo(
() =>
open && trimmed ? renderChatMarkdown(collapseBlankLines(trimmed), {}) : "",
[open, trimmed],
() => (trimmed ? renderChatMarkdown(collapseBlankLines(trimmed), {}) : ""),
[trimmed],
);
return (
@@ -27,9 +27,7 @@ export function useOpenAiChatForCurrentPage() {
// AiChatWindow lives in a pathless parent layout route, so useParams() can't
// see :pageSlug — match the full path against the authenticated page route.
const match = useMatch("/s/:spaceSlug/p/:pageSlug");
// A page slugId (10-char nanoid), NOT a uuid; the server resolves it to the
// real page uuid (PageRepo.findById accepts slugId or uuid).
const slugId = extractPageSlugId(match?.params?.pageSlug);
const pageId = extractPageSlugId(match?.params?.pageSlug);
return useCallback(async () => {
// Re-clicks while the window is already open (incl. minimized) must NOT
@@ -42,9 +40,9 @@ export function useOpenAiChatForCurrentPage() {
// connection the first click reads as a hung control until the POST returns.
setWindowOpen(true);
let resolved: string | null = activeChatId; // off-a-page: keep current
if (slugId) {
if (pageId) {
try {
resolved = await getBoundChat(slugId); // null => fresh chat
resolved = await getBoundChat(pageId); // null => fresh chat
} catch {
resolved = null; // fail-soft: a fresh chat is always a safe fallback
}
@@ -60,7 +58,7 @@ export function useOpenAiChatForCurrentPage() {
}, [
windowOpen,
activeChatId,
slugId,
pageId,
setWindowOpen,
setActiveChatId,
setDraft,
@@ -46,11 +46,9 @@ export async function getAiChatMessages(
* Resolve the chat bound to a document (the current user's most-recent chat
* created on that page), or null when there is none. Drives auto-open-on-page.
*/
export async function getBoundChat(slugId: string): Promise<string | null> {
// The `pageId` body field accepts a page slugId or a uuid; the server resolves
// it to the real page uuid (the wire key stays `pageId` for the DTO).
export async function getBoundChat(pageId: string): Promise<string | null> {
const req = await api.post<{ chatId: string | null }>("/ai-chat/bound-chat", {
pageId: slugId,
pageId,
});
return req.data.chatId;
}
@@ -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 { RESET } from "jotai/utils";
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() {
const { t } = useTranslation();
@@ -34,6 +34,20 @@ export default function useAuth() {
const handleSignIn = async (data: ILogin) => {
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 {
await login(data);
setIsLoading(false);
@@ -123,12 +137,14 @@ export default function useAuth() {
const handleLogout = async () => {
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();
// 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`);
};
@@ -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 { 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(
verify: IVerifyUserToken,
): UseQueryResult<any, Error> {
@@ -22,13 +43,7 @@ export function useCollabToken(): UseQueryResult<ICollabToken, Error> {
//refetchInterval: 12 * 60 * 60 * 1000, // 12hrs
//refetchIntervalInBackground: true,
refetchOnMount: true,
//@ts-ignore
retry: (failureCount, error) => {
if (isAxiosError(error) && error.response.status === 404) {
return false;
}
return 10;
},
retry: collabTokenRetry,
retryDelay: (retryAttempt) => {
// Exponential backoff: 5s, 10s, 20s, etc.
return 5000 * Math.pow(2, retryAttempt - 1);
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { IComment } from "@/features/comment/types/comment.types";
@@ -7,15 +7,10 @@ import { IComment } from "@/features/comment/types/comment.types";
// The comment mutation hooks reach out to react-query/network — stub them so the
// component renders in isolation. We only assert the AI-badge rendering branch.
const applyMutateAsync = vi.fn();
vi.mock("@/features/comment/queries/comment-query", () => ({
useDeleteCommentMutation: () => ({ mutateAsync: vi.fn() }),
useResolveCommentMutation: () => ({ mutateAsync: vi.fn() }),
useUpdateCommentMutation: () => ({ mutateAsync: vi.fn() }),
useApplySuggestionMutation: () => ({
mutateAsync: applyMutateAsync,
isPending: false,
}),
}));
// CommentEditor pulls in the full TipTap editor stack; replace it with a stub.
@@ -24,7 +19,6 @@ vi.mock("@/features/comment/components/comment-editor", () => ({
}));
import CommentListItem from "./comment-list-item";
import { canShowApply } from "@/features/comment/utils/suggestion";
const baseComment = (over?: Partial<IComment>): IComment =>
({
@@ -38,147 +32,28 @@ const baseComment = (over?: Partial<IComment>): IComment =>
...over,
}) as IComment;
function renderItem(comment: IComment, canEdit = true) {
function renderItem(comment: IComment) {
return render(
<MantineProvider>
<CommentListItem
comment={comment}
pageId="page-1"
canComment={true}
canEdit={canEdit}
/>
<CommentListItem comment={comment} pageId="page-1" canComment={true} />
</MantineProvider>,
);
}
describe("CommentListItem — agent avatar stack", () => {
it('flips the hierarchy for an agent comment: agent primary, launcher shown once', () => {
// Internal-chat shape with DISTINCT names so absence-of-duplication is
// assertable: creator is the human "Alice", the acting agent is "Researcher".
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();
describe("CommentListItem — AI badge", () => {
it('renders the AI-agent badge when createdSource === "agent"', () => {
renderItem(baseComment({ createdSource: "agent", aiChatId: null }));
expect(screen.getByText("AI-agent")).toBeDefined();
expect(screen.getByText("Service Bot")).toBeDefined();
});
// The stack's own behaviors (glyph priority, launcher-behind, deep-link click)
// are covered directly in agent-avatar-stack.test.tsx; this integration suite
// only guards the insertion gate (agent → stack, user → no stack).
});
describe("CommentListItem — suggested edit (#315)", () => {
const suggestion = (over?: Partial<IComment>): IComment =>
baseComment({
selection: "old wording here",
suggestedText: "new wording here",
...over,
});
it("renders the было→стало diff and an Apply button when canEdit and not applied/resolved", () => {
renderItem(suggestion(), true);
// Old text appears both as the selection quote and as the struck diff row.
expect(screen.getAllByText("old wording here").length).toBeGreaterThan(0);
expect(screen.getByText("new wording here")).toBeDefined();
// Apply button is present.
expect(screen.getByRole("button", { name: "Apply" })).toBeDefined();
// No Applied badge yet.
expect(screen.queryByText("Applied")).toBeNull();
});
it("hides the Apply button when canEdit is false", () => {
renderItem(suggestion(), false);
// Diff still renders...
expect(screen.getByText("new wording here")).toBeDefined();
// ...but no Apply button.
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
});
it("shows an Applied badge (no Apply button) once suggestionAppliedAt is set", () => {
renderItem(suggestion({ suggestionAppliedAt: new Date() }), true);
expect(screen.getByText("Applied")).toBeDefined();
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
});
it("hides the Apply button once the thread is resolved", () => {
renderItem(suggestion({ resolvedAt: new Date() }), true);
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
});
it("calls the apply mutation when the Apply button is clicked", () => {
applyMutateAsync.mockClear();
renderItem(suggestion(), true);
fireEvent.click(screen.getByRole("button", { name: "Apply" }));
expect(applyMutateAsync).toHaveBeenCalledWith({
commentId: "c-1",
pageId: "page-1",
});
});
it("does not render the diff block for a reply (child) comment", () => {
renderItem(
suggestion({ parentCommentId: "c-0" }),
true,
);
expect(screen.queryByText("new wording here")).toBeNull();
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
});
});
describe("canShowApply predicate", () => {
const c = (over?: Partial<IComment>): IComment =>
({ suggestedText: "x", ...over }) as IComment;
it("true when suggestion present, editable, not applied/resolved, top-level", () => {
expect(canShowApply(c(), true)).toBe(true);
});
it("false without edit permission", () => {
expect(canShowApply(c(), false)).toBe(false);
});
it("false when no suggestion", () => {
expect(canShowApply(c({ suggestedText: null }), true)).toBe(false);
});
it("false when already applied", () => {
expect(canShowApply(c({ suggestionAppliedAt: new Date() }), true)).toBe(
false,
);
});
it("false when resolved", () => {
expect(canShowApply(c({ resolvedAt: new Date() }), true)).toBe(false);
});
it("false for a reply comment", () => {
expect(canShowApply(c({ parentCommentId: "p" }), true)).toBe(false);
});
it('does NOT render the badge for a normal user comment (createdSource "user")', () => {
renderItem(baseComment({ createdSource: "user" }));
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, Badge, Button } from "@mantine/core";
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
import { Group, Text, Box } from "@mantine/core";
import { AiAgentBadge } from "@/components/ui/ai-agent-badge.tsx";
import React, { useEffect, useRef, useState } from "react";
import classes from "./comment.module.css";
import { useAtom, useAtomValue } from "jotai";
@@ -11,13 +11,11 @@ import CommentMenu from "@/features/comment/components/comment-menu";
import ResolveComment from "@/features/comment/components/resolve-comment";
import { useHover } from "@mantine/hooks";
import {
useApplySuggestionMutation,
useDeleteCommentMutation,
useResolveCommentMutation,
useUpdateCommentMutation,
} from "@/features/comment/queries/comment-query";
import { IComment } from "@/features/comment/types/comment.types";
import { canShowApply } from "@/features/comment/utils/suggestion";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useTranslation } from "react-i18next";
@@ -26,10 +24,6 @@ interface CommentListItemProps {
comment: IComment;
pageId: string;
canComment: boolean;
// Real page-edit permission (page.permissions.canEdit) — gates the suggestion
// "Apply" button. Distinct from `canComment`, which may be looser (viewers
// allowed to comment cannot apply edits).
canEdit?: boolean;
userSpaceRole?: string;
}
@@ -37,7 +31,6 @@ function CommentListItem({
comment,
pageId,
canComment,
canEdit,
userSpaceRole,
}: CommentListItemProps) {
const { t } = useTranslation();
@@ -50,7 +43,6 @@ function CommentListItem({
const updateCommentMutation = useUpdateCommentMutation();
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
const resolveCommentMutation = useResolveCommentMutation();
const applySuggestionMutation = useApplySuggestionMutation();
const [currentUser] = useAtom(currentUserAtom);
const createdAtAgo = useTimeAgo(comment.createdAt);
@@ -103,18 +95,6 @@ function CommentListItem({
}
}
async function handleApplySuggestion() {
try {
await applySuggestionMutation.mutateAsync({
commentId: comment.id,
pageId: comment.pageId,
});
} catch (error) {
// Errors surface via the mutation's onError notification (incl. 409).
console.error("Failed to apply suggestion:", error);
}
}
function handleCommentClick(comment: IComment) {
const el = document.querySelector(
`.comment-mark[data-comment-id="${comment.id}"]`,
@@ -139,44 +119,24 @@ function CommentListItem({
return (
<Box ref={ref} pb={6}>
<Group gap="xs">
{comment.createdSource === "agent" && comment.agent ? (
<AgentAvatarStack
agent={comment.agent}
launcher={comment.launcher}
aiChatId={comment.aiChatId}
showName={false}
/>
) : (
<CustomAvatar
size="sm"
avatarUrl={comment.creator.avatarUrl}
name={comment.creator.name}
/>
)}
<CustomAvatar
size="sm"
avatarUrl={comment.creator.avatarUrl}
name={comment.creator.name}
/>
<div style={{ flex: 1 }}>
<Group justify="space-between" wrap="nowrap">
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
{comment.createdSource === "agent" && comment.agent ? (
<>
<Text size="xs" fw={600} lineClamp={1} lh={1.2}>
{comment.agent.name}
</Text>
{comment.launcher && (
<>
<Text size="xs" c="dimmed" fw={400} aria-hidden>
·
</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>
<Text size="xs" fw={500} lineClamp={1} lh={1.2}>
{comment.creator.name}
</Text>
{comment.createdSource === "agent" && (
<AiAgentBadge
authorName={comment.creator?.name}
aiChatId={comment.aiChatId}
/>
)}
</Group>
@@ -231,47 +191,6 @@ function CommentListItem({
</Box>
)}
{/* Suggested-edit (#315): "было → стало" diff for a top-level comment
carrying a suggestion. Old text struck-through/red, new text green. */}
{!comment.parentCommentId && comment.suggestedText && (
<Box className={classes.suggestionBlock}>
{comment.selection && (
<Text size="xs" className={classes.suggestionOld}>
{comment.selection}
</Text>
)}
<Text size="xs" className={classes.suggestionNew}>
{comment.suggestedText}
</Text>
{comment.suggestionAppliedAt ? (
<Badge
size="sm"
color="green"
variant="light"
mt={6}
aria-label={t("Applied")}
>
{t("Applied")}
</Badge>
) : (
canShowApply(comment, canEdit) && (
<Button
size="compact-xs"
variant="light"
color="green"
mt={6}
onClick={handleApplySuggestion}
loading={applySuggestionMutation.isPending}
disabled={applySuggestionMutation.isPending}
>
{t("Apply")}
</Button>
)
)}
</Box>
)}
{!isEditing ? (
<CommentEditor defaultContent={content} editable={false} />
) : (
@@ -49,10 +49,8 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
const [isLoading, setIsLoading] = useState(false);
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
const canEdit = page?.permissions?.canEdit ?? false;
const canComment =
canEdit ||
(page?.permissions?.canEdit ?? false) ||
(space?.settings?.comments?.allowViewerComments === true);
// Separate active and resolved comments
@@ -139,7 +137,6 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
comment={comment}
pageId={page?.id}
canComment={canComment}
canEdit={canEdit}
userSpaceRole={space?.membership?.role}
/>
<MemoizedChildComments
@@ -147,7 +144,6 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
parentId={comment.id}
pageId={page?.id}
canComment={canComment}
canEdit={canEdit}
userSpaceRole={space?.membership?.role}
/>
</div>
@@ -164,14 +160,7 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
)}
</Paper>
),
[
comments,
handleAddReply,
isLoading,
space?.membership?.role,
canComment,
canEdit,
],
[comments, handleAddReply, isLoading, space?.membership?.role, canComment],
);
if (isCommentsLoading) {
@@ -311,7 +300,6 @@ interface ChildCommentsProps {
parentId: string;
pageId: string;
canComment: boolean;
canEdit?: boolean;
userSpaceRole?: string;
}
const ChildComments = ({
@@ -319,7 +307,6 @@ const ChildComments = ({
parentId,
pageId,
canComment,
canEdit,
userSpaceRole,
}: ChildCommentsProps) => {
const getChildComments = useCallback(
@@ -338,7 +325,6 @@ const ChildComments = ({
comment={childComment}
pageId={pageId}
canComment={canComment}
canEdit={canEdit}
userSpaceRole={userSpaceRole}
/>
<MemoizedChildComments
@@ -346,7 +332,6 @@ const ChildComments = ({
parentId={childComment.id}
pageId={pageId}
canComment={canComment}
canEdit={canEdit}
userSpaceRole={userSpaceRole}
/>
</div>
@@ -21,38 +21,6 @@
box-sizing: border-box;
}
/* Suggested-edit (#315) "было → стало" diff block. */
.suggestionBlock {
margin-top: 8px;
margin-left: 6px;
padding: 6px;
border-radius: var(--mantine-radius-sm);
border: 1px solid var(--mantine-color-default-border);
overflow-wrap: break-word;
word-break: break-word;
max-width: 100%;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.suggestionOld {
text-decoration: line-through;
color: var(--mantine-color-red-7);
background: var(--mantine-color-red-light);
border-radius: 2px;
padding: 1px 3px;
}
.suggestionNew {
color: var(--mantine-color-green-9);
background: var(--mantine-color-green-light);
border-radius: 2px;
padding: 1px 3px;
margin-top: 4px;
}
.commentEditor {
&[data-editable][data-surface="muted"] .ProseMirror:not(.focused) {
@@ -5,7 +5,6 @@ import {
InfiniteData,
} from "@tanstack/react-query";
import {
applySuggestion,
createComment,
deleteComment,
getPageComments,
@@ -21,6 +20,7 @@ import { notifications } from "@mantine/notifications";
import { IPagination } from "@/lib/types.ts";
import { useTranslation } from "react-i18next";
import { useEffect, useMemo } from "react";
import { offlineMutationKeys } from "@/features/offline/offline-mutations";
export const RQ_KEY = (pageId: string) => ["comments", pageId];
@@ -61,6 +61,9 @@ export function useCreateCommentMutation() {
const { t } = useTranslation();
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),
onSuccess: (newComment) => {
const cache = queryClient.getQueryData(
@@ -177,63 +180,6 @@ function updateCommentInCache(
};
}
export function useApplySuggestionMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<IComment, any, { commentId: string; pageId: string }>({
// No optimistic update: apply can fail with 409 (the commented text drifted),
// so we only mutate the cache once the server confirms.
mutationFn: ({ commentId }) => applySuggestion(commentId),
onSuccess: (data, variables) => {
const cache = queryClient.getQueryData(
RQ_KEY(variables.pageId),
) as InfiniteData<IPagination<IComment>> | undefined;
if (cache) {
queryClient.setQueryData(
RQ_KEY(variables.pageId),
updateCommentInCache(cache, variables.commentId, (comment) => ({
...comment,
suggestionAppliedAt: data.suggestionAppliedAt,
suggestionAppliedById: data.suggestionAppliedById,
// The server auto-resolves the thread on apply — carry that through.
resolvedAt: data.resolvedAt,
resolvedById: data.resolvedById,
resolvedBy: data.resolvedBy,
})),
);
}
notifications.show({ message: t("Suggestion applied") });
},
onError: (err: any) => {
// 409 => the commented text changed since the suggestion was made. Surface
// a specific message (with the current text) rather than a generic error.
const status = err?.response?.status;
const currentText = err?.response?.data?.currentText;
if (status === 409 && typeof currentText === "string") {
const shortText =
currentText.length > 80
? `${currentText.slice(0, 80)}`
: currentText;
notifications.show({
title: t(
"The commented text changed since this suggestion was made; it was not applied.",
),
message: shortText,
color: "red",
});
return;
}
notifications.show({
message: t("Failed to apply suggestion"),
color: "red",
});
},
});
}
export function useResolveCommentMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
@@ -18,13 +18,6 @@ export async function resolveComment(data: IResolveComment): Promise<IComment> {
return req.data;
}
export async function applySuggestion(commentId: string): Promise<IComment> {
// Mirrors resolveComment: let axios reject on non-2xx so the mutation can read
// the 409 body (`{ message, currentText }`) off err.response.data.
const req = await api.post("/comments/apply-suggestion", { commentId });
return req.data.data ?? req.data;
}
export async function updateComment(
data: Partial<IComment>,
): Promise<IComment> {
@@ -1,9 +1,5 @@
import { IUser } from "@/features/user/types/user.types";
import { QueryParams } from "@/lib/types.ts";
import type {
AgentInfo,
LauncherInfo,
} from "@/components/ui/agent-avatar-stack.tsx";
export interface IComment {
id: string;
@@ -28,18 +24,6 @@ export interface IComment {
createdSource?: string;
aiChatId?: string | null;
resolvedSource?: string | null;
// Suggested-edit (#315): when an agent proposes a replacement for the
// commented `selection`, `suggestedText` holds the "стало" text. Once a user
// applies it server-side the backend stamps `suggestionAppliedAt` /
// `suggestionAppliedById` and auto-resolves the thread.
suggestedText?: string | null;
suggestionAppliedAt?: Date | string | null;
suggestionAppliedById?: 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?: {
anchor: any;
head: any;
@@ -1,14 +0,0 @@
import { IComment } from "@/features/comment/types/comment.types";
// Whether the suggested-edit (#315) "Apply" button should be shown for a
// comment: it must carry a suggestion, not already be applied or resolved, be a
// top-level comment, and the viewer must be able to edit the page.
export function canShowApply(comment: IComment, canEdit?: boolean): boolean {
return Boolean(
canEdit &&
comment.suggestedText &&
!comment.suggestionAppliedAt &&
!comment.resolvedAt &&
!comment.parentCommentId,
);
}
@@ -10,6 +10,12 @@ export const readOnlyEditorAtom = atom<Editor | null>(null);
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);
// Current page's edit mode — initialized from the user's saved preference on
@@ -1,75 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { useEffect, useState } from "react";
import { render, act } from "@testing-library/react";
// Regression test for #311: on a page the user can edit, the byline mic stayed
// stuck disabled until an unrelated re-render happened, because DictationGroup
// read the non-reactive field `editor.isEditable` directly. The fix reads it via
// `useEditorState`, which subscribes to the editor's own events.
//
// The mock below mirrors the real `useEditorState` contract: it runs the
// selector, and re-runs it (re-rendering the consumer) whenever the editor emits
// an event. This is what makes the test faithful — with the pre-fix code
// (`disabled={!editor.isEditable}`) DictationGroup never subscribes, so emitting
// an event would NOT re-render and the mic would stay disabled.
vi.mock("@tiptap/react", () => ({
useEditorState: ({ editor, selector }: any) => {
const [value, setValue] = useState(() => selector({ editor }));
useEffect(() => {
const handler = () => setValue(selector({ editor }));
editor.on("update", handler);
return () => editor.off("update", handler);
}, [editor, selector]);
return value;
},
}));
// The mic only cares about the workspace's streaming flag; return a stable stub.
vi.mock("jotai", () => ({
useAtomValue: () => ({ settings: { ai: { dictationStreaming: false } } }),
}));
vi.mock("@/features/user/atoms/current-user-atom.ts", () => ({
workspaceAtom: {},
}));
// Detectable stand-in that surfaces the `disabled` prop the component computes.
vi.mock("@/features/dictation/components/mic-button", () => ({
MicButton: ({ disabled }: any) => (
<button data-testid="mic" disabled={disabled} />
),
}));
import { DictationGroup } from "./dictation-group";
// Minimal editor stand-in: a mutable `isEditable` field plus a tiny event
// emitter, matching the surface DictationGroup + the mocked useEditorState use.
function makeFakeEditor(isEditable: boolean) {
const listeners = new Set<() => void>();
return {
isEditable,
isDestroyed: false,
state: { selection: { from: 0, to: 0 }, doc: { content: { size: 0 } } },
on: (_event: string, cb: () => void) => listeners.add(cb),
off: (_event: string, cb: () => void) => listeners.delete(cb),
emit: () => listeners.forEach((cb) => cb()),
} as any;
}
describe("DictationGroup editable reactivity (#311)", () => {
it("re-enables the mic when the editor flips isEditable false -> true", () => {
const editor = makeFakeEditor(false);
const { getByTestId } = render(<DictationGroup editor={editor} />);
// Pre-sync: not editable yet, so the mic is disabled (preserves #218 intent).
expect(getByTestId("mic").hasAttribute("disabled")).toBe(true);
// Collab sync flips the editor editable via editor.setEditable(true), which
// mutates the field and emits — the mic must react and enable itself.
act(() => {
editor.isEditable = true;
editor.emit();
});
expect(getByTestId("mic").hasAttribute("disabled")).toBe(false);
});
});
@@ -1,5 +1,5 @@
import { FC, useRef } from "react";
import { Editor, useEditorState } from "@tiptap/react";
import type { Editor } from "@tiptap/react";
import { useAtomValue } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import { MicButton } from "@/features/dictation/components/mic-button";
@@ -22,14 +22,6 @@ export const DictationGroup: FC<Props> = ({ editor, color, iconSize }) => {
// end so the NEXT segment appends right after it, contiguously, regardless of
// where the user's caret currently is. Null until the first segment lands.
const insertPosRef = useRef<number | null>(null);
// editor.isEditable is a mutable, non-reactive field — read it via
// useEditorState so the mic re-enables when the body flips to editable after
// collab sync (otherwise it stays stuck disabled). Mirrors the body's own
// reactive read.
const isEditable = useEditorState({
editor,
selector: (ctx) => ctx.editor?.isEditable ?? false,
});
const handleStart = () => {
const { from, to } = editor.state.selection;
@@ -88,7 +80,7 @@ export const DictationGroup: FC<Props> = ({ editor, color, iconSize }) => {
streaming={streamingDictation}
onStart={handleStart}
onText={handleText}
disabled={!isEditable}
disabled={!editor.isEditable}
color={color}
iconSize={iconSize}
/>
@@ -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 { Superscript } from "@tiptap/extension-superscript";
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 { Color } from "@tiptap/extension-color";
import { Youtube } from "@tiptap/extension-youtube";
@@ -245,9 +245,7 @@ export const mainExtensions = [
return ReactMarkViewRenderer(SpoilerView);
},
}),
// Typography with an undo guard: does not re-apply a substitution the user
// just undid (e.g. Ctrl+Z on "1/2" -> "½" followed by another space).
CustomTypography,
Typography,
TrailingNode,
GlobalDragHandle.configure({
customNodes: ["transclusionSource", "transclusionReference", "pageEmbed"],
@@ -1,9 +1,5 @@
import { describe, it, expect } from "vitest";
import { htmlToMarkdown } from "@docmost/editor-ext";
import {
normalizeTableColumnWidths,
classifyClipboardSelection,
} from "./markdown-clipboard";
import { normalizeTableColumnWidths } from "./markdown-clipboard";
// normalizeTableColumnWidths mutates a DOM subtree (jsdom provides document).
function root(html: string): HTMLElement {
@@ -128,171 +124,3 @@ describe("normalizeTableColumnWidths", () => {
).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"),
props: {
clipboardTextSerializer: (slice) => {
const topLevelNodes: { name: string; childCount: number }[] = [];
const listTypes = ["bulletList", "orderedList", "taskList"];
let topLevelCount = 0;
let hasList = false;
slice.content.forEach((node) => {
topLevelNodes.push({
name: node.type.name,
childCount: node.childCount,
});
if (listTypes.includes(node.type.name)) {
hasList = true;
topLevelCount += node.childCount;
} else {
topLevelCount++;
}
});
const { asMarkdown, wrapBareRows } =
classifyClipboardSelection(topLevelNodes);
if (!asMarkdown) return null;
if (!hasList || topLevelCount < 2) return null;
const div = document.createElement("div");
const serializer = DOMSerializer.fromSchema(this.editor.schema);
const fragment = serializer.serializeFragment(slice.content);
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);
}
div.appendChild(fragment);
return htmlToMarkdown(div.innerHTML);
},
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
* canonical invariant (the live footnoteSyncPlugin never reorders an existing
+39 -25
View File
@@ -34,6 +34,8 @@ import {
} from "@/features/editor/atoms/editor-atoms.ts";
import { DictationGroup } from "@/features/editor/components/fixed-toolbar/groups/dictation-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 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
// that enables the chat agent); the server enforces it too (#199).
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 =
user.settings?.preferences?.editorToolbar ?? false;
user?.settings?.preferences?.editorToolbar ?? false;
const [currentPageEditMode, setCurrentPageEditMode] = useAtom(
currentPageEditModeAtom,
);
const userPageEditMode =
user.settings?.preferences?.pageEditMode ?? PageEditMode.Edit;
user?.settings?.preferences?.pageEditMode ?? 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
// page navigation — so the mode sticks across navigations within a session.
useEffect(() => {
@@ -110,28 +120,32 @@ export function FullEditor({
)}
<MemoizedDeletedPageBanner slugId={slugId} />
<MemoizedTemporaryNoteBanner slugId={slugId} />
<MemoizedTitleEditor
pageId={pageId}
slugId={slugId}
title={title}
spaceSlug={spaceSlug}
editable={editable}
/>
<PageByline
pageId={pageId}
creator={creator}
contributors={contributors}
editable={editable}
isEditMode={isEditMode}
isDictationEnabled={isDictationEnabled}
isTitleGenEnabled={isTitleGenEnabled}
/>
<MemoizedPageEditor
pageId={pageId}
editable={editable}
content={content}
canComment={canComment}
/>
<EditorProvidersContext.Provider
value={ydoc && remote ? { ydoc, remote, providersReady } : null}
>
<MemoizedTitleEditor
pageId={pageId}
slugId={slugId}
title={title}
spaceSlug={spaceSlug}
editable={editable}
/>
<PageByline
pageId={pageId}
creator={creator}
contributors={contributors}
editable={editable}
isEditMode={isEditMode}
isDictationEnabled={isDictationEnabled}
isTitleGenEnabled={isTitleGenEnabled}
/>
<MemoizedPageEditor
pageId={pageId}
editable={editable}
content={content}
canComment={canComment}
/>
</EditorProvidersContext.Provider>
</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 titleEditor = makeTitleEditor();
store.set(pageEditorAtom as never, makePageEditor("pageA"));
@@ -157,9 +157,11 @@ describe("useGeneratePageTitle", () => {
title: "Generated Title",
});
expect(updatePageDataMock).toHaveBeenCalledWith(PAGE_A);
expect(titleEditor.commands.setContent).toHaveBeenCalledWith(
"Generated Title",
);
// The title editor is bound to the Yjs `title` fragment; the server REST
// 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(emitMock).toHaveBeenCalled();
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 titleEditor = makeTitleEditor(); // persistent across navigation
store.set(pageEditorAtom as never, makePageEditor("pageA"));
@@ -203,55 +205,9 @@ describe("useGeneratePageTitle", () => {
pageId: "pageA",
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();
// 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();
// ...and the change is still broadcast to other clients.
expect(emitMock).toHaveBeenCalled();
});
@@ -1,13 +1,9 @@
import { useRef } from "react";
import { useMutation } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
import { htmlToMarkdown } from "@docmost/editor-ext";
import {
pageEditorAtom,
titleEditorAtom,
} from "@/features/editor/atoms/editor-atoms.ts";
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
import {
updatePageData,
useUpdateTitlePageMutation,
@@ -33,18 +29,9 @@ const MAX_CONTENT_CHARS = 20000;
export function useGeneratePageTitle(pageId: string) {
const { t } = useTranslation();
const pageEditor = useAtomValue(pageEditorAtom);
const titleEditor = useAtomValue(titleEditorAtom);
const { mutateAsync: updateTitle } = useUpdateTitlePageMutation();
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>({
mutationFn: async () => {
if (!pageEditor || pageEditor.isDestroyed) return;
@@ -70,33 +57,15 @@ export function useGeneratePageTitle(pageId: string) {
const page = await updateTitle({ pageId, title }); // POST /pages/update
updatePageData(page); // refresh the react-query cache
// Reflect the new title in the field immediately. The button lives in the
// byline, so the title editor is not focused — setContent is safe and stays
// undoable through its History extension (Ctrl/Cmd+Z reverts the change).
//
// Guard against navigation during generation: if the user switched pages
// while the model ran, the (persistent) title editor now shows ANOTHER
// page, so writing here would drop page A's title into page B's visible
// field. page-editor.tsx stamps the live page editor with its pageId
// (`editor.storage.pageId`), mirroring TitleEditor's `activePageId !==
// 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);
}
// Do NOT write the title into the editor here. The title editor is bound to
// the Yjs `title` fragment and Yjs is the source of truth. The server REST
// /pages/update reseeds that fragment (writePageTitle → writeTitleFragment,
// a full clear+replace) and the reseed reaches the bound title editor on
// its own as a remote provider update. The old REST-era setContent here
// would race that reseed and double/garble the title (the "Yjs duplication
// trap"), so it is intentionally omitted. The DB write above is keyed by
// the captured `pageId`, so it stays correct even if the user navigated
// away during generation.
// Broadcast to other clients, mirroring TitleEditor.saveTitle's event shape.
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 { 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:";
@@ -100,7 +100,7 @@ describe("useScrollPosition", () => {
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();
window.sessionStorage.setItem(`${KEY_PREFIX}once`, "500");
setScrollHeight(2000); // tall enough to restore synchronously
@@ -111,12 +111,8 @@ describe("useScrollPosition", () => {
});
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,
// restoreScrollPosition]) must NOT scroll again: the redundancy guard sees
// the window is already at the target and does nothing.
// restoreScrollPosition]) must NOT scroll again and yank the reader.
act(() => {
result.current.restoreScrollPosition();
});
@@ -166,84 +162,6 @@ describe("useScrollPosition", () => {
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", () => {
// Nothing saved.
const a = renderHook(() => useScrollPosition("nope"));
@@ -303,55 +221,6 @@ describe("useScrollPosition", () => {
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", () => {
const err = new Error("storage denied");
vi.spyOn(window.sessionStorage, "getItem").mockImplementation(() => {
@@ -372,23 +241,3 @@ describe("useScrollPosition", () => {
}).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 type { Editor } from "@tiptap/react";
import { useCallback, useEffect, useRef } from "react";
// Throttle interval for persisting the scroll position while the user reads.
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).
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 {
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
* their place across a reload (F5) or reopening the document.
*
* Returns `restoreScrollPosition`, which the page editor calls from two triggers
* (early, while the static/cached content is laid out, and again after the
* static->live editor swap); it is idempotent, so re-asserting the same target is
* a no-op. The two scroll mechanisms are mutually exclusive: if the URL has a
* `#hash` anchor, the existing anchor-scroll logic wins and restore is a no-op.
* Returns `restoreScrollPosition`, which the page editor calls once the live
* (non-static) content is laid out. The two scroll mechanisms are mutually
* exclusive: if the URL has a `#hash` anchor, the existing anchor-scroll logic
* wins and restore is a no-op.
*/
export function useScrollPosition(pageId: string): {
restoreScrollPosition: () => void;
} {
// CONTRACT: this hook assumes PageEditor REMOUNTS per page — page.tsx renders
// `<MemoizedFullEditor key={page.id} ...>`, so switching pages creates a fresh
// hook instance with fresh refs. Restore is idempotent and interaction-gated
// (not single-shot): it may be called from several triggers and re-asserts the
// SAME captured target, which is a no-op once the window is already positioned.
// The per-mount refs that latch are `initialTargetRef` (the captured target)
// and `userInteractedRef` (the reader has taken over scrolling). They are NOT
// 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.
// hook instance with fresh refs. These refs latch per-mount and are NOT 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 / already-restored flag) — in that
// case the refs must be reset on a pageId change.
//
// The target Y captured synchronously at mount, BEFORE any scroll/visibility
// handler can overwrite the stored value with a fresh 0 (the page starts
// scrolled to top on load). `null` means "not yet captured".
const initialTargetRef = useRef<number | null>(null);
// Set once the reader shows unambiguous scroll intent; restore must never yank
// a reader who has already started scrolling.
const userInteractedRef = useRef(false);
// Guards so restore runs at most once per page mount.
const hasRestoredRef = useRef(false);
// 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
// window.scrollTo against the NEW page's document (visible wrong-page scroll).
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
// 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("pagehide", onPageHide);
document.addEventListener("visibilitychange", onVisibilityChange);
window.addEventListener("wheel", onUserIntent, { passive: true });
window.addEventListener("touchstart", onUserIntent, { passive: true });
window.addEventListener("keydown", onUserIntent);
return () => {
window.removeEventListener("scroll", onScroll);
window.removeEventListener("pagehide", onPageHide);
document.removeEventListener("visibilitychange", onVisibilityChange);
window.removeEventListener("wheel", onUserIntent);
window.removeEventListener("touchstart", onUserIntent);
window.removeEventListener("keydown", onUserIntent);
if (throttleTimer !== null) {
window.clearTimeout(throttleTimer);
throttleTimer = null;
@@ -199,8 +137,9 @@ export function useScrollPosition(pageId: string): {
}, [pageId]);
const restoreScrollPosition = useCallback(() => {
// The reader took over — never yank them back.
if (userInteractedRef.current) return;
// Run at most once per page mount.
if (hasRestoredRef.current) return;
hasRestoredRef.current = true;
// Anchor priority: a `#hash` in the URL is handled by useEditorScroll.
if (window.location.hash) return;
@@ -209,26 +148,9 @@ export function useScrollPosition(pageId: string): {
// Nothing meaningful to restore to.
if (targetY <= 0) return;
// Cancel any in-flight poll before (re)starting, so overlapping triggers can
// 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 start = Date.now();
const tryRestore = () => {
// Bail mid-poll if the reader started scrolling while we were waiting.
if (userInteractedRef.current) {
pollTimerRef.current = null;
return;
}
const maxScroll =
document.documentElement.scrollHeight - window.innerHeight;
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
// after the timeout and scroll as far as currently possible.
if (maxScroll >= targetY || timedOut) {
const top = Math.min(targetY, Math.max(maxScroll, 0));
// Redundancy guard: re-asserting the SAME target when already positioned
// is a no-op, so this hook can be called from multiple triggers safely.
if (Math.abs(window.scrollY - top) > 1) {
window.scrollTo({ top, behavior: "auto" });
}
window.scrollTo({
top: Math.min(targetY, Math.max(maxScroll, 0)),
behavior: "auto",
});
pollTimerRef.current = null;
return;
}
@@ -255,37 +175,3 @@ export function useScrollPosition(pageId: string): {
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,164 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import type { RefObject } from "react";
import { useSwapHeightReservation } from "./use-swap-height-reservation";
// Controllable fake requestAnimationFrame. jsdom's rAF is timer-driven and hard
// to step deterministically, so we install a manual queue: `tickRaf()` drains the
// callbacks scheduled so far (a callback that reschedules enqueues a new one for
// the NEXT tick), letting each test advance the release loop frame by frame.
let rafQueue: Array<{ id: number; cb: FrameRequestCallback }> = [];
let nextRafId = 1;
let realRaf: typeof globalThis.requestAnimationFrame;
let realCancel: typeof globalThis.cancelAnimationFrame;
function tickRaf(): void {
const current = rafQueue;
rafQueue = [];
for (const { cb } of current) cb(0);
}
// A mutable stand-in for the live-content container. The hook only reads
// `scrollHeight`, so tests drive the release condition by mutating this.
function makeMenuRef(): {
ref: RefObject<HTMLElement | null>;
setScrollHeight: (h: number) => void;
} {
const el = { scrollHeight: 0 };
return {
ref: { current: el } as unknown as RefObject<HTMLElement | null>,
setScrollHeight: (h: number) => {
el.scrollHeight = h;
},
};
}
const H = 1000;
describe("useSwapHeightReservation", () => {
beforeEach(() => {
rafQueue = [];
nextRafId = 1;
realRaf = globalThis.requestAnimationFrame;
realCancel = globalThis.cancelAnimationFrame;
globalThis.requestAnimationFrame = ((cb: FrameRequestCallback) => {
const id = nextRafId++;
rafQueue.push({ id, cb });
return id;
}) as typeof globalThis.requestAnimationFrame;
globalThis.cancelAnimationFrame = ((id: number) => {
rafQueue = rafQueue.filter((e) => e.id !== id);
}) as typeof globalThis.cancelAnimationFrame;
});
afterEach(() => {
globalThis.requestAnimationFrame = realRaf;
globalThis.cancelAnimationFrame = realCancel;
vi.useRealTimers();
vi.restoreAllMocks();
});
// (a) reserve-on-swap: the captured height becomes `reservedHeight`, the value
// that drives the swap wrapper's minHeight. Captured while static is still up,
// then the swap flips showStatic; before any release frame runs the reservation
// is held at exactly H.
it("(a) holds the captured height as reservedHeight after the swap (drives minHeight)", () => {
const { ref, setScrollHeight } = makeMenuRef();
setScrollHeight(0); // live content not laid out yet -> release cannot fire.
const { result, rerender } = renderHook(
({ showStatic }) => useSwapHeightReservation(showStatic, ref),
{ initialProps: { showStatic: true } },
);
// Capture happens synchronously at the swap point (static still shown).
act(() => {
result.current.captureReservation(H);
});
// The swap flips to the live branch.
rerender({ showStatic: false });
expect(result.current.reservedHeight).toBe(H);
});
// (b) release when the live content is tall enough. Guard is `>=`: with
// liveHeight === H the reservation releases. This FAILS if the guard direction
// were `<` (liveHeight === H is not `< H`, so it would never release).
it("(b) releases once live content reaches the reserved height", () => {
const { ref, setScrollHeight } = makeMenuRef();
setScrollHeight(0);
const { result, rerender } = renderHook(
({ showStatic }) => useSwapHeightReservation(showStatic, ref),
{ initialProps: { showStatic: true } },
);
act(() => {
result.current.captureReservation(H);
});
rerender({ showStatic: false });
expect(result.current.reservedHeight).toBe(H); // still reserved (short live doc)
// Live editor finishes laying out to the reserved height.
setScrollHeight(H);
act(() => {
tickRaf();
});
expect(result.current.reservedHeight).toBeNull();
});
// (c) cap escape: the live content never reaches the reserved height, so the
// height match never fires; the reservation must still release at the 4000ms
// cap (no stuck reservation / dead space). This FAILS if there were no cap: the
// loop would poll forever while scrollHeight stays below H.
it("(c) releases at the 4000ms cap when live content stays too short", () => {
// Only fake Date so `Date.now()` (the cap clock) is controllable; leave our
// manual rAF queue in place (default fake timers would replace it).
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(0);
const { ref, setScrollHeight } = makeMenuRef();
setScrollHeight(H - 100); // always shorter than reserved -> height match never fires.
const { result, rerender } = renderHook(
({ showStatic }) => useSwapHeightReservation(showStatic, ref),
{ initialProps: { showStatic: true } },
);
act(() => {
result.current.captureReservation(H);
});
rerender({ showStatic: false });
// A few frames pass but time has not reached the cap: still reserved.
act(() => {
tickRaf();
});
act(() => {
tickRaf();
});
expect(result.current.reservedHeight).toBe(H);
// Advance past the cap; the next frame releases even though the live content
// is still shorter than the reservation.
vi.setSystemTime(4001);
act(() => {
tickRaf();
});
expect(result.current.reservedHeight).toBeNull();
});
// (d) non-swap: without a capture (and while static is shown) there is no
// reservation and the release loop never arms, so no rAF is scheduled.
it("(d) reserves nothing and arms no loop when the swap never happens", () => {
const { ref } = makeMenuRef();
const { result } = renderHook(() =>
useSwapHeightReservation(true, ref),
);
expect(result.current.reservedHeight).toBeNull();
expect(rafQueue.length).toBe(0); // release loop never armed
act(() => {
tickRaf();
});
expect(result.current.reservedHeight).toBeNull();
});
});
@@ -1,79 +0,0 @@
import { RefObject, useCallback, useEffect, useState } from "react";
// Last-resort release deadline. The primary release is the live-content height
// match below; this cap only exists so a slow/short live doc can never pin the
// reservation forever. It is generous (well past when the live content normally
// reaches the reserved height — it renders the SAME content as the static copy)
// so a slow load doesn't release mid-render and reintroduce the collapse.
const RELEASE_CAP_MS = 4000;
/**
* Reserves the document height across the static -> live editor swap.
*
* The live editor lays out its content over a few frames, so replacing the
* (full-height) static copy with it momentarily shrinks the document; the
* browser then clamps window scroll to the top, which yanked the reader off
* their restored reading position (and threw their scroll to 0 if they were
* scrolling at that moment). Pinning a min-height on the swap wrapper keeps the
* document tall through the swap so the scroll position simply survives (#266).
* `reservedHeight === null` means no reservation is active.
*
* The capture is intentionally a CALLBACK the page editor invokes, NOT something
* this hook derives by watching `showStatic`. The height MUST be read
* synchronously while the static content is still mounted (full natural height),
* right before the flip to the live branch. By the time any post-transition
* effect here could run, `showStatic` is already false and the wrapper shows the
* live/collapsed content, so `offsetHeight` would be wrong. So page-editor calls
* `captureReservation(wrapper.offsetHeight)` inside its collab-sync effect,
* before `setShowStatic(false)`, preserving that exact timing.
*
* @param showStatic whether the static (cached) content is still shown.
* @param menuContainerRef the live-branch content container. It is a descendant
* of the swap wrapper inside the live branch, so its `scrollHeight` is the live
* content height (not inflated by the ancestor min-height reservation).
*/
export function useSwapHeightReservation(
showStatic: boolean,
menuContainerRef: RefObject<HTMLElement | null>,
): {
reservedHeight: number | null;
captureReservation: (height: number | null) => void;
} {
const [reservedHeight, setReservedHeight] = useState<number | null>(null);
// Capture the current (static, full-height) content height BEFORE the swap so
// the wrapper can reserve it while the live editor lays out — otherwise the
// transient shrink clamps window scroll to the top. The caller reads
// `offsetHeight` synchronously at the swap point and hands it here.
const captureReservation = useCallback(
(height: number | null) => setReservedHeight(height),
[],
);
// Release the reserved height once the live editor's content has laid out to
// at least the reserved height (so removing the reservation cannot collapse
// the document). The primary release is that height match; the cap is only a
// last-resort so we never pin forever. A shorter-than-reserved live doc (rare:
// stale/longer cache) releases at the cap, leaving only harmless bottom dead
// space until then.
useEffect(() => {
if (showStatic || reservedHeight == null) return;
let raf = 0;
const startedAt = Date.now();
const check = () => {
const liveHeight = menuContainerRef.current?.scrollHeight ?? 0;
if (
liveHeight >= reservedHeight ||
Date.now() - startedAt > RELEASE_CAP_MS
) {
setReservedHeight(null);
return;
}
raf = requestAnimationFrame(check);
};
raf = requestAnimationFrame(check);
return () => cancelAnimationFrame(raf);
}, [showStatic, reservedHeight, menuContainerRef]);
return { reservedHeight, captureReservation };
}
@@ -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" });
});
});
+30 -189
View File
@@ -6,16 +6,7 @@ import React, {
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 { WebSocketStatus } from "@hocuspocus/provider";
import {
Editor,
EditorContent,
@@ -28,13 +19,15 @@ import {
mainExtensions,
} from "@/features/editor/extensions/extensions";
import { useAtom, useAtomValue } from "jotai";
import useCollaborationUrl from "@/features/editor/hooks/use-collaboration-url";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import {
currentPageEditModeAtom,
isLocalSyncedAtom,
isRemoteSyncedAtom,
pageEditorAtom,
yjsConnectionStatusAtom,
} 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 {
activeCommentIdAtom,
@@ -59,10 +52,8 @@ import {
} from "@/features/editor/components/common/editor-paste-handler.tsx";
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu-lazy";
import DrawioMenu from "./components/drawio/drawio-menu";
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
import SearchAndReplaceDialog from "@/features/editor/components/search-and-replace/search-and-replace-dialog.tsx";
import { useDebouncedCallback, useDocumentVisibility } from "@mantine/hooks";
import { useIdle } from "@/hooks/use-idle.ts";
import { useDebouncedCallback } from "@mantine/hooks";
import { queryClient } from "@/main.tsx";
import { IPage } from "@/features/page/types/page.types.ts";
import { useParams } from "react-router-dom";
@@ -73,13 +64,10 @@ import {
GitmostInsertRecordingResult,
gitmostInsertRecordingIntoEditor,
} from "@/features/editor/gitmost/gitmost-recording.ts";
import { FIVE_MINUTES } from "@/lib/constants.ts";
import { PageEditMode } from "@/features/user/types/user.types.ts";
import { jwtDecode } from "jwt-decode";
import { searchSpotlight } from "@/features/search/constants.ts";
import { useEditorScroll } from "./hooks/use-editor-scroll";
import { useScrollRestoreOnSwap } from "./hooks/use-scroll-position";
import { useSwapHeightReservation } from "./hooks/use-swap-height-reservation";
import { useScrollPosition } from "./hooks/use-scroll-position";
import { EditorLinkMenu } from "@/features/editor/components/link/link-menu";
import ColumnsMenu from "@/features/editor/components/columns/columns-menu.tsx";
import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context";
@@ -106,7 +94,6 @@ export default function PageEditor({
canComment,
}: PageEditorProps) {
const { t } = useTranslation();
const collaborationURL = useCollaborationUrl();
const isComponentMounted = useRef(false);
const editorRef = useRef<Editor | null>(null);
@@ -120,22 +107,10 @@ export default function PageEditor({
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const [showReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom);
const [isLocalSynced, setIsLocalSynced] = useState(false);
const [isRemoteSynced, setIsRemoteSynced] = useState(false);
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
yjsConnectionStatusAtom,
);
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 slugId = extractPageSlugId(pageSlug);
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
@@ -144,141 +119,34 @@ export default function PageEditor({
[isComponentMounted],
);
const { handleScrollTo } = useEditorScroll({ canScroll });
// Providers only created once per pageId
const providersRef = useRef<{
local: IndexeddbPersistence;
remote: HocuspocusProvider;
socket: HocuspocusProviderWebsocket;
} | null>(null);
const [providersReady, setProvidersReady] = useState(false);
// Scroll-position restore hook from develop. The provider state that develop
// also declared here (providersRef / providersReady useState) is intentionally
// dropped: the offline-sync feature moved provider creation into the shared
// usePageCollabProviders context, and `providersReady` is derived from that
// context below (via useEditorProviders), so redeclaring it here would shadow
// and conflict with the branch's provider model.
const { restoreScrollPosition } = useScrollPosition(pageId);
useEffect(() => {
if (!providersRef.current) {
const documentName = `page.${pageId}`;
const ydoc = new Y.Doc();
const local = new IndexeddbPersistence(documentName, ydoc);
const socket = new HocuspocusProviderWebsocket({
url: collaborationURL,
});
const onLocalSyncedHandler = () => {
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();
// Shared providers + Y.Doc lifted into full-editor via context. The provider
// lifecycle (creation, idle/visibility connect, attach, destroy, token
// refresh) lives in usePageCollabProviders. Null-safe when rendered without
// the context (defensive) — in practice full-editor always provides it.
const editorProviders = useEditorProviders();
const remote = editorProviders?.remote ?? null;
const providersReady = editorProviders?.providersReady ?? false;
const isLocalSynced = useAtomValue(isLocalSyncedAtom);
const isRemoteSynced = useAtomValue(isRemoteSyncedAtom);
const extensions = useMemo(() => {
if (!providersReady || !providersRef.current || !currentUser?.user) {
if (!providersReady || !remote || !currentUser?.user) {
return mainExtensions;
}
const remoteProvider = providersRef.current.remote;
return [
...mainExtensions,
...collabExtensions(remoteProvider, currentUser?.user),
...collabExtensions(remote, currentUser?.user),
];
}, [providersReady, currentUser?.user]);
}, [providersReady, remote, currentUser?.user]);
const editor = useEditor(
{
@@ -450,22 +318,6 @@ export default function PageEditor({
const hasConnectedOnceRef = useRef(false);
const [showStatic, setShowStatic] = useState(true);
// Reserved height held across the static -> live editor swap. The live editor
// lays out its content over a few frames, so replacing the (full-height) static
// copy with it momentarily shrinks the document; the browser then clamps window
// scroll to the top, which yanked the reader off their restored reading position
// (and threw their scroll to 0 if they were scrolling at that moment). Pinning a
// min-height on the swap wrapper keeps the document tall through the swap so the
// scroll position simply survives. `null` = no reservation active.
const swapWrapperRef = useRef<HTMLDivElement | null>(null);
// Reserve/release wiring lives in the hook so its capture trigger and release
// guard/cap are directly unit-testable. Capture stays synchronous at the swap
// point (see the collab-sync effect below); the hook only owns the release.
const { reservedHeight, captureReservation } = useSwapHeightReservation(
showStatic,
menuContainerRef,
);
useEffect(() => {
const timeout = setTimeout(() => {
if (yjsConnectionStatus === WebSocketStatus.Connecting || !isSynced) {
@@ -494,29 +346,19 @@ export default function PageEditor({
isCollabSynced(yjsConnectionStatus, isSynced)
) {
hasConnectedOnceRef.current = true;
// Capture the current (static, full-height) content height BEFORE the swap
// so the wrapper can reserve it while the live editor lays out — otherwise
// the transient shrink clamps window scroll to the top.
captureReservation(swapWrapperRef.current?.offsetHeight ?? null);
setShowStatic(false);
}
}, [yjsConnectionStatus, isSynced]);
// Restore the reader's scroll position across the static -> live editor swap.
// The wiring (early pre-paint restore + post-swap re-assert) lives in the hook
// so its triggers/guard are directly unit-testable.
useScrollRestoreOnSwap(pageId, editor, showStatic);
// Restore the saved reading position once the live content is laid out.
useEffect(() => {
if (!showStatic && editor) restoreScrollPosition();
}, [showStatic, editor, restoreScrollPosition]);
return (
<TransclusionLookupProvider>
<PageEmbedLookupProvider>
<PageEmbedAncestryProvider hostPageId={pageId}>
<div
ref={swapWrapperRef}
style={
reservedHeight != null ? { minHeight: reservedHeight } : undefined
}
>
{showStatic ? (
<div style={{ position: "relative" }}>
{/* Surface the pre-sync read-only window so edits typed before the
@@ -589,7 +431,7 @@ export default function PageEditor({
{editor &&
!editorIsEditable &&
(editable || canComment) &&
providersRef.current && <ReadonlyBubbleMenu editor={editor} />}
remote && <ReadonlyBubbleMenu editor={editor} />}
{showCommentPopup && (
<CommentDialog editor={editor} pageId={pageId} />
)}
@@ -604,7 +446,6 @@ export default function PageEditor({
></div>
</div>
)}
</div>
</PageEmbedAncestryProvider>
</PageEmbedLookupProvider>
</TransclusionLookupProvider>
@@ -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();
});
});
+131 -124
View File
@@ -1,5 +1,5 @@
import "@/features/editor/styles/index.css";
import React, { useCallback, useEffect, useState } from "react";
import { useEffect } from "react";
import { EditorContent, useEditor } from "@tiptap/react";
import { Document } from "@tiptap/extension-document";
import { Heading } from "@tiptap/extension-heading";
@@ -11,14 +11,11 @@ import {
pageEditorAtom,
titleEditorAtom,
} from "@/features/editor/atoms/editor-atoms";
import {
updatePageData,
useUpdateTitlePageMutation,
} from "@/features/page/queries/page-query";
import { pageKeys, updatePageData } from "@/features/page/queries/page-query";
import { useDebouncedCallback, getHotkeyHandler } from "@mantine/hooks";
import { useAtom } from "jotai";
import { useQueryEmit } from "@/features/websocket/use-query-emit.ts";
import { History } from "@tiptap/extension-history";
import { Collaboration } from "@tiptap/extension-collaboration";
import { shouldPropagateTitleChange } from "@/features/editor/title-collab";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { useNavigate } from "react-router-dom";
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 { searchSpotlight } from "@/features/search/constants.ts";
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 {
pageId: string;
@@ -46,65 +45,82 @@ export function TitleEditor({
editable,
}: TitleEditorProps) {
const { t } = useTranslation();
const { mutateAsync: updateTitlePageMutationAsync } =
useUpdateTitlePageMutation();
const pageEditor = useAtomValue(pageEditorAtom);
const [, setTitleEditor] = useAtom(titleEditorAtom);
const emit = useQueryEmit();
const navigate = useNavigate();
const [activePageId, setActivePageId] = useState(pageId);
const currentPageEditMode = useAtomValue(currentPageEditModeAtom);
const titleEditor = useEditor({
extensions: [
Document.extend({
content: "heading",
}),
Heading.configure({
levels: [1],
}),
Text,
Placeholder.configure({
placeholder: t("Untitled"),
showOnlyWhenEditable: false,
}),
History.configure({
depth: 20,
}),
EmojiCommand,
],
onCreate({ editor }) {
if (editor) {
// @ts-ignore
setTitleEditor(editor);
setActivePageId(pageId);
}
},
onUpdate({ editor }) {
debounceUpdate();
},
editable: editable,
content: title,
immediatelyRender: true,
shouldRerenderOnTransaction: false,
editorProps: {
attributes: {
"aria-label": t("Page title"),
// Shared Y.Doc (title lives in its own 'title' fragment of the same doc as
// the body). Yjs is the source of truth for the title content.
const editorProviders = useEditorProviders();
const ydoc = editorProviders?.ydoc ?? null;
const providersReady = editorProviders?.providersReady ?? false;
// Until the shared doc is ready, the collaborative editor binds nothing and
// would render an empty heading until the Yjs 'title' fragment hydrates. Show
// a non-editable static <h1> with the `title` prop in the meantime. The prop
// is NEVER fed into the collaborative editor (Yjs stays the single source of
// truth — seeding it would duplicate the title).
const titleReady = providersReady && !!ydoc;
const titleEditor = useEditor(
{
extensions: [
Document.extend({
content: "heading",
}),
Heading.configure({
levels: [1],
}),
Text,
Placeholder.configure({
placeholder: t("Untitled"),
showOnlyWhenEditable: false,
}),
// Bind the title to the dedicated 'title' fragment of the shared doc.
// Collaboration also manages undo/redo, so the History extension is
// intentionally omitted (it would conflict with Yjs). When the doc is
// not ready yet the editor renders empty until the doc arrives.
...(ydoc
? [Collaboration.configure({ document: ydoc, field: "title" })]
: []),
EmojiCommand,
],
onCreate({ editor }) {
if (editor) {
// @ts-ignore
setTitleEditor(editor);
}
},
handleDOMEvents: {
keydown: (_view, event) => {
if (platformModifierKey(event) && event.code === "KeyS") {
event.preventDefault();
return true;
}
if (platformModifierKey(event) && event.code === "KeyK") {
searchSpotlight.open();
return true;
}
onUpdate({ editor, transaction }) {
// Drive URL + tree propagation only on genuine local edits; skip
// remote/collab-origin Yjs updates to avoid feedback loops.
if (!shouldPropagateTitleChange(transaction)) return;
debouncedPropagateTitle(editor.getText());
},
editable: editable,
immediatelyRender: 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(() => {
const anchorId = window.location.hash
@@ -114,68 +130,53 @@ export function TitleEditor({
navigate(pageSlug, { replace: true });
}, [title]);
const saveTitle = useCallback(() => {
if (!titleEditor || activePageId !== pageId) return;
if (
titleEditor.getText() === title ||
(titleEditor.getText() === "" && title === null)
) {
return;
}
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);
// On a local title change: update the URL slug and propagate the change to
// 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
// emit and the title syncs on reconnect.
const debouncedPropagateTitle = useDebouncedCallback((titleText: string) => {
const anchorId = window.location.hash
? window.location.hash.substring(1)
: undefined;
navigate(buildPageUrl(spaceSlug, slugId, titleText, anchorId), {
replace: true,
});
}, [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(() => {
// 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]);
const updatedPage: IPage = { ...page, title: titleText };
useTitleAutofocus(titleEditor, pageId);
useEffect(() => {
return () => {
// force-save title on navigation
saveTitle();
const event: UpdateEvent = {
operation: "updateOne",
spaceId: page.spaceId,
entity: ["pages"],
id: page.id,
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(() => {
if (!titleEditor) return;
@@ -243,16 +244,22 @@ export function TitleEditor({
return (
<div className="page-title">
<EditorContent
editor={titleEditor}
onKeyDown={(event) => {
// First handle the search hotkey
getHotkeyHandler([["mod+F", openSearchDialog]])(event);
{titleReady ? (
<EditorContent
editor={titleEditor}
onKeyDown={(event) => {
// First handle the search hotkey
getHotkeyHandler([["mod+F", openSearchDialog]])(event);
// Then handle other key events
handleTitleKeyDown(event);
}}
/>
// Then handle other key events
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>
);
}
@@ -3,6 +3,8 @@ import { IconHourglass, IconPlus } from "@tabler/icons-react";
import { ReactNode } from "react";
import { useNavigate } from "react-router-dom";
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 { useCreatePageMutation } from "@/features/page/queries/page-query.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
@@ -36,21 +38,39 @@ function CreateNoteButton({
const createPageMutation = useCreatePageMutation();
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 {
// `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 createdPage = await createPageMutation.mutateAsync({
spaceId: space.id,
...(temporary ? { temporary: true } : {}),
} as any);
const createdPage = await createPageMutation.mutateAsync(variables);
navigate(buildPageUrl(space.slug, createdPage.slugId, createdPage.title));
} catch {
// 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.
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 { 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 classes from "./css/history.module.css";
import clsx from "clsx";
@@ -99,13 +99,12 @@ const HistoryItem = memo(function HistoryItem({
</>
)}
{isAgentEdit && historyItem.agent && (
<AgentAvatarStack
agent={historyItem.agent}
launcher={historyItem.launcher}
{isAgentEdit && (
<AiAgentBadge
authorName={historyItem.lastUpdatedBy?.name}
aiChatId={historyItem.lastUpdatedAiChatId}
// The history row owns the modal: close it when the stack deep-links
// into the chat (the stack no longer reaches into page-history).
// The history row owns the modal: close it when the badge deep-links
// into the chat (the badge no longer reaches into page-history).
onActivate={() => setHistoryModalOpen(false)}
/>
)}
@@ -1,8 +1,3 @@
import type {
AgentInfo,
LauncherInfo,
} from "@/components/ui/agent-avatar-stack.tsx";
interface IPageHistoryUser {
id: string;
name: string;
@@ -29,9 +24,4 @@ export interface IPageHistory {
// (when present) deep-links to the chat that produced the edit.
lastUpdatedSource?: string;
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,
IconMarkdown,
IconPrinter,
IconCloud,
IconCloudCheck,
IconStar,
IconStarFilled,
IconTrash,
@@ -39,6 +41,8 @@ import { Trans, useTranslation } from "react-i18next";
import ExportModal from "@/components/common/export-modal";
import { htmlToMarkdown } from "@docmost/editor-ext";
import {
isLocalSyncedAtom,
isRemoteSyncedAtom,
pageEditorAtom,
yjsConnectionStatusAtom,
} from "@/features/editor/atoms/editor-atoms.ts";
@@ -411,14 +415,16 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
function ConnectionWarning() {
const { t } = useTranslation();
const yjsConnectionStatus = useAtomValue(yjsConnectionStatusAtom);
const isLocalSynced = useAtomValue(isLocalSyncedAtom);
const isRemoteSynced = useAtomValue(isRemoteSyncedAtom);
const [showWarning, setShowWarning] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
const isDisconnected = ["disconnected", "connecting"].includes(
yjsConnectionStatus,
);
const isDisconnected = ["disconnected", "connecting"].includes(
yjsConnectionStatus,
);
useEffect(() => {
if (isDisconnected) {
if (!timeoutRef.current) {
timeoutRef.current = setTimeout(() => setShowWarning(true), 5000);
@@ -430,7 +436,7 @@ function ConnectionWarning() {
}
setShowWarning(false);
}
}, [yjsConnectionStatus]);
}, [isDisconnected]);
// Cleanup only on unmount
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 (
<Tooltip
label={t("Real-time editor connection lost. Retrying...")}
openDelay={250}
withArrow
>
<Tooltip label={syncedLabel} openDelay={250} withArrow>
<ThemeIcon
variant="default"
c="red"
c="dimmed"
role="status"
aria-label={t("Real-time editor connection lost. Retrying...")}
aria-label={syncedLabel}
style={{ border: "none" }}
>
<IconWifiOff size={20} stroke={2} />
<IconCloudCheck size={20} stroke={2} />
</ThemeIcon>
</Tooltip>
);
@@ -1,6 +1,7 @@
import {
InfiniteData,
QueryKey,
queryOptions,
useInfiniteQuery,
UseInfiniteQueryResult,
useMutation,
@@ -42,12 +43,38 @@ import { treeModel } from "@/features/page/tree/model/tree-model";
import { SpaceTreeNode } from "@/features/page/tree/types";
import { useQueryEmit } from "@/features/websocket/use-query-emit";
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(
pageInput: Partial<IPageInput>,
): UseQueryResult<IPage, Error> {
const query = useQuery({
queryKey: ["pages", pageInput.pageId],
queryKey: pageKeys.detail(pageInput.pageId),
queryFn: () => getPageById(pageInput),
enabled: !!pageInput.pageId,
staleTime: 5 * 60 * 1000,
@@ -56,9 +83,9 @@ export function usePageQuery(
useEffect(() => {
if (query.data) {
if (isValidUuid(pageInput.pageId)) {
queryClient.setQueryData(["pages", query.data.slugId], query.data);
queryClient.setQueryData(pageKeys.detail(query.data.slugId), query.data);
} else {
queryClient.setQueryData(["pages", query.data.id], query.data);
queryClient.setQueryData(pageKeys.detail(query.data.id), query.data);
}
}
}, [query.data]);
@@ -69,6 +96,10 @@ export function usePageQuery(
export function useCreatePageMutation() {
const { t } = useTranslation();
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),
onSuccess: (data) => {
invalidateOnCreatePage(data);
@@ -80,18 +111,20 @@ export function useCreatePageMutation() {
}
export function updatePageData(data: IPage) {
const pageBySlug = queryClient.getQueryData<IPage>(["pages", data.slugId]);
const pageById = queryClient.getQueryData<IPage>(["pages", data.id]);
const pageBySlug = queryClient.getQueryData<IPage>(
pageKeys.detail(data.slugId),
);
const pageById = queryClient.getQueryData<IPage>(pageKeys.detail(data.id));
if (pageBySlug) {
queryClient.setQueryData(["pages", data.slugId], {
queryClient.setQueryData(pageKeys.detail(data.slugId), {
...pageBySlug,
...data,
});
}
if (pageById) {
queryClient.setQueryData(["pages", data.id], { ...pageById, ...data });
queryClient.setQueryData(pageKeys.detail(data.id), { ...pageById, ...data });
}
invalidateOnUpdatePage(
@@ -145,11 +178,11 @@ export function useRemovePageMutation() {
});
// 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) {
const stamped = { ...cached, deletedAt: new Date() };
queryClient.setQueryData(["pages", cached.id], stamped);
queryClient.setQueryData(["pages", cached.slugId], stamped);
queryClient.setQueryData(pageKeys.detail(cached.id), stamped);
queryClient.setQueryData(pageKeys.detail(cached.slugId), stamped);
}
invalidateOnDeletePage(pageId);
@@ -188,6 +221,9 @@ export function useDeletePageMutation() {
export function useMovePageMutation() {
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),
});
}
@@ -267,8 +303,11 @@ export function useRestorePageMutation() {
// Replace would strip space/permissions/content and break the editor.
const merge = (cached: IPage | undefined) =>
cached ? { ...cached, ...restoredPage } : cached;
queryClient.setQueryData<IPage>(["pages", restoredPage.id], merge);
queryClient.setQueryData<IPage>(["pages", restoredPage.slugId], merge);
queryClient.setQueryData<IPage>(pageKeys.detail(restoredPage.id), merge);
queryClient.setQueryData<IPage>(
pageKeys.detail(restoredPage.slugId),
merge,
);
},
onError: (error) => {
notifications.show({
@@ -283,7 +322,7 @@ export function useGetSidebarPagesQuery(
data: SidebarPagesParams | null,
): UseInfiniteQueryResult<InfiniteData<IPagination<IPage>, unknown>> {
return useInfiniteQuery({
queryKey: ["sidebar-pages", data],
queryKey: pageKeys.sidebar(data),
enabled: !!data?.pageId || !!data?.spaceId,
queryFn: ({ pageParam }) =>
getSidebarPages({ ...data, cursor: pageParam, limit: 100 }),
@@ -294,7 +333,7 @@ export function useGetSidebarPagesQuery(
export function useGetRootSidebarPagesQuery(data: SidebarPagesParams) {
return useInfiniteQuery({
queryKey: ["root-sidebar-pages", data.spaceId],
queryKey: pageKeys.rootSidebar(data.spaceId),
queryFn: async ({ pageParam }) => {
return getSidebarPages({
spaceId: data.spaceId,
@@ -320,7 +359,7 @@ export function usePageBreadcrumbsQuery(
pageId: string,
): UseQueryResult<Partial<IPage[]>, Error> {
return useQuery({
queryKey: ["breadcrumbs", pageId],
queryKey: pageKeys.breadcrumbs(pageId),
queryFn: () => getPageBreadcrumbs(pageId),
enabled: !!pageId,
});
@@ -332,10 +371,12 @@ export async function fetchAllAncestorChildren(
// refresh (#159 #8), which must NOT receive the 30-min-cached children.
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({
queryKey: ["sidebar-pages", params],
queryFn: () => getAllSidebarPages(params),
...sidebarPagesQueryOptions(params),
staleTime: opts?.fresh ? 0 : 30 * 60 * 1000,
});
@@ -345,7 +386,7 @@ export async function fetchAllAncestorChildren(
export function useRecentChangesQuery(spaceId?: string) {
return useInfiniteQuery({
queryKey: ["recent-changes", spaceId],
queryKey: pageKeys.recentChanges(spaceId),
queryFn: ({ pageParam }) =>
getRecentChanges({ spaceId, cursor: pageParam, limit: 15 }),
initialPageParam: undefined as string | undefined,
@@ -416,12 +457,12 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
let queryKey: QueryKey = null;
if (data.parentPageId === null) {
queryKey = ["root-sidebar-pages", data.spaceId];
queryKey = pageKeys.rootSidebar(data.spaceId);
} else {
queryKey = [
"sidebar-pages",
{ pageId: data.parentPageId, spaceId: data.spaceId },
];
queryKey = pageKeys.sidebar({
pageId: data.parentPageId,
spaceId: data.spaceId,
});
}
//update all sidebar pages
@@ -481,7 +522,7 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
//update root sidebar pages haschildern
const rootSideBarMatches = queryClient.getQueriesData({
queryKey: ["root-sidebar-pages", data.spaceId],
queryKey: pageKeys.rootSidebar(data.spaceId),
exact: false,
});
@@ -505,7 +546,7 @@ export function invalidateOnCreatePage(data: Partial<IPage>) {
//update recent changes
queryClient.invalidateQueries({
queryKey: ["recent-changes", data.spaceId],
queryKey: pageKeys.recentChanges(data.spaceId),
});
}
@@ -519,9 +560,9 @@ export function invalidateOnUpdatePage(
invalidatePageTree();
let queryKey: QueryKey = null;
if (parentPageId === null) {
queryKey = ["root-sidebar-pages", spaceId];
queryKey = pageKeys.rootSidebar(spaceId);
} else {
queryKey = ["sidebar-pages", { pageId: parentPageId, spaceId: spaceId }];
queryKey = pageKeys.sidebar({ pageId: parentPageId, spaceId: spaceId });
}
//update all sidebar pages
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
@@ -544,7 +585,7 @@ export function invalidateOnUpdatePage(
//update recent changes
queryClient.invalidateQueries({
queryKey: ["recent-changes", spaceId],
queryKey: pageKeys.recentChanges(spaceId),
});
}
@@ -559,8 +600,8 @@ export function updateCacheOnMovePage(
// Remove page from old parent's cache
const oldQueryKey =
oldParentId === null
? ["root-sidebar-pages", spaceId]
: ["sidebar-pages", { pageId: oldParentId, spaceId }];
? pageKeys.rootSidebar(spaceId)
: pageKeys.sidebar({ pageId: oldParentId, spaceId });
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(
oldQueryKey,
@@ -580,7 +621,7 @@ export function updateCacheOnMovePage(
if (oldParentId !== null) {
const oldParentCache = queryClient.getQueryData<
InfiniteData<IPagination<IPage>>
>(["sidebar-pages", { pageId: oldParentId, spaceId }]);
>(pageKeys.sidebar({ pageId: oldParentId, spaceId }));
const remainingChildren =
oldParentCache?.pages.flatMap((p) => p.items).length ?? 0;
@@ -618,8 +659,8 @@ export function updateCacheOnMovePage(
// Add page to new parent's cache
const newQueryKey =
newParentId === null
? ["root-sidebar-pages", spaceId]
: ["sidebar-pages", { pageId: newParentId, spaceId }];
? pageKeys.rootSidebar(spaceId)
: pageKeys.sidebar({ pageId: newParentId, spaceId });
queryClient.setQueryData<InfiniteData<IPagination<Partial<IPage>>>>(
newQueryKey,
@@ -13,30 +13,20 @@ export type OpenMap = Record<string, boolean>;
// `OpenMap | Promise<OpenMap>` and break the functional-updater setter below).
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
// key prevents accounts that share a browser origin from leaking tree state.
// `getOnInit: true` reads localStorage synchronously at atom init (not on mount),
// 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.
const openTreeNodesFamily = atomFamily((scopeKey: string) =>
atomWithStorage<OpenMap>(
`${OPEN_TREE_NODES_KEY_PREFIX}${scopeKey}`,
{},
openTreeNodesStorage,
{ getOnInit: true },
),
atomWithStorage<OpenMap>(`openTreeNodes:${scopeKey}`, {}, openTreeNodesStorage, {
getOnInit: true,
}),
);
// 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).
// Shared by the open-map atom below and the persisted tree-data atom
// (tree-data-atom.ts) so both caches are scoped identically.
export const scopeKeyAtom = atom((get) => {
const scopeKeyAtom = atom((get) => {
const currentUser = get(currentUserAtom);
const workspaceId = currentUser?.workspace?.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 { atomFamily, atomWithStorage } from "jotai/utils";
import { SpaceTreeNode } from "@/features/page/tree/types";
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
// 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);
},
);
export const treeDataAtom = atom<SpaceTreeNode[]>([]);
// 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 {
IconArrowRight,
IconClockHour4,
IconCloudDownload,
IconCopy,
IconDotsVertical,
IconFileExport,
@@ -35,6 +36,12 @@ import {
useToggleTemplateMutation,
useToggleTemporaryMutation,
} 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 { treeModel } from "@/features/page/tree/model/tree-model";
import { pageToTreeNode } from "@/features/page/tree/utils";
@@ -72,6 +79,57 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
const isTemplate = !!node.isTemplate;
const toggleTemporary = useToggleTemporaryMutation();
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 next = !isTemplate;
@@ -228,6 +286,17 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) {
{t("Export")}
</Menu.Item>
<Menu.Item
leftSection={<IconCloudDownload size={16} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleMakeAvailableOffline();
}}
>
{t("Make available offline")}
</Menu.Item>
{canEdit && (
<>
<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
// 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
// unchanged while staying inside the test store. `scopeKeyAtom` is also
// re-exported (the real module exports it for the persisted tree-data atom).
// unchanged while staying inside the test store.
vi.mock("@/features/page/tree/atoms/open-tree-nodes-atom.ts", async () => {
const { atom } = await import("jotai");
type OpenMap = Record<string, boolean>;
@@ -87,17 +86,11 @@ vi.mock("@/features/page/tree/atoms/open-tree-nodes-atom.ts", async () => {
set(base, next);
},
);
// Fixed scope key: the tree-data atom family resolves through this, so all
// tests read/write the same (empty at start of each test) storage key.
const scopeKeyAtom = atom(() => "test-workspace:test-user");
return { openTreeNodesAtom, scopeKeyAtom };
return { openTreeNodesAtom };
});
import SpaceTree, { SpaceTreeApi } from "./space-tree";
import {
treeDataAtom,
flushPendingTreeDataWrites,
} from "@/features/page/tree/atoms/tree-data-atom.ts";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
import { openTreeNodesAtom } from "@/features/page/tree/atoms/open-tree-nodes-atom.ts";
import { createStore, Provider } from "jotai";
import type { SpaceTreeNode } from "@/features/page/tree/types.ts";
@@ -141,10 +134,6 @@ function renderTree(store: ReturnType<typeof createStore>) {
beforeEach(() => {
getSpaceTreeMock.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
// fresh jotai store anyway, so cross-test open-state never leaks.
try {
@@ -30,7 +30,6 @@ import {
openBranches,
closeIds,
loadedOpenBranchIds,
pruneCollapsedChildren,
} from "@/features/page/tree/utils/utils.ts";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
import { treeModel } from "@/features/page/tree/model/tree-model";
@@ -200,81 +199,45 @@ const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(function SpaceTree(
const openIdsRef = useRef(openIds);
openIdsRef.current = openIds;
// Boot-cache hygiene (#159 #8): the localStorage-hydrated tree carries the
// children of every branch ever expanded, including ones now COLLAPSED. Their
// 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
// Reconnect refresh (#159 #8): on a socket reconnect, re-fetch and reconcile
// the children of every currently-open, already-loaded branch of THIS space,
// 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.
// No first-connect guard is needed: space-tree usually mounts AFTER the
// initial connect, so every `connect` it sees is a reconnect; the rare
// The ROOT level is reconciled separately by the root-query refetch +
// 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.
useEffect(() => {
if (!socket) return;
const onConnect = () => {
refreshOpenBranches();
const onConnect = 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] reconnect branch refresh failed", err);
}
}
};
socket.on("connect", onConnect);
return () => {
socket.off("connect", onConnect);
};
}, [socket, refreshOpenBranches]);
// 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]);
}, [socket, setData]);
const handleToggle = useCallback(
async (id: string, isOpen: boolean) => {
@@ -370,17 +333,12 @@ const SpaceTree = forwardRef<SpaceTreeApi, SpaceTreeProps>(function SpaceTree(
return (
<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 && (
<Text size="xs" c="dimmed" py="xs" px="sm">
{t("No pages yet")}
</Text>
)}
{/* Cache-first paint: render as soon as ANY data exists (synchronous
localStorage hydration) instead of waiting for the server round-trip;
the background merge/refresh reconciles it afterwards. */}
{filteredData.length > 0 && (
{isDataLoaded && filteredData.length > 0 && (
<DocTree<SpaceTreeNode>
data={filteredData}
openIds={openIds}
@@ -15,7 +15,6 @@ import {
useCreatePageMutation,
useRemovePageMutation,
useMovePageMutation,
useUpdatePageMutation,
updateCacheOnMovePage,
} from "@/features/page/queries/page-query.ts";
import { buildPageUrl } from "@/features/page/page.utils.ts";
@@ -27,7 +26,6 @@ export type UseTreeMutation = {
parentId: string | null,
opts?: { temporary?: boolean },
) => Promise<void>;
handleRename: (id: string, name: 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.
const store = useStore();
const createPageMutation = useCreatePageMutation();
const updatePageMutation = useUpdatePageMutation();
const removePageMutation = useRemovePageMutation();
const movePageMutation = useMovePageMutation();
const navigate = useNavigate();
@@ -205,20 +202,6 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
[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(
async (id: string) => {
const node = treeModel.find(
@@ -264,7 +247,7 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
[removePageMutation, setData, store, pageSlug, navigate, spaceSlug],
);
return { handleMove, handleCreate, handleRename, handleDelete };
return { handleMove, handleCreate, handleDelete };
}
function isPageInNode(node: SpaceTreeNode, pageSlug: string): boolean {
@@ -8,7 +8,6 @@ import {
closeIds,
mergeRootTrees,
loadedOpenBranchIds,
pruneCollapsedChildren,
sortPositionKeys,
pageToTreeNode,
} from "./utils";
@@ -439,62 +438,3 @@ describe("loadedOpenBranchIds (#159 #8 reconnect refresh targets)", () => {
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;
}
/**
* 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
// collapseAll to clear the open-state map for all current-space nodes.
export function collectAllIds(nodes: SpaceTreeNode[]): string[] {
@@ -1,5 +1,6 @@
import {
keepPreviousData,
queryOptions,
useInfiniteQuery,
useMutation,
useQuery,
@@ -31,11 +32,37 @@ import { getRecentChanges } from "@/features/page/services/page-service.ts";
import { useEffect } from "react";
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(
params?: QueryParams,
): UseQueryResult<IPagination<ISpace>, Error> {
return useQuery({
queryKey: ["spaces", params],
queryKey: spaceKeys.list(params),
queryFn: () => getSpaces(params),
placeholderData: keepPreviousData,
refetchOnMount: true,
@@ -44,16 +71,16 @@ export function useGetSpacesQuery(
export function useSpaceQuery(spaceId: string): UseQueryResult<ISpace, Error> {
const query = useQuery({
queryKey: ["space", spaceId],
queryKey: spaceKeys.detail(spaceId),
queryFn: () => getSpaceById(spaceId),
enabled: !!spaceId,
});
useEffect(() => {
if (query.data) {
if (isValidUuid(spaceId)) {
queryClient.setQueryData(["space", query.data.slug], query.data);
queryClient.setQueryData(spaceKeys.detail(query.data.slug), query.data);
} else {
queryClient.setQueryData(["space", query.data.id], query.data);
queryClient.setQueryData(spaceKeys.detail(query.data.id), query.data);
}
}
}, [query.data]);
@@ -62,8 +89,11 @@ export function useSpaceQuery(spaceId: string): UseQueryResult<ISpace, Error> {
}
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({
queryKey: ["space", spaceSlug],
queryKey: spaceKeys.detail(spaceSlug),
queryFn: () => getSpaceById(spaceSlug),
});
@@ -100,10 +130,8 @@ export function useGetSpaceBySlugQuery(
spaceId: string,
): UseQueryResult<ISpace, Error> {
return useQuery({
queryKey: ["space", spaceId],
queryFn: () => getSpaceById(spaceId),
...spaceByIdQueryOptions(spaceId),
enabled: !!spaceId,
staleTime: 5 * 60 * 1000,
});
}
@@ -116,14 +144,16 @@ export function useUpdateSpaceMutation() {
onSuccess: (data, variables) => {
notifications.show({ message: t("Space updated successfully") });
const space = queryClient.getQueryData([
"space",
variables.spaceId,
]) as ISpace;
const space = queryClient.getQueryData(
spaceKeys.detail(variables.spaceId),
) as ISpace;
if (space) {
const updatedSpace = { ...space, ...data };
queryClient.setQueryData(["space", variables.spaceId], updatedSpace);
queryClient.setQueryData(["space", data.slug], updatedSpace);
queryClient.setQueryData(
spaceKeys.detail(variables.spaceId),
updatedSpace,
);
queryClient.setQueryData(spaceKeys.detail(data.slug), updatedSpace);
}
queryClient.invalidateQueries({
@@ -148,7 +178,7 @@ export function useDeleteSpaceMutation() {
if (variables.slug) {
queryClient.removeQueries({
queryKey: ["space", variables.slug],
queryKey: spaceKeys.detail(variables.slug),
exact: true,
});
}
@@ -156,7 +186,7 @@ export function useDeleteSpaceMutation() {
// Remove space-specific queries
if (variables.id) {
queryClient.removeQueries({
queryKey: ["space", variables.id],
queryKey: spaceKeys.detail(variables.id),
exact: true,
});
@@ -196,7 +226,7 @@ export function useSpaceMembersInfiniteQuery(
query?: string,
) {
return useInfiniteQuery({
queryKey: ["spaceMembers", spaceId, query],
queryKey: spaceKeys.members(spaceId, query),
queryFn: ({ pageParam }) =>
getSpaceMembers(spaceId, { cursor: pageParam, limit: 50, query }),
enabled: !!spaceId,
@@ -2,9 +2,19 @@ import { useQuery, UseQueryResult } from "@tanstack/react-query";
import { getMyInfo } from "@/features/user/services/user-service";
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> {
return useQuery({
queryKey: ["currentUser"],
queryKey: userKeys.currentUser(),
queryFn: async () => {
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 { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
import { Error404 } from "@/components/ui/error-404.tsx";
import { OfflineFallback } from "@/features/offline/offline-fallback.tsx";
import { queryClient } from "@/main.tsx";
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";
}, [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) {
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) {
return <></>;
return <OfflineFallback />;
}
return <>{children}</>;
-7
View File
@@ -1,7 +1,6 @@
import axios, { AxiosInstance } from "axios";
import APP_ROUTE from "@/lib/app-route.ts";
import { isCloud } from "@/lib/config.ts";
import { clearPersistedTreeCaches } from "@/features/page/tree/atoms/tree-data-atom";
const api: AxiosInstance = axios.create({
baseURL: "/api",
@@ -72,12 +71,6 @@ function redirectToLogin() {
"/invites",
];
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;
if (redirectTo === APP_ROUTE.HOME) {
window.location.href = APP_ROUTE.AUTH.LOGIN;
-107
View File
@@ -1,107 +0,0 @@
import { describe, it, expect } from "vitest";
import {
PALETTE,
avatarStyle,
avatarBackgroundCss,
normalizeName,
minPairwiseDistance,
relativeLuminance,
contrastRatio,
oklchToSrgb,
isInGamut,
} from "./avatar-palette";
/** Parse "#rrggbb" into sRGB components on the 0..1 scale relativeLuminance expects. */
function hexToRgb01(hex: string): [number, number, number] {
return [
parseInt(hex.slice(1, 3), 16) / 255,
parseInt(hex.slice(3, 5), 16) / 255,
parseInt(hex.slice(5, 7), 16) / 255,
];
}
describe("avatar-palette validation", () => {
it("palette colors stay distinguishable", () => {
// 0.06 in OKLab is ~4-5 JNDs — safely distinct at avatar size. If a future
// RINGS tweak drops this, "almost identical" colors would reappear.
expect(minPairwiseDistance().distance).toBeGreaterThanOrEqual(0.06);
expect(PALETTE.length).toBe(20);
});
it("every palette entry is WCAG-readable and in sRGB gamut", () => {
// white text = luminance 1, black text = luminance 0 (per buildPalette).
const textLum = { white: 1, black: 0 } as const;
for (const entry of PALETTE) {
expect(entry.hex).toMatch(/^#[0-9a-f]{6}$/);
// (a) The chosen text color really clears the code's 3:1 threshold on the
// actual background hex — recomputed independently from the hex, not from
// the build-time luminance. A slot that picked the wrong text (or a color
// too dim for either text) would fail here.
const hexLum = relativeLuminance(hexToRgb01(entry.hex));
const chosen = contrastRatio(textLum[entry.text], hexLum);
expect(chosen).toBeGreaterThanOrEqual(3);
// buildPalette prefers white and only falls back to black when white
// fails 3:1. Mirror that decision: black is used *only* when white would
// not clear the threshold — so a mis-assigned "black" on a dark color
// (where white was fine) fails here.
if (entry.text === "black") {
expect(contrastRatio(textLum.white, hexLum)).toBeLessThan(3);
}
// (b) The entry's OKLCH is inside the sRGB gamut after chroma clamping;
// an out-of-gamut slot (e.g. un-clamped chroma) would produce components
// outside [0,1] and fail here.
expect(isInGamut(oklchToSrgb(entry.L, entry.C, entry.h))).toBe(true);
}
});
});
describe("avatarStyle", () => {
it("name-to-avatar mapping is frozen (golden values)", () => {
// Golden slice: if this breaks, all existing avatars change — make sure
// that is intentional (a config change in avatar-palette.ts).
const s = avatarStyle("Backend Developer");
expect([s.bg, s.bg2, s.angleDeg]).toEqual(["#a55795", "#90355e", 150]);
expect(s.text).toBe("white");
});
it("is deterministic and normalizes the name", () => {
expect(avatarStyle("Researcher")).toEqual(avatarStyle("Researcher"));
// Casing, surrounding and repeated whitespace must not change the avatar.
expect(avatarStyle(" RESEARCHER ")).toEqual(avatarStyle("researcher"));
expect(avatarStyle("Backend Developer")).toEqual(
avatarStyle("backend developer"),
);
expect(normalizeName(" PM ")).toBe("pm");
});
it("returns a valid base color, angle and matching text", () => {
const s = avatarStyle("Нарратор");
const idx = PALETTE.findIndex((e) => e.hex === s.bg);
expect(idx).toBe(s.paletteIndex);
expect(idx).toBeGreaterThanOrEqual(0); // bg is a palette entry
// Text color comes from the chosen palette entry.
expect(s.text).toBe(PALETTE[idx].text);
// Split angle is one of the SPLIT_ANGLE_STEPS (24) directions → multiples of 15.
expect(s.angleDeg % 15).toBe(0);
expect(s.angleDeg).toBeGreaterThanOrEqual(0);
expect(s.angleDeg).toBeLessThan(360);
});
it("distinguishes the agents that used to collide as violet", () => {
// "Структурный редактор" and "Фактчекер" looked identically violet before.
expect(avatarStyle("Структурный редактор")).not.toEqual(
avatarStyle("Фактчекер"),
);
});
});
describe("avatarBackgroundCss", () => {
it("renders a two-stop gradient with a soft boundary", () => {
const s = avatarStyle("Backend Developer");
expect(avatarBackgroundCss(s)).toBe(
"linear-gradient(150deg, #a55795 42%, #90355e 58%)",
);
});
});
-267
View File
@@ -1,267 +0,0 @@
/**
* Deterministic avatar backgrounds for agent roles.
*
* The palette is generated from scratch at module load in OKLCH (a perceptually
* uniform color space), so every value below is tunable: change the ring
* configuration or the partner shifts and the whole palette regenerates.
*
* Pipeline: name -> normalize -> cyrb53 hash -> split into independent fields:
* - base color index (one of the validated palette colors)
* - partner hue shift: analogous 20..45deg (either side), complementary 180deg,
* or triadic +/-120deg classic color-wheel schemes; partner is also darker
* - split angle (SPLIT_ANGLE_STEPS directions, soft boundary)
* The same name always yields the same avatar, on any platform, forever.
*/
// ------------------------- Tunable configuration -------------------------
export interface RingConfig {
/** OKLCH lightness, 0..1 */
L: number;
/** OKLCH chroma target; clamped down per-hue to fit the sRGB gamut */
C: number;
/** Hue of the first color in the ring, degrees */
hueStart: number;
/** Number of evenly spaced hues in the ring */
count: number;
}
/**
* Two lightness rings. 12 light + 8 dark = 20 base colors with a validated
* min pairwise deltaE-OK of ~0.066 (clearly distinguishable at avatar size).
* Don't add more hues per ring without re-checking minPairwiseDistance():
* beyond ~20-24 colors humans stop telling them apart reliably.
*/
const RINGS: readonly RingConfig[] = [
{ L: 0.70, C: 0.14, hueStart: 15, count: 12 }, // light ring
{ L: 0.57, C: 0.13, hueStart: 20, count: 8 }, // darker ring
];
/** Partner color: lightness shifted by this much (negative = darker) */
const PARTNER_L_SHIFT = -0.10;
/** Analogous scheme: hue shift magnitude range, degrees (inclusive, 5-deg steps) */
const ANALOG_MIN_SHIFT = 20;
const ANALOG_SHIFT_STEP = 5;
const ANALOG_SHIFT_STEPS = 6; // 20, 25, 30, 35, 40, 45
/** Complementary scheme: fixed hue shift, degrees */
const COMPLEMENTARY_SHIFT = 180;
/** Triadic scheme: fixed hue shift magnitude, degrees (either side) */
const TRIADIC_SHIFT = 120;
/** Number of split directions (24 -> 15deg per step) */
const SPLIT_ANGLE_STEPS = 24;
/** Position of the color boundary, percent of the gradient axis */
const SPLIT_PERCENT = 50;
/** Width of the soft transition zone around the boundary, percent (0 = hard edge) */
const SPLIT_SOFTNESS = 16;
// ------------------------- OKLCH -> sRGB math -------------------------
// Matrices from Bjorn Ottosson's OKLab reference implementation.
function oklabToLinearSrgb(L: number, a: number, b: number): [number, number, number] {
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
const l = l_ ** 3, m = m_ ** 3, s = s_ ** 3;
return [
+4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
];
}
function gammaEncode(c: number): number {
return c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055;
}
export function oklchToSrgb(L: number, C: number, hDeg: number): [number, number, number] {
const h = (hDeg * Math.PI) / 180;
const [r, g, b] = oklabToLinearSrgb(L, C * Math.cos(h), C * Math.sin(h));
return [gammaEncode(r), gammaEncode(g), gammaEncode(b)];
}
export function isInGamut(rgb: readonly number[]): boolean {
return rgb.every((c) => c >= -1e-6 && c <= 1 + 1e-6);
}
/** Binary-search the max chroma <= C that fits into the sRGB gamut. */
function clampChroma(L: number, C: number, hDeg: number): number {
if (isInGamut(oklchToSrgb(L, C, hDeg))) return C;
let lo = 0, hi = C;
for (let i = 0; i < 40; i++) {
const mid = (lo + hi) / 2;
if (isInGamut(oklchToSrgb(L, mid, hDeg))) lo = mid;
else hi = mid;
}
return lo;
}
function toHex(rgb: readonly number[]): string {
return (
"#" +
rgb
.map((c) => Math.round(Math.min(1, Math.max(0, c)) * 255).toString(16).padStart(2, "0"))
.join("")
);
}
/** WCAG relative luminance of an sRGB color (components 0..1). */
export function relativeLuminance(rgb: readonly number[]): number {
const lin = rgb.map((c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4));
return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2];
}
export function contrastRatio(l1: number, l2: number): number {
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
// ------------------------- Palette generation -------------------------
export interface PaletteEntry {
/** Base background color */
hex: string;
/** OKLCH coordinates of the base color (used to derive partner colors) */
L: number;
C: number;
h: number;
/** Text/icon color with the best WCAG contrast on the base color */
text: "white" | "black";
/** OKLab coordinates of the base color (kept for validation) */
lab: readonly [number, number, number];
}
function buildPalette(): PaletteEntry[] {
const entries: PaletteEntry[] = [];
for (const ring of RINGS) {
const step = 360 / ring.count;
for (let i = 0; i < ring.count; i++) {
const h = (ring.hueStart + i * step) % 360;
const C = clampChroma(ring.L, ring.C, h);
const rgb = oklchToSrgb(ring.L, C, h);
const lum = relativeLuminance(rgb);
entries.push({
hex: toHex(rgb),
L: ring.L,
C,
h,
// White text needs >= 3:1 contrast; otherwise fall back to black.
text: contrastRatio(lum, 1) >= 3 ? "white" : "black",
lab: [
ring.L,
C * Math.cos((h * Math.PI) / 180),
C * Math.sin((h * Math.PI) / 180),
],
});
}
}
return entries;
}
/** Partner color for the split: base hue shifted by shiftDeg, darker by PARTNER_L_SHIFT. */
function partnerHex(entry: PaletteEntry, shiftDeg: number): string {
const h2 = (entry.h + shiftDeg + 360) % 360;
const L2 = entry.L + PARTNER_L_SHIFT;
return toHex(oklchToSrgb(L2, clampChroma(L2, entry.C, h2), h2));
}
/** Generated once at module load; regenerates on every build from the config above. */
export const PALETTE: readonly PaletteEntry[] = buildPalette();
// ------------------------- Name -> avatar style -------------------------
/** Normalize so that "PM ", "pm" and "Pm" map to the same avatar. */
export function normalizeName(name: string): string {
return name.normalize("NFC").trim().toLowerCase().replace(/\s+/g, " ");
}
/**
* cyrb53: deterministic 53-bit string hash with good avalanche.
* Pure JS, cross-platform never use language built-in hashing here.
*/
function cyrb53(str: string, seed = 0): number {
let h1 = 0xdeadbeef ^ seed;
let h2 = 0x41c6ce57 ^ seed;
for (let i = 0; i < str.length; i++) {
const ch = str.charCodeAt(i);
h1 = Math.imul(h1 ^ ch, 2654435761);
h2 = Math.imul(h2 ^ ch, 1597334677);
}
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
return 4294967296 * (2097151 & h2) + (h1 >>> 0);
}
export interface AvatarStyle {
/** Index of the base color in PALETTE */
paletteIndex: number;
/** Base color hex */
bg: string;
/** Second color hex (split partner) */
bg2: string;
/** Signed hue shift of the partner, degrees (e.g. -35, +45, 180, -120) */
hueShift: number;
/** Direction of the split, degrees */
angleDeg: number;
/** Text/icon color for the base color */
text: "white" | "black";
}
/** Pure function: the same (normalized) name always returns the same style. */
export function avatarStyle(agentName: string): AvatarStyle {
const h = cyrb53(normalizeName(agentName));
// Slice the hash into independent fields, like digits of a number:
const paletteIndex = h % PALETTE.length;
let rest = Math.floor(h / PALETTE.length);
const angleDeg = (rest % SPLIT_ANGLE_STEPS) * (360 / SPLIT_ANGLE_STEPS);
rest = Math.floor(rest / SPLIT_ANGLE_STEPS);
// Scheme: 0,1 -> analogous (minus/plus); 2 -> complementary; 3 -> triadic
const scheme = rest % 4;
rest = Math.floor(rest / 4);
let hueShift: number;
if (scheme === 2) {
hueShift = COMPLEMENTARY_SHIFT;
} else if (scheme === 3) {
hueShift = rest % 2 ? TRIADIC_SHIFT : -TRIADIC_SHIFT;
} else {
const magnitude = ANALOG_MIN_SHIFT + (rest % ANALOG_SHIFT_STEPS) * ANALOG_SHIFT_STEP;
hueShift = scheme === 0 ? -magnitude : magnitude;
}
const entry = PALETTE[paletteIndex];
return {
paletteIndex,
bg: entry.hex,
bg2: partnerHex(entry, hueShift),
hueShift,
angleDeg,
text: entry.text,
};
}
/** CSS background value: two colors with a slightly blurred boundary. */
export function avatarBackgroundCss(style: AvatarStyle): string {
const from = SPLIT_PERCENT - SPLIT_SOFTNESS / 2;
const to = SPLIT_PERCENT + SPLIT_SOFTNESS / 2;
return `linear-gradient(${style.angleDeg}deg, ${style.bg} ${from}%, ${style.bg2} ${to}%)`;
}
// ------------------------- Validation -------------------------
/**
* Min pairwise deltaE-OK (euclidean distance in OKLab) between base colors.
* Re-check after tweaking RINGS: keep it >= ~0.06 so no two palette colors
* look alike. Intended for a unit test or a dev-time assertion.
*/
export function minPairwiseDistance(): { distance: number; pair: [string, string] } {
let min = Infinity;
let pair: [string, string] = ["", ""];
for (let i = 0; i < PALETTE.length; i++) {
for (let j = i + 1; j < PALETTE.length; j++) {
const a = PALETTE[i].lab, b = PALETTE[j].lab;
const d = Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
if (d < min) {
min = d;
pair = [PALETTE[i].hex, PALETTE[j].hex];
}
}
}
return { distance: min, pair };
}
+60 -3
View File
@@ -11,7 +11,8 @@ import { MantineProvider } from "@mantine/core";
import { BrowserRouter } from "react-router-dom";
import { ModalsProvider } from "@mantine/modals";
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 "./i18n";
import { PostHogProvider } from "posthog-js/react";
@@ -21,6 +22,13 @@ import {
isCloud,
isPostHogEnabled,
} 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";
export const queryClient = new QueryClient({
@@ -30,10 +38,30 @@ export const queryClient = new QueryClient({
refetchOnWindowFocus: false,
retry: false,
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) {
posthog.init(getPostHogKey(), {
api_host: getPostHogHost(),
@@ -50,15 +78,44 @@ root.render(
<BrowserRouter>
<MantineProvider theme={theme} cssVariablesResolver={mantineCssResolver}>
<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} />
{/* Skip SW registration inside the Capacitor native WebView the
native shell serves assets itself; a browser SW would conflict. */}
{!isCapacitorNativePlatform() && <PwaUpdatePrompt />}
<HelmetProvider>
<PostHogProvider client={posthog}>
<App />
</PostHogProvider>
</HelmetProvider>
</QueryClientProvider>
</PersistQueryClientProvider>
</ModalsProvider>
</MantineProvider>
</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.
+14 -1
View File
@@ -9,6 +9,7 @@ import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts"
import { useTranslation } from "react-i18next";
import React from "react";
import { EmptyState } from "@/components/ui/empty-state.tsx";
import { OfflineFallback } from "@/features/offline/offline-fallback.tsx";
import { IconAlertTriangle, IconFileOff } from "@tabler/icons-react";
import { Button } from "@mantine/core";
import { Link } from "react-router-dom";
@@ -62,7 +63,19 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
}
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 (
<EmptyState
icon={IconFileOff}

Some files were not shown because too many files have changed in this diff Show More