Date: 2025-10-31 Branch: support/use-jotai
support/use-jotai ブランチで useLatestRevision が機能していなかった問題を解決し、リビジョン管理の状態管理を大幅に改善しました。
IPageInfoForEntity.latestRevisionId を導入useSWRxIsLatestRevision を SWR ベースで実装(Jotai atom から脱却)remoteRevisionIdAtom を完全削除(状態管理の簡素化)useIsRevisionOutdated の意味論を改善(「意図的な過去閲覧」を考慮)useRevisionIdFromUrl で URL パラメータ取得を一元化IPageInfoForEntity に latestRevisionId を追加ファイル: packages/core/src/interfaces/page.ts
export type IPageInfoForEntity = Omit<IPageInfo, 'isNotFound' | 'isEmpty'> & {
// ... existing fields
latestRevisionId?: string; // ✅ 追加
};
ファイル: apps/app/src/server/service/page/index.ts:2605
const infoForEntity: Omit<IPageInfoForEntity, 'bookmarkCount'> = {
// ... existing fields
latestRevisionId: page.revision != null ? getIdStringForRef(page.revision) : undefined,
};
データフロー: SSR で constructBasicPageInfo が自動的に latestRevisionId を設定 → useSWRxPageInfo で参照
useSWRxIsLatestRevision を SWR ベースで実装ファイル: stores/page.tsx:164-191
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 で「古いリビジョン」を検出
remoteRevisionIdAtom の完全削除削除理由:
useSWRxPageInfo.data.latestRevisionId で代替可能重要: RemoteRevisionData.remoteRevisionId は型定義に残した
→ コンフリクト解決時に「どのリビジョンに対して保存するか」の情報として必要
useIsRevisionOutdated の意味論的改善改善前: 単純に「現在のリビジョン ≠ 最新リビジョン」を判定
問題: URL ?revisionId=xxx で意図的に過去を見ている場合も true を返していた
改善後: 「ユーザーが意図的に過去リビジョンを見ているか」を考慮
ファイル: states/context.ts:82-100
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
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 | 「意図的な過去閲覧」 |
remoteRevisionIdAtom - 完全削除(useSWRxPageInfo.data.latestRevisionId で代替)remoteRevisionBodyAtom - ConflictDiffModal で使用remoteRevisionLastUpdateUserAtom - ConflictDiffModal, PageStatusAlert で使用remoteRevisionLastUpdatedAtAtom - ConflictDiffModal で使用useSetRemoteLatestPageData の役割定義: states/page/use-set-remote-latest-page-data.ts
export type RemoteRevisionData = {
remoteRevisionId: string; // 型には含むが atom には保存しない
remoteRevisionBody: string;
remoteRevisionLastUpdateUser?: IUserHasId;
remoteRevisionLastUpdatedAt: Date;
};
export const useSetRemoteLatestPageData = (): SetRemoteLatestPageData => {
// remoteRevisionBodyAtom, remoteRevisionLastUpdateUserAtom, remoteRevisionLastUpdatedAtAtom を更新
// remoteRevisionId は atom に保存しない(コンフリクト解決時のパラメータとしてのみ使用)
};
使用箇所(6箇所):
page-updated.ts - Socket.io でページ更新受信時
// 他のユーザーがページを更新したときに最新リビジョン情報を保存
setRemoteLatestPageData({
remoteRevisionId: s2cMessagePageUpdated.revisionId,
remoteRevisionBody: s2cMessagePageUpdated.revisionBody,
remoteRevisionLastUpdateUser: s2cMessagePageUpdated.remoteLastUpdateUser,
remoteRevisionLastUpdatedAt: s2cMessagePageUpdated.revisionUpdateAt,
});
page-operation.ts - 自分がページ保存した後(useUpdateStateAfterSave)
// 自分が保存した後の最新リビジョン情報を保存
setRemoteLatestPageData({
remoteRevisionId: updatedPage.revision._id,
remoteRevisionBody: updatedPage.revision.body,
remoteRevisionLastUpdateUser: updatedPage.lastUpdateUser,
remoteRevisionLastUpdatedAt: updatedPage.updatedAt,
});
conflict.tsx - コンフリクト解決時(useConflictResolver)
// コンフリクト発生時にリモートリビジョン情報を保存
setRemoteLatestPageData(remoteRevidsionData);
drawio-modal-launcher-for-view.ts - Drawio 編集でコンフリクト発生時
handsontable-modal-launcher-for-view.ts - Handsontable 編集でコンフリクト発生時
定義ファイル自体
┌─────────────────────────────────────────────────────┐
│ Socket.io / 保存処理 / コンフリクト │
└─────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────┐
│ useSetRemoteLatestPageData │
│ ├─ remoteRevisionBodyAtom ← body │
│ ├─ remoteRevisionLastUpdateUserAtom ← user │
│ └─ remoteRevisionLastUpdatedAtAtom ← date │
│ (remoteRevisionId は保存しない) │
└─────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────┐
│ 使用箇所 │
│ ├─ ConflictDiffModal: body, user, date を表示 │
│ └─ PageStatusAlert: user を表示 │
└─────────────────────────────────────────────────────┘
PageInfo (latestRevisionId) との同期がない:
remoteRevision* atom は更新されるuseSWRxPageInfo.data.latestRevisionId は更新されないuseSWRxIsLatestRevision() と useIsRevisionOutdated() がリアルタイム更新を検知できない用途が限定的:
useIsRevisionOutdated() で十分データの二重管理:
useSWRxPageInfo.data.latestRevisionId で管理問題:
pageWithMeta.meta (IPageInfoForEntity) が取得されているが、useSWRxPageInfo のキャッシュに入っていない実装方針:
// [[...path]]/index.page.tsx または適切な場所
const { mutate: mutatePageInfo } = useSWRxPageInfo(pageId, shareLinkId);
useEffect(() => {
if (pageWithMeta?.meta) {
mutatePageInfo(pageWithMeta.meta, { revalidate: false });
}
}, [pageWithMeta?.meta, mutatePageInfo]);
Note:
useSWRxPageInfo は既に initialData パラメータを持っているが、呼び出し側で渡していないmutatePageInfo は bound mutate(hook から返されるもの)を使う問題:
[[...path]] ルート内での遷移(例: /pageA → /pageB)時に PageInfo が更新されないuseFetchCurrentPage が新しいページを取得しても PageInfo は古いまま実装方針:
// 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 が取得できないため、再フェッチが必要問題:
useSWRxPageInfo のキャッシュが更新されないlatestRevisionId が古いままになるuseSWRxIsLatestRevision() と useIsRevisionOutdated() が正しく動作しない実装方針:
// 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:
revalidate: false で再フェッチを抑制(optimistic update のみ)Bound mutate (推奨):
const { data, mutate } = useSWRxPageInfo(pageId, shareLinkId);
mutate(newData, options); // 自動的に key に紐付いている
グローバル mutate:
import { mutate } from 'swr';
mutate(['/page/info', pageId, shareLinkId, isGuestUser], newData, options);
optimistic update のオプション:
{ revalidate: false } - 再フェッチせず、キャッシュのみ更新mutate() (引数なし) - 再フェッチmutate(updater, options) - updater 関数で部分更新ファイル: src/client/components/PageStatusAlert.tsx
現状: 独自に isRevisionOutdated を計算している
提案: useIsRevisionOutdated() を使用
initLatestRevisionField の役割ドキュメント化┌─────────────────────┐
│ latestRevisionAtom │ ← atom(true) でハードコード(機能せず)
└─────────────────────┘
┌─────────────────────┐
│ remoteRevisionIdAtom│ ← 複数の用途で混在(Socket.io更新 + 最新リビジョン保持)
└─────────────────────┘
┌──────────────────────────────┐
│ useSWRxPageInfo │
│ └─ data.latestRevisionId │ ← SSR で自動設定、SWR でキャッシュ管理
└──────────────────────────────┘
↓
┌──────────────────────────────┐
│ useSWRxIsLatestRevision() │ ← SWR ベース、汎用的な状態確認
└──────────────────────────────┘
↓
┌──────────────────────────────┐
│ useIsRevisionOutdated() │ ← 「再fetch推奨」のメッセージ性
│ + useIsViewingSpecificRevision│ ← URL パラメータを考慮
└──────────────────────────────┘
useIsRevisionOutdated が「再fetch推奨」を正確に表現useRevisionIdFromUrl に集約IPageInfoForEntity で厳密に型付け