Przeglądaj źródła

Merge pull request #10459 from growilabs/imprv/latest-revision-data-handling

imprv: Latest revision data handling
Yuki Takei 5 miesięcy temu
rodzic
commit
b5073f8a91

+ 440 - 0
.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<IPageInfo, 'isNotFound' | 'isEmpty'> & {
+  // ... existing fields
+  latestRevisionId?: string;  // ✅ 追加
+};
+```
+
+**ファイル**: `apps/app/src/server/service/page/index.ts:2605`
+
+```typescript
+const infoForEntity: Omit<IPageInfoForEntity, 'bookmarkCount'> = {
+  // ... 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<boolean, Error> => {
+  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` で厳密に型付け

+ 4 - 3
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 (
     <LazyRenderer shouldRender={isEditable === true && editorMode === EditorMode.Editor}>
-      { isLatestRevision
+      { isLatestRevision !== false
         ? <PageEditor />
         : <PageEditorReadOnly />
       }

+ 2 - 3
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<boolean>(false);
 

+ 5 - 3
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 <></>;
   }
 

+ 3 - 6
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 <></>;
   }

+ 1 - 1
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';

+ 1 - 1
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';
 

+ 8 - 1
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(() => {

+ 5 - 7
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 <></>;
   }

+ 15 - 0
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: 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: 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;

+ 23 - 0
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]);
+};

+ 3 - 0
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;

+ 10 - 14
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
  */

+ 3 - 10
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],
 

+ 4 - 1
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';

+ 9 - 19
apps/app/src/states/page/internal-atoms.ts

@@ -13,11 +13,13 @@ export const currentPageDataAtom = atom<IPagePopulatedToShowRevision>();
 export const pageNotFoundAtom = atom(false);
 export const isIdenticalPathAtom = atom<boolean>(false);
 export const isForbiddenAtom = atom<boolean>(false);
-export const latestRevisionAtom = atom(true);
 
 // ShareLink page state atoms (internal)
 export const shareLinkIdAtom = atom<string>();
 
+// URL query parameter atoms (internal)
+export const revisionIdFromUrlAtom = atom<string | undefined>(undefined);
+
 // Fetch state atoms (internal)
 export const pageLoadingAtom = atom(false);
 export const pageErrorAtom = atom<Error | null>(null);
@@ -62,7 +64,6 @@ export const isUntitledPageAtom = atom(
 );
 
 // Remote revision data atoms
-export const remoteRevisionIdAtom = atom<string>();
 export const remoteRevisionBodyAtom = atom<string>();
 export const remoteRevisionLastUpdateUserAtom = atom<IUserHasId>();
 export const remoteRevisionLastUpdatedAtAtom = atom<Date>();
@@ -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;

+ 19 - 3
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<NextRouter>();
@@ -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<typeof vi.fn>).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<typeof useSWRxPageInfo>);
+
     // 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();
     });
   });

+ 19 - 11
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<string, string>; 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],
     ),
   );
 

+ 2 - 4
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,
     ],
   );
 };

+ 70 - 2
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<boolean, Error> => {
+  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<IRevision>,

+ 1 - 0
packages/core/src/interfaces/page.ts

@@ -110,6 +110,7 @@ export type IPageInfoForEntity = Omit<IPageInfo, 'isNotFound' | 'isEmpty'> & {
   contentAge: number;
   descendantCount: number;
   commentCount: number;
+  latestRevisionId: Ref<IRevision>;
 };
 
 export type IPageInfoForOperation = IPageInfoForEntity & {