diff --git a/.serena/memories/page-state-hooks-useLatestRevision-degradation.md b/.serena/memories/page-state-hooks-useLatestRevision-degradation.md new file mode 100644 index 00000000000..97bf5abe8a1 --- /dev/null +++ b/.serena/memories/page-state-hooks-useLatestRevision-degradation.md @@ -0,0 +1,440 @@ +# Page State Hooks - useLatestRevision リファクタリング記録 + +**Date**: 2025-10-31 +**Branch**: support/use-jotai + +## 🎯 実施内容のサマリー + +`support/use-jotai` ブランチで `useLatestRevision` が機能していなかった問題を解決し、リビジョン管理の状態管理を大幅に改善しました。 + +### 主な成果 + +1. ✅ `IPageInfoForEntity.latestRevisionId` を導入 +2. ✅ `useSWRxIsLatestRevision` を SWR ベースで実装(Jotai atom から脱却) +3. ✅ `remoteRevisionIdAtom` を完全削除(状態管理の簡素化) +4. ✅ `useIsRevisionOutdated` の意味論を改善(「意図的な過去閲覧」を考慮) +5. ✅ `useRevisionIdFromUrl` で URL パラメータ取得を一元化 + +--- + +## 📋 実装の要点 + +### 1. `IPageInfoForEntity` に `latestRevisionId` を追加 + +**ファイル**: `packages/core/src/interfaces/page.ts` + +```typescript +export type IPageInfoForEntity = Omit & { + // ... existing fields + latestRevisionId?: string; // ✅ 追加 +}; +``` + +**ファイル**: `apps/app/src/server/service/page/index.ts:2605` + +```typescript +const infoForEntity: Omit = { + // ... existing fields + latestRevisionId: page.revision != null ? getIdStringForRef(page.revision) : undefined, +}; +``` + +**データフロー**: SSR で `constructBasicPageInfo` が自動的に `latestRevisionId` を設定 → `useSWRxPageInfo` で参照 + +--- + +### 2. `useSWRxIsLatestRevision` を SWR ベースで実装 + +**ファイル**: `stores/page.tsx:164-191` + +```typescript +export const useSWRxIsLatestRevision = (): SWRResponse => { + const currentPage = useCurrentPageData(); + const pageId = currentPage?._id; + const shareLinkId = useShareLinkId(); + const { data: pageInfo } = useSWRxPageInfo(pageId, shareLinkId); + + const latestRevisionId = pageInfo && 'latestRevisionId' in pageInfo + ? pageInfo.latestRevisionId + : undefined; + + const key = useMemo(() => { + if (currentPage?.revision?._id == null) { + return null; + } + return ['isLatestRevision', currentPage.revision._id, latestRevisionId ?? null]; + }, [currentPage?.revision?._id, latestRevisionId]); + + return useSWRImmutable( + key, + ([, currentRevisionId, latestRevisionId]) => { + if (latestRevisionId == null) { + return true; // Assume latest if not available + } + return latestRevisionId === currentRevisionId; + }, + ); +}; +``` + +**使用箇所**: OldRevisionAlert, DisplaySwitcher, PageEditorReadOnly + +**判定**: `.data !== false` で「古いリビジョン」を検出 + +--- + +### 3. `remoteRevisionIdAtom` の完全削除 + +**削除理由**: +- `useSWRxPageInfo.data.latestRevisionId` で代替可能 +- 「Socket.io 更新検知」と「最新リビジョン保持」の用途が混在していた +- 状態管理が複雑化していた + +**重要**: `RemoteRevisionData.remoteRevisionId` は型定義に残した +→ コンフリクト解決時に「どのリビジョンに対して保存するか」の情報として必要 + +--- + +### 4. `useIsRevisionOutdated` の意味論的改善 + +**改善前**: 単純に「現在のリビジョン ≠ 最新リビジョン」を判定 +**問題**: URL `?revisionId=xxx` で意図的に過去を見ている場合も `true` を返していた + +**改善後**: 「ユーザーが意図的に過去リビジョンを見ているか」を考慮 + +**ファイル**: `states/context.ts:82-100` + +```typescript +export const useRevisionIdFromUrl = (): string | undefined => { + const router = useRouter(); + const revisionId = router.query.revisionId; + return typeof revisionId === 'string' ? revisionId : undefined; +}; + +export const useIsViewingSpecificRevision = (): boolean => { + const revisionId = useRevisionIdFromUrl(); + return revisionId != null; +}; +``` + +**ファイル**: `stores/page.tsx:193-219` + +```typescript +export const useIsRevisionOutdated = (): boolean => { + const { data: isLatestRevision } = useSWRxIsLatestRevision(); + const isViewingSpecificRevision = useIsViewingSpecificRevision(); + + // If user intentionally views a specific revision, don't show "outdated" alert + if (isViewingSpecificRevision) { + return false; + } + + if (isLatestRevision == null) { + return false; + } + + // User expects latest, but it's not latest = outdated + return !isLatestRevision; +}; +``` + +--- + +## 🎭 動作例 + +| 状況 | isLatestRevision | isViewingSpecificRevision | isRevisionOutdated | 意味 | +|------|------------------|---------------------------|---------------------|------| +| 最新を表示中 | true | false | false | 正常 | +| Socket.io更新を受信 | false | false | **true** | 「再fetchせよ」 | +| URL `?revisionId=old` で過去を閲覧 | false | true | false | 「意図的な過去閲覧」 | + +--- + +## 🔄 現状の remoteRevision 系 atom と useSetRemoteLatestPageData + +### 削除済み +- ✅ `remoteRevisionIdAtom` - 完全削除(`useSWRxPageInfo.data.latestRevisionId` で代替) + +### 残存している atom(未整理) +- ⚠️ `remoteRevisionBodyAtom` - ConflictDiffModal で使用 +- ⚠️ `remoteRevisionLastUpdateUserAtom` - ConflictDiffModal, PageStatusAlert で使用 +- ⚠️ `remoteRevisionLastUpdatedAtAtom` - ConflictDiffModal で使用 + +### `useSetRemoteLatestPageData` の役割 + +**定義**: `states/page/use-set-remote-latest-page-data.ts` + +```typescript +export type RemoteRevisionData = { + remoteRevisionId: string; // 型には含むが atom には保存しない + remoteRevisionBody: string; + remoteRevisionLastUpdateUser?: IUserHasId; + remoteRevisionLastUpdatedAt: Date; +}; + +export const useSetRemoteLatestPageData = (): SetRemoteLatestPageData => { + // remoteRevisionBodyAtom, remoteRevisionLastUpdateUserAtom, remoteRevisionLastUpdatedAtAtom を更新 + // remoteRevisionId は atom に保存しない(コンフリクト解決時のパラメータとしてのみ使用) +}; +``` + +**使用箇所**(6箇所): + +1. **`page-updated.ts`** - Socket.io でページ更新受信時 + ```typescript + // 他のユーザーがページを更新したときに最新リビジョン情報を保存 + setRemoteLatestPageData({ + remoteRevisionId: s2cMessagePageUpdated.revisionId, + remoteRevisionBody: s2cMessagePageUpdated.revisionBody, + remoteRevisionLastUpdateUser: s2cMessagePageUpdated.remoteLastUpdateUser, + remoteRevisionLastUpdatedAt: s2cMessagePageUpdated.revisionUpdateAt, + }); + ``` + +2. **`page-operation.ts`** - 自分がページ保存した後(`useUpdateStateAfterSave`) + ```typescript + // 自分が保存した後の最新リビジョン情報を保存 + setRemoteLatestPageData({ + remoteRevisionId: updatedPage.revision._id, + remoteRevisionBody: updatedPage.revision.body, + remoteRevisionLastUpdateUser: updatedPage.lastUpdateUser, + remoteRevisionLastUpdatedAt: updatedPage.updatedAt, + }); + ``` + +3. **`conflict.tsx`** - コンフリクト解決時(`useConflictResolver`) + ```typescript + // コンフリクト発生時にリモートリビジョン情報を保存 + setRemoteLatestPageData(remoteRevidsionData); + ``` + +4. **`drawio-modal-launcher-for-view.ts`** - Drawio 編集でコンフリクト発生時 +5. **`handsontable-modal-launcher-for-view.ts`** - Handsontable 編集でコンフリクト発生時 +6. **定義ファイル自体** + +### 現在のデータフロー + +``` +┌─────────────────────────────────────────────────────┐ +│ Socket.io / 保存処理 / コンフリクト │ +└─────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────┐ +│ useSetRemoteLatestPageData │ +│ ├─ remoteRevisionBodyAtom ← body │ +│ ├─ remoteRevisionLastUpdateUserAtom ← user │ +│ └─ remoteRevisionLastUpdatedAtAtom ← date │ +│ (remoteRevisionId は保存しない) │ +└─────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────┐ +│ 使用箇所 │ +│ ├─ ConflictDiffModal: body, user, date を表示 │ +│ └─ PageStatusAlert: user を表示 │ +└─────────────────────────────────────────────────────┘ +``` + +### 問題点 + +1. **PageInfo (latestRevisionId) との同期がない**: + - Socket.io 更新時に `remoteRevision*` atom は更新される + - しかし `useSWRxPageInfo.data.latestRevisionId` は更新されない + - → `useSWRxIsLatestRevision()` と `useIsRevisionOutdated()` がリアルタイム更新を検知できない + +2. **用途が限定的**: + - 主に ConflictDiffModal でリモートリビジョンの詳細を表示するために使用 + - PageStatusAlert でも使用しているが、本来は `useIsRevisionOutdated()` で十分 + +3. **データの二重管理**: + - リビジョン ID: `useSWRxPageInfo.data.latestRevisionId` で管理 + - リビジョン詳細 (body, user, date): atom で管理 + - 一貫性のないデータ管理 + +--- + +## 🎯 次に取り組むべきタスク + +### PageInfo (useSWRxPageInfo) の mutate が必要な3つのタイミング + +#### 1. 🔴 SSR時の optimistic update + +**問題**: +- SSR で `pageWithMeta.meta` (IPageInfoForEntity) が取得されているが、`useSWRxPageInfo` のキャッシュに入っていない +- クライアント初回レンダリング時に PageInfo が未取得状態になる + +**実装方針**: +```typescript +// [[...path]]/index.page.tsx または適切な場所 +const { mutate: mutatePageInfo } = useSWRxPageInfo(pageId, shareLinkId); + +useEffect(() => { + if (pageWithMeta?.meta) { + mutatePageInfo(pageWithMeta.meta, { revalidate: false }); + } +}, [pageWithMeta?.meta, mutatePageInfo]); +``` + +**Note**: +- Jotai の hydrate とは別レイヤー(Jotai は atom、これは SWR のキャッシュ) +- `useSWRxPageInfo` は既に `initialData` パラメータを持っているが、呼び出し側で渡していない +- **重要**: `mutatePageInfo` は bound mutate(hook から返されるもの)を使う + +--- + +#### 2. 🔴 same route 遷移時の mutate + +**問題**: +- `[[...path]]` ルート内での遷移(例: `/pageA` → `/pageB`)時に PageInfo が更新されない +- `useFetchCurrentPage` が新しいページを取得しても PageInfo は古いまま + +**実装方針**: +```typescript +// states/page/use-fetch-current-page.ts +export const useFetchCurrentPage = () => { + const shareLinkId = useAtomValue(shareLinkIdAtom); + const revisionIdFromUrl = useRevisionIdFromUrl(); + + // ✅ 追加: PageInfo の mutate 関数を取得 + const { mutate: mutatePageInfo } = useSWRxPageInfo(currentPageId, shareLinkId); + + const fetchCurrentPage = useAtomCallback( + useCallback(async (get, set, args) => { + // ... 既存のフェッチ処理 ... + + const { data } = await apiv3Get('/page', params); + const { page: newData } = data; + + set(currentPageDataAtom, newData); + set(currentPageIdAtom, newData._id); + + // ✅ 追加: PageInfo を再フェッチ + mutatePageInfo(); // 引数なし = revalidate (再フェッチ) + + return newData; + }, [shareLinkId, revisionIdFromUrl, mutatePageInfo]) + ); +}; +``` + +**Note**: +- `mutatePageInfo()` を引数なしで呼ぶと SWR が再フェッチする +- `/page` API からは meta が取得できないため、再フェッチが必要 + +--- + +#### 3. 🔴 Socket.io 更新時の mutate + +**問題**: +- Socket.io で他のユーザーがページを更新したとき、`useSWRxPageInfo` のキャッシュが更新されない +- `latestRevisionId` が古いままになる +- **重要**: `useSWRxIsLatestRevision()` と `useIsRevisionOutdated()` が正しく動作しない + +**実装方針**: +```typescript +// client/services/side-effects/page-updated.ts +const { mutate: mutatePageInfo } = useSWRxPageInfo(currentPage?._id, shareLinkId); + +const remotePageDataUpdateHandler = useCallback((data) => { + const { s2cMessagePageUpdated } = data; + + // 既存: remoteRevision atom を更新 + setRemoteLatestPageData(remoteData); + + // ✅ 追加: PageInfo の latestRevisionId を optimistic update + if (currentPage?._id != null) { + mutatePageInfo((currentPageInfo) => { + if (currentPageInfo && 'latestRevisionId' in currentPageInfo) { + return { + ...currentPageInfo, + latestRevisionId: s2cMessagePageUpdated.revisionId, + }; + } + return currentPageInfo; + }, { revalidate: false }); + } +}, [currentPage?._id, mutatePageInfo, setRemoteLatestPageData]); +``` + +**Note**: +- 引数に updater 関数を渡して既存データを部分更新 +- `revalidate: false` で再フェッチを抑制(optimistic update のみ) + +--- + +### SWR の mutate の仕組み + +**Bound mutate** (推奨): +```typescript +const { data, mutate } = useSWRxPageInfo(pageId, shareLinkId); +mutate(newData, options); // 自動的に key に紐付いている +``` + +**グローバル mutate**: +```typescript +import { mutate } from 'swr'; +mutate(['/page/info', pageId, shareLinkId, isGuestUser], newData, options); +``` + +**optimistic update のオプション**: +- `{ revalidate: false }` - 再フェッチせず、キャッシュのみ更新 +- `mutate()` (引数なし) - 再フェッチ +- `mutate(updater, options)` - updater 関数で部分更新 + +--- + +### 🟡 優先度 中: PageStatusAlert の重複ロジック削除 + +**ファイル**: `src/client/components/PageStatusAlert.tsx` + +**現状**: 独自に `isRevisionOutdated` を計算している +**提案**: `useIsRevisionOutdated()` を使用 + +--- + +### 🟢 優先度 低 + +- テストコードの更新 +- `initLatestRevisionField` の役割ドキュメント化 + +--- + +## 📊 アーキテクチャの改善 + +### Before (問題のある状態) + +``` +┌─────────────────────┐ +│ latestRevisionAtom │ ← atom(true) でハードコード(機能せず) +└─────────────────────┘ +┌─────────────────────┐ +│ remoteRevisionIdAtom│ ← 複数の用途で混在(Socket.io更新 + 最新リビジョン保持) +└─────────────────────┘ +``` + +### After (改善後) + +``` +┌──────────────────────────────┐ +│ useSWRxPageInfo │ +│ └─ data.latestRevisionId │ ← SSR で自動設定、SWR でキャッシュ管理 +└──────────────────────────────┘ + ↓ +┌──────────────────────────────┐ +│ useSWRxIsLatestRevision() │ ← SWR ベース、汎用的な状態確認 +└──────────────────────────────┘ + ↓ +┌──────────────────────────────┐ +│ useIsRevisionOutdated() │ ← 「再fetch推奨」のメッセージ性 +│ + useIsViewingSpecificRevision│ ← URL パラメータを考慮 +└──────────────────────────────┘ +``` + +--- + +## ✅ メリット + +1. **状態管理の簡素化**: Jotai atom を削減、SWR の既存インフラを活用 +2. **データフローの明確化**: SSR → SWR → hooks という一貫した流れ +3. **意味論の改善**: `useIsRevisionOutdated` が「再fetch推奨」を正確に表現 +4. **保守性の向上**: URL パラメータ取得を `useRevisionIdFromUrl` に集約 +5. **型安全性**: `IPageInfoForEntity` で厳密に型付け diff --git a/apps/app/src/client/components/Page/DisplaySwitcher.tsx b/apps/app/src/client/components/Page/DisplaySwitcher.tsx index 95dc8cbb961..5e5102f01c0 100644 --- a/apps/app/src/client/components/Page/DisplaySwitcher.tsx +++ b/apps/app/src/client/components/Page/DisplaySwitcher.tsx @@ -3,8 +3,9 @@ import type { JSX } from 'react'; import dynamic from 'next/dynamic'; import { useHashChangedEffect } from '~/client/services/side-effects/hash-changed'; -import { useIsEditable, useLatestRevision } from '~/states/page'; +import { useIsEditable } from '~/states/page'; import { EditorMode, useEditorMode, useReservedNextCaretLine } from '~/states/ui/editor'; +import { useSWRxIsLatestRevision } from '~/stores/page'; import { LazyRenderer } from '../Common/LazyRenderer'; @@ -17,14 +18,14 @@ export const DisplaySwitcher = (): JSX.Element => { const { editorMode } = useEditorMode(); const isEditable = useIsEditable(); - const isLatestRevision = useLatestRevision(); + const { data: isLatestRevision } = useSWRxIsLatestRevision(); useHashChangedEffect(); useReservedNextCaretLine(); return ( - { isLatestRevision + { isLatestRevision !== false ? : } diff --git a/apps/app/src/client/components/PageEditor/ConflictDiffModal/ConflictDiffModal.tsx b/apps/app/src/client/components/PageEditor/ConflictDiffModal/ConflictDiffModal.tsx index d11e1adc1db..05142aae759 100644 --- a/apps/app/src/client/components/PageEditor/ConflictDiffModal/ConflictDiffModal.tsx +++ b/apps/app/src/client/components/PageEditor/ConflictDiffModal/ConflictDiffModal.tsx @@ -14,11 +14,11 @@ import { Modal, ModalHeader, ModalBody, ModalFooter, } from 'reactstrap'; + import { useCurrentUser } from '~/states/global'; import { useCurrentPageData, useRemoteRevisionBody, - useRemoteRevisionId, useRemoteRevisionLastUpdatedAt, useRemoteRevisionLastUpdateUser, } from '~/states/page'; @@ -206,12 +206,11 @@ export const ConflictDiffModal = (): React.JSX.Element => { const conflictDiffModalStatus = useConflictDiffModalStatus(); // state for latest page - const remoteRevisionId = useRemoteRevisionId(); const remoteRevisionBody = useRemoteRevisionBody(); const remoteRevisionLastUpdateUser = useRemoteRevisionLastUpdateUser(); const remoteRevisionLastUpdatedAt = useRemoteRevisionLastUpdatedAt(); - const isRemotePageDataInappropriate = remoteRevisionId == null || remoteRevisionBody == null || remoteRevisionLastUpdateUser == null; + const isRemotePageDataInappropriate = remoteRevisionBody == null || remoteRevisionLastUpdateUser == null; const [isModalExpanded, setIsModalExpanded] = useState(false); diff --git a/apps/app/src/client/components/PageEditor/PageEditorReadOnly.tsx b/apps/app/src/client/components/PageEditor/PageEditorReadOnly.tsx index 5f5b5610bc2..9a025e15ed7 100644 --- a/apps/app/src/client/components/PageEditor/PageEditorReadOnly.tsx +++ b/apps/app/src/client/components/PageEditor/PageEditorReadOnly.tsx @@ -5,7 +5,8 @@ import { CodeMirrorEditorReadOnly } from '@growi/editor/dist/client/components/C import { throttle } from 'throttle-debounce'; import { useShouldExpandContent } from '~/services/layout/use-should-expand-content'; -import { useCurrentPageData, useLatestRevision } from '~/states/page'; +import { useCurrentPageData } from '~/states/page'; +import { useSWRxIsLatestRevision } from '~/stores/page'; import { usePreviewOptions } from '~/stores/renderer'; import { EditorNavbar } from './EditorNavbar'; @@ -21,7 +22,7 @@ export const PageEditorReadOnly = react.memo(({ visibility }: Props): JSX.Elemen const currentPage = useCurrentPageData(); const { data: rendererOptions } = usePreviewOptions(); - const isLatestRevision = useLatestRevision(); + const { data: isLatestRevision } = useSWRxIsLatestRevision(); const shouldExpandContent = useShouldExpandContent(currentPage); const { scrollEditorHandler, scrollPreviewHandler } = useScrollSync(GlobalCodeMirrorEditorKey.READONLY, previewRef); @@ -30,7 +31,8 @@ export const PageEditorReadOnly = react.memo(({ visibility }: Props): JSX.Elemen const revisionBody = currentPage?.revision?.body; - if (rendererOptions == null || isLatestRevision) { + // Show read-only editor only when viewing an old revision + if (rendererOptions == null || isLatestRevision !== false) { return <>; } diff --git a/apps/app/src/client/components/PageStatusAlert.tsx b/apps/app/src/client/components/PageStatusAlert.tsx index ef764337048..56c14740c47 100644 --- a/apps/app/src/client/components/PageStatusAlert.tsx +++ b/apps/app/src/client/components/PageStatusAlert.tsx @@ -3,9 +3,10 @@ import React, { useCallback, type JSX } from 'react'; import { useTranslation } from 'next-i18next'; import { useIsGuestUser, useIsReadOnlyUser } from '~/states/context'; -import { useCurrentPageData, useRemoteRevisionId, useRemoteRevisionLastUpdateUser } from '~/states/page'; +import { useRemoteRevisionLastUpdateUser } from '~/states/page'; import { useEditorMode } from '~/states/ui/editor'; import { usePageStatusAlertStatus } from '~/states/ui/modal/page-status-alert'; +import { useIsRevisionOutdated } from '~/stores/page'; import { Username } from '../../components/User/Username'; @@ -18,9 +19,8 @@ export const PageStatusAlert = (): JSX.Element => { const isGuestUser = useIsGuestUser(); const isReadOnlyUser = useIsReadOnlyUser(); const pageStatusAlertData = usePageStatusAlertStatus(); - const remoteRevisionId = useRemoteRevisionId(); + const isRevisionOutdated = useIsRevisionOutdated(); const remoteRevisionLastUpdateUser = useRemoteRevisionLastUpdateUser(); - const pageData = useCurrentPageData(); const onClickRefreshPage = useCallback(() => { pageStatusAlertData?.onRefleshPage?.(); @@ -33,9 +33,6 @@ export const PageStatusAlert = (): JSX.Element => { const hasResolveConflictHandler = pageStatusAlertData?.onResolveConflict != null; const hasRefreshPageHandler = pageStatusAlertData?.onRefleshPage != null; - const currentRevisionId = pageData?.revision?._id; - const isRevisionOutdated = (currentRevisionId != null || remoteRevisionId != null) && currentRevisionId !== remoteRevisionId; - if (!pageStatusAlertData?.isOpen || !!isGuestUser || !!isReadOnlyUser || !isRevisionOutdated) { return <>; } diff --git a/apps/app/src/client/components/ReactMarkdownComponents/DrawioViewerWithEditButton.tsx b/apps/app/src/client/components/ReactMarkdownComponents/DrawioViewerWithEditButton.tsx index b83af949a99..cbda2bbe4c1 100644 --- a/apps/app/src/client/components/ReactMarkdownComponents/DrawioViewerWithEditButton.tsx +++ b/apps/app/src/client/components/ReactMarkdownComponents/DrawioViewerWithEditButton.tsx @@ -10,8 +10,8 @@ import { useTranslation } from 'next-i18next'; import { useCurrentPageYjsData } from '~/features/collaborative-editor/states'; import { useIsGuestUser, useIsReadOnlyUser, useIsSharedUser } from '~/states/context'; -import { useIsRevisionOutdated } from '~/states/page'; import { useShareLinkId } from '~/states/page/hooks'; +import { useIsRevisionOutdated } from '~/stores/page'; import '@growi/remark-drawio/dist/style.css'; import styles from './DrawioViewerWithEditButton.module.scss'; diff --git a/apps/app/src/client/components/ReactMarkdownComponents/TableWithEditButton.tsx b/apps/app/src/client/components/ReactMarkdownComponents/TableWithEditButton.tsx index 23445219e0f..ea044eeea65 100644 --- a/apps/app/src/client/components/ReactMarkdownComponents/TableWithEditButton.tsx +++ b/apps/app/src/client/components/ReactMarkdownComponents/TableWithEditButton.tsx @@ -6,8 +6,8 @@ import type { Element } from 'hast'; import type { LaunchHandsonTableModalEventDetail } from '~/client/interfaces/handsontable-modal'; import { useCurrentPageYjsData } from '~/features/collaborative-editor/states'; import { useIsGuestUser, useIsReadOnlyUser, useIsSharedUser } from '~/states/context'; -import { useIsRevisionOutdated } from '~/states/page'; import { useShareLinkId } from '~/states/page/hooks'; +import { useIsRevisionOutdated } from '~/stores/page'; import styles from './TableWithEditButton.module.scss'; diff --git a/apps/app/src/client/services/side-effects/page-updated.ts b/apps/app/src/client/services/side-effects/page-updated.ts index 125950091be..b152f920954 100644 --- a/apps/app/src/client/services/side-effects/page-updated.ts +++ b/apps/app/src/client/services/side-effects/page-updated.ts @@ -6,6 +6,7 @@ import type { RemoteRevisionData } from '~/states/page'; import { useGlobalSocket } from '~/states/socket-io'; import { useEditorMode, EditorMode } from '~/states/ui/editor'; import { usePageStatusAlertActions } from '~/states/ui/modal/page-status-alert'; +import { useSWRxPageInfo } from '~/stores/page'; export const usePageUpdatedEffect = (): void => { @@ -18,6 +19,8 @@ export const usePageUpdatedEffect = (): void => { const { fetchCurrentPage } = useFetchCurrentPage(); const { open: openPageStatusAlert, close: closePageStatusAlert } = usePageStatusAlertActions(); + const { mutate: mutatePageInfo } = useSWRxPageInfo(currentPage?._id); + const remotePageDataUpdateHandler = useCallback((data) => { // Set remote page data const { s2cMessagePageUpdated } = data; @@ -32,6 +35,9 @@ export const usePageUpdatedEffect = (): void => { if (currentPage?._id != null && currentPage._id === s2cMessagePageUpdated.pageId) { setRemoteLatestPageData(remoteData); + // Update PageInfo cache + mutatePageInfo(); + // Open PageStatusAlert const currentRevisionId = currentPage?.revision?._id; const remoteRevisionId = s2cMessagePageUpdated.revisionId; @@ -47,7 +53,8 @@ export const usePageUpdatedEffect = (): void => { closePageStatusAlert(); } } - }, [currentPage?._id, currentPage?.revision?._id, editorMode, fetchCurrentPage, openPageStatusAlert, closePageStatusAlert, setRemoteLatestPageData]); + // eslint-disable-next-line max-len + }, [currentPage?._id, currentPage?.revision?._id, setRemoteLatestPageData, mutatePageInfo, editorMode, openPageStatusAlert, fetchCurrentPage, closePageStatusAlert]); // listen socket for someone updating this page useEffect(() => { diff --git a/apps/app/src/components/PageView/PageAlerts/OldRevisionAlert.tsx b/apps/app/src/components/PageView/PageAlerts/OldRevisionAlert.tsx index 6047efed1d9..29903c23fb4 100644 --- a/apps/app/src/components/PageView/PageAlerts/OldRevisionAlert.tsx +++ b/apps/app/src/components/PageView/PageAlerts/OldRevisionAlert.tsx @@ -3,17 +3,14 @@ import { useRouter } from 'next/router'; import { returnPathForURL } from '@growi/core/dist/utils/path-utils'; import { useTranslation } from 'react-i18next'; -import { - useCurrentPageData, - useFetchCurrentPage, - useLatestRevision, -} from '~/states/page'; +import { useCurrentPageData, useFetchCurrentPage } from '~/states/page'; +import { useSWRxIsLatestRevision } from '~/stores/page'; export const OldRevisionAlert = (): JSX.Element => { const router = useRouter(); const { t } = useTranslation(); - const isOldRevisionPage = useLatestRevision(); + const { data: isLatestRevision } = useSWRxIsLatestRevision(); const page = useCurrentPageData(); const { fetchCurrentPage } = useFetchCurrentPage(); @@ -27,7 +24,8 @@ export const OldRevisionAlert = (): JSX.Element => { fetchCurrentPage({ force: true }); }, [fetchCurrentPage, page, router]); - if (page == null || isOldRevisionPage) { + // Show alert only when viewing an old revision (isLatestRevision === false) + if (isLatestRevision !== false) { // biome-ignore lint/complexity/noUselessFragments: ignore return <>; } diff --git a/apps/app/src/pages/[[...path]]/index.page.tsx b/apps/app/src/pages/[[...path]]/index.page.tsx index ac36934ed21..1aa793aede0 100644 --- a/apps/app/src/pages/[[...path]]/index.page.tsx +++ b/apps/app/src/pages/[[...path]]/index.page.tsx @@ -4,6 +4,8 @@ import { useEffect } from 'react'; import type { GetServerSideProps, GetServerSidePropsContext } from 'next'; import dynamic from 'next/dynamic'; import Head from 'next/head'; +import EventEmitter from 'node:events'; +import { isIPageInfo } from '@growi/core'; import { isClient } from '@growi/core/dist/utils'; // biome-ignore-start lint/style/noRestrictedImports: no-problem lazy loaded components @@ -29,6 +31,7 @@ import { useSetupGlobalSocketForPage, } from '~/states/socket-io'; import { useSetEditingMarkdown } from '~/states/ui/editor'; +import { useSWRxPageInfo } from '~/stores/page'; import type { NextPageWithLayout } from '../_app.page'; import { useHydrateBasicLayoutConfigurationAtoms } from '../basic-layout-page/hydrate'; @@ -50,6 +53,7 @@ import { import type { EachProps, InitialProps } from './types'; import { useSameRouteNavigation } from './use-same-route-navigation'; import { useShallowRouting } from './use-shallow-routing'; +import { useSyncRevisionIdFromUrl } from './use-sync-revision-id-from-url'; // call superjson custom register registerPageToShowRevisionWithMeta(); @@ -117,6 +121,9 @@ const Page: NextPageWithLayout = (props: Props) => { const rendererConfig = useRendererConfig(); const setEditingMarkdown = useSetEditingMarkdown(); + // Sync URL query parameter to atom + useSyncRevisionIdFromUrl(); + // setup socket.io useSetupGlobalSocket(); useSetupGlobalSocketForPage(); @@ -135,6 +142,14 @@ const Page: NextPageWithLayout = (props: Props) => { } }, [currentPagePath, currentPage?.revision?.body, setEditingMarkdown]); + // Optimistically update PageInfo SWR cache with SSR data + const { mutate: mutatePageInfo } = useSWRxPageInfo(currentPage?._id); + useEffect(() => { + if (isInitialProps(props) && pageMeta != null && isIPageInfo(pageMeta)) { + mutatePageInfo(pageMeta, { revalidate: false }); + } + }, [pageMeta, mutatePageInfo, props]); + // If the data on the page changes without router.push, pageWithMeta remains old because getServerSideProps() is not executed // So preferentially take page data from useSWRxCurrentPage const pagePath = currentPagePath ?? props.currentPathname; diff --git a/apps/app/src/pages/[[...path]]/use-sync-revision-id-from-url.ts b/apps/app/src/pages/[[...path]]/use-sync-revision-id-from-url.ts new file mode 100644 index 00000000000..b4bf0f8879c --- /dev/null +++ b/apps/app/src/pages/[[...path]]/use-sync-revision-id-from-url.ts @@ -0,0 +1,23 @@ +import { useEffect } from 'react'; +import { useRouter } from 'next/router'; +import { useSetAtom } from 'jotai'; + +import { _atomsForSyncRevisionIdFromUrl } from '~/states/page'; + +const { revisionIdFromUrlAtom } = _atomsForSyncRevisionIdFromUrl; + +/** + * Sync URL query parameter (revisionId) to Jotai atom + * This hook should be called in the main page component to keep the atom in sync with the URL + */ +export const useSyncRevisionIdFromUrl = (): void => { + const router = useRouter(); + const setRevisionIdFromUrl = useSetAtom(revisionIdFromUrlAtom); + + useEffect(() => { + const revisionId = router.query.revisionId; + setRevisionIdFromUrl( + typeof revisionId === 'string' ? revisionId : undefined, + ); + }, [router.query.revisionId, setRevisionIdFromUrl]); +}; diff --git a/apps/app/src/server/service/page/index.ts b/apps/app/src/server/service/page/index.ts index 4dcdeac5b8f..cbd18348603 100644 --- a/apps/app/src/server/service/page/index.ts +++ b/apps/app/src/server/service/page/index.ts @@ -2602,6 +2602,9 @@ class PageService implements IPageService { contentAge: page.getContentAge(), descendantCount: page.descendantCount, commentCount: page.commentCount, + // the page must have a revision if it is not empty + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + latestRevisionId: getIdStringForRef(page.revision!), }; return infoForEntity; diff --git a/apps/app/src/states/page/hooks.ts b/apps/app/src/states/page/hooks.ts index c77e6aaf958..ca362e89515 100644 --- a/apps/app/src/states/page/hooks.ts +++ b/apps/app/src/states/page/hooks.ts @@ -14,16 +14,14 @@ import { currentPagePathAtom, isForbiddenAtom, isIdenticalPathAtom, - isRevisionOutdatedAtom, isTrashPageAtom, isUntitledPageAtom, - latestRevisionAtom, pageNotFoundAtom, redirectFromAtom, remoteRevisionBodyAtom, - remoteRevisionIdAtom, remoteRevisionLastUpdatedAtAtom, remoteRevisionLastUpdateUserAtom, + revisionIdFromUrlAtom, shareLinkIdAtom, templateBodyAtom, templateTagsAtom, @@ -45,17 +43,22 @@ export const useIsIdenticalPath = () => useAtomValue(isIdenticalPathAtom); export const useIsForbidden = () => useAtomValue(isForbiddenAtom); -export const useLatestRevision = () => useAtomValue(latestRevisionAtom); - export const useShareLinkId = () => useAtomValue(shareLinkIdAtom); export const useTemplateTags = () => useAtomValue(templateTagsAtom); export const useTemplateBody = () => useAtomValue(templateBodyAtom); -// Remote revision hooks (replacements for stores/remote-latest-page.ts) -export const useRemoteRevisionId = () => useAtomValue(remoteRevisionIdAtom); +/** + * Hook to get revisionId from URL query parameters + * Returns undefined if revisionId is not present in the URL + * + * This hook reads from the revisionIdFromUrlAtom which should be updated + * by the page component when router.query.revisionId changes + */ +export const useRevisionIdFromUrl = () => useAtomValue(revisionIdFromUrlAtom); +// Remote revision hooks (replacements for stores/remote-latest-page.ts) export const useRemoteRevisionBody = () => useAtomValue(remoteRevisionBodyAtom); export const useRemoteRevisionLastUpdateUser = () => @@ -91,13 +94,6 @@ export const useCurrentPagePath = (): string | undefined => { */ export const useIsTrashPage = (): boolean => useAtomValue(isTrashPageAtom); -/** - * Check if current revision is outdated - * Pure Jotai replacement for stores/page.tsx useIsRevisionOutdated - */ -export const useIsRevisionOutdated = (): boolean => - useAtomValue(isRevisionOutdatedAtom); - /** * Computed hook for checking if current page is creatable */ diff --git a/apps/app/src/states/page/hydrate.ts b/apps/app/src/states/page/hydrate.ts index 2a1071d3ad8..d7ed4da52b0 100644 --- a/apps/app/src/states/page/hydrate.ts +++ b/apps/app/src/states/page/hydrate.ts @@ -11,11 +11,9 @@ import { currentPageDataAtom, currentPageIdAtom, isForbiddenAtom, - latestRevisionAtom, pageNotFoundAtom, redirectFromAtom, remoteRevisionBodyAtom, - remoteRevisionIdAtom, shareLinkIdAtom, templateBodyAtom, templateTagsAtom, @@ -30,8 +28,8 @@ import { * * Data sources: * - page._id, page.revision -> Auto-extracted from IPagePopulatedToShowRevision - * - remoteRevisionId, remoteRevisionBody -> Auto-extracted from page.revision - * - templateTags, templateBody, isLatestRevision -> Explicitly provided via options + * - remoteRevisionBody -> Auto-extracted from page.revision + * - templateTags, templateBody -> Explicitly provided via options * * @example * // Basic usage @@ -39,7 +37,6 @@ import { * * // With template data and custom flags * useHydratePageAtoms(pageWithMeta?.data, { - * isLatestRevision: false, * templateTags: ['tag1', 'tag2'], * templateBody: 'Template content' * }); @@ -49,7 +46,6 @@ export const useHydratePageAtoms = ( pageMeta: IPageNotFoundInfo | IPageInfo | undefined, options?: { // always overwrited - isLatestRevision?: boolean; shareLinkId?: string; redirectFrom?: string; templateTags?: string[]; @@ -71,16 +67,13 @@ export const useHydratePageAtoms = ( isIPageNotFoundInfo(pageMeta) ? pageMeta.isForbidden : false, ], - // Remote revision data - auto-extracted from page.revision - [remoteRevisionIdAtom, page?.revision?._id], + // Remote revision data - used by ConflictDiffModal [remoteRevisionBodyAtom, page?.revision?.body], ]); // always overwrited useHydrateAtoms( [ - [latestRevisionAtom, options?.isLatestRevision ?? true], - // ShareLink page state [shareLinkIdAtom, options?.shareLinkId], diff --git a/apps/app/src/states/page/index.ts b/apps/app/src/states/page/index.ts index 31b7f966d58..c246ad178f0 100644 --- a/apps/app/src/states/page/index.ts +++ b/apps/app/src/states/page/index.ts @@ -6,7 +6,10 @@ */ export * from './hooks'; -export { _atomsForDerivedAbilities } from './internal-atoms'; +export { + _atomsForDerivedAbilities, + _atomsForSyncRevisionIdFromUrl, +} from './internal-atoms'; export { useCurrentPageLoading } from './use-current-page-loading'; // Data fetching hooks export { useFetchCurrentPage } from './use-fetch-current-page'; diff --git a/apps/app/src/states/page/internal-atoms.ts b/apps/app/src/states/page/internal-atoms.ts index 7f56c15b9c0..2f8bb6fc5d3 100644 --- a/apps/app/src/states/page/internal-atoms.ts +++ b/apps/app/src/states/page/internal-atoms.ts @@ -13,11 +13,13 @@ export const currentPageDataAtom = atom(); export const pageNotFoundAtom = atom(false); export const isIdenticalPathAtom = atom(false); export const isForbiddenAtom = atom(false); -export const latestRevisionAtom = atom(true); // ShareLink page state atoms (internal) export const shareLinkIdAtom = atom(); +// URL query parameter atoms (internal) +export const revisionIdFromUrlAtom = atom(undefined); + // Fetch state atoms (internal) export const pageLoadingAtom = atom(false); export const pageErrorAtom = atom(null); @@ -62,7 +64,6 @@ export const isUntitledPageAtom = atom( ); // Remote revision data atoms -export const remoteRevisionIdAtom = atom(); export const remoteRevisionBodyAtom = atom(); export const remoteRevisionLastUpdateUserAtom = atom(); export const remoteRevisionLastUpdatedAtAtom = atom(); @@ -73,21 +74,10 @@ export const isTrashPageAtom = atom((get) => { return pagePath != null ? pagePathUtils.isTrashPage(pagePath) : false; }); -export const isRevisionOutdatedAtom = atom((get) => { - const currentRevisionId = get(currentRevisionIdAtom); - const remoteRevisionId = get(remoteRevisionIdAtom); - - if (currentRevisionId == null || remoteRevisionId == null) { - return false; - } - - return remoteRevisionId !== currentRevisionId; -}); - // Update atoms for template and remote revision data export const setTemplateContentAtom = atom( null, - (get, set, data: { tags?: string[]; body?: string }) => { + (_get, set, data: { tags?: string[]; body?: string }) => { if (data.tags !== undefined) { set(templateTagsAtom, data.tags); } @@ -100,18 +90,14 @@ export const setTemplateContentAtom = atom( export const setRemoteRevisionDataAtom = atom( null, ( - get, + _get, set, data: { - id?: string; body?: string; lastUpdateUser?: IUserHasId; lastUpdatedAt?: Date; }, ) => { - if (data.id !== undefined) { - set(remoteRevisionIdAtom, data.id); - } if (data.body !== undefined) { set(remoteRevisionBodyAtom, data.body); } @@ -141,3 +127,7 @@ export const _atomsForDerivedAbilities = { currentPageIdAtom, isTrashPageAtom, } as const; + +export const _atomsForSyncRevisionIdFromUrl = { + revisionIdFromUrlAtom, +} as const; diff --git a/apps/app/src/states/page/use-fetch-current-page.spec.tsx b/apps/app/src/states/page/use-fetch-current-page.spec.tsx index 0efab815674..91a385144f0 100644 --- a/apps/app/src/states/page/use-fetch-current-page.spec.tsx +++ b/apps/app/src/states/page/use-fetch-current-page.spec.tsx @@ -25,8 +25,8 @@ import { pageLoadingAtom, pageNotFoundAtom, remoteRevisionBodyAtom, - remoteRevisionIdAtom, } from '~/states/page/internal-atoms'; +import { useSWRxPageInfo } from '~/stores/page'; // Mock Next.js router const mockRouter = mockDeep(); @@ -38,6 +38,13 @@ vi.mock('next/router', () => ({ vi.mock('~/client/util/apiv3-client'); const mockedApiv3Get = vi.spyOn(apiv3Client, 'apiv3Get'); +// Mock useSWRxPageInfo +vi.mock('~/stores/page', () => ({ + useSWRxPageInfo: vi.fn(), +})); +const mockedUseSWRxPageInfo = vi.mocked(useSWRxPageInfo); +const mockMutatePageInfo = vi.fn(); + const mockUser: IUserHasId = { _id: 'user1', name: 'Test User', @@ -134,6 +141,16 @@ describe('useFetchCurrentPage - Integration Test', () => { mockRouter.pathname = '/[[...path]]'; (useRouter as ReturnType).mockReturnValue(mockRouter); + // Mock useSWRxPageInfo to return a mutate function + mockMutatePageInfo.mockClear(); + mockedUseSWRxPageInfo.mockReturnValue({ + mutate: mockMutatePageInfo, + data: undefined, + error: undefined, + isLoading: false, + isValidating: false, + } as ReturnType); + // Default API response const defaultPageData = createPageDataMock( 'defaultPageId', @@ -736,10 +753,10 @@ describe('useFetchCurrentPage - Integration Test', () => { ); store.set(currentPageIdAtom, existingPage._id); store.set(currentPageDataAtom, existingPage); - store.set(remoteRevisionIdAtom, 'rev_xxx'); store.set(remoteRevisionBodyAtom, 'remote body'); // Mock API rejection with ErrorV3 like object + // Note: error.args must have isNotFound property for isIPageNotFoundInfo check const notFoundError = { code: 'not_found', message: 'Page not found', @@ -760,7 +777,6 @@ describe('useFetchCurrentPage - Integration Test', () => { }); expect(store.get(currentPageDataAtom)).toBeUndefined(); expect(store.get(currentPageIdAtom)).toBeUndefined(); - expect(store.get(remoteRevisionIdAtom)).toBeUndefined(); expect(store.get(remoteRevisionBodyAtom)).toBeUndefined(); }); }); diff --git a/apps/app/src/states/page/use-fetch-current-page.ts b/apps/app/src/states/page/use-fetch-current-page.ts index d57c2b321cb..62afb772f5d 100644 --- a/apps/app/src/states/page/use-fetch-current-page.ts +++ b/apps/app/src/states/page/use-fetch-current-page.ts @@ -11,6 +11,7 @@ import { useAtomValue } from 'jotai'; import { useAtomCallback } from 'jotai/utils'; import { apiv3Get } from '~/client/util/apiv3-client'; +import { useSWRxPageInfo } from '~/stores/page'; import loggerFactory from '~/utils/logger'; import { @@ -21,7 +22,7 @@ import { pageLoadingAtom, pageNotFoundAtom, remoteRevisionBodyAtom, - remoteRevisionIdAtom, + revisionIdFromUrlAtom, shareLinkIdAtom, } from './internal-atoms'; @@ -105,6 +106,7 @@ type BuildApiParamsArgs = { decodedPathname: string | undefined; currentPageId: string | undefined; shareLinkId: string | undefined; + revisionIdFromUrl: string | undefined; }; type ApiParams = { params: Record; shouldSkip: boolean }; @@ -116,21 +118,17 @@ const buildApiParams = ({ decodedPathname, currentPageId, shareLinkId, + revisionIdFromUrl, }: BuildApiParamsArgs): ApiParams => { - const revisionId = - fetchPageArgs?.revisionId ?? - (isClient() - ? new URLSearchParams(window.location.search).get('revisionId') - : undefined); + // Priority: explicit arg > URL query parameter + const revisionId = fetchPageArgs?.revisionId ?? revisionIdFromUrl; const params: { path?: string; pageId?: string; revisionId?: string; shareLinkId?: string; - } = { - revisionId: fetchPageArgs?.revisionId, - }; + } = {}; if (shareLinkId != null) { params.shareLinkId = shareLinkId; @@ -179,10 +177,17 @@ export const useFetchCurrentPage = (): { error: Error | null; } => { const shareLinkId = useAtomValue(shareLinkIdAtom); + const revisionIdFromUrl = useAtomValue(revisionIdFromUrlAtom); + const currentPageId = useAtomValue(currentPageIdAtom); const isLoading = useAtomValue(pageLoadingAtom); const error = useAtomValue(pageErrorAtom); + const { mutate: mutatePageInfo } = useSWRxPageInfo( + currentPageId, + shareLinkId, + ); + const fetchCurrentPage = useAtomCallback( useCallback( async ( @@ -217,6 +222,7 @@ export const useFetchCurrentPage = (): { decodedPathname, currentPageId, shareLinkId, + revisionIdFromUrl, }); if (shouldSkip) { @@ -235,6 +241,9 @@ export const useFetchCurrentPage = (): { set(pageNotFoundAtom, false); set(isForbiddenAtom, false); + // Mutate PageInfo to refetch latest metadata including latestRevisionId + mutatePageInfo(); + return newData; } catch (err) { if (!Array.isArray(err) || err.length === 0) { @@ -252,7 +261,6 @@ export const useFetchCurrentPage = (): { set(isForbiddenAtom, error.args.isForbidden ?? false); set(currentPageDataAtom, undefined); set(currentPageIdAtom, undefined); - set(remoteRevisionIdAtom, undefined); set(remoteRevisionBodyAtom, undefined); } } @@ -262,7 +270,7 @@ export const useFetchCurrentPage = (): { return null; }, - [shareLinkId], + [shareLinkId, revisionIdFromUrl, mutatePageInfo], ), ); diff --git a/apps/app/src/states/page/use-set-remote-latest-page-data.ts b/apps/app/src/states/page/use-set-remote-latest-page-data.ts index c0ce8b6b120..95b2dbd81d8 100644 --- a/apps/app/src/states/page/use-set-remote-latest-page-data.ts +++ b/apps/app/src/states/page/use-set-remote-latest-page-data.ts @@ -4,7 +4,6 @@ import { useSetAtom } from 'jotai/react'; import { remoteRevisionBodyAtom, - remoteRevisionIdAtom, remoteRevisionLastUpdatedAtAtom, remoteRevisionLastUpdateUserAtom, } from './internal-atoms'; @@ -22,7 +21,6 @@ type SetRemoteLatestPageData = (pageData: RemoteRevisionData) => void; * Set remote data all at once */ export const useSetRemoteLatestPageData = (): SetRemoteLatestPageData => { - const setRemoteRevisionId = useSetAtom(remoteRevisionIdAtom); const setRemoteRevisionBody = useSetAtom(remoteRevisionBodyAtom); const setRemoteRevisionLastUpdateUser = useSetAtom( remoteRevisionLastUpdateUserAtom, @@ -33,7 +31,8 @@ export const useSetRemoteLatestPageData = (): SetRemoteLatestPageData => { return useCallback( (remoteRevisionData: RemoteRevisionData) => { - setRemoteRevisionId(remoteRevisionData.remoteRevisionId); + // Note: remoteRevisionId is part of the type for conflict resolution + // but not stored in atom (we use useSWRxPageInfo.data.latestRevisionId instead) setRemoteRevisionBody(remoteRevisionData.remoteRevisionBody); setRemoteRevisionLastUpdateUser( remoteRevisionData.remoteRevisionLastUpdateUser, @@ -46,7 +45,6 @@ export const useSetRemoteLatestPageData = (): SetRemoteLatestPageData => { setRemoteRevisionLastUpdateUser, setRemoteRevisionLastUpdatedAt, setRemoteRevisionBody, - setRemoteRevisionId, ], ); }; diff --git a/apps/app/src/stores/page.tsx b/apps/app/src/stores/page.tsx index 55c5085c45d..c2147d50d35 100644 --- a/apps/app/src/stores/page.tsx +++ b/apps/app/src/stores/page.tsx @@ -27,8 +27,8 @@ import type { IResCurrentGrantData, } from '~/interfaces/page-grant'; import { useIsGuestUser, useIsReadOnlyUser } from '~/states/context'; -import { usePageNotFound } from '~/states/page'; -import { useShareLinkId } from '~/states/page/hooks'; +import { useCurrentPageData, usePageNotFound } from '~/states/page'; +import { useRevisionIdFromUrl, useShareLinkId } from '~/states/page/hooks'; import type { IPageTagsInfo } from '../interfaces/tag'; @@ -154,6 +154,74 @@ export const useSWRMUTxPageInfo = ( ); }; +/** + * Hook to check if the current page is displaying the latest revision + * Returns SWRResponse with boolean value: + * - data: undefined - not yet determined (no currentPage data) + * - data: true - viewing the latest revision (or latestRevisionId not available) + * - data: false - viewing an old revision + */ +export const useSWRxIsLatestRevision = (): SWRResponse => { + const currentPage = useCurrentPageData(); + const pageId = currentPage?._id; + const shareLinkId = useShareLinkId(); + const { data: pageInfo } = useSWRxPageInfo(pageId, shareLinkId); + + // Extract latestRevisionId if available (only exists in IPageInfoForEntity) + const latestRevisionId = + pageInfo && 'latestRevisionId' in pageInfo + ? pageInfo.latestRevisionId + : undefined; + + const key = useMemo(() => { + // Cannot determine without currentPage + if (currentPage?.revision?._id == null) { + return null; + } + return [ + 'isLatestRevision', + currentPage.revision._id, + latestRevisionId ?? null, + ]; + }, [currentPage?.revision?._id, latestRevisionId]); + + return useSWRImmutable(key, ([, currentRevisionId, latestRevisionId]) => { + // If latestRevisionId is not available, assume it's the latest + if (latestRevisionId == null) { + return true; + } + return latestRevisionId === currentRevisionId; + }); +}; + +/** + * Check if current revision is outdated and user should be notified to refetch + * + * Returns true when: + * - User is NOT intentionally viewing a specific (old) revision (no ?revisionId in URL) + * - AND the current page data is not the latest revision + * + * This indicates "new data is available, please refetch" rather than + * "you are viewing an old revision" (which is handled by useSWRxIsLatestRevision) + */ +export const useIsRevisionOutdated = (): boolean => { + const { data: isLatestRevision } = useSWRxIsLatestRevision(); + const revisionIdFromUrl = useRevisionIdFromUrl(); + + // If user intentionally views a specific revision, don't show "outdated" alert + if (revisionIdFromUrl != null) { + return false; + } + + // If we can't determine yet, assume not outdated + if (isLatestRevision == null) { + return false; + } + + // User expects latest, but it's not latest = outdated + return !isLatestRevision; +}; + export const useSWRxPageRevision = ( pageId: string, revisionId: Ref, diff --git a/packages/core/src/interfaces/page.ts b/packages/core/src/interfaces/page.ts index 9dd079296bd..27d59226ecd 100644 --- a/packages/core/src/interfaces/page.ts +++ b/packages/core/src/interfaces/page.ts @@ -110,6 +110,7 @@ export type IPageInfoForEntity = Omit & { contentAge: number; descendantCount: number; commentCount: number; + latestRevisionId: Ref; }; export type IPageInfoForOperation = IPageInfoForEntity & {