# 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(currentPageEntityIdAtom, newData._id); set(currentPageEmptyIdAtom, undefined); // ✅ 追加: 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` で厳密に型付け