浏览代码

refactor: sync URL revisionId to atom

Yuki Takei 5 月之前
父节点
当前提交
15e9f9c78b

+ 7 - 7
.serena/memories/page-state-hooks-useLatestRevision-degradation.md

@@ -10,7 +10,7 @@
 ### 主な成果
 
 1. ✅ `IPageInfoForEntity.latestRevisionId` を導入
-2. ✅ `useIsLatestRevision` を SWR ベースで実装(Jotai atom から脱却)
+2. ✅ `useSWRxIsLatestRevision` を SWR ベースで実装(Jotai atom から脱却)
 3. ✅ `remoteRevisionIdAtom` を完全削除(状態管理の簡素化)
 4. ✅ `useIsRevisionOutdated` の意味論を改善(「意図的な過去閲覧」を考慮)
 5. ✅ `useRevisionIdFromUrl` で URL パラメータ取得を一元化
@@ -43,12 +43,12 @@ const infoForEntity: Omit<IPageInfoForEntity, 'bookmarkCount'> = {
 
 ---
 
-### 2. `useIsLatestRevision` を SWR ベースで実装
+### 2. `useSWRxIsLatestRevision` を SWR ベースで実装
 
 **ファイル**: `stores/page.tsx:164-191`
 
 ```typescript
-export const useIsLatestRevision = (): SWRResponse<boolean, Error> => {
+export const useSWRxIsLatestRevision = (): SWRResponse<boolean, Error> => {
   const currentPage = useCurrentPageData();
   const pageId = currentPage?._id;
   const shareLinkId = useShareLinkId();
@@ -121,7 +121,7 @@ export const useIsViewingSpecificRevision = (): boolean => {
 
 ```typescript
 export const useIsRevisionOutdated = (): boolean => {
-  const { data: isLatestRevision } = useIsLatestRevision();
+  const { data: isLatestRevision } = useSWRxIsLatestRevision();
   const isViewingSpecificRevision = useIsViewingSpecificRevision();
 
   // If user intentionally views a specific revision, don't show "outdated" alert
@@ -239,7 +239,7 @@ export const useSetRemoteLatestPageData = (): SetRemoteLatestPageData => {
 1. **PageInfo (latestRevisionId) との同期がない**:
    - Socket.io 更新時に `remoteRevision*` atom は更新される
    - しかし `useSWRxPageInfo.data.latestRevisionId` は更新されない
-   - → `useIsLatestRevision()` と `useIsRevisionOutdated()` がリアルタイム更新を検知できない
+   - → `useSWRxIsLatestRevision()` と `useIsRevisionOutdated()` がリアルタイム更新を検知できない
 
 2. **用途が限定的**:
    - 主に ConflictDiffModal でリモートリビジョンの詳細を表示するために使用
@@ -327,7 +327,7 @@ export const useFetchCurrentPage = () => {
 **問題**:
 - Socket.io で他のユーザーがページを更新したとき、`useSWRxPageInfo` のキャッシュが更新されない
 - `latestRevisionId` が古いままになる
-- **重要**: `useIsLatestRevision()` と `useIsRevisionOutdated()` が正しく動作しない
+- **重要**: `useSWRxIsLatestRevision()` と `useIsRevisionOutdated()` が正しく動作しない
 
 **実装方針**:
 ```typescript
@@ -420,7 +420,7 @@ mutate(['/page/info', pageId, shareLinkId, isGuestUser], newData, options);
 └──────────────────────────────┘
 ┌──────────────────────────────┐
-│ useIsLatestRevision()        │ ← SWR ベース、汎用的な状態確認
+│ useSWRxIsLatestRevision()        │ ← SWR ベース、汎用的な状態確認
 └──────────────────────────────┘
 ┌──────────────────────────────┐

+ 2 - 2
apps/app/src/client/components/Page/DisplaySwitcher.tsx

@@ -5,7 +5,7 @@ import dynamic from 'next/dynamic';
 import { useHashChangedEffect } from '~/client/services/side-effects/hash-changed';
 import { useIsEditable } from '~/states/page';
 import { EditorMode, useEditorMode, useReservedNextCaretLine } from '~/states/ui/editor';
-import { useIsLatestRevision } from '~/stores/page';
+import { useSWRxIsLatestRevision } from '~/stores/page';
 
 import { LazyRenderer } from '../Common/LazyRenderer';
 
@@ -18,7 +18,7 @@ export const DisplaySwitcher = (): JSX.Element => {
 
   const { editorMode } = useEditorMode();
   const isEditable = useIsEditable();
-  const { data: isLatestRevision } = useIsLatestRevision();
+  const { data: isLatestRevision } = useSWRxIsLatestRevision();
 
   useHashChangedEffect();
   useReservedNextCaretLine();

+ 2 - 2
apps/app/src/client/components/PageEditor/PageEditorReadOnly.tsx

@@ -6,7 +6,7 @@ import { throttle } from 'throttle-debounce';
 
 import { useShouldExpandContent } from '~/services/layout/use-should-expand-content';
 import { useCurrentPageData } from '~/states/page';
-import { useIsLatestRevision } from '~/stores/page';
+import { useSWRxIsLatestRevision } from '~/stores/page';
 import { usePreviewOptions } from '~/stores/renderer';
 
 import { EditorNavbar } from './EditorNavbar';
@@ -22,7 +22,7 @@ export const PageEditorReadOnly = react.memo(({ visibility }: Props): JSX.Elemen
 
   const currentPage = useCurrentPageData();
   const { data: rendererOptions } = usePreviewOptions();
-  const { data: isLatestRevision } = useIsLatestRevision();
+  const { data: isLatestRevision } = useSWRxIsLatestRevision();
   const shouldExpandContent = useShouldExpandContent(currentPage);
 
   const { scrollEditorHandler, scrollPreviewHandler } = useScrollSync(GlobalCodeMirrorEditorKey.READONLY, previewRef);

+ 2 - 2
apps/app/src/components/PageView/PageAlerts/OldRevisionAlert.tsx

@@ -4,13 +4,13 @@ import { returnPathForURL } from '@growi/core/dist/utils/path-utils';
 import { useTranslation } from 'react-i18next';
 
 import { useCurrentPageData, useFetchCurrentPage } from '~/states/page';
-import { useIsLatestRevision } from '~/stores/page';
+import { useSWRxIsLatestRevision } from '~/stores/page';
 
 export const OldRevisionAlert = (): JSX.Element => {
   const router = useRouter();
   const { t } = useTranslation();
 
-  const { data: isLatestRevision } = useIsLatestRevision();
+  const { data: isLatestRevision } = useSWRxIsLatestRevision();
   const page = useCurrentPageData();
   const { fetchCurrentPage } = useFetchCurrentPage();
 

+ 4 - 0
apps/app/src/pages/[[...path]]/index.page.tsx

@@ -53,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();
@@ -130,6 +131,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();

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

+ 0 - 21
apps/app/src/states/context.ts

@@ -1,4 +1,3 @@
-import { useRouter } from 'next/router';
 import { atom, useAtomValue, useSetAtom } from 'jotai';
 
 import { currentUserAtomGetter, growiCloudUriAtomGetter } from './global';
@@ -79,26 +78,6 @@ const growiDocumentationUrlAtom = atom((get) => {
 export const useGrowiDocumentationUrl = () =>
   useAtomValue(growiDocumentationUrlAtom);
 
-/**
- * Hook to get revisionId from URL query parameters
- * Returns undefined if revisionId is not present in the URL
- */
-export const useRevisionIdFromUrl = (): string | undefined => {
-  const router = useRouter();
-  const revisionId = router.query.revisionId;
-  return typeof revisionId === 'string' ? revisionId : undefined;
-};
-
-/**
- * Hook to check if user is intentionally viewing a specific (old) revision
- * Returns true when URL has ?revisionId=xxx parameter
- * This indicates the user explicitly wants to see that revision
- */
-export const useIsViewingSpecificRevision = (): boolean => {
-  const revisionId = useRevisionIdFromUrl();
-  return revisionId != null;
-};
-
 /**
  * Internal atoms for derived atom usage (special naming convention)
  * These atoms are exposed only for creating derived atoms in other modules

+ 10 - 0
apps/app/src/states/page/hooks.ts

@@ -21,6 +21,7 @@ import {
   remoteRevisionBodyAtom,
   remoteRevisionLastUpdatedAtAtom,
   remoteRevisionLastUpdateUserAtom,
+  revisionIdFromUrlAtom,
   shareLinkIdAtom,
   templateBodyAtom,
   templateTagsAtom,
@@ -48,6 +49,15 @@ export const useTemplateTags = () => useAtomValue(templateTagsAtom);
 
 export const useTemplateBody = () => useAtomValue(templateBodyAtom);
 
+/**
+ * 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);
 

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

+ 7 - 0
apps/app/src/states/page/internal-atoms.ts

@@ -17,6 +17,9 @@ export const isForbiddenAtom = atom<boolean>(false);
 // 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);
@@ -124,3 +127,7 @@ export const _atomsForDerivedAbilities = {
   currentPageIdAtom,
   isTrashPageAtom,
 } as const;
+
+export const _atomsForSyncRevisionIdFromUrl = {
+  revisionIdFromUrlAtom,
+} as const;

+ 2 - 2
apps/app/src/states/page/use-fetch-current-page.ts

@@ -14,7 +14,6 @@ import { apiv3Get } from '~/client/util/apiv3-client';
 import { useSWRxPageInfo } from '~/stores/page';
 import loggerFactory from '~/utils/logger';
 
-import { useRevisionIdFromUrl } from '../context';
 import {
   currentPageDataAtom,
   currentPageIdAtom,
@@ -23,6 +22,7 @@ import {
   pageLoadingAtom,
   pageNotFoundAtom,
   remoteRevisionBodyAtom,
+  revisionIdFromUrlAtom,
   shareLinkIdAtom,
 } from './internal-atoms';
 
@@ -177,7 +177,7 @@ export const useFetchCurrentPage = (): {
   error: Error | null;
 } => {
   const shareLinkId = useAtomValue(shareLinkIdAtom);
-  const revisionIdFromUrl = useRevisionIdFromUrl();
+  const revisionIdFromUrl = useAtomValue(revisionIdFromUrlAtom);
   const currentPageId = useAtomValue(currentPageIdAtom);
 
   const isLoading = useAtomValue(pageLoadingAtom);

+ 10 - 11
apps/app/src/stores/page.tsx

@@ -26,13 +26,12 @@ import type {
   IRecordApplicableGrant,
   IResCurrentGrantData,
 } from '~/interfaces/page-grant';
-import {
-  useIsGuestUser,
-  useIsReadOnlyUser,
-  useIsViewingSpecificRevision,
-} from '~/states/context';
+import { useIsGuestUser, useIsReadOnlyUser } from '~/states/context';
 import { useCurrentPageData, usePageNotFound } from '~/states/page';
-import { useShareLinkId } from '~/states/page/hooks';
+import {
+  useRevisionIdFromUrl,
+  useShareLinkId,
+} from '~/states/page/hooks';
 
 import type { IPageTagsInfo } from '../interfaces/tag';
 
@@ -165,7 +164,7 @@ export const useSWRMUTxPageInfo = (
  * - data: true - viewing the latest revision (or latestRevisionId not available)
  * - data: false - viewing an old revision
  */
-export const useIsLatestRevision = (): SWRResponse<boolean, Error> => {
+export const useSWRxIsLatestRevision = (): SWRResponse<boolean, Error> => {
   const currentPage = useCurrentPageData();
   const pageId = currentPage?._id;
   const shareLinkId = useShareLinkId();
@@ -206,14 +205,14 @@ export const useIsLatestRevision = (): SWRResponse<boolean, Error> => {
  * - 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 useIsLatestRevision)
+ * "you are viewing an old revision" (which is handled by useSWRxIsLatestRevision)
  */
 export const useIsRevisionOutdated = (): boolean => {
-  const { data: isLatestRevision } = useIsLatestRevision();
-  const isViewingSpecificRevision = useIsViewingSpecificRevision();
+  const { data: isLatestRevision } = useSWRxIsLatestRevision();
+  const revisionIdFromUrl = useRevisionIdFromUrl();
 
   // If user intentionally views a specific revision, don't show "outdated" alert
-  if (isViewingSpecificRevision) {
+  if (revisionIdFromUrl != null) {
     return false;
   }