Browse Source

WIP: refactor page state management

Yuki Takei 8 months ago
parent
commit
d8d172cf04

+ 185 - 0
apps/app/docs/plan/page-state-jotai-migration.md

@@ -0,0 +1,185 @@
+# ページ状態管理のJotai移行提案
+
+## 背景
+
+現在のページ状態管理(`useCurrentPageId`, `useSWRxCurrentPage`, `useSWRMUTxCurrentPage`)は以下の問題を抱えています:
+
+1. **複雑なshouldMutateロジック**: 4つの既知問題に対応するための複雑な条件分岐
+2. **責務の分散**: 同一データに対して複数のhookが異なる責任を持つ
+3. **状態同期の複雑さ**: 手動でのキャッシュ操作による同期問題
+4. **SSR/CSRの境界問題**: 初期データの扱いが複雑
+
+## 提案する移行戦略
+
+### **戦略: ハイブリッドアプローチ(推奨)**
+
+SWRとJotaiの併用により、それぞれの長所を活かしつつ問題を解決します。
+
+#### **役割分担**
+- **Jotai**: クライアントサイド状態管理(ページデータ、フラグ状態)
+- **SWR**: データフェッチング(APIコール、エラーハンドリング)
+
+#### **実装アーキテクチャ**
+
+```
+┌─────────────────┐    ┌─────────────────┐    ┌─────────────────┐
+│   Components    │◄───┤  Jotai Atoms    │◄───┤ SWR Fetchers    │
+│                 │    │                 │    │                 │
+│ - usePageData   │    │ - pageAtom      │    │ - usePageFetcher│
+│ - usePageId     │    │ - pageIdAtom    │    │ - API calls     │
+│ - usePagePath   │    │ - statusAtoms   │    │ - Error handling│
+└─────────────────┘    └─────────────────┘    └─────────────────┘
+```
+
+#### **メリット**
+
+1. **複雑性の削減**: shouldMutateロジックが不要
+2. **責務の明確化**: 状態管理とデータフェッチングが分離
+3. **段階的移行**: 既存コードへの影響を最小化
+4. **SWRの恩恵維持**: キャッシング、再試行、エラーハンドリング
+5. **TypeScript親和性**: 優れた型推論
+
+#### **デメリット**
+
+1. **一時的な複雑さ**: 移行期間中の2つのシステム併存
+2. **学習コスト**: Jotaiの新しいパターン習得
+
+## 実装計画
+
+### **一気移行戦略(推奨)**
+
+影響範囲調査の結果、段階的移行ではなく**一気に移行**することを推奨します。
+
+#### **移行可能性の根拠**
+
+1. **限定的な影響範囲**: 約44箇所の使用箇所(manageable)
+2. **一貫したパターン**: 各hookの使用方法が標準化されている
+3. **型安全性**: Jotai版は既存以上の型安全性を提供
+4. **リスクの低さ**: 機能変更ではなく、実装方法の変更のみ
+
+#### **具体的な移行箇所**
+
+- **`useCurrentPageId`**: 10箇所
+  ```typescript
+  // Before: { data: currentPageId } = useCurrentPageId()
+  // After:  [currentPageId] = useCurrentPageId()
+  ```
+
+- **`useSWRxCurrentPage`**: 19箇所  
+  ```typescript
+  // Before: { data: currentPage } = useSWRxCurrentPage()
+  // After:  [currentPage] = useCurrentPageData()
+  ```
+
+- **`useSWRMUTxCurrentPage`**: 15箇所
+  ```typescript
+  // Before: { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage()
+  // After:  { fetchAndUpdatePage } = usePageFetcher()
+  ```
+
+### **Phase 1: 一括置換(1日)**
+
+1. **Import文の更新**
+   ```typescript
+   // Before
+   import { useCurrentPageId, useSWRxCurrentPage, useSWRMUTxCurrentPage } from '~/stores/page';
+   
+   // After
+   import { useCurrentPageId, useCurrentPageData, usePageFetcher } from '~/states/page';
+   ```
+
+2. **Hook使用箇所の一括変更**
+   - 正規表現での一括置換が可能
+   - TypeScriptコンパイラーでの検証
+
+3. **SSRハイドレーション対応**
+   ```typescript
+   // ページコンポーネントで
+   useHydratePageAtoms({ 
+     currentPageId: pageId,
+     currentPage: pageData 
+   });
+   ```
+
+### **Phase 2: 検証とクリーンアップ(1日)**
+
+1. **動作確認**
+   - 主要ページでの動作テスト
+   - エラーログの確認
+
+2. **旧コードの削除**
+   - `~/stores/page.tsx`からの該当hook削除
+   - 未使用importの整理
+
+3. **型チェック**
+   - TypeScriptエラーの解消
+   - lint警告の対応
+
+### **Phase 3: 旧システム削除(半日)**
+
+1. **旧hook定義の削除**
+2. **関連する複雑なshouldMutateロジックの削除**  
+3. **ドキュメント更新**
+
+## 移行後の改善効果
+
+### **コードの簡潔性**
+```typescript
+// Before (複雑なshouldMutate)
+const shouldMutate = (() => {
+  if (initialData === undefined) return false;
+  if (initialData == null) return true;
+  
+  const cachedData = cache.get(key)?.data;
+  if (initialData._id !== cachedData?._id) return true;
+  
+  if (cachedData?.revision == null && initialData.revision != null) return true;
+  
+  if (!isLatestRevision && 
+      cachedData.revision?._id != null && 
+      initialData.revision?._id != null &&
+      cachedData.revision._id !== initialData.revision._id) {
+    return true;
+  }
+  return false;
+})();
+
+// After (シンプルなJotai更新)
+setCurrentPage(initialData);
+```
+
+### **型安全性の向上**
+```typescript
+// Jotaiの優れた型推論
+const [pageId, setPageId] = useCurrentPageIdNew(); // string | null
+const [pagePath] = useCurrentPagePath(); // string | null (computed)
+```
+
+### **デバッグの容易さ**
+- Jotai DevToolsによる状態可視化
+- 明確な状態変更フロー
+- 副作用の分離
+
+## リスク評価と対策
+
+### **リスク**
+1. **移行期間の複雑さ**: 2つのシステムが併存
+2. **パフォーマンス懸念**: 追加の状態管理レイヤー
+3. **学習コスト**: 新しいパターンの習得
+
+### **対策**
+1. **段階的移行**: 機能単位での慎重な移行
+2. **十分なテスト**: 既存機能の回帰防止
+3. **ドキュメント整備**: 移行パターンの標準化
+4. **レビュープロセス**: 移行品質の確保
+
+## 結論
+
+**一気移行による速やかな問題解決を推奨**します。この戦略により:
+
+1. **即座の効果**: shouldMutateの複雑性を2-3日で完全解決
+2. **リスクの最小化**: 段階的移行による中間状態の複雑さを回避
+3. **開発効率**: 移行期間中の二重管理コストを削減
+4. **保守性向上**: クリーンなアーキテクチャへの迅速な移行
+
+約44箇所という限定的な影響範囲であり、一貫したパターンの変更のため、一気に移行する方が効率的かつ安全です。

+ 62 - 0
apps/app/src/states/hydrate/page.ts

@@ -0,0 +1,62 @@
+import type { IPagePopulatedToShowRevision } from '@growi/core';
+import { useHydrateAtoms } from 'jotai/utils';
+
+import {
+  currentPageIdAtom,
+  currentPageDataAtom,
+  pageNotFoundAtom,
+  latestRevisionAtom,
+  templateTagsAtom,
+  templateContentAtom,
+  remoteRevisionIdAtom,
+  remoteRevisionBodyAtom,
+} from '../page/internal-atoms';
+
+/**
+ * Hook for hydrating page-related atoms with server-side data
+ * Simplified to focus on the most common use case: hydrating with page data
+ *
+ * This replaces the complex shouldMutate logic in useSWRxCurrentPage
+ * with simple, direct atom initialization
+ *
+ * Data sources:
+ * - page._id, page.revision -> Auto-extracted from IPagePopulatedToShowRevision
+ * - remoteRevisionId, remoteRevisionBody -> Auto-extracted from page.revision
+ * - templateTags, templateBody, isLatestRevision -> Explicitly provided via options
+ *
+ * @example
+ * // Basic usage
+ * useHydratePageAtoms(pageWithMeta?.data ?? null);
+ *
+ * // With template data and custom flags
+ * useHydratePageAtoms(pageWithMeta?.data ?? null, {
+ *   isLatestRevision: false,
+ *   templateTags: ['tag1', 'tag2'],
+ *   templateBody: 'Template content'
+ * });
+ */
+export const useHydratePageAtoms = (
+    page: IPagePopulatedToShowRevision | null,
+    options?: {
+      isNotFound?: boolean;
+      isLatestRevision?: boolean;
+      templateTags?: string[];
+      templateBody?: string;
+    },
+): void => {
+  useHydrateAtoms([
+    // Core page state - automatically extract from page object
+    [currentPageIdAtom, page?._id ?? null],
+    [currentPageDataAtom, page ?? null],
+    [pageNotFoundAtom, options?.isNotFound ?? (page == null)],
+    [latestRevisionAtom, options?.isLatestRevision ?? true],
+
+    // Template data - from options (not auto-extracted from page)
+    [templateTagsAtom, options?.templateTags ?? []],
+    [templateContentAtom, options?.templateBody ?? ''],
+
+    // Remote revision data - auto-extracted from page.revision
+    [remoteRevisionIdAtom, page?.revision?._id ?? null],
+    [remoteRevisionBodyAtom, page?.revision?.body ?? null],
+  ]);
+};

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

@@ -0,0 +1,126 @@
+import type { IPagePopulatedToShowRevision, IUserHasId } from '@growi/core';
+import { pagePathUtils } from '@growi/core/dist/utils';
+import { useAtom } from 'jotai';
+
+import { useCurrentPathname } from '~/stores-universal/context';
+
+import type { UseAtom } from '../ui/helper';
+
+import {
+  currentPageIdAtom,
+  currentPageDataAtom,
+  currentPagePathAtom,
+  pageNotFoundAtom,
+  latestRevisionAtom,
+  setCurrentPageAtom,
+  setPageStatusAtom,
+  // New atoms for enhanced functionality
+  remoteRevisionIdAtom,
+  remoteRevisionBodyAtom,
+  remoteRevisionLastUpdateUserAtom,
+  remoteRevisionLastUpdatedAtAtom,
+  setTemplateDataAtom,
+  setRemoteRevisionDataAtom,
+  isTrashPageAtom,
+  isRevisionOutdatedAtom,
+} from './internal-atoms';
+
+/**
+ * Public hooks for page state management
+ * These provide a clean interface while hiding internal atom implementation
+ */
+
+// Read-only hooks for page state
+export const useCurrentPageId = (): UseAtom<typeof currentPageIdAtom> => {
+  return useAtom(currentPageIdAtom);
+};
+
+export const useCurrentPageData = (): UseAtom<typeof currentPageDataAtom> => {
+  return useAtom(currentPageDataAtom);
+};
+
+export const usePageNotFound = (): UseAtom<typeof pageNotFoundAtom> => {
+  return useAtom(pageNotFoundAtom);
+};
+
+export const useLatestRevision = (): UseAtom<typeof latestRevisionAtom> => {
+  return useAtom(latestRevisionAtom);
+};
+
+export const useCurrentPagePath = (): readonly [string | null, never] => {
+  return useAtom(currentPagePathAtom);
+};
+
+// Write hooks for updating page state
+export const useSetCurrentPage = (): ((page: IPagePopulatedToShowRevision | null) => void) => {
+  return useAtom(setCurrentPageAtom)[1];
+};
+
+export const useSetPageStatus = (): ((status: { isNotFound?: boolean; isLatestRevision?: boolean }) => void) => {
+  return useAtom(setPageStatusAtom)[1];
+};
+
+export const useSetTemplateData = (): ((data: { tags?: string[]; body?: string }) => void) => {
+  return useAtom(setTemplateDataAtom)[1];
+};
+
+export const useSetRemoteRevisionData = (): ((data: {
+  id?: string | null;
+  body?: string | null;
+  lastUpdateUser?: IUserHasId | null;
+  lastUpdatedAt?: Date | null;
+}) => void) => {
+  return useAtom(setRemoteRevisionDataAtom)[1];
+};
+
+// Remote revision hooks (replacements for stores/remote-latest-page.ts)
+export const useRemoteRevisionId = (): UseAtom<typeof remoteRevisionIdAtom> => {
+  return useAtom(remoteRevisionIdAtom);
+};
+
+export const useRemoteRevisionBody = (): UseAtom<typeof remoteRevisionBodyAtom> => {
+  return useAtom(remoteRevisionBodyAtom);
+};
+
+export const useRemoteRevisionLastUpdateUser = (): UseAtom<typeof remoteRevisionLastUpdateUserAtom> => {
+  return useAtom(remoteRevisionLastUpdateUserAtom);
+};
+
+export const useRemoteRevisionLastUpdatedAt = (): UseAtom<typeof remoteRevisionLastUpdatedAtAtom> => {
+  return useAtom(remoteRevisionLastUpdatedAtAtom);
+};
+
+// Enhanced computed hooks (pure Jotai - no SWR needed)
+
+/**
+ * Get current page path with fallback to pathname
+ * Pure Jotai replacement for stores/page.tsx useCurrentPagePath
+ */
+export const useCurrentPagePathWithFallback = (): string | undefined => {
+  const [currentPagePath] = useAtom(currentPagePathAtom);
+  const { data: currentPathname } = useCurrentPathname();
+
+  if (currentPagePath != null) {
+    return currentPagePath;
+  }
+  if (currentPathname != null && !pagePathUtils.isPermalink(currentPathname)) {
+    return currentPathname;
+  }
+  return undefined;
+};
+
+/**
+ * Check if current page is in trash
+ * Pure Jotai replacement for stores/page.tsx useIsTrashPage
+ */
+export const useIsTrashPage = (): readonly [boolean, never] => {
+  return useAtom(isTrashPageAtom);
+};
+
+/**
+ * Check if current revision is outdated
+ * Pure Jotai replacement for stores/page.tsx useIsRevisionOutdated
+ */
+export const useIsRevisionOutdated = (): readonly [boolean, never] => {
+  return useAtom(isRevisionOutdatedAtom);
+};

+ 43 - 0
apps/app/src/states/page/index.ts

@@ -0,0 +1,43 @@
+/**
+ * Page state management - Public API
+ *
+ * This module provides a clean interface for page state management,
+ * hiding internal implementation details while exposing only the necessary hooks.
+ */
+
+// Core page state hooks
+export {
+  useCurrentPageId,
+  useCurrentPageData,
+  useCurrentPagePath,
+  usePageNotFound,
+  useLatestRevision,
+  useSetCurrentPage,
+  useSetPageStatus,
+  useSetTemplateData,
+  useSetRemoteRevisionData,
+  // Remote revision hooks (replacements for stores/remote-latest-page.ts)
+  useRemoteRevisionId,
+  useRemoteRevisionBody,
+  useRemoteRevisionLastUpdateUser,
+  useRemoteRevisionLastUpdatedAt,
+  // Enhanced computed hooks (pure Jotai replacements for stores/page.tsx)
+  useCurrentPagePathWithFallback,
+  useIsTrashPage,
+  useIsRevisionOutdated,
+} from './hooks';
+
+// Data fetching hooks
+export {
+  usePageFetcher,
+  useInitializePageData,
+} from './page-fetcher';
+
+// Template data atoms (these need to be directly accessible for some use cases)
+export {
+  templateTagsAtom,
+  templateContentAtom,
+} from './internal-atoms';
+
+// Re-export types that external consumers might need
+export type { UseAtom } from '../ui/helper';

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

@@ -0,0 +1,112 @@
+import type { IPagePopulatedToShowRevision, Nullable, IUserHasId } from '@growi/core';
+import { pagePathUtils } from '@growi/core/dist/utils';
+import { atom } from 'jotai';
+
+/**
+ * Internal atoms for page state management
+ * These should not be imported directly by external modules
+ */
+
+// Core page state atoms (internal)
+export const currentPageIdAtom = atom<Nullable<string>>(null);
+export const currentPageDataAtom = atom<IPagePopulatedToShowRevision | null>(null);
+export const pageNotFoundAtom = atom(false);
+export const latestRevisionAtom = atom(true);
+
+// Template data atoms (internal)
+export const templateTagsAtom = atom<string[]>([]);
+export const templateContentAtom = atom<string>('');
+
+// Derived atoms for computed states
+export const currentPagePathAtom = atom((get) => {
+  const currentPage = get(currentPageDataAtom);
+  return currentPage?.path ?? null;
+});
+
+// Additional computed atoms for migrated hooks
+export const currentRevisionIdAtom = atom((get) => {
+  const currentPage = get(currentPageDataAtom);
+  return currentPage?.revision?._id ?? null;
+});
+
+// Remote revision data atoms (migrated from useSWRStatic)
+export const remoteRevisionIdAtom = atom<string | null>(null);
+export const remoteRevisionBodyAtom = atom<string | null>(null);
+export const remoteRevisionLastUpdateUserAtom = atom<IUserHasId | null>(null);
+export const remoteRevisionLastUpdatedAtAtom = atom<Date | null>(null);
+
+// Enhanced computed atoms that replace SWR-based hooks
+export const isTrashPageAtom = atom((get) => {
+  const pagePath = get(currentPagePathAtom);
+  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;
+});
+
+// Action atoms for state updates
+export const setCurrentPageAtom = atom(
+  null,
+  (get, set, page: IPagePopulatedToShowRevision | null) => {
+    set(currentPageDataAtom, page);
+    if (page?._id) {
+      set(currentPageIdAtom, page._id);
+    }
+  },
+);
+
+export const setPageStatusAtom = atom(
+  null,
+  (get, set, status: { isNotFound?: boolean; isLatestRevision?: boolean }) => {
+    if (status.isNotFound !== undefined) {
+      set(pageNotFoundAtom, status.isNotFound);
+    }
+    if (status.isLatestRevision !== undefined) {
+      set(latestRevisionAtom, status.isLatestRevision);
+    }
+  },
+);
+
+// Update atoms for template and remote revision data
+export const setTemplateDataAtom = atom(
+  null,
+  (get, set, data: { tags?: string[]; body?: string }) => {
+    if (data.tags !== undefined) {
+      set(templateTagsAtom, data.tags);
+    }
+    if (data.body !== undefined) {
+      set(templateContentAtom, data.body);
+    }
+  },
+);
+
+export const setRemoteRevisionDataAtom = atom(
+  null,
+  (get, set, data: {
+    id?: string | null;
+    body?: string | null;
+    lastUpdateUser?: IUserHasId | null;
+    lastUpdatedAt?: Date | null;
+  }) => {
+    if (data.id !== undefined) {
+      set(remoteRevisionIdAtom, data.id);
+    }
+    if (data.body !== undefined) {
+      set(remoteRevisionBodyAtom, data.body);
+    }
+    if (data.lastUpdateUser !== undefined) {
+      set(remoteRevisionLastUpdateUserAtom, data.lastUpdateUser);
+    }
+    if (data.lastUpdatedAt !== undefined) {
+      set(remoteRevisionLastUpdatedAtAtom, data.lastUpdatedAt);
+    }
+  },
+);

+ 105 - 0
apps/app/src/states/page/page-fetcher.ts

@@ -0,0 +1,105 @@
+import { useCallback } from 'react';
+
+import type { IPagePopulatedToShowRevision } from '@growi/core';
+import { isClient } from '@growi/core/dist/utils';
+import { useAtom } from 'jotai';
+import useSWRMutation, { type SWRMutationResponse } from 'swr/mutation';
+
+import { apiv3Get } from '~/client/util/apiv3-client';
+import { useShareLinkId } from '~/stores-universal/context';
+import type { AxiosResponse } from '~/utils/axios';
+
+import { currentPageIdAtom, setCurrentPageAtom } from './internal-atoms';
+
+/**
+ * Hybrid approach: Use Jotai for state management, SWR for data fetching
+ * This eliminates the complex shouldMutate logic while keeping SWR's benefits
+ */
+
+const getPageApiErrorHandler = (errs: AxiosResponse[]): IPagePopulatedToShowRevision | null => {
+  if (!Array.isArray(errs)) {
+    throw Error('error is not array');
+  }
+
+  const statusCode = errs[0].status;
+  if (statusCode === 403 || statusCode === 404) {
+    // for NotFoundPage
+    return null;
+  }
+  throw Error('failed to get page');
+};
+
+/**
+ * Simplified page fetching hook using Jotai + SWR
+ * Replaces the complex useSWRMUTxCurrentPage with cleaner state management
+ */
+export const usePageFetcher = (): SWRMutationResponse<IPagePopulatedToShowRevision | null, Error> & {
+  fetchAndUpdatePage: () => Promise<IPagePopulatedToShowRevision | null>;
+} => {
+  const [currentPageId] = useAtom(currentPageIdAtom);
+  const { data: shareLinkId } = useShareLinkId();
+  const setCurrentPage = useAtom(setCurrentPageAtom)[1];
+
+  // Get URL parameter for specific revisionId
+  let revisionId: string | undefined;
+  if (isClient()) {
+    const urlParams = new URLSearchParams(window.location.search);
+    const requestRevisionId = urlParams.get('revisionId');
+    revisionId = requestRevisionId != null ? requestRevisionId : undefined;
+  }
+
+  const key = 'fetchCurrentPage';
+
+  const swrMutationResult = useSWRMutation(
+    key,
+    async() => {
+      if (!currentPageId) {
+        return null;
+      }
+
+      try {
+        const response = await apiv3Get<{ page: IPagePopulatedToShowRevision }>(
+          '/page',
+          { pageId: currentPageId, shareLinkId, revisionId },
+        );
+
+        const newData = response.data.page;
+
+        // Update Jotai state instead of manual SWR cache mutation
+        setCurrentPage(newData);
+
+        return newData;
+      }
+      catch (error) {
+        return getPageApiErrorHandler([error]);
+      }
+    },
+    {
+      populateCache: false, // We're using Jotai for state, not SWR cache
+      revalidate: false,
+    },
+  );
+
+  const fetchAndUpdatePage = useCallback(async() => {
+    const result = await swrMutationResult.trigger();
+    return result ?? null;
+  }, [swrMutationResult]);
+
+  return {
+    ...swrMutationResult,
+    fetchAndUpdatePage,
+  };
+};
+
+/**
+ * Hook for initializing page data (replaces useSWRxCurrentPage complexity)
+ * This simplifies the shouldMutate logic by using Jotai's reactive state
+ */
+export const useInitializePageData = (): ((initialData: IPagePopulatedToShowRevision | null) => void) => {
+  const setCurrentPage = useAtom(setCurrentPageAtom)[1];
+
+  return useCallback((initialData: IPagePopulatedToShowRevision | null) => {
+    // Simple direct update - no complex shouldMutate logic needed
+    setCurrentPage(initialData);
+  }, [setCurrentPage]);
+};

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

@@ -319,8 +319,12 @@ export const useSWRxApplicableGrant = (
 
 /** **********************************************************
  *                     Computed states
+ *                     @deprecated Use enhanced versions from ~/states/page instead
  *********************************************************** */
 
+/**
+ * @deprecated Use useCurrentPagePathEnhanced from ~/states/page instead
+ */
 export const useCurrentPagePath = (): SWRResponse<string | undefined, Error> => {
   const { data: currentPage } = useSWRxCurrentPage();
   const { data: currentPathname } = useCurrentPathname();
@@ -341,6 +345,9 @@ export const useCurrentPagePath = (): SWRResponse<string | undefined, Error> =>
   );
 };
 
+/**
+ * @deprecated Use useIsTrashPageEnhanced from ~/states/page instead
+ */
 export const useIsTrashPage = (): SWRResponse<boolean, Error> => {
   const { data: pagePath } = useCurrentPagePath();
 
@@ -352,6 +359,9 @@ export const useIsTrashPage = (): SWRResponse<boolean, Error> => {
   );
 };
 
+/**
+ * @deprecated Use useIsRevisionOutdatedEnhanced from ~/states/page instead
+ */
 export const useIsRevisionOutdated = (): SWRResponse<boolean, Error> => {
   const { data: currentPage } = useSWRxCurrentPage();
   const { data: remoteRevisionId } = useRemoteRevisionId();