Просмотр исходного кода

Merge pull request #10474 from growilabs/support/use-jotai

support: Use jotai for state management
Yuki Takei 5 месяцев назад
Родитель
Сommit
8f316daa14
100 измененных файлов с 3257 добавлено и 931 удалено
  1. 5 0
      .changeset/healthy-pianos-brake.md
  2. 5 0
      .changeset/lazy-penguins-hammer.md
  3. 2 0
      .gitignore
  4. 104 0
      .serena/memories/apps-app-detailed-architecture.md
  5. 163 0
      .serena/memories/apps-app-development-patterns.md
  6. 131 0
      .serena/memories/apps-app-jotai-directory-structure.md
  7. 84 0
      .serena/memories/apps-app-modal-performance-optimization-v2-completion-summary.md
  8. 640 0
      .serena/memories/apps-app-modal-performance-optimization-v3-completion-summary.md
  9. 35 0
      .serena/memories/apps-app-technical-specs.md
  10. 0 45
      .serena/memories/development_environment.md
  11. 390 0
      .serena/memories/nextjs-pages-router-getLayout-pattern.md
  12. 440 0
      .serena/memories/page-state-hooks-useLatestRevision-degradation.md
  13. 65 0
      .serena/memories/page-transition-and-rendering-flow.md
  14. 0 100
      .serena/memories/suggested_commands.md
  15. 41 42
      .serena/memories/tech_stack.md
  16. 95 0
      .serena/memories/vitest-testing-tips-and-best-practices.md
  17. 10 0
      .serena/serena_config.yml
  18. 3 1
      CLAUDE.md
  19. 3 0
      apps/app/.eslintrc.js
  20. BIN
      apps/app/.swc/plugins/v7_linux_x86_64_0.106.16/85face98bcf0ea217842cb93383692497c6ae8a7da71a76bf3d93a3a42b4228c
  21. 40 0
      apps/app/docs/plan/README.md
  22. 1 0
      apps/app/next.config.js
  23. 6 2
      apps/app/package.json
  24. 2 2
      apps/app/src/client/components/Admin/App/AppSettingsPageContents.tsx
  25. 4 7
      apps/app/src/client/components/Admin/App/MaintenanceMode.tsx
  26. 2 2
      apps/app/src/client/components/Admin/App/V5PageMigration.tsx
  27. 4 5
      apps/app/src/client/components/Admin/AuditLog/AuditLogSettings.tsx
  28. 4 3
      apps/app/src/client/components/Admin/AuditLogManagement.tsx
  29. 17 14
      apps/app/src/client/components/Admin/Customize/CustomizeLogoSetting.tsx
  30. 4 4
      apps/app/src/client/components/Admin/Customize/CustomizeTitle.tsx
  31. 7 6
      apps/app/src/client/components/Admin/ElasticsearchManagement/ElasticsearchManagement.tsx
  32. 2 2
      apps/app/src/client/components/Admin/ElasticsearchManagement/RebuildIndexControls.jsx
  33. 2 2
      apps/app/src/client/components/Admin/ExportArchiveDataPage.tsx
  34. 4 4
      apps/app/src/client/components/Admin/G2GDataTransfer.tsx
  35. 2 2
      apps/app/src/client/components/Admin/ImportData/GrowiArchive/ImportForm.jsx
  36. 4 2
      apps/app/src/client/components/Admin/Notification/ManageGlobalNotification.tsx
  37. 12 18
      apps/app/src/client/components/Admin/Security/GitHubSecuritySettingContents.tsx
  38. 13 18
      apps/app/src/client/components/Admin/Security/GoogleSecuritySettingContents.tsx
  39. 4 4
      apps/app/src/client/components/Admin/Security/LocalSecuritySettingContents.tsx
  40. 3 6
      apps/app/src/client/components/Admin/Security/OidcSecuritySettingContents.tsx
  41. 3 6
      apps/app/src/client/components/Admin/Security/SamlSecuritySettingContents.tsx
  42. 2 2
      apps/app/src/client/components/Admin/SlackIntegration/CustomBotWithProxySettings.jsx
  43. 2 2
      apps/app/src/client/components/Admin/SlackIntegration/CustomBotWithoutProxySettings.jsx
  44. 56 39
      apps/app/src/client/components/Admin/SlackIntegration/DeleteSlackBotSettingsModal.tsx
  45. 2 2
      apps/app/src/client/components/Admin/SlackIntegration/OfficialBotSettings.jsx
  46. 2 2
      apps/app/src/client/components/Admin/SlackIntegration/WithProxyAccordions.jsx
  47. 75 60
      apps/app/src/client/components/Admin/UserGroup/UserGroupModal.tsx
  48. 3 2
      apps/app/src/client/components/Admin/UserGroup/UserGroupPage.tsx
  49. 3 2
      apps/app/src/client/components/Admin/UserGroupDetail/UpdateParentConfirmModal.tsx
  50. 5 4
      apps/app/src/client/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  51. 3 2
      apps/app/src/client/components/Admin/Users/PasswordResetModal.jsx
  52. 2 2
      apps/app/src/client/components/Admin/Users/RevokeAdminButton.tsx
  53. 2 2
      apps/app/src/client/components/Admin/Users/RevokeAdminMenuItem.tsx
  54. 2 2
      apps/app/src/client/components/Admin/Users/StatusSuspendMenuItem.tsx
  55. 3 3
      apps/app/src/client/components/Admin/Users/UserInviteModal.jsx
  56. 2 7
      apps/app/src/client/components/AlertSiteUrlUndefined.tsx
  57. 2 2
      apps/app/src/client/components/Bookmarks/BookmarkFolderItem.tsx
  58. 2 2
      apps/app/src/client/components/Bookmarks/BookmarkFolderMenu.tsx
  59. 7 6
      apps/app/src/client/components/Bookmarks/BookmarkFolderTree.tsx
  60. 9 8
      apps/app/src/client/components/Bookmarks/BookmarkItem.tsx
  61. 5 4
      apps/app/src/client/components/Comments.tsx
  62. 3 4
      apps/app/src/client/components/Common/DrawerToggler/DrawerToggler.tsx
  63. 13 10
      apps/app/src/client/components/Common/Dropdown/PageItemControl.tsx
  64. 65 61
      apps/app/src/client/components/Common/ImageCropModal.tsx
  65. 25 23
      apps/app/src/client/components/CreateTemplateModal/CreateTemplateModal.tsx
  66. 19 0
      apps/app/src/client/components/CreateTemplateModal/dynamic.tsx
  67. 1 0
      apps/app/src/client/components/CreateTemplateModal/index.ts
  68. 2 2
      apps/app/src/client/components/DataTransferForm.tsx
  69. 0 71
      apps/app/src/client/components/DeleteBookmarkFolderModal.tsx
  70. 96 0
      apps/app/src/client/components/DeleteBookmarkFolderModal/DeleteBookmarkFolderModal.tsx
  71. 18 0
      apps/app/src/client/components/DeleteBookmarkFolderModal/dynamic.tsx
  72. 1 0
      apps/app/src/client/components/DeleteBookmarkFolderModal/index.ts
  73. 4 4
      apps/app/src/client/components/DescendantsPageList.tsx
  74. 0 129
      apps/app/src/client/components/DescendantsPageListModal.tsx
  75. 0 0
      apps/app/src/client/components/DescendantsPageListModal/DescendantsPageListModal.module.scss
  76. 22 7
      apps/app/src/client/components/DescendantsPageListModal/DescendantsPageListModal.spec.tsx
  77. 164 0
      apps/app/src/client/components/DescendantsPageListModal/DescendantsPageListModal.tsx
  78. 18 0
      apps/app/src/client/components/DescendantsPageListModal/dynamic.tsx
  79. 1 0
      apps/app/src/client/components/DescendantsPageListModal/index.ts
  80. 0 93
      apps/app/src/client/components/EmptyTrashModal.tsx
  81. 117 0
      apps/app/src/client/components/EmptyTrashModal/EmptyTrashModal.tsx
  82. 19 0
      apps/app/src/client/components/EmptyTrashModal/dynamic.tsx
  83. 1 0
      apps/app/src/client/components/EmptyTrashModal/index.ts
  84. 46 16
      apps/app/src/client/components/GrantedGroupsInheritanceSelectModal/GrantedGroupsInheritanceSelectModal.tsx
  85. 18 0
      apps/app/src/client/components/GrantedGroupsInheritanceSelectModal/dynamic.tsx
  86. 1 0
      apps/app/src/client/components/GrantedGroupsInheritanceSelectModal/index.ts
  87. 6 6
      apps/app/src/client/components/Hotkeys/Subscribers/CreatePage.jsx
  88. 3 3
      apps/app/src/client/components/Hotkeys/Subscribers/EditPage.jsx
  89. 5 4
      apps/app/src/client/components/Hotkeys/Subscribers/FocusToGlobalSearch.jsx
  90. 3 2
      apps/app/src/client/components/Hotkeys/Subscribers/ShowShortcutsModal.tsx
  91. 2 2
      apps/app/src/client/components/IdenticalPathPage.tsx
  92. 2 2
      apps/app/src/client/components/InAppNotification/InAppNotificationDropdown.tsx
  93. 3 2
      apps/app/src/client/components/InAppNotification/InAppNotificationPage.tsx
  94. 2 2
      apps/app/src/client/components/InvitedForm.tsx
  95. 18 16
      apps/app/src/client/components/ItemsTree/ItemsTree.tsx
  96. 5 10
      apps/app/src/client/components/Maintenance/Maintenance.tsx
  97. 2 2
      apps/app/src/client/components/Me/AccessTokenScopeList.tsx
  98. 2 2
      apps/app/src/client/components/Me/AccessTokenScopeSelect.tsx
  99. 3 3
      apps/app/src/client/components/Me/ApiSettings.tsx
  100. 2 3
      apps/app/src/client/components/Me/ApiTokenSettings.tsx

+ 5 - 0
.changeset/healthy-pianos-brake.md

@@ -0,0 +1,5 @@
+---
+'@growi/core': major
+---
+
+Remove global socket management and useSWRStatic

+ 5 - 0
.changeset/lazy-penguins-hammer.md

@@ -0,0 +1,5 @@
+---
+'@growi/core': major
+---
+
+Update IPage interfaces family

+ 2 - 0
.gitignore

@@ -36,8 +36,10 @@ yarn-error.log*
 
 # IDE, dev #
 .idea
+.claude
 *.orig
 *.code-workspace
+*.timestamp-*.mjs
 
 # turborepo
 .turbo

+ 104 - 0
.serena/memories/apps-app-detailed-architecture.md

@@ -0,0 +1,104 @@
+# apps/app アーキテクチャ詳細ガイド
+
+## 概要
+`apps/app` は GROWI のメインアプリケーションで、Next.js ベースのフルスタック Web アプリケーションです。
+
+## エントリーポイント
+- **サーバーサイド**: `server/app.ts` - OpenTelemetry 初期化と Crowi サーバー起動を担当
+- **クライアントサイド**: `pages/_app.page.tsx` - Next.js アプリのルートコンポーネント
+
+## ディレクトリ構成の方針
+
+### フィーチャーベース(新しい方針)
+`features/` ディレクトリは機能ごとに整理され、各フィーチャーは以下の構造を持つ:
+- `interfaces/` - TypeScript 型定義
+- `server/` - サーバーサイドロジック(models, routes, services)
+- `client/` - クライアントサイドロジック(components, stores, services)
+- `utils/` - 共通ユーティリティ
+
+#### 主要フィーチャー
+- `openai/` - AI アシスタント機能(OpenAI 統合)
+- `external-user-group/` - 外部ユーザーグループ管理
+- `page-bulk-export/` - ページ一括エクスポート
+- `growi-plugin/` - プラグインシステム
+- `search/` - 検索機能
+- `mermaid/` - Mermaid 図表レンダリング
+- `plantuml/` - PlantUML 図表レンダリング
+- `callout/` - コールアウト(注意書き)機能
+- `comment/` - コメント機能
+- `templates/` - テンプレート機能
+- `rate-limiter/` - レート制限
+- `opentelemetry/` - テレメトリ・監視
+
+### レガシー構造(段階的移行予定)
+
+#### ユニバーサル(サーバー・クライアント共通)
+- `components/` - React コンポーネント(ページレベル、レイアウト、共通)
+- `interfaces/` - TypeScript インターフェース
+- `models/` - データモデル定義
+- `services/` - ビジネスロジック(レンダラーなど)
+- `stores-universal/` - ユニバーサル状態管理(SWR コンテキスト等)
+
+#### サーバーサイド専用
+- `server/` - サーバーサイドコード
+  - `models/` - Mongoose モデル
+  - `routes/` - Express ルート(API v3含む)
+  - `service/` - サーバーサイドサービス
+  - `middlewares/` - Express ミドルウェア
+  - `util/` - サーバーサイドユーティリティ
+  - `events/` - イベントエミッター
+  - `crowi/` - アプリケーション初期化
+
+#### クライアントサイド専用
+- `client/` - クライアントサイドコード
+  - `components/` - React コンポーネント
+  - `services/` - クライアントサイドサービス
+  - `util/` - クライアントサイドユーティリティ
+  - `interfaces/` - クライアント固有の型定義
+  - `models/` - クライアントサイドモデル
+
+#### Next.js Pages Router
+- `pages/` - Next.js ページルート
+  - `admin/` - 管理画面ページ
+  - `me/` - ユーザー設定ページ
+  - `[[...path]]/` - 動的ページルート(Catch-all)
+  - `share/` - 共有ページ
+  - `login/` - ログインページ
+
+#### 状態管理・UI
+- `states/` - Jotai 状態管理(ページ、UI、サーバー設定)
+- `stores/` - レガシー状態管理(段階的に states/ に移行)
+- `styles/` - SCSS スタイル
+
+#### その他
+- `utils/` - 汎用ユーティリティ
+- `migrations/` - データベースマイグレーション
+- `@types/` - TypeScript 型拡張
+
+## 開発指針
+
+### 新機能開発
+新しい機能は `features/` ディレクトリにフィーチャーベースで実装し、以下を含める:
+1. インターフェース定義
+2. サーバーサイド実装(必要に応じて)
+3. クライアントサイド実装(必要に応じて)
+4. 共通ユーティリティ
+
+### 既存機能の改修
+既存のレガシー構造は段階的に features/ に移行することが推奨される。
+
+### 重要な技術スタック
+- **フレームワーク**: Next.js (Pages Router)
+- **状態管理**: Jotai (新), SWR (データフェッチング)
+- **スタイル**: SCSS, CSS Modules
+- **サーバー**: Express.js
+- **データベース**: MongoDB (Mongoose)
+- **型システム**: TypeScript
+- **監視**: OpenTelemetry
+
+## 特記事項
+- AI 統合機能(OpenAI)は最も複雑なフィーチャーの一つ
+- プラグインシステムにより機能拡張可能
+- 多言語対応(i18next)
+- 複数の認証方式サポート
+- レート制限・セキュリティ機能内蔵

+ 163 - 0
.serena/memories/apps-app-development-patterns.md

@@ -0,0 +1,163 @@
+# apps/app 開発ワークフロー・パターン集
+
+## よくある開発パターン
+
+### 新しいページ作成
+1. `pages/` にページファイル作成(`.page.tsx`)
+2. 必要に応じてレイアウト定義
+3. サーバーサイドプロパティ設定 (`getServerSideProps`)
+4. 状態管理セットアップ
+5. スタイル追加
+
+### 新しい API エンドポイント
+1. `server/routes/apiv3/` にルートファイル作成
+2. バリデーション定義
+3. サービス層実装
+4. レスポンス形式定義
+5. OpenAPI 仕様更新
+
+### 新しいフィーチャー実装
+1. `features/新機能名/` ディレクトリ作成
+2. `interfaces/` で型定義
+3. `server/` でバックエンド実装
+4. `client/` でフロントエンド実装
+5. `utils/` で共通ロジック
+
+### コンポーネント作成
+1. 適切なディレクトリに配置
+2. TypeScript プロパティ定義
+3. CSS Modules でスタイル
+4. JSDoc コメント追加
+5. テストファイル作成
+
+## 重要な設計パターン
+
+### SWR データフェッチング
+```typescript
+const { data, error, mutate } = useSWR('/api/v3/pages', fetcher);
+```
+
+### Jotai 状態管理
+```typescript
+const pageAtom = atom(initialPageState);
+const [page, setPage] = useAtom(pageAtom);
+```
+
+### CSS Modules スタイリング
+```scss
+.componentName {
+  @extend %some-placeholder;
+  @include some-mixin;
+}
+```
+
+### API ルート実装
+```typescript
+export const getPageHandler = async(req: NextApiRequest, res: NextApiResponse) => {
+  // バリデーション
+  // ビジネスロジック
+  // レスポンス
+};
+```
+
+## ファイル構成のベストプラクティス
+
+### フィーチャーディレクトリ例
+```
+features/my-feature/
+├── interfaces/
+│   └── my-feature.ts
+├── server/
+│   ├── models/
+│   ├── routes/
+│   └── services/
+├── client/
+│   ├── components/
+│   ├── stores/
+│   └── services/
+└── utils/
+    └── common-logic.ts
+```
+
+### コンポーネントディレクトリ例
+```
+components/MyComponent/
+├── MyComponent.tsx
+├── MyComponent.module.scss
+├── MyComponent.spec.tsx
+├── index.ts
+└── sub-components/
+```
+
+## 開発時のチェックリスト
+
+### コード品質
+- [ ] TypeScript エラーなし
+- [ ] ESLint ルール準拠
+- [ ] テストケース作成
+- [ ] 型安全性確保
+- [ ] パフォーマンス影響確認
+
+### 機能要件
+- [ ] 国際化対応(i18n)
+- [ ] セキュリティチェック
+- [ ] アクセシビリティ対応
+- [ ] レスポンシブデザイン
+- [ ] エラーハンドリング
+
+### API 設計
+- [ ] RESTful 設計原則
+- [ ] 適切な HTTP ステータスコード
+- [ ] バリデーション実装
+- [ ] レート制限対応
+- [ ] ドキュメント更新
+
+## デバッグ・トラブルシューティング
+
+### よくある問題
+1. **型エラー**: tsconfig.json 設定確認
+2. **スタイル適用されない**: CSS Modules インポート確認
+3. **API エラー**: ミドルウェア順序確認
+4. **状態同期問題**: SWR キー重複確認
+5. **ビルドエラー**: 依存関係バージョン確認
+
+### デバッグツール
+- Next.js Dev Tools
+- React Developer Tools
+- Network タブ(API 監視)
+- Console ログ
+- Lighthouse(パフォーマンス)
+
+## パフォーマンス最適化
+
+### フロントエンド
+- コンポーネント lazy loading
+- 画像最適化
+- Bundle サイズ監視
+- メモ化(useMemo, useCallback)
+
+### バックエンド
+- データベースクエリ最適化
+- キャッシュ戦略
+- 非同期処理
+- リソース使用量監視
+
+## セキュリティ考慮事項
+
+### 実装時の注意
+- 入力サニタイゼーション
+- CSRF 対策
+- XSS 防止
+- 認証・認可チェック
+- 機密情報の適切な取り扱い
+
+## デプロイ・運用
+
+### 環境設定
+- 環境変数管理
+- データベース接続
+- 外部サービス連携
+- ログ設定
+- 監視設定
+
+このガイドは apps/app の開発を効率的に進めるための包括的な情報源として活用してください。

+ 131 - 0
.serena/memories/apps-app-jotai-directory-structure.md

@@ -0,0 +1,131 @@
+# Jotai ディレクトリ構造・ファイル配置
+
+## 📁 確立されたディレクトリ構造
+
+```
+states/
+├── ui/
+│   ├── sidebar/                    # サイドバー状態 ✅
+│   ├── editor/                     # エディター状態 ✅
+│   ├── device.ts                   # デバイス状態 ✅
+│   ├── page.ts                     # ページUI状態 ✅
+│   ├── toc.ts                      # TOC状態 ✅
+│   ├── untitled-page.ts            # 無題ページ状態 ✅
+│   ├── page-abilities.ts           # ページ権限判定状態 ✅ DERIVED ATOM!
+│   ├── unsaved-warning.ts          # 未保存警告状態 ✅ JOTAI PATTERN!
+│   ├── page-tree-desc-count-map.ts # ページツリー子孫カウント ✅ JOTAI PATTERN!
+│   └── modal/                      # 個別モーダルファイル ✅
+│       ├── page-create.ts          # ページ作成モーダル ✅
+│       ├── page-delete.ts          # ページ削除モーダル ✅
+│       ├── empty-trash.ts          # ゴミ箱空モーダル ✅
+│       ├── delete-attachment.ts    # 添付ファイル削除 ✅
+│       ├── delete-bookmark-folder.ts # ブックマークフォルダ削除 ✅
+│       ├── update-user-group-confirm.ts # ユーザーグループ更新確認 ✅
+│       ├── page-select.ts          # ページ選択モーダル ✅
+│       ├── page-presentation.ts    # プレゼンテーションモーダル ✅
+│       ├── put-back-page.ts        # ページ復元モーダル ✅
+│       ├── granted-groups-inheritance-select.ts # 権限グループ継承選択 ✅
+│       ├── drawio.ts               # Draw.ioモーダル ✅
+│       ├── handsontable.ts         # Handsontableモーダル ✅
+│       ├── private-legacy-pages-migration.ts # プライベートレガシーページ移行 ✅
+│       ├── descendants-page-list.ts # 子孫ページリスト ✅
+│       ├── conflict-diff.ts        # 競合差分モーダル ✅
+│       ├── page-bulk-export-select.ts # ページ一括エクスポート選択 ✅
+│       ├── drawio-for-editor.ts    # エディタ用Draw.io ✅
+│       ├── link-edit.ts            # リンク編集モーダル ✅
+│       └── template.ts             # テンプレートモーダル ✅
+├── page/                           # ページ関連状態 ✅
+├── server-configurations/          # サーバー設定状態 ✅
+├── global/                         # グローバル状態 ✅
+├── socket-io/                      # Socket.IO状態 ✅
+├── context.ts                      # 共通コンテキスト ✅
+└── features/
+    └── openai/
+        └── client/
+            └── states/             # OpenAI専用状態 ✅
+                ├── index.ts        # exports ✅
+                └── unified-merge-view.ts # UnifiedMergeView状態 ✅
+```
+
+## 📋 ファイル配置ルール
+
+### UI状態系 (`states/ui/`)
+- **個別機能ファイル**: デバイス、TOC、無題ページ等の単一機能
+- **複合機能ディレクトリ**: サイドバー、エディター等の複数機能
+- **モーダル専用ディレクトリ**: `modal/` 配下に個別モーダルファイル
+
+### データ関連状態 (`states/`)
+- **ページ関連**: `page/` ディレクトリ
+- **サーバー設定**: `server-configurations/` ディレクトリ
+- **グローバル状態**: `global/` ディレクトリ
+- **通信系**: `socket-io/` ディレクトリ
+
+### 機能別専用states (`states/features/`)
+- **OpenAI機能**: `features/openai/client/states/`
+- **将来の機能**: `features/{feature-name}/client/states/`
+
+## 🏷️ ファイル命名規則
+
+### 状態ファイル
+- **単一機能**: `{機能名}.ts` (例: `device.ts`, `toc.ts`)
+- **複合機能**: `{機能名}/` ディレクトリ(例: `sidebar/`, `editor/`)
+- **モーダル**: `modal/{モーダル名}.ts`(例: `modal/page-create.ts`)
+
+### export/import規則
+- **公開API**: `index.ts` でのre-export
+- **内部atom**: `_atomsForDerivedAbilities` 特殊名export
+- **機能専用**: 機能ディレクトリ配下の独立したstates
+
+## 📊 ファイルサイズ・複雑度の目安
+
+### 適切なファイル分割
+- **単一ファイル**: ~100行以内、単一責務
+- **ディレクトリ分割**: 複数のhook・機能がある場合
+- **個別モーダルファイル**: 1モーダル = 1ファイル原則
+
+### 複雑度による分類
+- **シンプル**: Boolean状態、基本的な値管理
+- **中程度**: 複数プロパティ、actions分離
+- **複雑**: Derived Atom、Map操作、副作用統合
+
+## 🔗 依存関係・インポート構造
+
+### インポート階層
+```
+components/
+├── import from states/ui/          # UI状態
+├── import from states/page/        # ページ状態  
+├── import from states/global/      # グローバル状態
+└── import from states/features/    # 機能別状態
+
+states/ui/
+├── 内部相互参照可能
+└── states/page/, states/global/ からのimport
+
+states/features/{feature}/
+├── states/ui/ からのimport
+├── 他のfeatures からのimport禁止
+└── 独立性を保つ
+```
+
+### 特殊名Export使用箇所
+```
+states/page/internal-atoms.ts → _atomsForDerivedAbilities
+states/ui/editor/atoms.ts → _atomsForDerivedAbilities  
+states/global/global.ts → _atomsForDerivedAbilities
+states/context.ts → _atomsForDerivedAbilities
+```
+
+## 🎯 今後の拡張指針
+
+### 新規機能追加時
+1. **機能専用度評価**: 汎用 → `states/ui/`、専用 → `states/features/`
+2. **複雑度評価**: シンプル → 単一ファイル、複雑 → ディレクトリ
+3. **依存関係確認**: 既存atomの活用可能性
+4. **命名規則遵守**: 確立された命名パターンに従う
+
+### ディレクトリ構造維持
+- **責務単一原則**: 1ファイル = 1機能・責務
+- **依存関係最小化**: 循環参照の回避
+- **拡張性**: 将来の機能追加を考慮した構造
+- **検索性**: ファイル名から機能が推測できる命名

+ 84 - 0
.serena/memories/apps-app-modal-performance-optimization-v2-completion-summary.md

@@ -0,0 +1,84 @@
+# モーダル最適化 V2 完了サマリー
+
+## 📊 最終結果
+
+**完了日**: 2025-10-15  
+**達成率**: **46/51モーダル (90%)**
+
+## ✅ 完了内容
+
+### Phase 1-7: 全46モーダル最適化完了
+
+#### 主要最適化パターン
+1. **Container-Presentation分離** (14モーダル)
+   - 重いロジックをSubstanceに分離
+   - Containerで条件付きレンダリング
+   
+2. **Container超軽量化** (11モーダル - Category B)
+   - Container: 6-15行に削減
+   - 全hooks/state/callbacksをSubstanceに移動
+   - Props最小化 (1-4個のみ)
+   - **実績**: AssociateModal 40行→6行 (85%削減)
+
+3. **Fadeout Transition修正** (25モーダル)
+   - 早期return削除: `if (!isOpen) return <></>;` → `{isOpen && <Substance />}`
+   - Modal常時レンダリングでtransition保証
+
+4. **計算処理メモ化** (全モーダル)
+   - useMemo/useCallbackで不要な再計算防止
+
+## 🎯 確立されたパターン
+
+### Ultra Slim Container Pattern
+```tsx
+// Container (6-10行)
+const Modal = () => {
+  const status = useModalStatus();
+  const { close } = useModalActions();
+  return (
+    <Modal isOpen={status?.isOpened} toggle={close}>
+      {status?.isOpened && <Substance data={status.data} closeModal={close} />}
+    </Modal>
+  );
+};
+
+// Substance (全ロジック)
+const Substance = ({ data, closeModal }) => {
+  const { t } = useTranslation();
+  const { mutate } = useSWR(...);
+  const handler = useCallback(...);
+  // 全てのロジック
+};
+```
+
+## 🔶 未完了 (優先度低)
+
+### Admin系モーダル (11個)
+ユーザー要望により優先度低下、V3では対象外:
+- UserGroupDeleteModal.tsx
+- UserGroupUserModal.tsx
+- UpdateParentConfirmModal.tsx
+- SelectCollectionsModal.tsx
+- ConfirmModal.tsx
+- その他6個
+
+### クラスコンポーネント (2個) - 対象外
+- UserInviteModal.jsx
+- GridEditModal.jsx
+
+## 📈 期待される効果
+
+1. **初期読み込み高速化** - 不要なコンポーネントレンダリング削減
+2. **メモリ効率化** - Container-Presentation分離
+3. **レンダリング最適化** - 計算処理のメモ化
+4. **UX向上** - Fadeout transition保証
+5. **保守性向上** - Container超軽量化 (最大85%削減)
+
+## ➡️ Next: V3へ
+
+V3では動的ロード最適化に移行:
+- モーダルの遅延読み込み実装
+- 初期バンドルサイズ削減
+- useDynamicModalLoader実装
+
+**V2の成果物を基盤として、V3でさらなる最適化を実現**

+ 640 - 0
.serena/memories/apps-app-modal-performance-optimization-v3-completion-summary.md

@@ -0,0 +1,640 @@
+# モーダル・コンポーネント パフォーマンス最適化 V3 - 完了記録
+
+**完了日**: 2025-10-20  
+**プロジェクト期間**: 2025-10-15 〜 2025-10-20  
+**最終成果**: 34コンポーネント最適化完了 🎉
+
+---
+
+## 📊 最終成果サマリー
+
+### 実装完了コンポーネント
+
+| カテゴリ | 完了数 | 詳細 |
+|---------|--------|------|
+| **モーダル** | 25個 | useLazyLoader動的ロード |
+| **PageAlerts** | 4個 | Container-Presentation分離 + 条件付きレンダリング |
+| **Sidebar** | 1個 | AiAssistantSidebar (useLazyLoader + SWR最適化) |
+| **その他** | 4個 | 既存のLazyLoaded実装 |
+| **合計** | **34個** | **全体最適化達成** ✨ |
+
+### V3の主要改善
+
+1. **useLazyLoader実装**: 汎用的な動的ローディングフック
+   - グローバルキャッシュによる重複実行防止
+   - 表示条件に基づく真の遅延ロード
+   - テストカバレッジ完備 (12 tests passing)
+
+2. **3つのケース別最適化パターン確立**:
+   - **ケースA**: 単一ファイル → ディレクトリ構造化
+   - **ケースB**: Container-Presentation分離 (Modal外枠なし) → リファクタリング
+   - **ケースC**: Container-Presentation分離 (Modal外枠あり) → 最短経路 ⭐
+
+3. **PageAlerts最適化**: Next.js dynamic()からuseLazyLoaderへの移行
+   - 全ページの初期ロード削減
+   - Container-Presentation分離による不要なレンダリング削減
+   - 条件付きレンダリングによるパフォーマンス向上
+
+4. **Sidebar最適化**: AiAssistantSidebar
+   - useLazyLoader適用(isOpened時のみロード)
+   - useSWRxThreads を Substance へ移動(条件付き実行)
+
+---
+
+## 🎯 パフォーマンス効果
+
+### 初期バンドルサイズ削減
+- **34コンポーネント分の遅延ロード**
+- モーダル平均150行 × 25個 = 約3,750行
+- PageAlerts 4個(最大412行)
+- Sidebar 1個(約600行)
+- **合計: 約5,000行以上のコード削減**
+
+### 初期レンダリングコスト削減
+- Container-Presentation分離による無駄なレンダリング回避
+- 条件が満たされない場合、Substance が全くレンダリングされない
+- SWR hooks の不要な実行を防止
+
+### メモリ効率向上
+- グローバルキャッシュによる重複ロード防止
+- 一度ロードされたコンポーネントは再利用
+
+---
+
+## 📚 技術ガイド
+
+### 1. useLazyLoader フック
+
+**ファイル**: `apps/app/src/client/util/use-lazy-loader.ts`
+
+**特徴**:
+- グローバルキャッシュによる重複実行防止
+- 型安全性(ジェネリクス対応)
+- エラーハンドリング内蔵
+
+**基本的な使い方**:
+```tsx
+const Component = useLazyLoader(
+  'unique-key',           // グローバルキャッシュ用の一意なキー
+  () => import('./Component'), // dynamic import
+  isActive,               // ロードトリガー条件
+);
+
+return Component ? <Component /> : null;
+```
+
+**テスト**: 12 tests passing
+
+---
+
+### 2. ディレクトリ構造と命名規則
+
+```
+apps/app/.../[ComponentName]/
+├── index.ts                    # エクスポート用 (named export)
+├── [ComponentName].tsx         # 実際のコンポーネント (named export)
+└── dynamic.tsx                 # 動的ローダー (named export)
+```
+
+**命名規則**:
+- Hook: `useLazyLoader`
+- 動的ローダーコンポーネント: `[ComponentName]LazyLoaded`
+- ファイル名: `dynamic.tsx`
+- Named Export: 全てのコンポーネントで使用
+
+---
+
+### 3. 実装パターン: モーダル
+
+#### モーダル最適化の3ケース
+
+**ケースA: 単一ファイル**
+- 現状: 単一ファイルで完結
+- 対応: ディレクトリ化 + dynamic.tsx作成
+- 所要時間: 約10分
+
+**ケースB: Container無Modal**
+- 現状: Substance と Container あり、但し Container に `<Modal>` なし
+- 対応: Container に `<Modal>` 外枠追加 + リファクタリング
+- 所要時間: 約15分
+
+**ケースC: Container有Modal** ⭐
+- 現状: 理想的な構造(V2完了済み)
+- 対応: named export化 + dynamic.tsx作成のみ
+- 所要時間: 約5分(最短経路)
+
+#### 実装例: ShortcutsModal (ケースC)
+
+**dynamic.tsx**:
+```tsx
+import type { JSX } from 'react';
+import { useLazyLoader } from '~/components/utils/use-lazy-loader';
+import { useShortcutsModalStatus } from '~/states/ui/modal/shortcuts';
+
+export const ShortcutsModalLazyLoaded = (): JSX.Element => {
+  const status = useShortcutsModalStatus();
+
+  const ShortcutsModal = useLazyLoader(
+    'shortcuts-modal',
+    () => import('./ShortcutsModal').then(mod => ({ default: mod.ShortcutsModal })),
+    status?.isOpened ?? false,
+  );
+
+  return ShortcutsModal ? <ShortcutsModal /> : <></>;
+};
+```
+
+**index.ts**:
+```tsx
+export { ShortcutsModalLazyLoaded } from './dynamic';
+```
+
+**BasicLayout.tsx**:
+```tsx
+// Before: Next.js dynamic()
+const ShortcutsModal = dynamic(() => import('~/client/components/ShortcutsModal'), { ssr: false });
+
+// After: 直接import (named)
+import { ShortcutsModalLazyLoaded } from '~/client/components/ShortcutsModal';
+```
+
+---
+
+### 4. 実装パターン: PageAlerts
+
+#### Container-Presentation分離による最適化
+
+**特徴**:
+- Container: 軽量な条件チェックのみ(SWR hooks を含まない)
+- Substance: UI + 状態管理 + SWR データフェッチ
+- 条件が満たされない場合、Substance は全くレンダリングされない
+
+#### 実装例: FixPageGrantAlert
+
+**構造**:
+```
+FixPageGrantAlert/
+├── FixPageGrantModal.tsx (新規) - 342行のモーダルコンポーネント
+├── FixPageGrantAlert.tsx (リファクタリング済み)
+│   ├── FixPageGrantAlert (Container) - ~35行、簡素化
+│   └── FixPageGrantAlertSubstance (Presentation) - ~30行
+└── dynamic.tsx (useLazyLoader パターン)
+```
+
+**Container** (~35行):
+```tsx
+export const FixPageGrantAlert = (): JSX.Element => {
+  const currentUser = useCurrentUser();
+  const pageData = useCurrentPageData();
+  const hasParent = pageData != null ? pageData.parent != null : false;
+  const pageId = pageData?._id;
+
+  const { data: dataIsGrantNormalized } = useSWRxCurrentGrantData(
+    currentUser != null ? pageId : null,
+  );
+  const { data: dataApplicableGrant } = useSWRxApplicableGrant(
+    currentUser != null ? pageId : null,
+  );
+
+  // Early returns for invalid states
+  if (pageData == null) return <></>;
+  if (!hasParent) return <></>;
+  if (dataIsGrantNormalized?.isGrantNormalized == null || dataIsGrantNormalized.isGrantNormalized) {
+    return <></>;
+  }
+
+  // Render Substance only when all conditions are met
+  if (pageId != null && dataApplicableGrant != null) {
+    return (
+      <FixPageGrantAlertSubstance
+        pageId={pageId}
+        dataApplicableGrant={dataApplicableGrant}
+        currentAndParentPageGrantData={dataIsGrantNormalized.grantData}
+      />
+    );
+  }
+
+  return <></>;
+};
+```
+
+**効果**:
+- 条件が満たされない場合、Substance が全くレンダリングされない
+- Modal コンポーネント(342行)が別ファイルで管理しやすい
+- コードサイズ: 412行 → Container 35行 + Substance 30行 + Modal 342行(別ファイル)
+
+#### 実装例: TrashPageAlert
+
+**特徴**:
+- Container で条件チェックのみ
+- Substance 内で useSWRxPageInfo を実行(条件付き)
+
+**Container** (~20行):
+```tsx
+export const TrashPageAlert = (): JSX.Element => {
+  const pageData = useCurrentPageData();
+  const isTrashPage = useIsTrashPage();
+  const pageId = pageData?._id;
+  const pagePath = pageData?.path;
+  const revisionId = pageData?.revision?._id;
+
+  // Lightweight condition checks in Container
+  const isEmptyPage = pageId == null || revisionId == null || pagePath == null;
+
+  // Show this alert only for non-empty pages in trash.
+  if (!isTrashPage || isEmptyPage) {
+    return <></>;
+  }
+
+  // Render Substance only when conditions are met
+  // useSWRxPageInfo will be executed only here
+  return (
+    <TrashPageAlertSubstance
+      pageId={pageId}
+      pagePath={pagePath}
+      revisionId={revisionId}
+    />
+  );
+};
+```
+
+**Substance** (~130行):
+```tsx
+const TrashPageAlertSubstance = (props: SubstanceProps): JSX.Element => {
+  const { pageId, pagePath, revisionId } = props;
+  
+  const pageData = useCurrentPageData();
+  
+  // useSWRxPageInfo is executed only when Substance is rendered
+  const { data: pageInfo } = useSWRxPageInfo(pageId);
+  
+  // ... UI レンダリング + モーダル操作
+};
+```
+
+**効果**:
+- ❌ **Before**: `useSWRxPageInfo` が常に実行される
+- ✅ **After**: Substance がレンダリングされる時のみ `useSWRxPageInfo` が実行される
+- ゴミ箱ページでない場合、不要な API 呼び出しを回避
+
+---
+
+### 5. 実装パターン: Sidebar
+
+#### AiAssistantSidebar の最適化
+
+**構造**:
+```
+AiAssistantSidebar/
+├── dynamic.tsx (新規) - useLazyLoader パターン
+├── AiAssistantSidebar.tsx (リファクタリング済み)
+│   ├── AiAssistantSidebar (Container) - 簡素化、~30行
+│   └── AiAssistantSidebarSubstance (Presentation) - 複雑なロジック、~500行
+└── (その他のサブコンポーネント)
+```
+
+**dynamic.tsx**:
+```tsx
+import type { FC } from 'react';
+import { memo } from 'react';
+import { useLazyLoader } from '~/components/utils/use-lazy-loader';
+import { useAiAssistantSidebarStatus } from '../../../states';
+
+export const AiAssistantSidebarLazyLoaded: FC = memo(() => {
+  const aiAssistantSidebarData = useAiAssistantSidebarStatus();
+  const isOpened = aiAssistantSidebarData?.isOpened ?? false;
+
+  const ComponentToRender = useLazyLoader(
+    'ai-assistant-sidebar',
+    () => import('./AiAssistantSidebar').then(mod => ({ default: mod.AiAssistantSidebar })),
+    isOpened,
+  );
+
+  if (ComponentToRender == null) {
+    return null;
+  }
+
+  return <ComponentToRender />;
+});
+```
+
+**Container の軽量化**:
+```tsx
+export const AiAssistantSidebar: FC = memo((): JSX.Element => {
+  const aiAssistantSidebarData = useAiAssistantSidebarStatus();
+  const { close: closeAiAssistantSidebar } = useAiAssistantSidebarActions();
+  const { disable: disableUnifiedMergeView } = useUnifiedMergeViewActions();
+
+  const aiAssistantData = aiAssistantSidebarData?.aiAssistantData;
+  const threadData = aiAssistantSidebarData?.threadData;
+  const isOpened = aiAssistantSidebarData?.isOpened;
+  const isEditorAssistant = aiAssistantSidebarData?.isEditorAssistant ?? false;
+
+  // useSWRxThreads を削除(Substance に移動)
+
+  useEffect(() => {
+    if (!aiAssistantSidebarData?.isOpened) {
+      disableUnifiedMergeView();
+    }
+  }, [aiAssistantSidebarData?.isOpened, disableUnifiedMergeView]);
+
+  if (!isOpened) {
+    return <></>;
+  }
+
+  return (
+    <div className="...">
+      <AiAssistantSidebarSubstance
+        isEditorAssistant={isEditorAssistant}
+        threadData={threadData}
+        aiAssistantData={aiAssistantData}
+        onCloseButtonClicked={closeAiAssistantSidebar}
+      />
+    </div>
+  );
+});
+```
+
+**Substance に useSWRxThreads を移動**:
+```tsx
+const AiAssistantSidebarSubstance: React.FC<Props> = (props) => {
+  // useSWRxThreads is executed only when Substance is rendered
+  const { data: threads, mutate: mutateThreads } = useSWRxThreads(aiAssistantData?._id);
+  const { refreshThreadData } = useAiAssistantSidebarActions();
+
+  // refresh thread data when the data is changed
+  useEffect(() => {
+    if (threads == null) return;
+    const currentThread = threads.find(t => t.threadId === threadData?.threadId);
+    if (currentThread != null) {
+      refreshThreadData(currentThread);
+    }
+  }, [threads, refreshThreadData, threadData?.threadId]);
+
+  // ... UI レンダリング
+};
+```
+
+**効果**:
+- ❌ **Before**: Container で `useSWRxThreads` が実行される(isOpened が false でも)
+- ✅ **After**: Substance がレンダリングされる時のみ `useSWRxThreads` が実行される
+- サイドバーが開かれていない場合、不要な API 呼び出しを回避
+
+---
+
+## ✅ 完了コンポーネント一覧
+
+### モーダル (25個)
+
+#### 高頻度モーダル (0/2 - 意図的にスキップ) ⏭️
+- ⏭️ SearchModal (192行) - 検索機能、初期ロード維持
+- ⏭️ PageCreateModal (319行) - ページ作成、初期ロード維持
+
+#### 中頻度モーダル (6/6 - 100%完了) ✅
+- ✅ PageAccessoriesModal (2025-10-15) - ケースB
+- ✅ ShortcutsModal (2025-10-15) - ケースC
+- ✅ PageRenameModal (2025-10-16) - ケースC
+- ✅ PageDuplicateModal (2025-10-16) - ケースC
+- ✅ DescendantsPageListModal (2025-10-16) - ケースC
+- ✅ PageDeleteModal (2025-10-16) - ケースA
+
+#### 低頻度モーダル (19/38完了)
+
+**Session 1完了 (6個)** ✅:
+- ✅ DrawioModal (2025-10-16) - ケースC
+- ✅ HandsontableModal (2025-10-16) - ケースC + 複数ステータス対応
+- ✅ TemplateModal (2025-10-16) - ケースC + @growi/editor state
+- ✅ LinkEditModal (2025-10-16) - ケースC + @growi/editor state
+- ✅ TagEditModal (2025-10-16) - ケースC
+- ✅ ConflictDiffModal (2025-10-16) - ケースC
+
+**Session 2完了 (11個)** ✅:
+- ✅ DeleteBookmarkFolderModal (2025-10-17) - ケースC, BasicLayout
+- ✅ PutbackPageModal (2025-10-17) - ケースC, JSX→TSX変換
+- ✅ AiAssistantManagementModal (2025-10-17) - ケースC
+- ✅ PageSelectModal (2025-10-17) - ケースC
+- ✅ GrantedGroupsInheritanceSelectModal (2025-10-17) - ケースC
+- ✅ DeleteAttachmentModal (2025-10-17) - ケースC
+- ✅ PageBulkExportSelectModal (2025-10-17) - ケースC
+- ✅ PagePresentationModal (2025-10-17) - ケースC
+- ✅ EmptyTrashModal (2025-10-17) - ケースB
+- ✅ CreateTemplateModal (2025-10-17) - ケースB
+- ✅ DeleteCommentModal (2025-10-17) - ケースB
+
+**Session 3 & 4完了 (2個)** ✅:
+- ✅ SearchOptionModal (2025-10-17) - ケースA, SearchPage配下
+- ✅ DeleteAiAssistantModal (2025-10-17) - ケースC, AiAssistantSidebar配下
+
+---
+
+### PageAlerts (4個) 🎉
+
+**Session 5完了 (2025-10-17)** ✅:
+
+全てPageAlerts.tsxで`useLazyLoader`を使用した動的ロード実装に変更。
+
+1. **TrashPageAlert** (171行)
+   - **Container**: ~20行、条件チェックのみ
+   - **Substance**: ~130行、useSWRxPageInfo + UI
+   - **表示条件**: `isTrashPage`
+   - **効果**: ゴミ箱ページでない場合、useSWRxPageInfo が実行されない
+
+2. **PageRedirectedAlert** (60行)
+   - **Container**: ~12行、条件チェックのみ
+   - **Substance**: ~65行、UI + 状態管理 + 非同期処理
+   - **表示条件**: `redirectFrom != null && redirectFrom !== ''`
+   - **効果**: リダイレクトされていない場合、Substance が全くレンダリングされない
+
+3. **FullTextSearchNotCoverAlert** (40行)
+   - **isActive props パターン**: 条件付きレンダリング
+   - **表示条件**: `markdownLength > elasticsearchMaxBodyLengthToIndex`
+   - **効果**: 長いページのみで表示
+
+4. **FixPageGrantAlert** ⭐ 最重要 (412行)
+   - **構造**: Modal分離 + Container-Presentation分離
+   - **Container**: ~35行、SWR hooks + 条件チェック
+   - **Substance**: ~30行、Alert UI + Modal 状態管理
+   - **Modal**: 342行、別ファイル
+   - **表示条件**: `!dataIsGrantNormalized.isGrantNormalized`
+   - **効果**: 最大のバンドル削減、条件が満たされない場合 Substance レンダリングなし
+
+---
+
+### Sidebar (1個) ✨
+
+**Session 6完了 (2025-10-20)** ✅:
+
+**AiAssistantSidebar** (約600行)
+- **dynamic.tsx**: useLazyLoader パターン
+- **Container**: ~30行、aiAssistantSidebarData + actions
+- **Substance**: ~500行、useSWRxThreads + UI + ハンドラー
+- **最適化**:
+  - isOpened 時のみコンポーネントをロード
+  - useSWRxThreads を Substance へ移動(条件付き実行)
+  - threads のリフレッシュロジックも Substance 内に移動
+- **効果**: サイドバーが開かれていない場合、useSWRxThreads が実行されない
+
+---
+
+### 既存のLazyLoaded実装 (4個)
+
+既にuseLazyLoaderパターンで実装済み:
+- ✅ DeleteBookmarkFolderModalLazyLoaded
+- ✅ DeleteAttachmentModalLazyLoaded
+- ✅ PageSelectModalLazyLoaded
+- ✅ PutBackPageModalLazyLoaded
+
+---
+
+## ⏭️ 最適化不要/スキップ(19個)
+
+### 非モーダルコンポーネント(1個)
+- ❌ **ShowShortcutsModal** (35行) - 実体はモーダルではなくホットキートリガーのみ
+
+### 親ページ低頻度 - Me画面(2個)
+- ⏸️ **AssociateModal** (142行) - Me画面(低頻度)内のモーダル
+- ⏸️ **DisassociateModal** (94行) - Me画面(低頻度)内のモーダル
+
+### 親ページ低頻度 - Admin画面(3個)
+- ⏸️ **ImageCropModal** (194行) - Admin/Customize(低頻度)内のモーダル
+- ⏸️ **DeleteSlackBotSettingsModal** (103行) - Admin/SlackIntegration(低頻度)内のモーダル
+- ⏸️ **PluginDeleteModal** (103行) - Admin/Plugins(低頻度)内のモーダル
+
+### 低優先スキップ(1個)
+- ⏸️ **PrivateLegacyPagesMigrationModal** (133行) - ユーザー指示によりスキップ
+
+### クラスコンポーネント(2個)
+- ❌ **UserInviteModal** (299行) - .jsx、対象外
+- ❌ **GridEditModal** (263行) - .jsx、対象外
+
+### 管理画面専用・低頻度(10個)
+
+管理画面自体が遅延ロードされており、使用頻度が極めて低いため最適化不要:
+
+- SelectCollectionsModal (222行) - ExportArchiveData
+- ImportCollectionConfigurationModal (228行) - ImportData
+- NotificationDeleteModal (53行) - Notification
+- DeleteAllShareLinksModal (61行) - Security
+- LdapAuthTestModal (72行) - Security
+- ConfirmBotChangeModal (58行) - SlackIntegration
+- UpdateParentConfirmModal (93行) - UserGroupDetail
+- UserGroupUserModal (110行) - UserGroupDetail
+- UserGroupDeleteModal (208行) - UserGroup
+- UserGroupModal (138行) - ExternalUserGroupManagement
+
+---
+
+## 📈 最適化進捗チャート
+
+```
+完了済み: ████████████████████████████████████████████████████████████  34/53 (64%) 🎉
+スキップ:  ████████                                                      8/53 (15%)
+対象外:   ██                                                            2/53 (4%)
+不要:     ███████████                                                  11/53 (21%)
+```
+
+**V3最適化完了!** 🎉
+
+---
+
+## 🎉 V3最適化完了サマリー
+
+### 達成内容
+- **モーダル最適化**: 25個
+- **PageAlerts最適化**: 4個
+- **Sidebar最適化**: 1個
+- **既存LazyLoaded**: 4個
+- **合計**: 34/53 (64%)
+
+### 主要成果
+
+1. **useLazyLoader実装**: 汎用的な動的ローディングフック
+   - グローバルキャッシュによる重複実行防止
+   - 表示条件に基づく真の遅延ロード
+   - テストカバレッジ完備
+
+2. **3つのケース別最適化パターン確立**:
+   - ケースA: 単一ファイル → ディレクトリ構造化
+   - ケースB: Container-Presentation分離 (Modal外枠なし) → リファクタリング
+   - ケースC: Container-Presentation分離 (Modal外枠あり) → 最短経路 ⭐
+
+3. **PageAlerts最適化**: Next.js dynamic()からuseLazyLoaderへの移行
+   - 全ページの初期ロード削減
+   - Container-Presentation分離による不要なレンダリング削減
+   - FixPageGrantAlert (412行) の大規模バンドル削減
+
+4. **Sidebar最適化**: AiAssistantSidebar
+   - useLazyLoader適用(isOpened時のみロード)
+   - useSWRxThreads を Substance へ移動(条件付き実行)
+
+### パフォーマンス効果
+
+- **初期バンドルサイズ削減**: 34コンポーネント分の遅延ロード(約5,000行以上)
+- **初期レンダリングコスト削減**: Container-Presentation分離による無駄なレンダリング回避
+- **メモリ効率向上**: グローバルキャッシュによる重複ロード防止
+- **API呼び出し削減**: SWR hooks の条件付き実行
+
+### 技術的成果
+
+- **Named Export標準化**: コード可読性とメンテナンス性向上
+- **型安全性保持**: ジェネリクスによる完全な型サポート
+- **開発体験向上**: 既存のインポートパスは変更不要
+- **テストカバレッジ**: useLazyLoader に12テスト
+
+---
+
+## 📝 今後の展開(オプション)
+
+### 残りの19個の評価
+
+現在スキップ・対象外としている19個について、将来的に再評価可能:
+
+1. **Me画面モーダル** (2個): Me画面自体の使用頻度が上がれば最適化検討
+2. **Admin画面モーダル** (13個): 管理機能の使用パターン変化で再評価
+3. **クラスコンポーネント** (2個): Function Component化後に最適化可能
+4. **高頻度モーダル** (2個): コード分割などの別アプローチを検討
+
+### さらなる最適化の可能性
+
+- 高頻度モーダル (SearchModal, PageCreateModal) のコード分割検討
+- 他のレイアウトでの同様パターン適用
+- ページトランジションの最適化
+- Sidebar系コンポーネントの同様最適化
+
+---
+
+## 🏆 完了日: 2025-10-20
+
+**V3最適化プロジェクト完了!** 🎉
+
+- モーダル最適化: 25個 ✅
+- PageAlerts最適化: 4個 ✅
+- Sidebar最適化: 1個 ✅
+- 既存LazyLoaded: 4個 ✅
+- 合計達成率: 64% (34/53) ✅
+- 目標達成! 🎊
+
+---
+
+## 📚 参考情報
+
+### 関連ドキュメント
+- V2完了サマリー: `apps-app-modal-performance-optimization-v2-completion-summary.md`
+- useLazyLoader実装: `apps/app/src/client/util/use-lazy-loader.ts`
+- useLazyLoaderテスト: `apps/app/src/client/util/use-lazy-loader.spec.tsx`
+
+### 重要な学び
+
+1. **正しい判断基準**:
+   - モーダル自身の利用頻度(親ページの頻度ではない)
+   - ファイルサイズ/複雑さ(50行以上で効果的、100行以上で強く推奨)
+   - レンダリングコスト
+
+2. **親の遅延ロード ≠ 子の遅延ロード**:
+   - 親がdynamic()でも、子モーダルは親と一緒にダウンロードされる
+   - 子モーダル自体の最適化が必要
+
+3. **Container-Presentation分離の効果**:
+   - Containerで条件チェック
+   - 条件が満たされない場合、Substanceは全くレンダリングされない
+   - SWR hooksの不要な実行を防止

+ 35 - 0
.serena/memories/apps-app-technical-specs.md

@@ -0,0 +1,35 @@
+# apps/app 技術仕様
+
+## ファイル構造・命名
+- Next.js: `*.page.tsx`
+- テスト: `*.spec.ts`, `*.integ.ts`
+- コンポーネント: `ComponentName.tsx`
+
+## API構造
+- **API v3**: `server/routes/apiv3/` (RESTful + OpenAPI準拠)
+- **Features API**: `features/*/server/routes/`
+
+## 状態管理
+- **Jotai** (推奨): `states/` - アトミック分離
+- **SWR**: `stores/` - データフェッチ・キャッシュ
+
+## データベース
+- **Mongoose**: `server/models/` (スキーマ定義)
+- **Serializers**: `serializers/` (レスポンス変換)
+
+## セキュリティ・i18n
+- **認証**: 複数プロバイダー + アクセストークン
+- **XSS対策**: `services/general-xss-filter/`
+- **i18n**: next-i18next (サーバー・クライアント両対応)
+
+## システム機能
+- **検索**: Elasticsearch統合
+- **監視**: OpenTelemetry (`features/opentelemetry/`)
+- **プラグイン**: 動的読み込み (`features/growi-plugin/`)
+
+## 開発ガイドライン
+1. 新機能は `features/` 実装
+2. TypeScript strict準拠
+3. Jotai状態管理優先
+4. API v3形式
+5. セキュリティ・i18n・テスト必須

+ 0 - 45
.serena/memories/development_environment.md

@@ -1,45 +0,0 @@
-# 開発環境とツール
-
-## 推奨システム要件
-- **Node.js**: ^20 || ^22
-- **パッケージマネージャー**: pnpm 10.4.1
-- **OS**: Linux(Ubuntuベース)、macOS、Windows
-
-## 利用可能なLinuxコマンド
-基本的なLinuxコマンドが利用可能:
-- `apt`, `dpkg`: パッケージ管理
-- `git`: バージョン管理
-- `curl`, `wget`: HTTP クライアント
-- `ssh`, `scp`, `rsync`: ネットワーク関連
-- `ps`, `lsof`, `netstat`, `top`: プロセス・ネットワーク監視
-- `tree`, `find`, `grep`: ファイル検索・操作
-- `zip`, `unzip`, `tar`, `gzip`, `bzip2`, `xz`: アーカイブ操作
-
-## 開発用ブラウザ
-```bash
-# ローカルサーバーをブラウザで開く
-"$BROWSER" http://localhost:3000
-```
-
-## 環境変数管理
-- **dotenv-flow**: 環境ごとの設定管理
-- 環境ファイル:
-  - `.env.development`: 開発環境
-  - `.env.production`: 本番環境
-  - `.env.test`: テスト環境
-  - `.env.*.local`: ローカル固有設定
-
-## デバッグ
-```bash
-# デバッグモードでサーバー起動
-cd apps/app && pnpm run dev  # --inspectフラグ付きでnodemon起動
-
-# REPL(Read-Eval-Print Loop)
-cd apps/app && pnpm run repl
-```
-
-## VS Code設定
-`.vscode/` ディレクトリに設定ファイルが含まれており、推奨拡張機能や設定が適用される。
-
-## Docker対応
-各アプリケーションにDockerファイルが含まれており、コンテナベースでの開発も可能。

+ 390 - 0
.serena/memories/nextjs-pages-router-getLayout-pattern.md

@@ -0,0 +1,390 @@
+# Next.js Pages Router における getLayout パターン完全ガイド
+
+## getLayout パターンの基本概念と仕組み
+
+getLayout パターンは、Next.js Pages Router における**ページごとのレイアウト定義を可能にする強力なアーキテクチャパターン**です。このパターンを使用することで、各ページが独自のレイアウト階層を静的な `getLayout` 関数を通じて定義できます。
+
+### 技術的な仕組み
+
+getLayout パターンは React のコンポーネントツリー構成を活用して動作します:
+
+```typescript
+// pages/dashboard.tsx
+import DashboardLayout from '../components/DashboardLayout'
+
+const Dashboard = () => <div>ダッシュボードコンテンツ</div>
+
+Dashboard.getLayout = function getLayout(page) {
+  return <DashboardLayout>{page}</DashboardLayout>
+}
+
+export default Dashboard
+
+// pages/_app.tsx
+export default function MyApp({ Component, pageProps }) {
+  const getLayout = Component.getLayout ?? ((page) => page)
+  return getLayout(<Component {...pageProps} />)
+}
+```
+
+**動作原理:**
+1. Next.js がページを初期化する際、`getLayout` プロパティをチェック
+2. `getLayout` 関数がページコンポーネントを受け取り、完全なレイアウトツリーを返す
+3. React の差分アルゴリズムがコンポーネントツリーの同じ位置を維持し、効率的な差分更新を実現
+
+## パフォーマンス向上の具体的なメリット
+
+### レンダリング回数の削減
+
+getLayout パターンの最大の利点は、**ページ遷移時のレイアウトコンポーネントの再マウント防止**です。React の差分アルゴリズムは、コンポーネントツリーの同じ位置に同じタイプのコンポーネントが存在する場合、そのインスタンスを再利用します。
+
+**実測データ(Zenn.dev の事例):**
+```
+実装前:
+├ /_app      97.7 kB (全ページで Recoil を含む)
+├ /articles  98 kB
+├ /profile   98 kB
+
+実装後:
+├ /_app      75 kB (22.7 kB 削減)
+├ /articles  75.3 kB (最適化されたバンドル)
+├ /profile   98.3 kB (必要な依存関係のみ)
+```
+
+### メモリ効率の改善
+
+**主要な最適化ポイント:**
+- **状態の永続化**: 入力値、スクロール位置、コンポーネント状態がナビゲーション間で保持
+- **イベントリスナーの永続性**: イベントハンドラーの再アタッチ回避
+- **DOM 参照の安定性**: サードパーティ統合用の DOM ノード参照の維持
+
+## 実装のベストプラクティス
+
+### TypeScript での型安全な実装
+
+```typescript
+// types/layout.ts
+import type { NextPage } from 'next'
+import type { AppProps } from 'next/app'
+import type { ReactElement, ReactNode } from 'react'
+
+export type NextPageWithLayout<P = {}, IP = P> = NextPage<P, IP> & {
+  getLayout?: (page: ReactElement) => ReactNode
+}
+
+export type AppPropsWithLayout = AppProps & {
+  Component: NextPageWithLayout
+}
+
+// pages/_app.tsx
+import type { AppPropsWithLayout } from '../types/layout'
+
+export default function MyApp({ Component, pageProps }: AppPropsWithLayout) {
+  const getLayout = Component.getLayout ?? ((page) => page)
+  return getLayout(<Component {...pageProps} />)
+}
+```
+
+### ネストレイアウトの実装
+
+```typescript
+// utils/nestLayout.ts
+export function nestLayout(
+  parentLayout: (page: ReactElement) => ReactNode,
+  childLayout: (page: ReactElement) => ReactNode
+) {
+  return (page: ReactElement) => parentLayout(childLayout(page))
+}
+
+// pages/dashboard/profile.tsx
+import { nestLayout } from '../../utils/nestLayout'
+import { getLayout as getBaseLayout } from '../../components/BaseLayout'
+import { getLayout as getDashboardLayout } from '../../components/DashboardLayout'
+
+const ProfilePage: NextPageWithLayout = () => {
+  return <div>プロフィールコンテンツ</div>
+}
+
+ProfilePage.getLayout = nestLayout(getBaseLayout, getDashboardLayout)
+```
+
+### 状態管理の最適化
+
+```typescript
+// レイアウトごとのコンテキスト分割
+const AuthLayout = ({ children }) => (
+  <AuthProvider>
+    <UserProvider>
+      {children}
+    </UserProvider>
+  </AuthProvider>
+)
+
+const PublicLayout = ({ children }) => (
+  <ThemeProvider>
+    {children}
+  </ThemeProvider>
+)
+
+// 各ページで適切なレイアウトを選択
+Page.getLayout = (page) => <AuthLayout>{page}</AuthLayout>
+```
+
+## バッドプラクティスと実装時の落とし穴
+
+### 避けるべきアンチパターン
+
+**❌ レイアウトの再作成**
+```typescript
+// 悪い例:レイアウトの永続性が失われる
+const BadPage = () => {
+  return (
+    <Layout>
+      <div>ページコンテンツ</div>
+    </Layout>
+  )
+}
+
+// ✅ 良い例:getLayout パターンを使用
+const GoodPage = () => <div>ページコンテンツ</div>
+GoodPage.getLayout = (page) => <Layout>{page}</Layout>
+```
+
+**❌ _app.tsx での条件付きレンダリング**
+```typescript
+// 悪い例:レイアウトの再マウントを引き起こす
+function MyApp({ Component, pageProps, router }) {
+  if (router.pathname.startsWith('/dashboard')) {
+    return <DashboardLayout><Component {...pageProps} /></DashboardLayout>
+  }
+  return <Component {...pageProps} />
+}
+```
+
+### メモリリークの防止
+
+```typescript
+// ✅ 適切なクリーンアップ
+const Layout = ({ children }) => {
+  useEffect(() => {
+    const handleResize = () => { /* 処理 */ }
+    
+    window.addEventListener('resize', handleResize)
+    
+    return () => {
+      window.removeEventListener('resize', handleResize)
+    }
+  }, [])
+
+  return <div>{children}</div>
+}
+```
+
+## 他のレイアウト管理手法との比較
+
+### Pages Router 内での比較
+
+| 手法 | 複雑度 | パフォーマンス | 柔軟性 | 学習曲線 |
+|------|--------|----------------|--------|----------|
+| getLayout | 中 | 高 | 高 | 中 |
+| HOCs | 高 | 中 | 高 | 高 |
+| _app.js ルーティング | 低 | 高 | 低 | 低 |
+| Context ベース | 高 | 中 | 高 | 高 |
+| ラッパーコンポーネント | 低 | 低 | 低 | 低 |
+
+### Next.js 13+ App Router との比較
+
+**App Router の利点:**
+- ビルトインのレイアウトネスティング
+- ファイルシステムベースの直感的な構造
+- 自動的な状態永続化
+- `loading.js` と `error.js` による組み込みの状態管理
+
+**getLayout パターンの利点:**
+- 明示的なレイアウト制御
+- 成熟した安定したパターン
+- シンプルなメンタルモデル
+- 優れた TTFB パフォーマンス
+
+**パフォーマンス比較:**
+- **TTFB**: Pages Router が App Router より最大 2 倍高速
+- **開発サーバー**: Pages Router がより安定
+- **バンドルサイズ**: getLayout により選択的な読み込みが可能
+
+## SEO と SSR/SSG への影響
+
+### Core Web Vitals への影響
+
+**測定された改善効果:**
+- **LCP (Largest Contentful Paint)**: レイアウトの永続化により改善
+- **INP (Interaction to Next Paint)**: JavaScript 実行時間の削減
+- **CLS (Cumulative Layout Shift)**: レイアウトシフトの除去
+
+**Netflix の事例:**
+- Time-to-Interactive が **50% 削減**
+- JavaScript バンドルサイズが **200KB 削減**
+- デスクトップユーザーの 97% が高速な First Input Delay を体験
+
+### SSR/SSG との統合
+
+```typescript
+// SSR との完全な互換性
+export async function getServerSideProps() {
+  const data = await fetchData()
+  return { props: { data } }
+}
+
+function Page({ data }) {
+  return <div>{data.content}</div>
+}
+
+Page.getLayout = (page) => <Layout>{page}</Layout>
+```
+
+## 実際のプロジェクトでの活用例
+
+### 企業での実装事例
+
+**Netflix:**
+- ログアウト済みホームページで Time-to-Interactive を 50% 削減
+- 戦略的なプリフェッチで後続ページロードを 30% 改善
+
+**Hulu:**
+- Next.js による統一されたフロントエンドアーキテクチャ
+- CSS-in-JS の自動コード分割を実装
+
+**Sonos:**
+- ビルド時間を **75% 短縮**
+- パフォーマンススコアを **10% 改善**
+
+## パフォーマンス測定と最適化
+
+### 測定ツールの設定
+
+```javascript
+// next.config.js - Bundle Analyzer の設定
+const withBundleAnalyzer = require('@next/bundle-analyzer')({
+  enabled: process.env.ANALYZE === 'true',
+});
+
+module.exports = withBundleAnalyzer(nextConfig);
+
+// 使用方法
+// ANALYZE=true npm run build
+```
+
+### React DevTools Profiler の活用
+
+```javascript
+import { Profiler } from 'react';
+
+function onRenderCallback(id, phase, actualDuration, baseDuration) {
+  console.log({ id, phase, actualDuration, baseDuration });
+}
+
+<Profiler id="LayoutProfile" onRender={onRenderCallback}>
+  <MyLayout>{children}</MyLayout>
+</Profiler>
+```
+
+### 最適化テクニック
+
+**メモ化の実装:**
+```typescript
+import { memo, useMemo, useCallback } from 'react'
+
+const Layout = memo(({ children, menuItems }) => {
+  const processedMenu = useMemo(() => 
+    menuItems.filter(item => item.visible).sort(), 
+    [menuItems]
+  );
+  
+  const handleNavigation = useCallback((path) => {
+    router.push(path);
+  }, [router]);
+  
+  return (
+    <div>
+      <Navigation items={processedMenu} onNavigate={handleNavigation} />
+      {children}
+    </div>
+  );
+});
+```
+
+**動的インポートによるコード分割:**
+```typescript
+import dynamic from 'next/dynamic';
+
+const DynamicSidebar = dynamic(() => import('../components/Sidebar'), {
+  loading: () => <SidebarSkeleton />,
+  ssr: false
+});
+
+const Layout = ({ children }) => (
+  <div>
+    <Header />
+    <DynamicSidebar />
+    <main>{children}</main>
+  </div>
+);
+```
+
+### パフォーマンスバジェットの実装
+
+```javascript
+export const PERFORMANCE_BUDGETS = {
+  layoutRenderTime: 16, // 60fps のための 16ms
+  memoryUsage: 50 * 1024 * 1024, // 50MB
+  bundleSize: 200 * 1024, // 200KB
+  firstContentfulPaint: 2000, // 2秒
+};
+
+const measureLayoutPerformance = (layoutName, renderFn) => {
+  const start = performance.now();
+  const result = renderFn();
+  const duration = performance.now() - start;
+  
+  if (duration > PERFORMANCE_BUDGETS.layoutRenderTime) {
+    console.warn(`Layout ${layoutName} がレンダーバジェットを超過: ${duration}ms`);
+  }
+  
+  return result;
+};
+```
+
+## 実装チェックリスト
+
+### 初期設定
+- [ ] TypeScript の型定義を設定
+- [ ] `_app.tsx` に getLayout パターンを実装
+- [ ] React DevTools をインストール
+- [ ] Bundle Analyzer を設定
+
+### 最適化の優先順位
+
+**高影響・低労力:**
+- [ ] レイアウトコンポーネントに React.memo を実装
+- [ ] Bundle Analyzer で大きな依存関係を特定
+- [ ] Context Provider をレイアウトごとに分割
+
+**中影響・中労力:**
+- [ ] 非クリティカルなレイアウトコンポーネントに動的インポートを実装
+- [ ] Suspense 境界を追加してストリーミングを改善
+- [ ] 自動パフォーマンス監視を設定
+
+**高影響・高労力:**
+- [ ] 状態管理アーキテクチャの再設計
+- [ ] 包括的なプログレッシブエンハンスメントの実装
+- [ ] 高度なパフォーマンスバジェットシステムの作成
+
+## まとめ
+
+getLayout パターンは、Next.js Pages Router において**強力なパフォーマンス最適化とアーキテクチャの柔軟性**を提供します。適切に実装すれば、以下の利点が得られます:
+
+1. **パフォーマンスの向上**: 不要な再レンダリングの削減とバンドルサイズの最適化
+2. **ユーザー体験の向上**: 状態の永続化とスムーズなページ遷移
+3. **アーキテクチャの柔軟性**: ページごとのレイアウトカスタマイズとパフォーマンスの維持
+4. **メモリ効率**: コンポーネントの再利用による最適なリソース使用
+
+App Router が新しい代替手段を提供する一方で、getLayout パターンの理解は React のレンダリング最適化とコンポーネントライフサイクル管理への深い洞察を提供します。Pages Router アプリケーションでは、プロジェクトの開始時から getLayout を実装することで、アプリケーションのスケールに応じて最大限のパフォーマンス利点とアーキテクチャの柔軟性を維持できます。

+ 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` で厳密に型付け

+ 65 - 0
.serena/memories/page-transition-and-rendering-flow.md

@@ -0,0 +1,65 @@
+# ページ遷移とレンダリングのデータフロー
+
+このドキュメントは、GROWIのページ遷移からレンダリングまでのデータフローを解説します。
+
+## 登場人物
+
+1.  **`[[...path]].page.tsx`**: Next.js の動的ルーティングを担うメインコンポーネント。サーバーサイドとクライアントサイドの両方で動作します。
+2.  **`useSameRouteNavigation.ts`**: クライアントサイドでのパス変更を検知し、データ取得を**トリガー**するフック。
+3.  **`useFetchCurrentPage.ts`**: データ取得と関連する Jotai atom の更新を一元管理するフック。データ取得が本当に必要かどうかの最終判断も担います。
+4.  **`useShallowRouting.ts`**: サーバーサイドで正規化されたパスとブラウザのURLを同期させるフック。
+5.  **`server-side-props.ts`**: サーバーサイドレンダリング(SSR)時にページデータを取得し、`props` としてページコンポーネントに渡します。
+
+---
+
+## フロー1: サーバーサイドレンダリング(初回アクセス時)
+
+ユーザーがURLに直接アクセスするか、ページをリロードした際のフローです。
+
+1.  **リクエスト受信**: サーバーがユーザーからのリクエスト(例: `/user/username/memo`)を受け取ります。
+2.  **`getServerSideProps` の実行**:
+    - `server-side-props.ts` の `getServerSidePropsForInitial` が実行されます。
+    - `retrievePageData` が呼び出され、パスの正規化(例: `/user/username` → `/user/username/`)が行われ、APIからページデータを取得します。
+    - 取得したデータと、正規化後のパス (`currentPathname`) を `props` として `[[...path]].page.tsx` に渡します。
+3.  **コンポーネントのレンダリングとJotai Atomの初期化**:
+    - `[[...path]].page.tsx` は `props` を受け取り、そのデータで `currentPageDataAtom` などのJotai Atomを初期化します。
+    - `PageView` などのコンポーネントがサーバーサイドでレンダリングされます。
+4.  **クライアントサイドでのハイドレーションとURL正規化**:
+    - レンダリングされたHTMLがブラウザに送信され、Reactがハイドレーションを行います。
+    - **`useShallowRouting`** が実行され、ブラウザのURL (`/user/username/memo`) と `props.currentPathname` (`/user/username/memo/`) を比較します。
+    - 差異がある場合、`router.replace` を `shallow: true` で実行し、ブラウザのURLをサーバーが認識している正規化後のパスに静かに更新します。
+
+---
+
+## フロー2: クライアントサイドナビゲーション(`<Link>` クリック時)
+
+アプリケーション内でページ間を移動する際のフローです。
+
+1.  **ナビゲーション開始**:
+    - ユーザーが `<Link href="/new/page">` をクリックします。
+    - Next.js の `useRouter` がURLの変更を検出し、`[[...path]].page.tsx` が再評価されます。
+2.  **`useSameRouteNavigation` によるトリガー**:
+    - このフックの `useEffect` が `router.asPath` の変更 (`/new/page`) を検知します。
+    - **`fetchCurrentPage({ path: '/new/page' })`** を呼び出します。このフックは常にデータ取得を試みます。
+3.  **`useFetchCurrentPage` によるデータ取得の判断と実行**:
+    - `fetchCurrentPage` 関数が実行されます。
+    - **3a. パスの前処理**:
+        - まず、引数で渡された `path` をデコードします(例: `encoded%2Fpath` → `encoded/path`)。
+        - 次に、パスがパーマリンク形式(例: `/65d4e0a0f7b7b2e5a8652e86`)かどうかを判定します。
+    - **3b. 重複取得の防止(ガード節)**:
+        - 前処理したパスや、パーマリンクから抽出したページIDが、現在Jotaiで管理されているページのパスやIDと同じでないかチェックします。
+        - 同じであれば、APIを叩かずに処理を中断し、現在のページデータを返します。
+    - **3c. 読み込み状態開始**: `pageLoadingAtom` を `true` に設定します。
+    - **3d. API通信**: `apiv3Get('/page', ...)` を実行してサーバーから新しいページデータを取得します。パラメータには、パス、ページID、リビジョンIDなどが含まれます。
+4.  **アトミックな状態更新**:
+    - **API成功時**:
+        - 関連する **すべてのatomを一度に更新** します (`currentPageDataAtom`, `currentPageIdAtom`, `pageNotFoundAtom`, `pageLoadingAtom` など)。
+        - これにより、中間的な状態(`pageId`が`undefined`になるなど)が発生することなく、データが完全に揃った状態で一度だけ状態が更新されます。
+    - **APIエラー時 (例: 404 Not Found)**:
+        - `pageErrorAtom` にエラーオブジェクトを設定します。
+        - `pageNotFoundAtom` を `true` に設定します。
+        - 最後に `pageLoadingAtom` を `false` に設定します。
+5.  **`PageView` の最終レンダリング**:
+    - `currentPageDataAtom` の更新がトリガーとなり、`PageView` コンポーネントが新しいデータで再レンダリングされます。
+6.  **副作用の実行**:
+    - `useSameRouteNavigation` 内で `fetchCurrentPage` が完了した後、`mutateEditingMarkdown` が呼び出され、エディタの状態が更新されます。

+ 0 - 100
.serena/memories/suggested_commands.md

@@ -1,100 +0,0 @@
-# 推奨開発コマンド集
-
-## セットアップ
-```bash
-# 初期セットアップ
-pnpm run bootstrap
-# または
-pnpm install
-```
-
-## 開発サーバー
-```bash
-# メインアプリケーション開発モード
-cd /workspace/growi/apps/app && pnpm run dev
-
-# ルートから起動(本番用ビルド後)
-pnpm start
-```
-
-## ビルド
-```bash
-# メインアプリケーションのビルド
-pnpm run app:build
-
-# Slackbot Proxyのビルド
-pnpm run slackbot-proxy:build
-
-# 全体ビルド(Turboで並列実行)
-turbo run build
-```
-
-## Lint・フォーマット
-```bash
-# 全てのLint実行
-pnpm run lint
-```
-
-## apps/app の Lint・フォーマット
-```bash
-# 【推奨】Biome実行(lint + format)
-cd /workspace/growi/apps/app pnpm run lint:biome
-
-# 【過渡期】ESLint実行(廃止予定)
-cd /workspace/growi/apps/app pnpm run lint:eslint
-
-# Stylelint実行
-cd /workspace/growi/apps/app pnpm run lint:styles
-
-# 全てのLint実行
-cd /workspace/growi/apps/app pnpm run lint
-
-# TypeScript型チェック
-cd /workspace/growi/apps/app pnpm run lint:typecheck
-```
-
-## テスト
-```bash
-# 【推奨】Vitestテスト実行
-pnpm run test:vitest
-
-# 【過渡期】Jest(統合テスト)(廃止予定)
-pnpm run test:jest
-
-# 全てのテスト実行(過渡期対応)
-pnpm run test
-
-# Vitestで特定のファイルに絞って実行
-pnpm run test:vitest {target-file-name}
-
-# E2Eテスト(Playwright)
-npx playwright test
-```
-
-## データベース関連
-```bash
-# マイグレーション実行
-cd apps/app && pnpm run migrate
-
-# 開発環境でのマイグレーション
-cd apps/app && pnpm run dev:migrate
-
-# マイグレーション状態確認
-cd apps/app && pnpm run dev:migrate:status
-```
-
-## その他の便利コマンド
-```bash
-# REPL起動
-cd apps/app && pnpm run repl
-
-# OpenAPI仕様生成
-cd apps/app && pnpm run openapi:generate-spec:apiv3
-
-# クリーンアップ
-cd apps/app && pnpm run clean
-```
-
-## 注意事項
-- ESLintとJestは廃止予定のため、新規開発ではBiomeとVitestを使用してください
-- 既存のコードは段階的に移行中です

+ 41 - 42
.serena/memories/tech_stack.md

@@ -1,42 +1,41 @@
-# 技術スタック
-
-## プログラミング言語
-- **TypeScript**: メイン言語(~5.0.0)
-- **JavaScript**: 一部のコンポーネント
-
-## フロントエンド
-- **Next.js**: Reactベースのフレームワーク
-- **React**: UIライブラリ
-- **Vite**: ビルドツール、開発サーバー
-- **SCSS**: スタイルシート
-- **SWR**: グローバルステート管理、データフェッチ・キャッシュ管理(^2.3.2)
-
-## バックエンド
-- **Node.js**: ランタイム(^20 || ^22)
-- **Express.js**: Webフレームワーク(推測)
-- **MongoDB**: データベース
-- **Mongoose**: MongoDB用ORM(^6.13.6)
-  - mongoose-gridfs: GridFS対応(^1.2.42)
-  - mongoose-paginate-v2: ページネーション(^1.3.9)
-  - mongoose-unique-validator: バリデーション(^2.0.3)
-
-## 開発ツール
-- **pnpm**: パッケージマネージャー(10.4.1)
-- **Turbo**: モノレポビルドシステム(^2.1.3)
-- **ESLint**: Linter(weseek設定を使用)【廃止予定 - 現在は過渡期】
-- **Biome**: 統一予定のLinter/Formatter
-- **Stylelint**: CSS/SCSSのLinter
-- **Jest**: テスティングフレームワーク【廃止予定 - 現在は過渡期】
-- **Vitest**: 高速テスティングフレームワーク【統一予定】
-- **Playwright**: E2Eテスト【統一予定】
-
-## その他のツール
-- **SWC**: TypeScriptコンパイラー(高速)
-- **ts-node**: TypeScript直接実行
-- **nodemon**: 開発時のホットリロード
-- **dotenv-flow**: 環境変数管理
-- **Swagger/OpenAPI**: API仕様
-
-## 移行計画
-- **Linter**: ESLint → Biome に統一予定
-- **テスト**: Jest → Vitest + Playwright に統一予定
+# 技術スタック & 開発環境
+
+## コア技術
+- **TypeScript** ~5.0.0 + **Next.js** (React)
+- **Node.js** ^20||^22 + **MongoDB** + **Mongoose** ^6.13.6
+- **pnpm** 10.4.1 + **Turbo** ^2.1.3 (モノレポ)
+
+## 状態管理・データ
+- **Jotai**: アトミック状態管理(推奨)
+- **SWR** ^2.3.2: データフェッチ・キャッシュ
+
+## 開発ツール移行状況
+| 従来 | 移行先 | 状況 |
+|------|--------|------|
+| ESLint | **Biome** | 新規推奨 |
+| Jest | **Vitest** + **Playwright** | 新規推奨 |
+
+## 主要コマンド
+```bash
+# 開発
+cd apps/app && pnpm run dev
+
+# 品質チェック
+pnpm run lint:biome        # 新規推奨
+pnpm run lint:typecheck    # 型チェック正式コマンド
+pnpm run test:vitest       # 新規推奨
+
+# ビルド
+pnpm run app:build
+turbo run build           # 並列ビルド
+```
+
+## ファイル命名規則
+- Next.js: `*.page.tsx`
+- テスト: `*.spec.ts` (Vitest), `*.integ.ts`
+- コンポーネント: `ComponentName.tsx`
+
+## API・アーキテクチャ
+- **API v3**: `server/routes/apiv3/` (RESTful + OpenAPI)
+- **Features**: `features/*/` (機能別分離)
+- **SCSS**: CSS Modules使用

+ 95 - 0
.serena/memories/vitest-testing-tips-and-best-practices.md

@@ -0,0 +1,95 @@
+# Vitest + TypeScript Testing Guide
+
+## 核心技術要素
+
+### tsconfig.json最適設定
+```json
+{
+  "compilerOptions": {
+    "types": ["vitest/globals"]  // グローバルAPI: describe, it, expect等をインポート不要化
+  }
+}
+```
+
+### vitest-mock-extended: 型安全モッキング
+```typescript
+import { mockDeep, type DeepMockProxy } from 'vitest-mock-extended';
+
+// 完全型安全なNext.js Routerモック
+const mockRouter: DeepMockProxy<NextRouter> = mockDeep<NextRouter>();
+mockRouter.asPath = '/test-path';  // TypeScript補完・型チェック有効
+
+// 複雑なUnion型も完全サポート
+interface ComplexProps {
+  currentPageId?: string | null;
+  currentPathname?: string | null;
+}
+const mockProps: DeepMockProxy<ComplexProps> = mockDeep<ComplexProps>();
+```
+
+### React Testing Library + Jotai統合
+```typescript
+const renderWithProvider = (ui: React.ReactElement, scope?: Scope) => {
+  const Wrapper = ({ children }: { children: React.ReactNode }) => (
+    <Provider scope={scope}>{children}</Provider>
+  );
+  return render(ui, { wrapper: Wrapper });
+};
+```
+
+## 実践パターン
+
+### 非同期テスト
+```typescript
+import { waitFor, act } from '@testing-library/react';
+
+await act(async () => {
+  result.current.triggerAsyncAction();
+});
+
+await waitFor(() => {
+  expect(result.current.isLoading).toBe(false);
+});
+```
+
+### 詳細アサーション
+```typescript
+expect(mockFunction).toHaveBeenCalledWith(
+  expect.objectContaining({
+    pathname: '/expected-path',
+    data: expect.any(Object)
+  })
+);
+```
+
+## 実行コマンド
+
+### 基本テスト実行
+```bash
+# Vitest単体
+pnpm run test:vitest
+
+# Vitest単体(coverageあり)
+pnpm run test:vitest:coverage
+
+# 特定ファイルのみ実行(coverageあり)
+pnpm run test:vitest src/path/to/test.spec.tsx
+```
+
+### package.jsonスクリプト参照
+```json
+{
+  "scripts": {
+    "test": "run-p test:*",
+    "test:jest": "cross-env NODE_ENV=test TS_NODE_PROJECT=test/integration/tsconfig.json jest",
+    "test:vitest": "vitest run --coverage"
+  }
+}
+```
+
+## Jest→Vitest移行要点
+- `jest.config.js` → `vitest.config.ts`
+- `@types/jest` → `vitest/globals`
+- ESModulesネイティブサポート → 高速起動・実行
+
+この設定により型安全性と保守性を両立した高品質テストが可能。

+ 10 - 0
.serena/serena_config.yml

@@ -0,0 +1,10 @@
+web_dashboard: false
+# whether to open the Serena web dashboard (which will be accessible through your web browser) that
+# shows Serena's current session logs - as an alternative to the GUI log window which
+# is supported on all platforms.
+
+web_dashboard_open_on_launch: false
+# whether to open a browser window with the web dashboard when Serena starts (provided that web_dashboard
+# is enabled). If set to False, you can still open the dashboard manually by navigating to
+# http://localhost:24282/dashboard/ in your web browser (24282 = 0x5EDA, SErena DAshboard).
+# If you have multiple instances running, a higher port will be used; try port 24283, 24284, etc.

+ 3 - 1
CLAUDE.md

@@ -4,7 +4,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
 
 ## Language
 
-If it is detected at the start or during a session that the user's primary language is not English, always respond in that language from then on. However, technical terms may remain in English as needed.
+If we detect at the beginning of a conversation that the user's primary language is not English, we will always respond in that language. However, we may retain technical terms in English if necessary.
+
+When generating source code, all comments and explanations within the code will be written in English.
 
 ## Project Overview
 

+ 3 - 0
apps/app/.eslintrc.js

@@ -33,6 +33,7 @@ module.exports = {
     'src/migrations/**',
     'src/models/**',
     'src/features/callout/**',
+    'src/features/collaborative-editor/**',
     'src/features/comment/**',
     'src/features/templates/**',
     'src/features/mermaid/**',
@@ -49,6 +50,7 @@ module.exports = {
     'src/utils/**',
     'src/components/**',
     'src/services/**',
+    'src/states/**',
     'src/stores/**',
     'src/pages/**',
     'src/server/crowi/**',
@@ -66,6 +68,7 @@ module.exports = {
     },
   },
   rules: {
+    'space-before-function-paren': 'off',
     '@typescript-eslint/no-var-requires': 'off',
 
     // set 'warn' temporarily -- 2021.08.02 Yuki Takei

BIN
apps/app/.swc/plugins/v7_linux_x86_64_0.106.16/85face98bcf0ea217842cb93383692497c6ae8a7da71a76bf3d93a3a42b4228c


+ 40 - 0
apps/app/docs/plan/README.md

@@ -0,0 +1,40 @@
+# React State Management Documentation
+
+# React State Management Documentation
+
+## Current Documentation
+
+### [`jotai-migration.md`](jotai-migration.md)
+Jotai移行のガイドドキュメント(実装方針・パターン)
+
+- 移行方針と背景
+- 実装パターンとガイドライン
+- 判断基準とベストプラクティス
+- 移行の成果と技術スタック
+
+### [`jotai-migration-progress.md`](jotai-migration-progress.md)
+実装進捗と次のステップ(随時更新)
+
+- 完了済み実装の一覧
+- 次の実装ステップと優先順位
+- 進捗サマリーと更新履歴
+
+---
+
+## ドキュメント構造について
+
+### 設計方針
+**役割分離**: 安定的なガイドと頻繁に更新される進捗を分離
+
+- **`jotai-migration.md`**: 実装方針とパターン(安定的)
+- **`jotai-migration-progress.md`**: 進捗と次のステップ(頻繁更新)
+
+### メリット
+- **メンテナンス性向上**: 更新頻度に応じた適切な構造
+- **情報の明確化**: 各ドキュメントの責務が明確
+- **開発効率向上**: 必要な情報に素早くアクセス可能
+
+### 推奨される利用方法
+1. **新規参入者**: `jotai-migration.md` で実装方針とパターンを理解
+2. **開発者**: `jotai-migration-progress.md` で次のタスクを確認
+3. **レビュー**: `jotai-migration.md` の実装パターンで一貫性を保証

+ 1 - 0
apps/app/next.config.js

@@ -58,6 +58,7 @@ const getTranspilePackages = () => {
     'github-slugger',
     'html-url-attributes',
     'estree-util-is-identifier-name',
+    'superjson',
     ...listPrefixedPackages([
       'remark-',
       'rehype-',

+ 6 - 2
apps/app/package.json

@@ -147,6 +147,8 @@
     "i18next-resources-to-backend": "^1.2.1",
     "is-absolute-url": "^4.0.1",
     "is-iso-date": "^0.0.1",
+    "jotai": "^2.12.3",
+    "js-cookie": "^3.0.5",
     "js-tiktoken": "^1.0.15",
     "js-yaml": "^4.1.0",
     "jsonrepair": "^3.12.0",
@@ -175,7 +177,7 @@
     "next": "^14.2.32",
     "next-dynamic-loading-props": "^0.1.1",
     "next-i18next": "^15.3.1",
-    "next-superjson": "^0.0.4",
+    "next-superjson": "^1.0.7",
     "next-themes": "^0.2.1",
     "nocache": "^4.0.0",
     "node-cron": "^3.0.2",
@@ -230,7 +232,7 @@
     "sanitize-filename": "^1.6.3",
     "socket.io": "^4.7.5",
     "string-width": "=4.2.2",
-    "superjson": "^1.9.1",
+    "superjson": "^2.2.2",
     "swagger-jsdoc": "^6.2.8",
     "swr": "^2.3.2",
     "throttle-debounce": "^5.0.0",
@@ -280,6 +282,7 @@
     "@types/express": "^4.17.21",
     "@types/hast": "^3.0.4",
     "@types/jest": "^29.5.2",
+    "@types/js-cookie": "^3.0.6",
     "@types/ldapjs": "^2.2.5",
     "@types/mdast": "^4.0.4",
     "@types/node-cron": "^3.0.11",
@@ -315,6 +318,7 @@
     "jest": "^29.5.0",
     "jest-date-mock": "^1.0.8",
     "jest-localstorage-mock": "^2.4.14",
+    "jotai-devtools": "^0.11.0",
     "load-css-file": "^1.0.0",
     "material-icons": "^1.11.3",
     "mdast-util-directive": "^3.0.0",

+ 2 - 2
apps/app/src/client/components/Admin/App/AppSettingsPageContents.tsx

@@ -4,7 +4,7 @@ import { useTranslation } from 'next-i18next';
 
 import AdminAppContainer from '~/client/services/AdminAppContainer';
 import { toastError } from '~/client/util/toastr';
-import { useIsMaintenanceMode } from '~/stores/maintenanceMode';
+import { useIsMaintenanceMode } from '~/states/global';
 import { toArrayIfNot } from '~/utils/array-utils';
 import loggerFactory from '~/utils/logger';
 
@@ -29,7 +29,7 @@ const AppSettingsPageContents = (props: Props) => {
   const { t } = useTranslation('admin');
   const { adminAppContainer } = props;
 
-  const { data: isMaintenanceMode } = useIsMaintenanceMode();
+  const isMaintenanceMode = useIsMaintenanceMode();
 
   const { isV5Compatible } = adminAppContainer.state;
 

+ 4 - 7
apps/app/src/client/components/Admin/App/MaintenanceMode.tsx

@@ -3,21 +3,18 @@ import React, { useState, useCallback } from 'react';
 
 import { useTranslation } from 'next-i18next';
 
+import { useMaintenanceModeActions } from '~/client/services/maintenance-mode';
 import { toastSuccess, toastError } from '~/client/util/toastr';
-import { useIsMaintenanceMode } from '~/stores/maintenanceMode';
-import loggerFactory from '~/utils/logger';
+import { useIsMaintenanceMode } from '~/states/global';
 
 import { ConfirmModal } from './ConfirmModal';
 
-const logger = loggerFactory('growi:maintenanceMode');
-
 
 export const MaintenanceMode: FC = () => {
   const { t } = useTranslation();
 
-  const {
-    data: isMaintenanceMode, start: startMaintenanceMode, end: endMaintenanceMode,
-  } = useIsMaintenanceMode();
+  const isMaintenanceMode = useIsMaintenanceMode();
+  const { start: startMaintenanceMode, end: endMaintenanceMode } = useMaintenanceModeActions();
 
   const [isModalOpen, setModalOpen] = useState<boolean>(false);
 

+ 2 - 2
apps/app/src/client/components/Admin/App/V5PageMigration.tsx

@@ -4,13 +4,13 @@ import React, { useCallback, useEffect, useState } from 'react';
 import { useTranslation } from 'next-i18next';
 
 import { toastError, toastSuccess } from '~/client/util/toastr';
+import { useAdminSocket } from '~/features/admin/states/socket-io';
 import type {
   PMStartedData, PMMigratingData, PMErrorCountData, PMEndedData,
 } from '~/interfaces/websocket';
 import {
   SocketEventName,
 } from '~/interfaces/websocket';
-import { useGlobalAdminSocket } from '~/stores/websocket';
 
 import AdminAppContainer from '../../../services/AdminAppContainer';
 import { withUnstatedContainers } from '../../UnstatedUtils';
@@ -33,7 +33,7 @@ const V5PageMigration: FC<Props> = (props: Props) => {
   const [current, setCurrent] = useState<number>(0);
   const [isSucceeded, setSucceeded] = useState<boolean | undefined>(undefined);
 
-  const { data: adminSocket } = useGlobalAdminSocket();
+  const adminSocket = useAdminSocket();
   const { t } = useTranslation();
 
   const { adminAppContainer } = props;

+ 4 - 5
apps/app/src/client/components/Admin/AuditLog/AuditLogSettings.tsx

@@ -1,22 +1,21 @@
 import type { FC } from 'react';
 import React, { useState } from 'react';
 
+import { useAtomValue } from 'jotai';
 import { useTranslation } from 'react-i18next';
 import { Collapse } from 'reactstrap';
 
 import { AllSupportedActions } from '~/interfaces/activity';
-import { useActivityExpirationSeconds, useAuditLogAvailableActions } from '~/stores-universal/context';
+import { activityExpirationSecondsAtom, auditLogAvailableActionsAtom } from '~/states/server-configurations';
 
 export const AuditLogSettings: FC = () => {
   const { t } = useTranslation();
 
   const [isExpandActionList, setIsExpandActionList] = useState(false);
 
-  const { data: activityExpirationSecondsData } = useActivityExpirationSeconds();
-  const activityExpirationSeconds = activityExpirationSecondsData != null ? activityExpirationSecondsData : 2592000;
+  const activityExpirationSeconds = useAtomValue(activityExpirationSecondsAtom) || 2592000;
 
-  const { data: availableActionsData } = useAuditLogAvailableActions();
-  const availableActions = availableActionsData != null ? availableActionsData : [];
+  const availableActions = useAtomValue(auditLogAvailableActionsAtom);
 
   return (
     <>

+ 4 - 3
apps/app/src/client/components/Admin/AuditLogManagement.tsx

@@ -3,12 +3,13 @@ import React, { useState, useCallback, useRef } from 'react';
 
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { format } from 'date-fns/format';
+import { useAtomValue } from 'jotai';
 import { useTranslation } from 'react-i18next';
 
 import type { IClearable } from '~/client/interfaces/clearable';
 import { toastError } from '~/client/util/toastr';
 import type { SupportedActionType } from '~/interfaces/activity';
-import { useAuditLogEnabled, useAuditLogAvailableActions } from '~/stores-universal/context';
+import { auditLogEnabledAtom, auditLogAvailableActionsAtom } from '~/states/server-configurations';
 import { useSWRxActivity } from '~/stores/activity';
 
 import PaginationWrapper from '../PaginationWrapper';
@@ -34,7 +35,7 @@ export const AuditLogManagement: FC = () => {
 
   const typeaheadRef = useRef<IClearable>(null);
 
-  const { data: auditLogAvailableActionsData } = useAuditLogAvailableActions();
+  const auditLogAvailableActionsData = useAtomValue(auditLogAvailableActionsAtom);
 
   /*
    * State
@@ -67,7 +68,7 @@ export const AuditLogManagement: FC = () => {
     toastError('Failed to get Audit Log');
   }
 
-  const { data: auditLogEnabled } = useAuditLogEnabled();
+  const auditLogEnabled = useAtomValue(auditLogEnabledAtom);
 
   /*
    * Functions

+ 17 - 14
apps/app/src/client/components/Admin/Customize/CustomizeLogoSetting.tsx

@@ -1,5 +1,6 @@
 import React, { useCallback, useState, type JSX } from 'react';
 
+import { useAtomValue, useSetAtom } from 'jotai';
 import { useTranslation } from 'react-i18next';
 
 import ImageCropModal from '~/client/components/Common/ImageCropModal';
@@ -7,7 +8,8 @@ import {
   apiv3Delete, apiv3PostForm, apiv3Put,
 } from '~/client/util/apiv3-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';
-import { useIsDefaultLogo, useIsCustomizedLogoUploaded } from '~/stores-universal/context';
+import { useIsDefaultLogo } from '~/states/global';
+import { isCustomizedLogoUploadedAtom } from '~/states/server-configurations';
 
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 
@@ -18,8 +20,9 @@ const CUSTOMIZED_LOGO = '/attachment/brand-logo';
 const CustomizeLogoSetting = (): JSX.Element => {
 
   const { t } = useTranslation();
-  const { data: isDefaultLogo } = useIsDefaultLogo();
-  const { data: isCustomizedLogoUploaded, mutate: mutateIsCustomizedLogoUploaded } = useIsCustomizedLogoUploaded();
+  const isDefaultLogo = useIsDefaultLogo();
+  const isCustomizedLogoUploaded = useAtomValue(isCustomizedLogoUploadedAtom);
+  const setIsCustomizedLogoUploaded = useSetAtom(isCustomizedLogoUploadedAtom);
 
   const [uploadLogoSrc, setUploadLogoSrc] = useState<ArrayBuffer | string | null>(null);
   const [isImageCropModalShow, setIsImageCropModalShow] = useState<boolean>(false);
@@ -35,7 +38,7 @@ const CustomizeLogoSetting = (): JSX.Element => {
     }
   }, []);
 
-  const onClickSubmit = useCallback(async() => {
+  const onClickSubmit = useCallback(async () => {
     try {
       await apiv3Put('/customize-setting/customize-logo', { isDefaultLogo: isDefaultLogoSelected });
       toastSuccess(t('toaster.update_successed', { target: t('admin:customize_settings.custom_logo'), ns: 'commons' }));
@@ -45,10 +48,10 @@ const CustomizeLogoSetting = (): JSX.Element => {
     }
   }, [t, isDefaultLogoSelected]);
 
-  const onClickDeleteBtn = useCallback(async() => {
+  const onClickDeleteBtn = useCallback(async () => {
     try {
       await apiv3Delete('/customize-setting/delete-brand-logo');
-      mutateIsCustomizedLogoUploaded(false);
+      setIsCustomizedLogoUploaded(false);
       toastSuccess(t('toaster.update_successed', { target: t('admin:customize_settings.current_logo'), ns: 'commons' }));
     }
     catch (err) {
@@ -56,15 +59,15 @@ const CustomizeLogoSetting = (): JSX.Element => {
       setRetrieveError(err);
       throw new Error('Failed to delete logo');
     }
-  }, [mutateIsCustomizedLogoUploaded, t]);
+  }, [setIsCustomizedLogoUploaded, t]);
 
 
-  const processImageCompletedHandler = useCallback(async(croppedImage) => {
+  const processImageCompletedHandler = useCallback(async (croppedImage) => {
     try {
       const formData = new FormData();
       formData.append('file', croppedImage);
       await apiv3PostForm('/customize-setting/upload-brand-logo', formData);
-      mutateIsCustomizedLogoUploaded(true);
+      setIsCustomizedLogoUploaded(true);
       toastSuccess(t('toaster.update_successed', { target: t('admin:customize_settings.current_logo'), ns: 'commons' }));
     }
     catch (err) {
@@ -72,7 +75,7 @@ const CustomizeLogoSetting = (): JSX.Element => {
       setRetrieveError(err);
       throw new Error('Failed to upload brand logo');
     }
-  }, [mutateIsCustomizedLogoUploaded, t]);
+  }, [setIsCustomizedLogoUploaded, t]);
 
   return (
     <React.Fragment>
@@ -113,13 +116,13 @@ const CustomizeLogoSetting = (): JSX.Element => {
                       onChange={() => { setIsDefaultLogoSelected(false) }}
                     />
                     <label className="form-check-label" htmlFor="radioUploadLogo">
-                      { t('admin:customize_settings.upload_logo') }
+                      {t('admin:customize_settings.upload_logo')}
                     </label>
                   </div>
                 </h4>
                 <div className="row mb-3">
                   <label className="col-sm-4 col-12 col-form-label text-start">
-                    { t('admin:customize_settings.current_logo') }
+                    {t('admin:customize_settings.current_logo')}
                   </label>
                   <div className="col-sm-8 col-12">
                     {isCustomizedLogoUploaded && (
@@ -128,7 +131,7 @@ const CustomizeLogoSetting = (): JSX.Element => {
                           <img src={CUSTOMIZED_LOGO} id="settingBrandLogo" width="64" />
                         </p>
                         <button type="button" className="btn btn-danger" onClick={onClickDeleteBtn}>
-                          { t('admin:customize_settings.delete_logo') }
+                          {t('admin:customize_settings.delete_logo')}
                         </button>
                       </>
                     )}
@@ -136,7 +139,7 @@ const CustomizeLogoSetting = (): JSX.Element => {
                 </div>
                 <div className="row">
                   <label className="col-sm-4 col-12 col-form-label text-start">
-                    { t('admin:customize_settings.upload_new_logo') }
+                    {t('admin:customize_settings.upload_new_logo')}
                   </label>
                   <div className="col-sm-8 col-12">
                     <input type="file" onChange={onSelectFile} name="brandLogo" accept="image/*" />

+ 4 - 4
apps/app/src/client/components/Admin/Customize/CustomizeTitle.tsx

@@ -7,7 +7,7 @@ import { Card, CardBody } from 'reactstrap';
 
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { toastSuccess, toastError } from '~/client/util/toastr';
-import { useCustomizeTitle } from '~/stores-universal/context';
+import { useCustomTitleTemplate } from '~/states/global';
 
 import AdminUpdateButtonRow from '../Common/AdminUpdateButtonRow';
 
@@ -15,7 +15,7 @@ export const CustomizeTitle: FC = () => {
 
   const { t } = useTranslation('admin');
 
-  const { data: customizeTitle } = useCustomizeTitle();
+  const customTitleTemplate = useCustomTitleTemplate();
 
   const {
     register,
@@ -26,9 +26,9 @@ export const CustomizeTitle: FC = () => {
   // Sync form with store data
   useEffect(() => {
     reset({
-      customizeTitle: customizeTitle ?? '',
+      customizeTitle: customTitleTemplate ?? '',
     });
-  }, [customizeTitle, reset]);
+  }, [customTitleTemplate, reset]);
 
   const onSubmit = useCallback(async(data) => {
     try {

+ 7 - 6
apps/app/src/client/components/Admin/ElasticsearchManagement/ElasticsearchManagement.tsx

@@ -1,13 +1,13 @@
 import React, { useEffect, useState, useCallback } from 'react';
 
+import { useAtomValue } from 'jotai';
 import { useTranslation } from 'next-i18next';
 
-
 import { apiv3Get, apiv3Post, apiv3Put } from '~/client/util/apiv3-client';
 import { toastSuccess, toastError } from '~/client/util/toastr';
+import { useAdminSocket } from '~/features/admin/states/socket-io';
 import { SocketEventName } from '~/interfaces/websocket';
-import { useIsSearchServiceReachable } from '~/stores-universal/context';
-import { useAdminSocket } from '~/stores/socket-io';
+import { isSearchServiceReachableAtom } from '~/states/server-configurations';
 
 import NormalizeIndicesControls from './NormalizeIndicesControls';
 import RebuildIndexControls from './RebuildIndexControls';
@@ -16,8 +16,9 @@ import StatusTable from './StatusTable';
 
 const ElasticsearchManagement = (): JSX.Element => {
   const { t } = useTranslation('admin');
-  const { data: isSearchServiceReachable } = useIsSearchServiceReachable();
-  const { data: socket } = useAdminSocket();
+  // Get search service reachable flag from atom
+  const isSearchServiceReachable = useAtomValue(isSearchServiceReachableAtom);
+  const socket = useAdminSocket();
 
   const [isInitialized, setIsInitialized] = useState(false);
 
@@ -77,7 +78,7 @@ const ElasticsearchManagement = (): JSX.Element => {
     if (socket == null) {
       return;
     }
-    socket.on(SocketEventName.AddPageProgress, (data) => {
+    socket.on(SocketEventName.AddPageProgress, () => {
       setIsRebuildingProcessing(true);
     });
 

+ 2 - 2
apps/app/src/client/components/Admin/ElasticsearchManagement/RebuildIndexControls.jsx

@@ -3,8 +3,8 @@ import React from 'react';
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 
+import { useAdminSocket } from '~/features/admin/states/socket-io';
 import { SocketEventName } from '~/interfaces/websocket';
-import { useAdminSocket } from '~/stores/socket-io';
 
 import LabeledProgressBar from '../Common/LabeledProgressBar';
 
@@ -99,7 +99,7 @@ class RebuildIndexControls extends React.Component {
 
 const RebuildIndexControlsFC = (props) => {
   const { t } = useTranslation('admin');
-  const { data: socket } = useAdminSocket();
+  const socket = useAdminSocket();
   return <RebuildIndexControls t={t} socket={socket} {...props} />;
 };
 

+ 2 - 2
apps/app/src/client/components/Admin/ExportArchiveDataPage.tsx

@@ -8,7 +8,7 @@ import { useTranslation } from 'react-i18next';
 import { apiDelete } from '~/client/util/apiv1-client';
 import { apiv3Get } from '~/client/util/apiv3-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';
-import { useAdminSocket } from '~/stores/socket-io';
+import { useAdminSocket } from '~/features/admin/states/socket-io';
 
 import LabeledProgressBar from './Common/LabeledProgressBar';
 import ArchiveFilesTable from './ExportArchiveData/ArchiveFilesTable';
@@ -20,7 +20,7 @@ const IGNORED_COLLECTION_NAMES = [
 ];
 
 const ExportArchiveDataPage = (): JSX.Element => {
-  const { data: socket } = useAdminSocket();
+  const socket = useAdminSocket();
   const { t } = useTranslation('admin');
 
   const [collections, setCollections] = useState<any[]>([]);

+ 4 - 4
apps/app/src/client/components/Admin/G2GDataTransfer.tsx

@@ -7,9 +7,9 @@ import { useTranslation } from 'next-i18next';
 import { useGenerateTransferKey } from '~/client/services/g2g-transfer';
 import { apiv3Get, apiv3Post } from '~/client/util/apiv3-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';
+import { useAdminSocket } from '~/features/admin/states/socket-io';
 import { G2G_PROGRESS_STATUS, type G2GProgress } from '~/interfaces/g2g-transfer';
-import { useGrowiDocumentationUrl } from '~/stores-universal/context';
-import { useAdminSocket } from '~/stores/socket-io';
+import { useGrowiDocumentationUrl } from '~/states/context';
 
 import CustomCopyToClipBoard from '../Common/CustomCopyToClipBoard';
 
@@ -22,7 +22,7 @@ const IGNORED_COLLECTION_NAMES = [
 ];
 
 const G2GDataTransfer = (): JSX.Element => {
-  const { data: socket } = useAdminSocket();
+  const socket = useAdminSocket();
   const { t } = useTranslation(['admin', 'commons']);
 
   const [startTransferKey, setStartTransferKey] = useState('');
@@ -124,7 +124,7 @@ const G2GDataTransfer = (): JSX.Element => {
     }
   }, [setTransferring, startTransferKey, selectedCollections, optionsMap]);
 
-  const { data: documentationUrl } = useGrowiDocumentationUrl();
+  const documentationUrl = useGrowiDocumentationUrl();
 
   // File upload
   // const onChangeFileUploadTypeHandler = useCallback((e: ChangeEvent, type: string) => {

+ 2 - 2
apps/app/src/client/components/Admin/ImportData/GrowiArchive/ImportForm.jsx

@@ -5,10 +5,10 @@ import PropTypes from 'prop-types';
 
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { toastSuccess, toastError } from '~/client/util/toastr';
+import { useAdminSocket } from '~/features/admin/states/socket-io';
 import { GrowiArchiveImportOption } from '~/models/admin/growi-archive-import-option';
 import { ImportOptionForPages } from '~/models/admin/import-option-for-pages';
 import { ImportOptionForRevisions } from '~/models/admin/import-option-for-revisions';
-import { useAdminSocket } from '~/stores/socket-io';
 
 
 import ErrorViewer from './ErrorViewer';
@@ -507,7 +507,7 @@ ImportForm.propTypes = {
 
 const ImportFormWrapperFc = (props) => {
   const { t } = useTranslation('admin');
-  const { data: socket } = useAdminSocket();
+  const socket = useAdminSocket();
 
   if (socket == null) {
     return;

+ 4 - 2
apps/app/src/client/components/Admin/Notification/ManageGlobalNotification.tsx

@@ -2,6 +2,7 @@ import React, {
   useCallback, useMemo, useEffect, useState, type JSX,
 } from 'react';
 
+import { useAtomValue } from 'jotai';
 import { useTranslation } from 'next-i18next';
 import Link from 'next/link';
 import { useRouter } from 'next/router';
@@ -9,7 +10,7 @@ import { useRouter } from 'next/router';
 import { NotifyType, TriggerEventType } from '~/client/interfaces/global-notification';
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { toastError } from '~/client/util/toastr';
-import { useIsMailerSetup } from '~/stores-universal/context';
+import { isMailerSetupAtom } from '~/states/server-configurations';
 import { useSWRxGlobalNotification } from '~/stores/global-notification';
 import loggerFactory from '~/utils/logger';
 
@@ -106,7 +107,8 @@ const ManageGlobalNotification = (props: Props): JSX.Element => {
   }, [emailToSend, notifyType, props.globalNotificationId, router, slackChannelToSend, triggerEvents, triggerPath, updateGlobalNotification]);
 
 
-  const { data: isMailerSetup } = useIsMailerSetup();
+  // Mailer setup status (unused yet but kept for potential conditional logic)
+  const isMailerSetup = useAtomValue(isMailerSetupAtom);
   const { t } = useTranslation('admin');
 
   return (

+ 12 - 18
apps/app/src/client/components/Admin/Security/GitHubSecuritySettingContents.jsx → apps/app/src/client/components/Admin/Security/GitHubSecuritySettingContents.tsx

@@ -3,7 +3,6 @@ import React, { useCallback, useEffect } from 'react';
 
 import { pathUtils } from '@growi/core/dist/utils';
 import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
 import { useForm } from 'react-hook-form';
 import urljoin from 'url-join';
 
@@ -11,15 +10,23 @@ import urljoin from 'url-join';
 import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
 import AdminGitHubSecurityContainer from '~/client/services/AdminGitHubSecurityContainer';
 import { toastSuccess, toastError } from '~/client/util/toastr';
-import { useSiteUrl } from '~/stores-universal/context';
+import { useSiteUrlWithEmptyValueWarn } from '~/states/global';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
-const GitHubSecurityManagementContents = (props) => {
+type Props = {
+  adminGeneralSecurityContainer: AdminGeneralSecurityContainer
+  adminGitHubSecurityContainer: AdminGitHubSecurityContainer
+};
+
+const GitHubSecurityManagementContents = (props: Props) => {
   const {
-    t, adminGeneralSecurityContainer, adminGitHubSecurityContainer, siteUrl,
+    adminGeneralSecurityContainer, adminGitHubSecurityContainer,
   } = props;
 
+  const { t } = useTranslation('admin');
+  const siteUrl = useSiteUrlWithEmptyValueWarn();
+
   const { isGitHubEnabled } = adminGeneralSecurityContainer.state;
   const { githubClientId, githubClientSecret, retrieveError } = adminGitHubSecurityContainer.state;
   const gitHubCallbackUrl = urljoin(pathUtils.removeTrailingSlash(siteUrl), '/passport/github/callback');
@@ -190,23 +197,10 @@ const GitHubSecurityManagementContents = (props) => {
   );
 };
 
-GitHubSecurityManagementContents.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
-  adminGitHubSecurityContainer: PropTypes.instanceOf(AdminGitHubSecurityContainer).isRequired,
-  siteUrl: PropTypes.string,
-};
-
-const GitHubSecurityManagementContentsFC = (props) => {
-  const { t } = useTranslation('admin');
-  const { data: siteUrl } = useSiteUrl();
-  return <GitHubSecurityManagementContents t={t} siteUrl={siteUrl} {...props} />;
-};
-
 /**
  * Wrapper component for using unstated
  */
-const GitHubSecurityManagementContentsWrapper = withUnstatedContainers(GitHubSecurityManagementContentsFC, [
+const GitHubSecurityManagementContentsWrapper = withUnstatedContainers(GitHubSecurityManagementContents, [
   AdminGeneralSecurityContainer,
   AdminGitHubSecurityContainer,
 ]);

+ 13 - 18
apps/app/src/client/components/Admin/Security/GoogleSecuritySettingContents.jsx → apps/app/src/client/components/Admin/Security/GoogleSecuritySettingContents.tsx

@@ -1,23 +1,31 @@
+/* eslint-disable react/no-danger */
 import React, { useCallback, useEffect } from 'react';
 
 import { pathUtils } from '@growi/core/dist/utils';
 import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
 import { useForm } from 'react-hook-form';
 import urljoin from 'url-join';
 
 import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
 import AdminGoogleSecurityContainer from '~/client/services/AdminGoogleSecurityContainer';
 import { toastSuccess, toastError } from '~/client/util/toastr';
-import { useSiteUrl } from '~/stores-universal/context';
+import { useSiteUrlWithEmptyValueWarn } from '~/states/global';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
-const GoogleSecurityManagementContents = (props) => {
+type Props = {
+  adminGeneralSecurityContainer: AdminGeneralSecurityContainer
+  adminGoogleSecurityContainer: AdminGoogleSecurityContainer
+};
+
+const GoogleSecurityManagementContents = (props: Props) => {
   const {
-    t, adminGeneralSecurityContainer, adminGoogleSecurityContainer, siteUrl,
+    adminGeneralSecurityContainer, adminGoogleSecurityContainer,
   } = props;
 
+  const { t } = useTranslation('admin');
+  const siteUrl = useSiteUrlWithEmptyValueWarn();
+
   const { isGoogleEnabled } = adminGeneralSecurityContainer.state;
   const { googleClientId, googleClientSecret, retrieveError } = adminGoogleSecurityContainer.state;
   const googleCallbackUrl = urljoin(pathUtils.removeTrailingSlash(siteUrl), '/passport/google/callback');
@@ -191,20 +199,7 @@ const GoogleSecurityManagementContents = (props) => {
   );
 };
 
-GoogleSecurityManagementContents.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  adminGeneralSecurityContainer: PropTypes.instanceOf(AdminGeneralSecurityContainer).isRequired,
-  adminGoogleSecurityContainer: PropTypes.instanceOf(AdminGoogleSecurityContainer).isRequired,
-  siteUrl: PropTypes.string,
-};
-
-const GoogleSecurityManagementContentsFc = (props) => {
-  const { t } = useTranslation('admin');
-  const { data: siteUrl } = useSiteUrl();
-  return <GoogleSecurityManagementContents t={t} siteUrl={siteUrl} {...props} />;
-};
-
-const GoogleSecurityManagementContentsWrapper = withUnstatedContainers(GoogleSecurityManagementContentsFc, [
+const GoogleSecurityManagementContentsWrapper = withUnstatedContainers(GoogleSecurityManagementContents, [
   AdminGeneralSecurityContainer,
   AdminGoogleSecurityContainer,
 ]);

+ 4 - 4
apps/app/src/client/components/Admin/Security/LocalSecuritySettingContents.tsx

@@ -1,13 +1,13 @@
 import React, { useCallback, useEffect } from 'react';
-
-import { useTranslation } from 'next-i18next';
 import Link from 'next/link';
+import { useTranslation } from 'next-i18next';
+import { useAtomValue } from 'jotai';
 import { useForm } from 'react-hook-form';
 
 import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
 import AdminLocalSecurityContainer from '~/client/services/AdminLocalSecurityContainer';
 import { toastSuccess, toastError } from '~/client/util/toastr';
-import { useIsMailerSetup } from '~/stores-universal/context';
+import { isMailerSetupAtom } from '~/states/server-configurations';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
@@ -23,7 +23,7 @@ const LocalSecuritySettingContents = (props: Props): JSX.Element => {
   } = props;
 
   const { t } = useTranslation('admin');
-  const { data: isMailerSetup = false } = useIsMailerSetup();
+  const isMailerSetup = useAtomValue(isMailerSetupAtom);
 
   const { register, handleSubmit, reset } = useForm();
 

+ 3 - 6
apps/app/src/client/components/Admin/Security/OidcSecuritySettingContents.tsx

@@ -9,7 +9,7 @@ import urljoin from 'url-join';
 import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
 import AdminOidcSecurityContainer from '~/client/services/AdminOidcSecurityContainer';
 import { toastSuccess, toastError } from '~/client/util/toastr';
-import { useSiteUrl } from '~/stores-universal/context';
+import { useSiteUrlWithEmptyValueWarn } from '~/states/global';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
@@ -20,7 +20,7 @@ type Props = {
 
 const OidcSecurityManagementContents = (props: Props) => {
   const { t } = useTranslation('admin');
-  const { data: siteUrl } = useSiteUrl();
+  const siteUrl = useSiteUrlWithEmptyValueWarn();
 
   const {
     adminGeneralSecurityContainer, adminOidcSecurityContainer,
@@ -33,10 +33,7 @@ const OidcSecurityManagementContents = (props: Props) => {
     oidcAttrMapId, oidcAttrMapUserName, oidcAttrMapName, oidcAttrMapEmail,
   } = adminOidcSecurityContainer.state;
 
-  const oidcCallbackUrl = urljoin(
-    siteUrl == null ? '' : pathUtils.removeTrailingSlash(siteUrl),
-    '/passport/oidc/callback',
-  );
+  const oidcCallbackUrl = urljoin(pathUtils.removeTrailingSlash(siteUrl), '/passport/oidc/callback');
 
   const { register, handleSubmit, reset } = useForm();
 

+ 3 - 6
apps/app/src/client/components/Admin/Security/SamlSecuritySettingContents.tsx

@@ -11,7 +11,7 @@ import urljoin from 'url-join';
 import AdminGeneralSecurityContainer from '~/client/services/AdminGeneralSecurityContainer';
 import AdminSamlSecurityContainer from '~/client/services/AdminSamlSecurityContainer';
 import { toastSuccess, toastError } from '~/client/util/toastr';
-import { useSiteUrl } from '~/stores-universal/context';
+import { useSiteUrlWithEmptyValueWarn } from '~/states/global';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
@@ -26,7 +26,7 @@ const SamlSecurityManagementContents = (props: Props) => {
   } = props;
 
   const { t } = useTranslation('admin');
-  const { data: siteUrl } = useSiteUrl();
+  const siteUrl = useSiteUrlWithEmptyValueWarn();
 
   const [isHelpOpened, setIsHelpOpened] = useState(false);
   const { register, handleSubmit, reset } = useForm();
@@ -75,10 +75,7 @@ const SamlSecurityManagementContents = (props: Props) => {
   const { useOnlyEnvVars } = adminSamlSecurityContainer.state;
   const { isSamlEnabled } = adminGeneralSecurityContainer.state;
 
-  const samlCallbackUrl = urljoin(
-    siteUrl == null ? '' : pathUtils.removeTrailingSlash(siteUrl),
-    '/passport/saml/callback',
-  );
+  const samlCallbackUrl = urljoin(pathUtils.removeTrailingSlash(siteUrl), '/passport/saml/callback');
 
   return (
     <React.Fragment>

+ 2 - 2
apps/app/src/client/components/Admin/SlackIntegration/CustomBotWithProxySettings.jsx

@@ -5,7 +5,7 @@ import PropTypes from 'prop-types';
 
 import { apiv3Delete, apiv3Put } from '~/client/util/apiv3-client';
 import { toastSuccess, toastError } from '~/client/util/toastr';
-import { useAppTitle } from '~/stores-universal/context';
+import { useAppTitle } from '~/states/global';
 import loggerFactory from '~/utils/logger';
 
 
@@ -26,7 +26,7 @@ const CustomBotWithProxySettings = (props) => {
   const [integrationIdToDelete, setIntegrationIdToDelete] = useState(null);
   const [siteName, setSiteName] = useState('');
   const { t } = useTranslation();
-  const { data: appTitle } = useAppTitle();
+  const appTitle = useAppTitle();
 
   // componentDidUpdate
   useEffect(() => {

+ 2 - 2
apps/app/src/client/components/Admin/SlackIntegration/CustomBotWithoutProxySettings.jsx

@@ -3,7 +3,7 @@ import React, { useState, useEffect } from 'react';
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 
-import { useAppTitle } from '~/stores-universal/context';
+import { useAppTitle } from '~/states/global';
 
 import { CustomBotWithoutProxyConnectionStatus } from './CustomBotWithoutProxyConnectionStatus';
 import CustomBotWithoutProxySettingsAccordion, { botInstallationStep } from './CustomBotWithoutProxySettingsAccordion';
@@ -11,7 +11,7 @@ import CustomBotWithoutProxySettingsAccordion, { botInstallationStep } from './C
 const CustomBotWithoutProxySettings = (props) => {
   const { connectionStatuses } = props;
   const { t } = useTranslation();
-  const { data: appTitle } = useAppTitle();
+  const appTitle = useAppTitle();
   const [siteName, setSiteName] = useState('');
 
   useEffect(() => {

+ 56 - 39
apps/app/src/client/components/Admin/SlackIntegration/DeleteSlackBotSettingsModal.tsx

@@ -1,4 +1,4 @@
-import React, { useCallback } from 'react';
+import React, { useCallback, useMemo } from 'react';
 
 import { useTranslation } from 'next-i18next';
 import {
@@ -29,53 +29,70 @@ export const DeleteSlackBotSettingsModal = React.memo((props: DeleteSlackBotSett
     onClose?.();
   }, [onClose]);
 
+  // Memoize conditional content
+  const headerContent = useMemo(() => {
+    if (isResetAll) {
+      return (
+        <>
+          <span className="material-symbols-outlined">delete_forever</span>
+          {t('admin:slack_integration.reset_all_settings')}
+        </>
+      );
+    }
+    return (
+      <>
+        <span className="material-symbols-outlined">delete</span>
+        {t('admin:slack_integration.delete_slackbot_settings')}
+      </>
+    );
+  }, [isResetAll, t]);
+
+  const bodyContent = useMemo(() => {
+    const htmlContent = isResetAll
+      ? t('admin:slack_integration.all_settings_of_the_bot_will_be_reset')
+      : t('admin:slack_integration.slackbot_settings_notice');
+    return (
+      <span
+        // eslint-disable-next-line react/no-danger
+        dangerouslySetInnerHTML={{ __html: htmlContent }}
+      />
+    );
+  }, [isResetAll, t]);
+
+  const deleteButtonContent = useMemo(() => {
+    if (isResetAll) {
+      return (
+        <>
+          <span className="material-symbols-outlined">delete_forever</span>
+          {t('admin:slack_integration.reset')}
+        </>
+      );
+    }
+    return (
+      <>
+        <span className="material-symbols-outlined">delete</span>
+        {t('admin:slack_integration.delete')}
+      </>
+    );
+  }, [isResetAll, t]);
+
+  // Early return optimization
+  if (!isOpen) {
+    return <></>;
+  }
+
   return (
     <Modal isOpen={isOpen} toggle={closeButtonHandler} className="page-comment-delete-modal">
       <ModalHeader tag="h4" toggle={closeButtonHandler} className="text-danger">
-        <span>
-          {isResetAll && (
-            <>
-              <span className="material-symbols-outlined">delete_forever</span>
-              {t('admin:slack_integration.reset_all_settings')}
-            </>
-          )}
-          {!isResetAll && (
-            <>
-              <span className="material-symbols-outlined">delete</span>
-              {t('admin:slack_integration.delete_slackbot_settings')}
-            </>
-          )}
-        </span>
+        <span>{headerContent}</span>
       </ModalHeader>
       <ModalBody>
-        {isResetAll && (
-          <span
-            // eslint-disable-next-line react/no-danger
-            dangerouslySetInnerHTML={{ __html: t('admin:slack_integration.all_settings_of_the_bot_will_be_reset') }}
-          />
-        )}
-        {!isResetAll && (
-          <span
-            // eslint-disable-next-line react/no-danger
-            dangerouslySetInnerHTML={{ __html: t('admin:slack_integration.slackbot_settings_notice') }}
-          />
-        )}
+        {bodyContent}
       </ModalBody>
       <ModalFooter>
         <Button onClick={closeButtonHandler}>{t('Cancel')}</Button>
         <Button color="danger" onClick={deleteSlackCredentialsHandler}>
-          {isResetAll && (
-            <>
-              <span className="material-symbols-outlined">delete_forever</span>
-              {t('admin:slack_integration.reset')}
-            </>
-          )}
-          {!isResetAll && (
-            <>
-              <span className="material-symbols-outlined">delete</span>
-              {t('admin:slack_integration.delete')}
-            </>
-          )}
+          {deleteButtonContent}
         </Button>
       </ModalFooter>
     </Modal>

+ 2 - 2
apps/app/src/client/components/Admin/SlackIntegration/OfficialBotSettings.jsx

@@ -7,7 +7,7 @@ import PropTypes from 'prop-types';
 
 import { apiv3Delete, apiv3Put } from '~/client/util/apiv3-client';
 import { toastSuccess, toastError } from '~/client/util/toastr';
-import { useAppTitle } from '~/stores-universal/context';
+import { useAppTitle } from '~/states/global';
 import loggerFactory from '~/utils/logger';
 
 
@@ -27,7 +27,7 @@ const OfficialBotSettings = (props) => {
   const [siteName, setSiteName] = useState('');
   const [integrationIdToDelete, setIntegrationIdToDelete] = useState(null);
   const { t } = useTranslation();
-  const { data: appTitle } = useAppTitle();
+  const appTitle = useAppTitle();
 
   const addSlackAppIntegrationHandler = async() => {
     if (onClickAddSlackWorkspaceBtn != null) {

+ 2 - 2
apps/app/src/client/components/Admin/SlackIntegration/WithProxyAccordions.jsx

@@ -7,7 +7,7 @@ import PropTypes from 'prop-types';
 
 import { apiv3Put, apiv3Post } from '~/client/util/apiv3-client';
 import { toastSuccess, toastError } from '~/client/util/toastr';
-import { useSiteUrl } from '~/stores-universal/context';
+import { useSiteUrlWithEmptyValueWarn } from '~/states/global';
 import loggerFactory from '~/utils/logger';
 
 import CustomCopyToClipBoard from '../../Common/CustomCopyToClipBoard';
@@ -286,7 +286,7 @@ const TestProcess = ({
 
 const WithProxyAccordions = (props) => {
   const { t } = useTranslation();
-  const { data: siteUrl } = useSiteUrl();
+  const siteUrl = useSiteUrlWithEmptyValueWarn();
   const [isLatestConnectionSuccess, setIsLatestConnectionSuccess] = useState(false);
 
   const submitForm = () => {

+ 75 - 60
apps/app/src/client/components/Admin/UserGroup/UserGroupModal.tsx

@@ -1,5 +1,7 @@
 import type { FC } from 'react';
-import React, { useState, useEffect, useCallback } from 'react';
+import React, {
+  useState, useEffect, useCallback, useMemo,
+} from 'react';
 
 import type { Ref, IUserGroup, IUserGroupHasId } from '@growi/core';
 import { useTranslation } from 'next-i18next';
@@ -16,12 +18,12 @@ type Props = {
   isExternalGroup?: boolean
 };
 
-export const UserGroupModal: FC<Props> = (props: Props) => {
+const UserGroupModalSubstance: FC<Props> = (props: Props) => {
 
   const { t } = useTranslation('admin');
 
   const {
-    userGroup, buttonLabel, onClickSubmit, isShow, onHide, isExternalGroup = false,
+    userGroup, buttonLabel, onClickSubmit, onHide, isExternalGroup = false,
   } = props;
 
   /*
@@ -42,6 +44,14 @@ export const UserGroupModal: FC<Props> = (props: Props) => {
     setDescription(e.target.value);
   }, []);
 
+  // Memoized user group data for submission
+  const userGroupData = useMemo(() => ({
+    _id: userGroup?._id,
+    name: currentName,
+    description: currentDescription,
+    parent: currentParent,
+  }), [userGroup?._id, currentName, currentDescription, currentParent]);
+
   const onSubmitHandler = useCallback(async(e) => {
     e.preventDefault(); // no reload
 
@@ -49,13 +59,8 @@ export const UserGroupModal: FC<Props> = (props: Props) => {
       return;
     }
 
-    await onClickSubmit({
-      _id: userGroup?._id,
-      name: currentName,
-      description: currentDescription,
-      parent: currentParent,
-    });
-  }, [userGroup, currentName, currentDescription, currentParent, onClickSubmit]);
+    await onClickSubmit(userGroupData);
+  }, [onClickSubmit, userGroupData]);
 
   // componentDidMount
   useEffect(() => {
@@ -66,58 +71,68 @@ export const UserGroupModal: FC<Props> = (props: Props) => {
     }
   }, [userGroup]);
 
+  return (
+    <form onSubmit={onSubmitHandler}>
+      <ModalHeader tag="h4" toggle={onHide}>
+        {t('user_group_management.basic_info')}
+      </ModalHeader>
+
+      <ModalBody>
+        <div>
+          <label htmlFor="name" className="form-label">
+            {t('user_group_management.group_name')}
+          </label>
+          <input
+            className="form-control"
+            type="text"
+            name="name"
+            placeholder={t('user_group_management.group_example')}
+            value={currentName}
+            onChange={onChangeNameHandler}
+            required
+            disabled={isExternalGroup}
+          />
+        </div>
+
+        <div>
+          <label htmlFor="description" className="form-label">
+            {t('Description')}
+          </label>
+          <textarea className="form-control" name="description" value={currentDescription} onChange={onChangeDescriptionHandler} />
+          {isExternalGroup && (
+            <p className="form-text text-muted">
+              <small>
+                {t('external_user_group.description_form_detail')}
+              </small>
+            </p>
+          )}
+        </div>
+
+        {/* TODO 90732: Add a drop-down to show selectable parents */}
+
+        {/* TODO 85462: Add a note that "if you change the parent, the offspring will also be moved together */}
+
+      </ModalBody>
+
+      <ModalFooter>
+        <div>
+          <button type="submit" className="btn btn-primary">
+            {buttonLabel}
+          </button>
+        </div>
+      </ModalFooter>
+    </form>
+  );
+};
+
+export const UserGroupModal: FC<Props> = (props: Props) => {
+  const { isShow, onHide } = props;
+
   return (
     <Modal className="modal-md" isOpen={isShow} toggle={onHide}>
-      <form onSubmit={onSubmitHandler}>
-        <ModalHeader tag="h4" toggle={onHide}>
-          {t('user_group_management.basic_info')}
-        </ModalHeader>
-
-        <ModalBody>
-          <div>
-            <label htmlFor="name" className="form-label">
-              {t('user_group_management.group_name')}
-            </label>
-            <input
-              className="form-control"
-              type="text"
-              name="name"
-              placeholder={t('user_group_management.group_example')}
-              value={currentName}
-              onChange={onChangeNameHandler}
-              required
-              disabled={isExternalGroup}
-            />
-          </div>
-
-          <div>
-            <label htmlFor="description" className="form-label">
-              {t('Description')}
-            </label>
-            <textarea className="form-control" name="description" value={currentDescription} onChange={onChangeDescriptionHandler} />
-            {isExternalGroup && (
-              <p className="form-text text-muted">
-                <small>
-                  {t('external_user_group.description_form_detail')}
-                </small>
-              </p>
-            )}
-          </div>
-
-          {/* TODO 90732: Add a drop-down to show selectable parents */}
-
-          {/* TODO 85462: Add a note that "if you change the parent, the offspring will also be moved together */}
-
-        </ModalBody>
-
-        <ModalFooter>
-          <div>
-            <button type="submit" className="btn btn-primary">
-              {buttonLabel}
-            </button>
-          </div>
-        </ModalFooter>
-      </form>
+      {isShow && (
+        <UserGroupModalSubstance {...props} />
+      )}
     </Modal>
   );
 };

+ 3 - 2
apps/app/src/client/components/Admin/UserGroup/UserGroupPage.tsx

@@ -4,6 +4,7 @@ import React, { useState, useCallback } from 'react';
 import {
   GroupType, getIdForRef, type IGrantedGroup, type IUserGroup, type IUserGroupHasId,
 } from '@growi/core';
+import { useAtomValue } from 'jotai';
 import dynamic from 'next/dynamic';
 import { useTranslation } from 'react-i18next';
 
@@ -12,7 +13,7 @@ import { toastSuccess, toastError } from '~/client/util/toastr';
 import { ExternalGroupManagement } from '~/features/external-user-group/client/components/ExternalUserGroup/ExternalUserGroupManagement';
 import { useSWRxExternalUserGroupList } from '~/features/external-user-group/client/stores/external-user-group';
 import type { PageActionOnGroupDelete } from '~/interfaces/user-group';
-import { useIsAclEnabled } from '~/stores-universal/context';
+import { isAclEnabledAtom } from '~/states/server-configurations';
 import { useSWRxUserGroupList, useSWRxChildUserGroupList, useSWRxUserGroupRelationList } from '~/stores/user-group';
 
 
@@ -23,7 +24,7 @@ const UserGroupTable = dynamic(() => import('./UserGroupTable').then(mod => mod.
 export const UserGroupPage: FC = () => {
   const { t } = useTranslation();
 
-  const { data: isAclEnabled } = useIsAclEnabled();
+  const isAclEnabled = useAtomValue(isAclEnabledAtom);
 
   /*
    * Fetch

+ 3 - 2
apps/app/src/client/components/Admin/UserGroupDetail/UpdateParentConfirmModal.tsx

@@ -6,7 +6,7 @@ import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 
-import { useUpdateUserGroupConfirmModal } from '~/stores/modal';
+import { useUpdateUserGroupConfirmModalStatus, useUpdateUserGroupConfirmModalActions } from '~/states/ui/modal/update-user-group-confirm';
 
 
 export const UpdateParentConfirmModal: FC = () => {
@@ -14,7 +14,8 @@ export const UpdateParentConfirmModal: FC = () => {
 
   const [isForceUpdate, setForceUpdate] = useState(false);
 
-  const { data: modalStatus, close: closeModal } = useUpdateUserGroupConfirmModal();
+  const modalStatus = useUpdateUserGroupConfirmModalStatus();
+  const { close: closeModal } = useUpdateUserGroupConfirmModalActions();
 
   if (modalStatus == null) {
     closeModal();

+ 5 - 4
apps/app/src/client/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx

@@ -6,6 +6,7 @@ import {
   GroupType, getIdStringForRef, type IGrantedGroup, type IUserGroup, type IUserGroupHasId,
 } from '@growi/core';
 import { objectIdUtils } from '@growi/core/dist/utils';
+import { useAtomValue } from 'jotai';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
 import Link from 'next/link';
@@ -18,8 +19,8 @@ import { toastSuccess, toastError } from '~/client/util/toastr';
 import type { IExternalUserGroupHasId } from '~/features/external-user-group/interfaces/external-user-group';
 import type { PageActionOnGroupDelete, SearchType } from '~/interfaces/user-group';
 import { SearchTypes } from '~/interfaces/user-group';
-import { useIsAclEnabled } from '~/stores-universal/context';
-import { useUpdateUserGroupConfirmModal } from '~/stores/modal';
+import { isAclEnabledAtom } from '~/states/server-configurations';
+import { useUpdateUserGroupConfirmModalActions } from '~/states/ui/modal/update-user-group-confirm';
 import { useSWRxUserGroupPages, useSWRxSelectableParentUserGroups, useSWRxSelectableChildUserGroups } from '~/stores/user-group';
 import loggerFactory from '~/utils/logger';
 
@@ -104,9 +105,9 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
 
   const { data: ancestorUserGroups, mutate: mutateAncestorUserGroups } = useAncestorUserGroups(currentUserGroupId, isExternalGroup);
 
-  const { data: isAclEnabled } = useIsAclEnabled();
+  const isAclEnabled = useAtomValue(isAclEnabledAtom);
 
-  const { open: openUpdateParentConfirmModal } = useUpdateUserGroupConfirmModal();
+  const { open: openUpdateParentConfirmModal } = useUpdateUserGroupConfirmModalActions();
 
   const parentUserGroup = (() => {
     if (isExternalGroup) {

+ 3 - 2
apps/app/src/client/components/Admin/Users/PasswordResetModal.jsx

@@ -1,6 +1,7 @@
 import React from 'react';
 
 import { LoadingSpinner } from '@growi/ui/dist/components';
+import { useAtomValue } from 'jotai';
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 import { CopyToClipboard } from 'react-copy-to-clipboard';
@@ -11,7 +12,7 @@ import {
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { toastError } from '~/client/util/toastr';
-import { useIsMailerSetup } from '~/stores-universal/context';
+import { isMailerSetupAtom } from '~/states/server-configurations';
 
 class PasswordResetModal extends React.Component {
 
@@ -204,7 +205,7 @@ class PasswordResetModal extends React.Component {
 
 const PasswordResetModalWrapperFC = (props) => {
   const { t } = useTranslation('admin');
-  const { data: isMailerSetup } = useIsMailerSetup();
+  const isMailerSetup = useAtomValue(isMailerSetupAtom);
   return <PasswordResetModal t={t} isMailerSetup={isMailerSetup ?? false} {...props} />;
 };
 

+ 2 - 2
apps/app/src/client/components/Admin/Users/RevokeAdminButton.tsx

@@ -5,7 +5,7 @@ import { useTranslation } from 'next-i18next';
 
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import { toastSuccess, toastError } from '~/client/util/toastr';
-import { useCurrentUser } from '~/stores-universal/context';
+import { useCurrentUser } from '~/states/global';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
@@ -17,7 +17,7 @@ type RevokeAdminButtonProps = {
 const RevokeAdminButton = (props: RevokeAdminButtonProps): JSX.Element => {
 
   const { t } = useTranslation('admin');
-  const { data: currentUser } = useCurrentUser();
+  const currentUser = useCurrentUser(); // hook returns single value now
   const { adminUsersContainer, user } = props;
 
   const onClickRevokeAdminBtnHandler = useCallback(async() => {

+ 2 - 2
apps/app/src/client/components/Admin/Users/RevokeAdminMenuItem.tsx

@@ -5,7 +5,7 @@ import { useTranslation } from 'next-i18next';
 
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import { toastSuccess, toastError } from '~/client/util/toastr';
-import { useCurrentUser } from '~/stores-universal/context';
+import { useCurrentUser } from '~/states/global';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
@@ -33,7 +33,7 @@ const RevokeAdminMenuItem = (props: Props): JSX.Element => {
 
   const { adminUsersContainer, user } = props;
 
-  const { data: currentUser } = useCurrentUser();
+  const currentUser = useCurrentUser();
 
   const clickRevokeAdminBtnHandler = useCallback(async() => {
     try {

+ 2 - 2
apps/app/src/client/components/Admin/Users/StatusSuspendMenuItem.tsx

@@ -6,7 +6,7 @@ import { useTranslation } from 'next-i18next';
 import { withUnstatedContainers } from '~/client/components/UnstatedUtils';
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import { toastSuccess, toastError } from '~/client/util/toastr';
-import { useCurrentUser } from '~/stores-universal/context';
+import { useCurrentUser } from '~/states/global';
 
 
 const SuspendAlert = React.memo((): JSX.Element => {
@@ -32,7 +32,7 @@ const StatusSuspendMenuItem = (props: Props): JSX.Element => {
 
   const { adminUsersContainer, user } = props;
 
-  const { data: currentUser } = useCurrentUser();
+  const currentUser = useCurrentUser(); // custom hook now returns single value
 
   const clickDeactiveBtnHandler = useCallback(async() => {
     try {

+ 3 - 3
apps/app/src/client/components/Admin/Users/UserInviteModal.jsx

@@ -1,5 +1,6 @@
 import React from 'react';
 
+import { useAtomValue } from 'jotai';
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
 import { CopyToClipboard } from 'react-copy-to-clipboard';
@@ -8,10 +9,9 @@ import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 
-
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import { toastSuccess, toastError, toastWarning } from '~/client/util/toastr';
-import { useIsMailerSetup } from '~/stores-universal/context';
+import { isMailerSetupAtom } from '~/states/server-configurations';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';
 
@@ -280,7 +280,7 @@ class UserInviteModal extends React.Component {
 
 const UserInviteModalWrapperFC = (props) => {
   const { t } = useTranslation();
-  const { data: isMailerSetup } = useIsMailerSetup();
+  const isMailerSetup = useAtomValue(isMailerSetupAtom);
   return <UserInviteModal t={t} isMailerSetup={isMailerSetup ?? false} {...props} />;
 };
 

+ 2 - 7
apps/app/src/client/components/AlertSiteUrlUndefined.tsx

@@ -2,7 +2,7 @@ import type { JSX } from 'react';
 
 import { useTranslation } from 'next-i18next';
 
-import { useSiteUrl } from '~/stores-universal/context';
+import { useSiteUrl } from '~/states/global';
 
 const isValidUrl = (str: string): boolean => {
   try {
@@ -17,12 +17,7 @@ const isValidUrl = (str: string): boolean => {
 
 export const AlertSiteUrlUndefined = (): JSX.Element => {
   const { t } = useTranslation('commons');
-  const { data: siteUrl, error: errorSiteUrl } = useSiteUrl();
-  const isLoadingSiteUrl = siteUrl === undefined && errorSiteUrl === undefined;
-
-  if (isLoadingSiteUrl) {
-    return <></>;
-  }
+  const siteUrl = useSiteUrl();
 
   if (typeof siteUrl === 'string' && isValidUrl(siteUrl)) {
     return <></>;

+ 2 - 2
apps/app/src/client/components/Bookmarks/BookmarkFolderItem.tsx

@@ -12,7 +12,7 @@ import { toastError } from '~/client/util/toastr';
 import type { BookmarkFolderItems, DragItemDataType, DragItemType } from '~/interfaces/bookmark-info';
 import { DRAG_ITEM_TYPE } from '~/interfaces/bookmark-info';
 import type { onDeletedBookmarkFolderFunction } from '~/interfaces/ui';
-import { useBookmarkFolderDeleteModal } from '~/stores/modal';
+import { useDeleteBookmarkFolderModalActions } from '~/states/ui/modal/delete-bookmark-folder';
 
 import { BookmarkFolderItemControl } from './BookmarkFolderItemControl';
 import { BookmarkFolderNameInput } from './BookmarkFolderNameInput';
@@ -49,7 +49,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
   const [isRenameAction, setIsRenameAction] = useState<boolean>(false);
   const [isCreateAction, setIsCreateAction] = useState<boolean>(false);
 
-  const { open: openDeleteBookmarkFolderModal } = useBookmarkFolderDeleteModal();
+  const { open: openDeleteBookmarkFolderModal } = useDeleteBookmarkFolderModalActions();
 
   const childrenExists = hasChildren({ childFolder, bookmarks });
 

+ 2 - 2
apps/app/src/client/components/Bookmarks/BookmarkFolderMenu.tsx

@@ -7,7 +7,7 @@ import { DropdownItem, DropdownMenu, UncontrolledDropdown } from 'reactstrap';
 
 import { addBookmarkToFolder, toggleBookmark } from '~/client/util/bookmark-utils';
 import { toastError } from '~/client/util/toastr';
-import { useCurrentUser } from '~/stores-universal/context';
+import { useCurrentUser } from '~/states/global';
 import { useSWRMUTxCurrentUserBookmarks } from '~/stores/bookmark';
 import { useSWRxBookmarkFolderAndChild } from '~/stores/bookmark-folder';
 import { useSWRMUTxPageInfo } from '~/stores/page';
@@ -34,7 +34,7 @@ export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element
 
   const [selectedItem, setSelectedItem] = useState<string | null>(null);
 
-  const { data: currentUser } = useCurrentUser();
+  const currentUser = useCurrentUser();
   const { data: bookmarkFolders, mutate: mutateBookmarkFolders } = useSWRxBookmarkFolderAndChild(currentUser?._id);
 
   const { trigger: mutateCurrentUserBookmarks } = useSWRMUTxCurrentUserBookmarks();

+ 7 - 6
apps/app/src/client/components/Bookmarks/BookmarkFolderTree.tsx

@@ -9,13 +9,14 @@ import { HTML5Backend } from 'react-dnd-html5-backend';
 
 import { toastSuccess } from '~/client/util/toastr';
 import type { OnDeletedFunction } from '~/interfaces/ui';
-import { useIsReadOnlyUser } from '~/stores-universal/context';
+import { useIsReadOnlyUser } from '~/states/context';
+import { useCurrentPageData } from '~/states/page';
+import { usePageDeleteModalActions } from '~/states/ui/modal/page-delete';
 import {
   useSWRxUserBookmarks, useSWRMUTxCurrentUserBookmarks,
 } from '~/stores/bookmark';
 import { useSWRxBookmarkFolderAndChild } from '~/stores/bookmark-folder';
-import { usePageDeleteModal } from '~/stores/modal';
-import { mutateAllPageInfo, useSWRMUTxPageInfo, useSWRxCurrentPage } from '~/stores/page';
+import { mutateAllPageInfo, useSWRMUTxPageInfo } from '~/stores/page';
 
 import { BookmarkFolderItem } from './BookmarkFolderItem';
 import { BookmarkItem } from './BookmarkItem';
@@ -41,13 +42,13 @@ export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
   const { t } = useTranslation();
   const router = useRouter();
 
-  const { data: isReadOnlyUser } = useIsReadOnlyUser();
-  const { data: currentPage } = useSWRxCurrentPage();
+  const isReadOnlyUser = useIsReadOnlyUser();
+  const currentPage = useCurrentPageData();
   const { data: bookmarkFolders, mutate: mutateBookmarkFolders } = useSWRxBookmarkFolderAndChild(userId);
   const { data: userBookmarks, mutate: mutateUserBookmarks } = useSWRxUserBookmarks(userId ?? null);
   const { trigger: mutatePageInfo } = useSWRMUTxPageInfo(currentPage?._id ?? null);
   const { trigger: mutateCurrentUserBookmarks } = useSWRMUTxCurrentUserBookmarks();
-  const { open: openDeleteModal } = usePageDeleteModal();
+  const { open: openDeleteModal } = usePageDeleteModalActions();
 
   const bookmarkFolderTreeMutation = useCallback(() => {
     mutateUserBookmarks();

+ 9 - 8
apps/app/src/client/components/Bookmarks/BookmarkItem.tsx

@@ -2,7 +2,7 @@ import React, { useCallback, useState, type JSX } from 'react';
 
 import nodePath from 'path';
 
-import type { IPageHasId, IPageInfoAll, IPageToDeleteWithMeta } from '@growi/core';
+import type { IPageHasId, IPageInfoExt, IPageToDeleteWithMeta } from '@growi/core';
 import { DevidedPagePath } from '@growi/core/dist/models';
 import { pathUtils } from '@growi/core/dist/utils';
 import { useRouter } from 'next/router';
@@ -15,8 +15,9 @@ import { addBookmarkToFolder, renamePage } from '~/client/util/bookmark-utils';
 import { toastError, toastSuccess } from '~/client/util/toastr';
 import type { BookmarkFolderItems, DragItemDataType } from '~/interfaces/bookmark-info';
 import { DRAG_ITEM_TYPE } from '~/interfaces/bookmark-info';
-import { usePutBackPageModal } from '~/stores/modal';
-import { mutateAllPageInfo, useSWRMUTxCurrentPage, useSWRxPageInfo } from '~/stores/page';
+import { useFetchCurrentPage } from '~/states/page';
+import { usePutBackPageModalActions } from '~/states/ui/modal/put-back-page';
+import { mutateAllPageInfo, useSWRxPageInfo } from '~/stores/page';
 
 import { MenuItemType, PageItemControl } from '../Common/Dropdown/PageItemControl';
 import { PageListItemS } from '../PageList/PageListItemS';
@@ -47,11 +48,11 @@ export const BookmarkItem = (props: Props): JSX.Element => {
     isReadOnlyUser, isOperable, bookmarkedPage, onClickDeleteMenuItemHandler,
     parentFolder, level, canMoveToRoot, bookmarkFolderTreeMutation,
   } = props;
-  const { open: openPutBackPageModal } = usePutBackPageModal();
+  const { open: openPutBackPageModal } = usePutBackPageModalActions();
   const [isRenameInputShown, setRenameInputShown] = useState(false);
 
   const { data: pageInfo, mutate: mutatePageInfo } = useSWRxPageInfo(bookmarkedPage?._id);
-  const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
+  const { fetchCurrentPage } = useFetchCurrentPage();
 
   const paddingLeft = BASE_BOOKMARK_PADDING + (BASE_FOLDER_PADDING * (level));
   const dragItem: Partial<DragItemDataType> = {
@@ -116,7 +117,7 @@ export const BookmarkItem = (props: Props): JSX.Element => {
     }
   }, [bookmarkedPage, cancel, bookmarkFolderTreeMutation, mutatePageInfo]);
 
-  const deleteMenuItemClickHandler = useCallback(async(_pageId: string, pageInfo: IPageInfoAll | undefined): Promise<void> => {
+  const deleteMenuItemClickHandler = useCallback(async(_pageId: string, pageInfo: IPageInfoExt | undefined): Promise<void> => {
     if (bookmarkedPage == null) return;
 
     if (bookmarkedPage._id == null || bookmarkedPage.path == null) {
@@ -145,7 +146,7 @@ export const BookmarkItem = (props: Props): JSX.Element => {
         mutateAllPageInfo();
         bookmarkFolderTreeMutation();
         router.push(`/${pageId}`);
-        mutateCurrentPage();
+        fetchCurrentPage({ force: true });
         toastSuccess(t('page_has_been_reverted', { path }));
       }
       catch (err) {
@@ -153,7 +154,7 @@ export const BookmarkItem = (props: Props): JSX.Element => {
       }
     };
     openPutBackPageModal({ pageId, path }, { onPutBacked: putBackedHandler });
-  }, [bookmarkedPage, openPutBackPageModal, bookmarkFolderTreeMutation, router, mutateCurrentPage, t]);
+  }, [bookmarkedPage, openPutBackPageModal, bookmarkFolderTreeMutation, router, fetchCurrentPage, t]);
 
   if (bookmarkedPage == null) {
     return <></>;

+ 5 - 4
apps/app/src/client/components/Comments.tsx

@@ -8,9 +8,10 @@ import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
 import { debounce } from 'throttle-debounce';
 
-import { useCurrentUser } from '~/stores-universal/context';
+import { useCurrentUser } from '~/states/global';
+import { useIsTrashPage } from '~/states/page';
 import { useSWRxPageComment } from '~/stores/comment';
-import { useIsTrashPage, useSWRMUTxPageInfo } from '~/stores/page';
+import { useSWRMUTxPageInfo } from '~/stores/page';
 
 
 const { isTopPage } = pagePathUtils;
@@ -36,8 +37,8 @@ export const Comments = (props: CommentsProps): JSX.Element => {
 
   const { mutate } = useSWRxPageComment(pageId);
   const { trigger: mutatePageInfo } = useSWRMUTxPageInfo(pageId);
-  const { data: isDeleted } = useIsTrashPage();
-  const { data: currentUser } = useCurrentUser();
+  const isDeleted = useIsTrashPage();
+  const currentUser = useCurrentUser();
 
   const pageCommentParentRef = useRef<HTMLDivElement>(null);
 

+ 3 - 4
apps/app/src/client/components/Common/DrawerToggler/DrawerToggler.tsx

@@ -1,7 +1,6 @@
 import { type ReactNode, type JSX } from 'react';
 
-import { useDrawerOpened } from '~/stores/ui';
-
+import { useDrawerOpened } from '~/states/ui/sidebar';
 
 import styles from './DrawerToggler.module.scss';
 
@@ -17,7 +16,7 @@ export const DrawerToggler = (props: Props): JSX.Element => {
 
   const { className, children } = props;
 
-  const { data: isOpened, mutate } = useDrawerOpened();
+  const [isOpened, setIsOpened] = useDrawerOpened();
 
   return (
     <div className={`${moduleClass} ${className ?? ''}`}>
@@ -26,7 +25,7 @@ export const DrawerToggler = (props: Props): JSX.Element => {
         type="button"
         aria-expanded="false"
         aria-label="Toggle navigation"
-        onClick={() => mutate(!isOpened)}
+        onClick={() => setIsOpened(!isOpened)}
       >
         {children}
       </button>

+ 13 - 10
apps/app/src/client/components/Common/Dropdown/PageItemControl.tsx

@@ -3,7 +3,7 @@ import React, {
 } from 'react';
 
 import {
-  type IPageInfoAll, isIPageInfoForOperation,
+  type IPageInfoExt, isIPageInfoForOperation,
 } from '@growi/core/dist/interfaces';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
@@ -33,18 +33,18 @@ export type MenuItemType = typeof MenuItemType[keyof typeof MenuItemType];
 
 export type ForceHideMenuItems = MenuItemType[];
 
-export type AdditionalMenuItemsRendererProps = { pageInfo: IPageInfoAll };
+export type AdditionalMenuItemsRendererProps = { pageInfo: IPageInfoExt };
 
 type CommonProps = {
-  pageInfo?: IPageInfoAll,
+  pageInfo?: IPageInfoExt,
   isEnableActions?: boolean,
   isReadOnlyUser?: boolean,
   forceHideMenuItems?: ForceHideMenuItems,
 
   onClickBookmarkMenuItem?: (pageId: string, newValue?: boolean) => Promise<void>,
-  onClickRenameMenuItem?: (pageId: string, pageInfo: IPageInfoAll | undefined) => Promise<void> | void,
+  onClickRenameMenuItem?: (pageId: string, pageInfo: IPageInfoExt | undefined) => Promise<void> | void,
   onClickDuplicateMenuItem?: (pageId: string) => Promise<void> | void,
-  onClickDeleteMenuItem?: (pageId: string, pageInfo: IPageInfoAll | undefined) => Promise<void> | void,
+  onClickDeleteMenuItem?: (pageId: string, pageInfo: IPageInfoExt | undefined) => Promise<void> | void,
   onClickRevertMenuItem?: (pageId: string) => Promise<void> | void,
   onClickPathRecoveryMenuItem?: (pageId: string) => Promise<void> | void,
 
@@ -86,7 +86,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
     if (onClickRenameMenuItem == null) {
       return;
     }
-    if (!pageInfo?.isMovable) {
+    if (!isIPageInfoForOperation(pageInfo) || !pageInfo?.isMovable) {
       logger.warn('This page could not be renamed.');
       return;
     }
@@ -113,7 +113,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
     if (pageInfo == null || onClickDeleteMenuItem == null) {
       return;
     }
-    if (!pageInfo.isDeletable) {
+    if (!isIPageInfoForOperation(pageInfo) || !pageInfo?.isDeletable) {
       logger.warn('This page could not be deleted.');
       return;
     }
@@ -176,7 +176,8 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
         ) }
 
         {/* Move/Rename */}
-        { !forceHideMenuItems?.includes(MenuItemType.RENAME) && isEnableActions && !isReadOnlyUser && pageInfo.isMovable && (
+        { !forceHideMenuItems?.includes(MenuItemType.RENAME) && isEnableActions && !isReadOnlyUser
+          && isIPageInfoForOperation(pageInfo) && pageInfo.isMovable && (
           <DropdownItem
             onClick={renameItemClickedHandler}
             data-testid="rename-page-btn"
@@ -200,7 +201,8 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
         ) }
 
         {/* Revert */}
-        { !forceHideMenuItems?.includes(MenuItemType.REVERT) && isEnableActions && !isReadOnlyUser && pageInfo.isRevertible && (
+        { !forceHideMenuItems?.includes(MenuItemType.REVERT) && isEnableActions && !isReadOnlyUser
+          && isIPageInfoForOperation(pageInfo) && pageInfo.isRevertible && (
           <DropdownItem
             onClick={revertItemClickedHandler}
             className="grw-page-control-dropdown-item"
@@ -230,7 +232,8 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
 
         {/* divider */}
         {/* Delete */}
-        { !forceHideMenuItems?.includes(MenuItemType.DELETE) && isEnableActions && !isReadOnlyUser && pageInfo.isDeletable && (
+        { !forceHideMenuItems?.includes(MenuItemType.DELETE) && isEnableActions && !isReadOnlyUser
+          && isIPageInfoForOperation(pageInfo) && pageInfo.isDeletable && (
           <>
             { showDeviderBeforeDelete && <DropdownItem divider /> }
             <DropdownItem

+ 65 - 61
apps/app/src/client/components/Common/ImageCropModal.tsx

@@ -76,14 +76,14 @@ const ImageCropModal: FC<Props> = (props: Props) => {
     reset();
   }, [reset]);
 
-  const onImageLoaded = (image) => {
+  // Memoize image processing functions
+  const onImageLoaded = useCallback((image) => {
     setImageRef(image);
     reset();
     return false;
-  };
-
+  }, [reset]);
 
-  const getCroppedImg = async(image: HTMLImageElement, crop: ICropOptions) => {
+  const getCroppedImg = useCallback(async(image: HTMLImageElement, crop: ICropOptions) => {
     const {
       naturalWidth: imageNaturalWidth, naturalHeight: imageNaturalHeight, width: imageWidth, height: imageHeight,
     } = image;
@@ -107,82 +107,86 @@ const ImageCropModal: FC<Props> = (props: Props) => {
       logger.error(err);
       toastError(new Error('Failed to draw image'));
     }
-  };
+  }, []);
 
   // Convert base64 Image to blob
-  const convertBase64ToBlob = async(base64Image: string) => {
+  const convertBase64ToBlob = useCallback(async(base64Image: string) => {
     const base64Response = await fetch(base64Image);
     return base64Response.blob();
-  };
+  }, []);
 
 
-  // Clear image and set isImageCrop true on modal close
-  const onModalCloseHandler = async() => {
+  // Memoize event handlers
+  const onModalCloseHandler = useCallback(async() => {
     setImageRef(null);
     onModalClose();
-  };
+  }, [onModalClose]);
 
-  // Process and save image
-  // Cropping image is optional
-  // If crop is active , the saved image is cropped image (png). Otherwise, the original image will be saved (Original size and file type)
-  const processAndSaveImage = async() => {
+  const processAndSaveImage = useCallback(async() => {
     if (imageRef && cropOptions?.width && cropOptions.height) {
       const processedImage = isCropImage ? await getCroppedImg(imageRef, cropOptions) : await convertBase64ToBlob(imageRef.src);
       // Save image to database
       onImageProcessCompleted(processedImage);
     }
     onModalCloseHandler();
-  };
+  }, [imageRef, cropOptions, isCropImage, getCroppedImg, convertBase64ToBlob, onImageProcessCompleted, onModalCloseHandler]);
+
+  const toggleCropMode = useCallback(() => setIsCropImage(!isCropImage), [isCropImage]);
+  const handleCropChange = useCallback((crop: CropOptions) => setCropOtions(crop), []);
 
   return (
     <Modal isOpen={isShow} toggle={onModalCloseHandler}>
-      <ModalHeader tag="h4" toggle={onModalCloseHandler} className="text-info">
-        {t('crop_image_modal.image_crop')}
-      </ModalHeader>
-      <ModalBody className="my-4">
-        {
-          isCropImage
-            ? (
-              <ReactCrop
-                style={{ backgroundColor: 'transparent' }}
-                src={src}
-                crop={cropOptions}
-                onImageLoaded={onImageLoaded}
-                onChange={crop => setCropOtions(crop)}
-                circularCrop={isCircular}
-              />
+      {isShow && (
+        <>
+          <ModalHeader tag="h4" toggle={onModalCloseHandler} className="text-info">
+            {t('crop_image_modal.image_crop')}
+          </ModalHeader>
+          <ModalBody className="my-4">
+            {
+              isCropImage
+                ? (
+                  <ReactCrop
+                    style={{ backgroundColor: 'transparent' }}
+                    src={src}
+                    crop={cropOptions}
+                    onImageLoaded={onImageLoaded}
+                    onChange={handleCropChange}
+                    circularCrop={isCircular}
+                  />
+                )
+                : (<img style={{ maxWidth: imageRef?.width }} src={imageRef?.src} />)
+            }
+          </ModalBody>
+          <ModalFooter>
+            <button type="button" className="btn btn-outline-danger rounded-pill me-auto" disabled={!isCropImage} onClick={reset}>
+              {t('commons:Reset')}
+            </button>
+            { !showCropOption && (
+              <div className="me-auto">
+                <div className="form-check form-switch">
+                  <input
+                    id="cropImageOption"
+                    className="form-check-input me-auto"
+                    type="checkbox"
+                    checked={isCropImage}
+                    onChange={toggleCropMode}
+                  />
+                  <label className="form-label form-check-label" htmlFor="cropImageOption">
+                    { t('crop_image_modal.image_crop') }
+                  </label>
+                </div>
+              </div>
             )
-            : (<img style={{ maxWidth: imageRef?.width }} src={imageRef?.src} />)
-        }
-      </ModalBody>
-      <ModalFooter>
-        <button type="button" className="btn btn-outline-danger rounded-pill me-auto" disabled={!isCropImage} onClick={reset}>
-          {t('commons:Reset')}
-        </button>
-        { !showCropOption && (
-          <div className="me-auto">
-            <div className="form-check form-switch">
-              <input
-                id="cropImageOption"
-                className="form-check-input me-auto"
-                type="checkbox"
-                checked={isCropImage}
-                onChange={() => { setIsCropImage(!isCropImage) }}
-              />
-              <label className="form-label form-check-label" htmlFor="cropImageOption">
-                { t('crop_image_modal.image_crop') }
-              </label>
-            </div>
-          </div>
-        )
-        }
-        <button type="button" className="btn btn-outline-secondary rounded-pill me-2" onClick={onModalCloseHandler}>
-          {t('crop_image_modal.cancel')}
-        </button>
-        <button type="button" className="btn btn-outline-primary rounded-pill" onClick={processAndSaveImage}>
-          { isCropImage ? t('crop_image_modal.crop') : t('crop_image_modal.save') }
-        </button>
-      </ModalFooter>
+            }
+            <button type="button" className="btn btn-outline-secondary rounded-pill me-2" onClick={onModalCloseHandler}>
+              {t('crop_image_modal.cancel')}
+            </button>
+            <button type="button" className="btn btn-outline-primary rounded-pill" onClick={processAndSaveImage}>
+              { isCropImage ? t('crop_image_modal.crop') : t('crop_image_modal.save') }
+            </button>
+          </ModalFooter>
+        </>
+      )}
     </Modal>
   );
 };

+ 25 - 23
apps/app/src/client/components/CreateTemplateModal.tsx → apps/app/src/client/components/CreateTemplateModal/CreateTemplateModal.tsx

@@ -1,4 +1,4 @@
-import React, { useCallback } from 'react';
+import React, { useCallback, useMemo } from 'react';
 
 import { pathUtils } from '@growi/core/dist/utils';
 import { useTranslation } from 'next-i18next';
@@ -67,9 +67,11 @@ export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
     }
   }, [createTemplate, onClose, path, t]);
 
-  const parentPath = pathUtils.addTrailingSlash(path);
+  // Memoize computed path
+  const parentPath = useMemo(() => pathUtils.addTrailingSlash(path), [path]);
 
-  const renderTemplateCard = (target: TargetType, label: LabelType) => (
+  // Memoize template card rendering function
+  const renderTemplateCard = useCallback((target: TargetType, label: LabelType) => (
     <div className="col">
       <TemplateCard
         target={target}
@@ -78,29 +80,29 @@ export const CreateTemplateModal: React.FC<CreateTemplateModalProps> = ({
         onClickHandler={() => onClickTemplateButtonHandler(label)}
       />
     </div>
-  );
-
-  if (!isCreatable) {
-    return <></>;
-  }
+  ), [isCreating, onClickTemplateButtonHandler]);
 
   return (
     <Modal isOpen={isOpen} toggle={onClose} data-testid="page-template-modal">
-      <ModalHeader tag="h4" toggle={onClose}>
-        {t('template.modal_label.Create/Edit Template Page')}
-      </ModalHeader>
-      <ModalBody>
-        <div>
-          <label className="form-label mb-4">
-            <code>{parentPath}</code><br />
-            {t('template.modal_label.Create template under')}
-          </label>
-          <div className="row row-cols-2">
-            {renderTemplateCard('children', '_template')}
-            {renderTemplateCard('descendants', '__template')}
-          </div>
-        </div>
-      </ModalBody>
+      {(isCreatable && isOpen) && (
+        <>
+          <ModalHeader tag="h4" toggle={onClose}>
+            {t('template.modal_label.Create/Edit Template Page')}
+          </ModalHeader>
+          <ModalBody>
+            <div>
+              <label className="form-label mb-4">
+                <code>{parentPath}</code><br />
+                {t('template.modal_label.Create template under')}
+              </label>
+              <div className="row row-cols-2">
+                {renderTemplateCard('children', '_template')}
+                {renderTemplateCard('descendants', '__template')}
+              </div>
+            </div>
+          </ModalBody>
+        </>
+      )}
     </Modal>
   );
 };

+ 19 - 0
apps/app/src/client/components/CreateTemplateModal/dynamic.tsx

@@ -0,0 +1,19 @@
+import type { JSX } from 'react';
+
+import { useLazyLoader } from '../../../components/utils/use-lazy-loader';
+
+type CreateTemplateModalProps = {
+  path: string;
+  isOpen: boolean;
+  onClose: () => void;
+};
+
+export const CreateTemplateModalLazyLoaded = (props: CreateTemplateModalProps): JSX.Element => {
+  const CreateTemplateModal = useLazyLoader<CreateTemplateModalProps>(
+    'create-template-modal',
+    () => import('./CreateTemplateModal').then(mod => ({ default: mod.CreateTemplateModal })),
+    props.isOpen,
+  );
+
+  return CreateTemplateModal != null ? <CreateTemplateModal {...props} /> : <></>;
+};

+ 1 - 0
apps/app/src/client/components/CreateTemplateModal/index.ts

@@ -0,0 +1 @@
+export { CreateTemplateModalLazyLoaded } from './dynamic';

+ 2 - 2
apps/app/src/client/components/DataTransferForm.tsx

@@ -3,14 +3,14 @@ import React, { type JSX } from 'react';
 import { useTranslation } from 'next-i18next';
 
 import { useGenerateTransferKey } from '~/client/services/g2g-transfer';
-import { useGrowiDocumentationUrl } from '~/stores-universal/context';
+import { useGrowiDocumentationUrl } from '~/states/context';
 
 import CustomCopyToClipBoard from './Common/CustomCopyToClipBoard';
 
 const DataTransferForm = (): JSX.Element => {
   const { t } = useTranslation('commons');
   const { transferKey, generateTransferKey } = useGenerateTransferKey();
-  const { data: documentationUrl } = useGrowiDocumentationUrl();
+  const documentationUrl = useGrowiDocumentationUrl();
 
   return (
     <div data-testid="installerForm" className="py-3 px-4">

+ 0 - 71
apps/app/src/client/components/DeleteBookmarkFolderModal.tsx

@@ -1,71 +0,0 @@
-
-import type { FC } from 'react';
-import React from 'react';
-
-import { useTranslation } from 'next-i18next';
-import {
-  Modal, ModalBody, ModalFooter, ModalHeader,
-} from 'reactstrap';
-
-import { FolderIcon } from '~/client/components/Icons/FolderIcon';
-import { deleteBookmarkFolder } from '~/client/util/bookmark-utils';
-import { toastError } from '~/client/util/toastr';
-import { useBookmarkFolderDeleteModal } from '~/stores/modal';
-
-
-const DeleteBookmarkFolderModal: FC = () => {
-  const { t } = useTranslation();
-  const { data: deleteBookmarkFolderModalData, close: closeBookmarkFolderDeleteModal } = useBookmarkFolderDeleteModal();
-  const isOpened = deleteBookmarkFolderModalData?.isOpened ?? false;
-
-  async function deleteBookmark() {
-    if (deleteBookmarkFolderModalData == null || deleteBookmarkFolderModalData.bookmarkFolder == null) {
-      return;
-    }
-    if (deleteBookmarkFolderModalData.bookmarkFolder != null) {
-      try {
-        await deleteBookmarkFolder(deleteBookmarkFolderModalData.bookmarkFolder._id);
-        const onDeleted = deleteBookmarkFolderModalData.opts?.onDeleted;
-        if (onDeleted != null) {
-          onDeleted(deleteBookmarkFolderModalData.bookmarkFolder._id);
-        }
-        closeBookmarkFolderDeleteModal();
-      }
-      catch (err) {
-        toastError(err);
-      }
-    }
-  }
-  async function onClickDeleteButton() {
-    await deleteBookmark();
-  }
-
-  return (
-    <Modal size="md" isOpen={isOpened} toggle={closeBookmarkFolderDeleteModal} data-testid="page-delete-modal" className="grw-create-page">
-      <ModalHeader tag="h4" toggle={closeBookmarkFolderDeleteModal} className="text-danger">
-        <span className="material-symbols-outlined">delete</span>
-        {t('bookmark_folder.delete_modal.modal_header_label')}
-      </ModalHeader>
-      <ModalBody>
-        <div className="pb-1 text-break">
-          <label className="form-label">{ t('bookmark_folder.delete_modal.modal_body_description') }:</label><br />
-          <FolderIcon isOpen={false} /> {deleteBookmarkFolderModalData?.bookmarkFolder?.name}
-        </div>
-        {t('bookmark_folder.delete_modal.modal_body_alert')}
-      </ModalBody>
-      <ModalFooter>
-        <button
-          type="button"
-          className="btn btn-danger"
-          onClick={onClickDeleteButton}
-        >
-          <span className="material-symbols-outlined" aria-hidden="true">delete</span>
-          {t('bookmark_folder.delete_modal.modal_footer_button')}
-        </button>
-      </ModalFooter>
-    </Modal>
-
-  );
-};
-
-export { DeleteBookmarkFolderModal };

+ 96 - 0
apps/app/src/client/components/DeleteBookmarkFolderModal/DeleteBookmarkFolderModal.tsx

@@ -0,0 +1,96 @@
+
+import type { FC } from 'react';
+import { useCallback } from 'react';
+
+import { useTranslation } from 'next-i18next';
+import {
+  Modal, ModalBody, ModalFooter, ModalHeader,
+} from 'reactstrap';
+
+import { FolderIcon } from '~/client/components/Icons/FolderIcon';
+import { deleteBookmarkFolder } from '~/client/util/bookmark-utils';
+import { toastError } from '~/client/util/toastr';
+import type { BookmarkFolderItems } from '~/interfaces/bookmark-info';
+import { useDeleteBookmarkFolderModalStatus, useDeleteBookmarkFolderModalActions } from '~/states/ui/modal/delete-bookmark-folder';
+
+/**
+ * DeleteBookmarkFolderModalSubstance - Presentation component (all logic here)
+ */
+type DeleteBookmarkFolderModalSubstanceProps = {
+  bookmarkFolder: BookmarkFolderItems;
+  onDeleted?: (folderId: string) => void;
+  closeModal: () => void;
+};
+
+const DeleteBookmarkFolderModalSubstance = ({
+  bookmarkFolder,
+  onDeleted,
+  closeModal,
+}: DeleteBookmarkFolderModalSubstanceProps): React.JSX.Element => {
+  const { t } = useTranslation();
+
+  const deleteBookmark = useCallback(async() => {
+    try {
+      await deleteBookmarkFolder(bookmarkFolder._id);
+      if (onDeleted != null) {
+        onDeleted(bookmarkFolder._id);
+      }
+      closeModal();
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [bookmarkFolder, onDeleted, closeModal]);
+
+  const onClickDeleteButton = useCallback(async() => {
+    await deleteBookmark();
+  }, [deleteBookmark]);
+
+  return (
+    <div>
+      <ModalHeader tag="h4" toggle={closeModal} className="text-danger">
+        <span className="material-symbols-outlined">delete</span>
+        {t('bookmark_folder.delete_modal.modal_header_label')}
+      </ModalHeader>
+      <ModalBody>
+        <div className="pb-1 text-break">
+          <label className="form-label">{ t('bookmark_folder.delete_modal.modal_body_description') }:</label><br />
+          <FolderIcon isOpen={false} /> {bookmarkFolder?.name}
+        </div>
+        {t('bookmark_folder.delete_modal.modal_body_alert')}
+      </ModalBody>
+      <ModalFooter>
+        <button
+          type="button"
+          className="btn btn-danger"
+          onClick={onClickDeleteButton}
+        >
+          <span className="material-symbols-outlined" aria-hidden="true">delete</span>
+          {t('bookmark_folder.delete_modal.modal_footer_button')}
+        </button>
+      </ModalFooter>
+    </div>
+  );
+};
+
+/**
+ * DeleteBookmarkFolderModal - Container component (lightweight, always rendered)
+ */
+const DeleteBookmarkFolderModal: FC = () => {
+  const { isOpened, bookmarkFolder, opts } = useDeleteBookmarkFolderModalStatus();
+  const { close: closeModal } = useDeleteBookmarkFolderModalActions();
+
+  return (
+    <Modal size="md" isOpen={isOpened} toggle={closeModal} data-testid="page-delete-modal" className="grw-create-page">
+      {isOpened && bookmarkFolder != null && (
+        <DeleteBookmarkFolderModalSubstance
+          bookmarkFolder={bookmarkFolder}
+          onDeleted={opts?.onDeleted}
+          closeModal={closeModal}
+        />
+      )}
+    </Modal>
+  );
+};
+
+export { DeleteBookmarkFolderModal };

+ 18 - 0
apps/app/src/client/components/DeleteBookmarkFolderModal/dynamic.tsx

@@ -0,0 +1,18 @@
+import type { JSX } from 'react';
+
+import { useLazyLoader } from '~/components/utils/use-lazy-loader';
+import { useDeleteBookmarkFolderModalStatus } from '~/states/ui/modal/delete-bookmark-folder';
+
+type DeleteBookmarkFolderModalProps = Record<string, unknown>;
+
+export const DeleteBookmarkFolderModalLazyLoaded = (): JSX.Element => {
+  const status = useDeleteBookmarkFolderModalStatus();
+
+  const DeleteBookmarkFolderModal = useLazyLoader<DeleteBookmarkFolderModalProps>(
+    'delete-bookmark-folder-modal',
+    () => import('./DeleteBookmarkFolderModal').then(mod => ({ default: mod.DeleteBookmarkFolderModal })),
+    status?.isOpened ?? false,
+  );
+
+  return DeleteBookmarkFolderModal ? <DeleteBookmarkFolderModal /> : <></>;
+};

+ 1 - 0
apps/app/src/client/components/DeleteBookmarkFolderModal/index.ts

@@ -0,0 +1 @@
+export { DeleteBookmarkFolderModalLazyLoaded } from './dynamic';

+ 4 - 4
apps/app/src/client/components/DescendantsPageList.tsx

@@ -11,7 +11,7 @@ import { useTranslation } from 'next-i18next';
 import { toastSuccess } from '~/client/util/toastr';
 import type { IPagingResult } from '~/interfaces/paging-result';
 import type { OnDeletedFunction, OnPutBackedFunction } from '~/interfaces/ui';
-import { useIsGuestUser, useIsReadOnlyUser, useIsSharedUser } from '~/stores-universal/context';
+import { useIsGuestUser, useIsReadOnlyUser, useIsSharedUser } from '~/states/context';
 import {
   mutatePageTree,
   useSWRxPageInfoForList, useSWRxPageList, mutateRecentlyUpdated,
@@ -42,8 +42,8 @@ const DescendantsPageListSubstance = (props: SubstanceProps): JSX.Element => {
     pagingResult, activePage, setActivePage, forceHideMenuItems, onPagesDeleted, onPagePutBacked,
   } = props;
 
-  const { data: isGuestUser } = useIsGuestUser();
-  const { data: isReadOnlyUser } = useIsReadOnlyUser();
+  const isGuestUser = useIsGuestUser();
+  const isReadOnlyUser = useIsReadOnlyUser();
 
   const pageIds = pagingResult?.items?.map(page => page._id);
   const { injectTo } = useSWRxPageInfoForList(pageIds, null, true, true);
@@ -133,7 +133,7 @@ export const DescendantsPageList = (props: DescendantsPageListProps): JSX.Elemen
 
   const [activePage, setActivePage] = useState(1);
 
-  const { data: isSharedUser } = useIsSharedUser();
+  const isSharedUser = useIsSharedUser();
 
   const { data: pagingResult, error, mutate } = useSWRxPageList(isSharedUser ? null : path, activePage, limit);
 

+ 0 - 129
apps/app/src/client/components/DescendantsPageListModal.tsx

@@ -1,129 +0,0 @@
-
-import React, {
-  useState, useMemo, useEffect, type JSX,
-} from 'react';
-
-import { useTranslation } from 'next-i18next';
-import dynamic from 'next/dynamic';
-import { useRouter } from 'next/router';
-import {
-  Modal, ModalHeader, ModalBody,
-} from 'reactstrap';
-
-import { useIsSharedUser } from '~/stores-universal/context';
-import { useDescendantsPageListModal } from '~/stores/modal';
-import { useIsDeviceLargerThanLg } from '~/stores/ui';
-
-import { CustomNavDropdown, CustomNavTab } from './CustomNavigation/CustomNav';
-import CustomTabContent from './CustomNavigation/CustomTabContent';
-import type { DescendantsPageListProps } from './DescendantsPageList';
-import ExpandOrContractButton from './ExpandOrContractButton';
-
-import styles from './DescendantsPageListModal.module.scss';
-
-const DescendantsPageList = dynamic<DescendantsPageListProps>(() => import('./DescendantsPageList').then(mod => mod.DescendantsPageList), { ssr: false });
-
-const PageTimeline = dynamic(() => import('./PageTimeline').then(mod => mod.PageTimeline), { ssr: false });
-
-export const DescendantsPageListModal = (): JSX.Element => {
-  const { t } = useTranslation();
-
-  const [activeTab, setActiveTab] = useState('pagelist');
-  const [isWindowExpanded, setIsWindowExpanded] = useState(false);
-
-  const { data: isSharedUser } = useIsSharedUser();
-
-  const { data: status, close } = useDescendantsPageListModal();
-
-  const { events } = useRouter();
-
-  const { data: isDeviceLargerThanLg } = useIsDeviceLargerThanLg();
-
-  useEffect(() => {
-    events.on('routeChangeStart', close);
-    return () => {
-      events.off('routeChangeStart', close);
-    };
-  }, [close, events]);
-
-  const navTabMapping = useMemo(() => {
-    return {
-      pagelist: {
-        Icon: () => <span className="material-symbols-outlined">subject</span>,
-        Content: () => {
-          if (status == null || status.path == null || !status.isOpened) {
-            return <></>;
-          }
-          return <DescendantsPageList path={status.path} />;
-        },
-        i18n: t('page_list'),
-        isLinkEnabled: () => !isSharedUser,
-      },
-      timeline: {
-        Icon: () => <span data-testid="timeline-tab-button" className="material-symbols-outlined">timeline</span>,
-        Content: () => {
-          if (status == null || !status.isOpened) {
-            return <></>;
-          }
-          return <PageTimeline />;
-        },
-        i18n: t('Timeline View'),
-        isLinkEnabled: () => !isSharedUser,
-      },
-    };
-  }, [isSharedUser, status, t]);
-
-  const buttons = useMemo(() => (
-    <span className="me-3">
-      <ExpandOrContractButton
-        isWindowExpanded={isWindowExpanded}
-        expandWindow={() => setIsWindowExpanded(true)}
-        contractWindow={() => setIsWindowExpanded(false)}
-      />
-      <button type="button" className="btn btn-close ms-2" onClick={close} aria-label="Close"></button>
-    </span>
-  ), [close, isWindowExpanded]);
-
-  if (status == null) {
-    return <></>;
-  }
-
-  const { isOpened } = status;
-
-  return (
-    <Modal
-      size="xl"
-      isOpen={isOpened}
-      toggle={close}
-      data-testid="descendants-page-list-modal"
-      className={`grw-descendants-page-list-modal ${styles['grw-descendants-page-list-modal']} ${isWindowExpanded ? 'grw-modal-expanded' : ''} `}
-    >
-      <ModalHeader className={isDeviceLargerThanLg ? 'p-0' : ''} toggle={close} close={buttons}>
-        {isDeviceLargerThanLg && (
-          <CustomNavTab
-            activeTab={activeTab}
-            navTabMapping={navTabMapping}
-            breakpointToHideInactiveTabsDown="md"
-            onNavSelected={v => setActiveTab(v)}
-            hideBorderBottom
-          />
-        )}
-      </ModalHeader>
-      <ModalBody>
-        {!isDeviceLargerThanLg && (
-          <CustomNavDropdown
-            activeTab={activeTab}
-            navTabMapping={navTabMapping}
-            onNavSelected={v => setActiveTab(v)}
-          />
-        )}
-        <CustomTabContent
-          activeTab={activeTab}
-          navTabMapping={navTabMapping}
-          additionalClassNames={!isDeviceLargerThanLg ? ['grw-tab-content-style-md-down'] : undefined}
-        />
-      </ModalBody>
-    </Modal>
-  );
-
-};

+ 0 - 0
apps/app/src/client/components/DescendantsPageListModal.module.scss → apps/app/src/client/components/DescendantsPageListModal/DescendantsPageListModal.module.scss


+ 22 - 7
apps/app/src/client/components/DescendantsPageListModal.spec.tsx → apps/app/src/client/components/DescendantsPageListModal/DescendantsPageListModal.spec.tsx

@@ -3,7 +3,7 @@ import { render, screen, fireEvent } from '@testing-library/react';
 import { DescendantsPageListModal } from './DescendantsPageListModal';
 
 const mockClose = vi.hoisted(() => vi.fn());
-const useIsDeviceLargerThanLg = vi.hoisted(() => vi.fn().mockReturnValue({ data: true }));
+const useDeviceLargerThanLg = vi.hoisted(() => vi.fn().mockReturnValue([true]));
 
 vi.mock('next/router', () => ({
   useRouter: () => ({
@@ -14,15 +14,30 @@ vi.mock('next/router', () => ({
   }),
 }));
 
-vi.mock('~/stores/modal', () => ({
-  useDescendantsPageListModal: vi.fn().mockReturnValue({
-    data: { isOpened: true },
+vi.mock('~/states/ui/modal/descendants-page-list', () => ({
+  useDescendantsPageListModalStatus: vi.fn().mockReturnValue({
+    isOpened: true,
+    path: '/test/path',
+  }),
+  useDescendantsPageListModalActions: vi.fn().mockReturnValue({
     close: mockClose,
   }),
 }));
 
-vi.mock('~/stores/ui', () => ({
-  useIsDeviceLargerThanLg,
+vi.mock('~/states/ui/device', () => ({
+  useDeviceLargerThanLg,
+}));
+
+vi.mock('~/states/context', () => ({
+  useIsSharedUser: vi.fn().mockReturnValue(false),
+}));
+
+vi.mock('../DescendantsPageList', () => ({
+  DescendantsPageList: () => <div data-testid="descendants-page-list">DescendantsPageList</div>,
+}));
+
+vi.mock('../PageTimeline', () => ({
+  PageTimeline: () => <div data-testid="page-timeline">PageTimeline</div>,
 }));
 
 describe('DescendantsPageListModal.tsx', () => {
@@ -54,7 +69,7 @@ describe('DescendantsPageListModal.tsx', () => {
 
   describe('when device is smaller than lg', () => {
     beforeEach(() => {
-      useIsDeviceLargerThanLg.mockReturnValue({ data: false });
+      useDeviceLargerThanLg.mockReturnValue([false]);
     });
 
     it('should render CustomNavDropdown on devices smaller than lg', () => {

+ 164 - 0
apps/app/src/client/components/DescendantsPageListModal/DescendantsPageListModal.tsx

@@ -0,0 +1,164 @@
+
+import React, {
+  useState, useMemo, useEffect, useCallback,
+} from 'react';
+
+import { useTranslation } from 'next-i18next';
+import dynamic from 'next/dynamic';
+import { useRouter } from 'next/router';
+import {
+  Modal, ModalHeader, ModalBody,
+} from 'reactstrap';
+
+import { useIsSharedUser } from '~/states/context';
+import { useDeviceLargerThanLg } from '~/states/ui/device';
+import { useDescendantsPageListModalActions, useDescendantsPageListModalStatus } from '~/states/ui/modal/descendants-page-list';
+
+import { CustomNavDropdown, CustomNavTab } from '../CustomNavigation/CustomNav';
+import CustomTabContent from '../CustomNavigation/CustomTabContent';
+import type { DescendantsPageListProps } from '../DescendantsPageList';
+import ExpandOrContractButton from '../ExpandOrContractButton';
+
+import styles from './DescendantsPageListModal.module.scss';
+
+const DescendantsPageList = dynamic<DescendantsPageListProps>(() => import('../DescendantsPageList').then(mod => mod.DescendantsPageList), { ssr: false });
+
+const PageTimeline = dynamic(() => import('../PageTimeline').then(mod => mod.PageTimeline), { ssr: false });
+
+/**
+ * DescendantsPageListModalSubstance - Presentation component (all logic here)
+ */
+type DescendantsPageListModalSubstanceProps = {
+  path: string | undefined;
+  closeModal: () => void;
+  onExpandedChange?: (isExpanded: boolean) => void;
+};
+
+const DescendantsPageListModalSubstance = ({
+  path,
+  closeModal,
+  onExpandedChange,
+}: DescendantsPageListModalSubstanceProps): React.JSX.Element => {
+  const { t } = useTranslation();
+
+  const [activeTab, setActiveTab] = useState('pagelist');
+  const [isWindowExpanded, setIsWindowExpanded] = useState(false);
+
+  const isSharedUser = useIsSharedUser();
+  const { events } = useRouter();
+  const [isDeviceLargerThanLg] = useDeviceLargerThanLg();
+
+  useEffect(() => {
+    events.on('routeChangeStart', closeModal);
+    return () => {
+      events.off('routeChangeStart', closeModal);
+    };
+  }, [closeModal, events]);
+
+  const navTabMapping = useMemo(() => {
+    return {
+      pagelist: {
+        Icon: () => <span className="material-symbols-outlined">subject</span>,
+        Content: () => {
+          if (path == null) {
+            return <></>;
+          }
+          return <DescendantsPageList path={path} />;
+        },
+        i18n: t('page_list'),
+        isLinkEnabled: () => !isSharedUser,
+      },
+      timeline: {
+        Icon: () => <span data-testid="timeline-tab-button" className="material-symbols-outlined">timeline</span>,
+        Content: () => {
+          return <PageTimeline />;
+        },
+        i18n: t('Timeline View'),
+        isLinkEnabled: () => !isSharedUser,
+      },
+    };
+  }, [isSharedUser, path, t]);
+
+  // Memoize event handlers
+  const expandWindow = useCallback(() => {
+    setIsWindowExpanded(true);
+    onExpandedChange?.(true);
+  }, [onExpandedChange]);
+  const contractWindow = useCallback(() => {
+    setIsWindowExpanded(false);
+    onExpandedChange?.(false);
+  }, [onExpandedChange]);
+  const onNavSelected = useCallback((v: string) => setActiveTab(v), []);
+
+  const buttons = useMemo(() => (
+    <span className="me-3">
+      <ExpandOrContractButton
+        isWindowExpanded={isWindowExpanded}
+        expandWindow={expandWindow}
+        contractWindow={contractWindow}
+      />
+      <button type="button" className="btn btn-close ms-2" onClick={closeModal} aria-label="Close"></button>
+    </span>
+  ), [closeModal, isWindowExpanded, expandWindow, contractWindow]);
+
+  return (
+    <div>
+      <ModalHeader className={isDeviceLargerThanLg ? 'p-0' : ''} toggle={closeModal} close={buttons}>
+        {isDeviceLargerThanLg && (
+          <CustomNavTab
+            activeTab={activeTab}
+            navTabMapping={navTabMapping}
+            breakpointToHideInactiveTabsDown="md"
+            onNavSelected={onNavSelected}
+            hideBorderBottom
+          />
+        )}
+      </ModalHeader>
+      <ModalBody>
+        {!isDeviceLargerThanLg && (
+          <CustomNavDropdown
+            activeTab={activeTab}
+            navTabMapping={navTabMapping}
+            onNavSelected={onNavSelected}
+          />
+        )}
+        <CustomTabContent
+          activeTab={activeTab}
+          navTabMapping={navTabMapping}
+          additionalClassNames={!isDeviceLargerThanLg ? ['grw-tab-content-style-md-down'] : undefined}
+        />
+      </ModalBody>
+    </div>
+  );
+};
+
+/**
+ * DescendantsPageListModal - Container component (lightweight, always rendered)
+ */
+export const DescendantsPageListModal = (): React.JSX.Element => {
+  const [isWindowExpanded, setIsWindowExpanded] = useState(false);
+  const status = useDescendantsPageListModalStatus();
+  const { close } = useDescendantsPageListModalActions();
+
+  const handleExpandedChange = useCallback((isExpanded: boolean) => {
+    setIsWindowExpanded(isExpanded);
+  }, []);
+
+  return (
+    <Modal
+      size="xl"
+      isOpen={status.isOpened}
+      toggle={close}
+      data-testid="descendants-page-list-modal"
+      className={`grw-descendants-page-list-modal ${styles['grw-descendants-page-list-modal']} ${isWindowExpanded ? 'grw-modal-expanded' : ''}`}
+    >
+      {status.isOpened && (
+        <DescendantsPageListModalSubstance
+          path={status?.path}
+          closeModal={close}
+          onExpandedChange={handleExpandedChange}
+        />
+      )}
+    </Modal>
+  );
+};

+ 18 - 0
apps/app/src/client/components/DescendantsPageListModal/dynamic.tsx

@@ -0,0 +1,18 @@
+import type { JSX } from 'react';
+
+import { useLazyLoader } from '~/components/utils/use-lazy-loader';
+import { useDescendantsPageListModalStatus } from '~/states/ui/modal/descendants-page-list';
+
+type DescendantsPageListModalProps = Record<string, unknown>;
+
+export const DescendantsPageListModalLazyLoaded = (): JSX.Element => {
+  const status = useDescendantsPageListModalStatus();
+
+  const DescendantsPageListModal = useLazyLoader<DescendantsPageListModalProps>(
+    'descendants-page-list-modal',
+    () => import('./DescendantsPageListModal').then(mod => ({ default: mod.DescendantsPageListModal })),
+    status?.isOpened ?? false,
+  );
+
+  return DescendantsPageListModal ? <DescendantsPageListModal /> : <></>;
+};

+ 1 - 0
apps/app/src/client/components/DescendantsPageListModal/index.ts

@@ -0,0 +1 @@
+export { DescendantsPageListModalLazyLoaded } from './dynamic';

+ 0 - 93
apps/app/src/client/components/EmptyTrashModal.tsx

@@ -1,93 +0,0 @@
-import type { FC } from 'react';
-import React, {
-  useState,
-} from 'react';
-
-import { useTranslation } from 'next-i18next';
-import {
-  Modal, ModalHeader, ModalBody, ModalFooter,
-} from 'reactstrap';
-
-import { apiv3Delete } from '~/client/util/apiv3-client';
-import { useEmptyTrashModal } from '~/stores/modal';
-
-import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
-
-const EmptyTrashModal: FC = () => {
-  const { t } = useTranslation();
-
-  const { data: emptyTrashModalData, close: closeEmptyTrashModal } = useEmptyTrashModal();
-
-  const isOpened = emptyTrashModalData?.isOpened ?? false;
-
-  const canDeleteAllpages = emptyTrashModalData?.opts?.canDeleteAllPages ?? false;
-
-  const [errs, setErrs] = useState<Error[] | null>(null);
-
-  async function emptyTrash() {
-    if (emptyTrashModalData == null || emptyTrashModalData.pages == null) {
-      return;
-    }
-
-    try {
-      await apiv3Delete('/pages/empty-trash');
-      const onEmptiedTrash = emptyTrashModalData.opts?.onEmptiedTrash;
-      if (onEmptiedTrash != null) {
-        onEmptiedTrash();
-      }
-      closeEmptyTrashModal();
-    }
-    catch (err) {
-      setErrs([err]);
-    }
-  }
-
-  async function emptyTrashButtonHandler() {
-    await emptyTrash();
-  }
-
-  const renderPagePaths = () => {
-    const pages = emptyTrashModalData?.pages;
-
-    if (pages != null) {
-      return pages.map(page => (
-        <p key={page.data._id} className="mb-1">
-          <code>{ page.data.path }</code>
-        </p>
-      ));
-    }
-    return <></>;
-  };
-
-  return (
-    <Modal size="lg" isOpen={isOpened} toggle={closeEmptyTrashModal} data-testid="page-delete-modal">
-      <ModalHeader tag="h4" toggle={closeEmptyTrashModal} className="text-danger">
-        <span className="material-symbols-outlined">delete_forever</span>
-        {t('modal_empty.empty_the_trash')}
-      </ModalHeader>
-      <ModalBody>
-        <div className="grw-scrollable-modal-body pb-1">
-          <label className="form-label">{ t('modal_delete.deleting_page') }:</label><br />
-          {/* Todo: change the way to show path on modal when too many pages are selected */}
-          {renderPagePaths()}
-        </div>
-        {!canDeleteAllpages && t('modal_empty.not_deletable_notice')}<br />
-        {t('modal_empty.notice')}
-      </ModalBody>
-      <ModalFooter>
-        <ApiErrorMessageList errs={errs} />
-        <button
-          type="button"
-          className="btn btn-danger"
-          onClick={emptyTrashButtonHandler}
-        >
-          <span className="material-symbols-outlined" aria-hidden="true">delete_forever</span>
-          {t('modal_empty.empty_the_trash_button')}
-        </button>
-      </ModalFooter>
-    </Modal>
-
-  );
-};
-
-export default EmptyTrashModal;

+ 117 - 0
apps/app/src/client/components/EmptyTrashModal/EmptyTrashModal.tsx

@@ -0,0 +1,117 @@
+import type { FC } from 'react';
+import React, { useState, useCallback, useMemo } from 'react';
+
+import type { IPageToDeleteWithMeta } from '@growi/core';
+import { useTranslation } from 'next-i18next';
+import {
+  Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+
+import { apiv3Delete } from '~/client/util/apiv3-client';
+import { useEmptyTrashModalStatus, useEmptyTrashModalActions } from '~/states/ui/modal/empty-trash';
+
+import ApiErrorMessageList from '../PageManagement/ApiErrorMessageList';
+
+/**
+ * EmptyTrashModalSubstance - Presentation component (all logic here)
+ */
+type EmptyTrashModalSubstanceProps = {
+  pages: IPageToDeleteWithMeta[] | undefined;
+  canDeleteAllPages: boolean;
+  onEmptiedTrash?: () => void;
+  closeModal: () => void;
+};
+
+const EmptyTrashModalSubstance = ({
+  pages,
+  canDeleteAllPages,
+  onEmptiedTrash,
+  closeModal,
+}: EmptyTrashModalSubstanceProps): React.JSX.Element => {
+  const { t } = useTranslation();
+
+  const [errs, setErrs] = useState<Error[] | null>(null);
+
+  const emptyTrash = useCallback(async() => {
+    if (pages == null) {
+      return;
+    }
+
+    try {
+      await apiv3Delete('/pages/empty-trash');
+      if (onEmptiedTrash != null) {
+        onEmptiedTrash();
+      }
+      closeModal();
+    }
+    catch (err) {
+      setErrs([err]);
+    }
+  }, [pages, onEmptiedTrash, closeModal]);
+
+  const emptyTrashButtonHandler = useCallback(async() => {
+    await emptyTrash();
+  }, [emptyTrash]);
+
+  // Memoize page paths rendering
+  const renderPagePaths = useMemo(() => {
+    if (pages != null) {
+      return pages.map(page => (
+        <p key={page.data._id} className="mb-1">
+          <code>{ page.data.path }</code>
+        </p>
+      ));
+    }
+    return <></>;
+  }, [pages]);
+
+  return (
+    <div>
+      <ModalHeader tag="h4" toggle={closeModal} className="text-danger">
+        <span className="material-symbols-outlined">delete_forever</span>
+        {t('modal_empty.empty_the_trash')}
+      </ModalHeader>
+      <ModalBody>
+        <div className="grw-scrollable-modal-body pb-1">
+          <label className="form-label">{ t('modal_delete.deleting_page') }:</label><br />
+          {/* Todo: change the way to show path on modal when too many pages are selected */}
+          {renderPagePaths}
+        </div>
+        {!canDeleteAllPages && t('modal_empty.not_deletable_notice')}<br />
+        {t('modal_empty.notice')}
+      </ModalBody>
+      <ModalFooter>
+        <ApiErrorMessageList errs={errs} />
+        <button
+          type="button"
+          className="btn btn-danger"
+          onClick={emptyTrashButtonHandler}
+        >
+          <span className="material-symbols-outlined" aria-hidden="true">delete_forever</span>
+          {t('modal_empty.empty_the_trash_button')}
+        </button>
+      </ModalFooter>
+    </div>
+  );
+};
+
+/**
+ * EmptyTrashModal - Container component (lightweight, always rendered)
+ */
+export const EmptyTrashModal: FC = () => {
+  const { isOpened, pages, opts } = useEmptyTrashModalStatus();
+  const { close: closeModal } = useEmptyTrashModalActions();
+
+  return (
+    <Modal size="lg" isOpen={isOpened} toggle={closeModal} data-testid="page-delete-modal">
+      {isOpened && (
+        <EmptyTrashModalSubstance
+          pages={pages}
+          canDeleteAllPages={opts?.canDeleteAllPages ?? false}
+          onEmptiedTrash={opts?.onEmptiedTrash}
+          closeModal={closeModal}
+        />
+      )}
+    </Modal>
+  );
+};

+ 19 - 0
apps/app/src/client/components/EmptyTrashModal/dynamic.tsx

@@ -0,0 +1,19 @@
+import type { JSX } from 'react';
+
+import { useEmptyTrashModalStatus } from '~/states/ui/modal/empty-trash';
+
+import { useLazyLoader } from '../../../components/utils/use-lazy-loader';
+
+type EmptyTrashModalProps = Record<string, unknown>;
+
+export const EmptyTrashModalLazyLoaded = (): JSX.Element => {
+  const status = useEmptyTrashModalStatus();
+
+  const EmptyTrashModal = useLazyLoader<EmptyTrashModalProps>(
+    'empty-trash-modal',
+    () => import('./EmptyTrashModal').then(mod => ({ default: mod.EmptyTrashModal })),
+    status?.isOpened ?? false,
+  );
+
+  return EmptyTrashModal != null ? <EmptyTrashModal /> : <></>;
+};

+ 1 - 0
apps/app/src/client/components/EmptyTrashModal/index.ts

@@ -0,0 +1 @@
+export { EmptyTrashModalLazyLoaded } from './dynamic';

+ 46 - 16
apps/app/src/client/components/GrantedGroupsInheritanceSelectModal.tsx → apps/app/src/client/components/GrantedGroupsInheritanceSelectModal/GrantedGroupsInheritanceSelectModal.tsx

@@ -1,28 +1,38 @@
-import { useState, type JSX } from 'react';
+import { useState, useCallback } from 'react';
 
 import { useTranslation } from 'react-i18next';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 
-import { useGrantedGroupsInheritanceSelectModal } from '~/stores/modal';
+import {
+  useGrantedGroupsInheritanceSelectModalActions, useGrantedGroupsInheritanceSelectModalStatus,
+} from '~/states/ui/modal/granted-groups-inheritance-select';
+
+/**
+ * GrantedGroupsInheritanceSelectModalSubstance - Presentation component (heavy logic, rendered only when isOpen)
+ */
+type GrantedGroupsInheritanceSelectModalSubstanceProps = {
+  onCreateBtnClick: ((onlyInheritUserRelatedGrantedGroups: boolean) => Promise<void>) | undefined;
+  closeModal: () => void;
+};
 
-const GrantedGroupsInheritanceSelectModal = (): JSX.Element => {
+const GrantedGroupsInheritanceSelectModalSubstance = (props: GrantedGroupsInheritanceSelectModalSubstanceProps): React.JSX.Element => {
+  const { onCreateBtnClick: _onCreateBtnClick, closeModal } = props;
   const { t } = useTranslation();
-  const { data: modalData, close: closeModal } = useGrantedGroupsInheritanceSelectModal();
+
   const [onlyInheritUserRelatedGrantedGroups, setOnlyInheritUserRelatedGrantedGroups] = useState(false);
 
-  const onCreateBtnClick = async() => {
-    await modalData?.onCreateBtnClick?.(onlyInheritUserRelatedGrantedGroups);
+  const onCreateBtnClick = useCallback(async() => {
+    await _onCreateBtnClick?.(onlyInheritUserRelatedGrantedGroups);
     setOnlyInheritUserRelatedGrantedGroups(false); // reset to false after create request
-  };
-  const isOpened = modalData?.isOpened ?? false;
+  }, [_onCreateBtnClick, onlyInheritUserRelatedGrantedGroups]);
+
+  const setInheritAll = useCallback(() => setOnlyInheritUserRelatedGrantedGroups(false), []);
+  const setInheritRelatedOnly = useCallback(() => setOnlyInheritUserRelatedGrantedGroups(true), []);
 
   return (
-    <Modal
-      isOpen={isOpened}
-      toggle={() => closeModal()}
-    >
+    <>
       <ModalHeader tag="h4" toggle={() => closeModal()}>
         {t('modal_granted_groups_inheritance_select.select_granted_groups')}
       </ModalHeader>
@@ -35,7 +45,7 @@ const GrantedGroupsInheritanceSelectModal = (): JSX.Element => {
               className="form-check-input"
               form="formImageType"
               checked={!onlyInheritUserRelatedGrantedGroups}
-              onChange={() => { setOnlyInheritUserRelatedGrantedGroups(false) }}
+              onChange={setInheritAll}
             />
             <label className="form-check-label" htmlFor="inheritAllGroupsRadio">
               {t('modal_granted_groups_inheritance_select.inherit_all_granted_groups_from_parent')}
@@ -48,7 +58,7 @@ const GrantedGroupsInheritanceSelectModal = (): JSX.Element => {
               className="form-check-input"
               form="formImageType"
               checked={onlyInheritUserRelatedGrantedGroups}
-              onChange={() => { setOnlyInheritUserRelatedGrantedGroups(true) }}
+              onChange={setInheritRelatedOnly}
             />
             <label className="form-check-label" htmlFor="onlyInheritRelatedGroupsRadio">
               {t('modal_granted_groups_inheritance_select.only_inherit_related_groups')}
@@ -62,8 +72,28 @@ const GrantedGroupsInheritanceSelectModal = (): JSX.Element => {
           {t('modal_granted_groups_inheritance_select.create_page')}
         </button>
       </ModalFooter>
-    </Modal>
+    </>
   );
 };
 
-export default GrantedGroupsInheritanceSelectModal;
+/**
+ * GrantedGroupsInheritanceSelectModal - Container component (lightweight, always rendered)
+ */
+export const GrantedGroupsInheritanceSelectModal = (): React.JSX.Element => {
+  const { isOpened, onCreateBtnClick } = useGrantedGroupsInheritanceSelectModalStatus();
+  const { close: closeModal } = useGrantedGroupsInheritanceSelectModalActions();
+
+  return (
+    <Modal
+      isOpen={isOpened}
+      toggle={() => closeModal()}
+    >
+      {isOpened && (
+        <GrantedGroupsInheritanceSelectModalSubstance
+          onCreateBtnClick={onCreateBtnClick}
+          closeModal={closeModal}
+        />
+      )}
+    </Modal>
+  );
+};

+ 18 - 0
apps/app/src/client/components/GrantedGroupsInheritanceSelectModal/dynamic.tsx

@@ -0,0 +1,18 @@
+import type { JSX } from 'react';
+
+import { useLazyLoader } from '~/components/utils/use-lazy-loader';
+import { useGrantedGroupsInheritanceSelectModalStatus } from '~/states/ui/modal/granted-groups-inheritance-select';
+
+type GrantedGroupsInheritanceSelectModalProps = Record<string, unknown>;
+
+export const GrantedGroupsInheritanceSelectModalLazyLoaded = (): JSX.Element => {
+  const status = useGrantedGroupsInheritanceSelectModalStatus();
+
+  const GrantedGroupsInheritanceSelectModal = useLazyLoader<GrantedGroupsInheritanceSelectModalProps>(
+    'granted-groups-inheritance-select-modal',
+    () => import('./GrantedGroupsInheritanceSelectModal').then(mod => ({ default: mod.GrantedGroupsInheritanceSelectModal })),
+    status?.isOpened ?? false,
+  );
+
+  return GrantedGroupsInheritanceSelectModal ? <GrantedGroupsInheritanceSelectModal /> : <></>;
+};

+ 1 - 0
apps/app/src/client/components/GrantedGroupsInheritanceSelectModal/index.ts

@@ -0,0 +1 @@
+export { GrantedGroupsInheritanceSelectModalLazyLoaded } from './dynamic';

+ 6 - 6
apps/app/src/client/components/Hotkeys/Subscribers/CreatePage.jsx

@@ -2,21 +2,21 @@ import React, { useEffect } from 'react';
 
 import PropTypes from 'prop-types';
 
-import { usePageCreateModal } from '~/stores/modal';
-import { useCurrentPagePath } from '~/stores/page';
+import { useCurrentPagePath } from '~/states/page';
+import { usePageCreateModalActions } from '~/states/ui/modal/page-create';
 
 const CreatePage = React.memo((props) => {
 
-  const { open: openCreateModal } = usePageCreateModal();
-  const { data: currentPath = '' } = useCurrentPagePath();
+  const { open: openCreateModal } = usePageCreateModalActions();
+  const currentPath = useCurrentPagePath();
 
   // setup effect
   useEffect(() => {
-    openCreateModal(currentPath);
+    openCreateModal(currentPath ?? '');
 
     // remove this
     props.onDeleteRender(this);
-  }, [openCreateModal, props]);
+  }, [currentPath, openCreateModal, props]);
 
   return <></>;
 });

+ 3 - 3
apps/app/src/client/components/Hotkeys/Subscribers/EditPage.jsx

@@ -2,11 +2,11 @@ import { useEffect } from 'react';
 
 import PropTypes from 'prop-types';
 
-import { useIsEditable } from '~/stores-universal/context';
-import { EditorMode, useEditorMode } from '~/stores-universal/ui';
+import { useIsEditable } from '~/states/page';
+import { EditorMode, useEditorMode } from '~/states/ui/editor';
 
 const EditPage = (props) => {
-  const { data: isEditable } = useIsEditable();
+  const isEditable = useIsEditable();
   const { mutate: mutateEditorMode } = useEditorMode();
 
   // setup effect

+ 5 - 4
apps/app/src/client/components/Hotkeys/Subscribers/FocusToGlobalSearch.jsx

@@ -1,12 +1,13 @@
 import { useEffect } from 'react';
 
-import { useSearchModal } from '~/features/search/client/stores/search';
-import { useIsEditable } from '~/stores-universal/context';
+import { useSearchModalStatus, useSearchModalActions } from '~/features/search/client/states/modal/search';
+import { useIsEditable } from '~/states/page';
 
 
 const FocusToGlobalSearch = (props) => {
-  const { data: isEditable } = useIsEditable();
-  const { data: searchModalData, open: openSearchModal } = useSearchModal();
+  const isEditable = useIsEditable();
+  const searchModalData = useSearchModalStatus();
+  const { open: openSearchModal } = useSearchModalActions();
 
   // setup effect
   useEffect(() => {

+ 3 - 2
apps/app/src/client/components/Hotkeys/Subscribers/ShowShortcutsModal.tsx

@@ -1,13 +1,14 @@
 import React, { useEffect, type JSX } from 'react';
 
-import { useShortcutsModal } from '~/stores/modal';
+import { useShortcutsModalStatus, useShortcutsModalActions } from '~/states/ui/modal/shortcuts';
 
 type Props = {
   onDeleteRender: () => void,
 }
 const ShowShortcutsModal = (props: Props): JSX.Element => {
 
-  const { data: status, open } = useShortcutsModal();
+  const status = useShortcutsModalStatus();
+  const { open } = useShortcutsModalActions();
 
   const { onDeleteRender } = props;
 

+ 2 - 2
apps/app/src/client/components/IdenticalPathPage.tsx

@@ -4,7 +4,7 @@ import React from 'react';
 import { DevidedPagePath } from '@growi/core/dist/models';
 import { useTranslation } from 'next-i18next';
 
-import { useCurrentPathname } from '~/stores-universal/context';
+import { useCurrentPathname } from '~/states/global';
 import { useSWRxPageInfoForList, useSWRxPagesByPath } from '~/stores/page-listing';
 
 import { PageListItemL } from './PageList/PageListItemL';
@@ -50,7 +50,7 @@ const IdenticalPathAlert : FC<IdenticalPathAlertProps> = (props: IdenticalPathAl
 
 export const IdenticalPathPage = (): JSX.Element => {
 
-  const { data: currentPath } = useCurrentPathname();
+  const currentPath = useCurrentPathname();
 
   const { data: pages } = useSWRxPagesByPath(currentPath);
   const { injectTo } = useSWRxPageInfoForList(null, currentPath, true, true);

+ 2 - 2
apps/app/src/client/components/InAppNotification/InAppNotificationDropdown.tsx

@@ -8,8 +8,8 @@ import {
   Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
 } from 'reactstrap';
 
+import { useGlobalSocket } from '~/states/socket-io';
 import { useSWRxInAppNotifications, useSWRxInAppNotificationStatus } from '~/stores/in-app-notification';
-import { useDefaultSocket } from '~/stores/socket-io';
 
 import InAppNotificationList from './InAppNotificationList';
 
@@ -19,7 +19,7 @@ export const InAppNotificationDropdown = (): JSX.Element => {
   const [isOpen, setIsOpen] = useState(false);
   const limit = 6;
 
-  const { data: socket } = useDefaultSocket();
+  const socket = useGlobalSocket();
   const { data: inAppNotificationData, mutate: mutateInAppNotificationData } = useSWRxInAppNotifications(
     limit, undefined, undefined,
     { revalidateOnFocus: isOpen },

+ 3 - 2
apps/app/src/client/components/InAppNotification/InAppNotificationPage.tsx

@@ -2,11 +2,12 @@ import type { FC } from 'react';
 import React, { useState } from 'react';
 
 import { LoadingSpinner } from '@growi/ui/dist/components';
+import { useAtomValue } from 'jotai';
 import { useTranslation } from 'next-i18next';
 
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { InAppNotificationStatuses } from '~/interfaces/in-app-notification';
-import { useShowPageLimitationXL } from '~/stores-universal/context';
+import { showPageLimitationXLAtom } from '~/states/server-configurations';
 import { useSWRxInAppNotifications, useSWRxInAppNotificationStatus } from '~/stores/in-app-notification';
 
 import CustomNavAndContents from '../CustomNavigation/CustomNavAndContents';
@@ -17,7 +18,7 @@ import InAppNotificationList from './InAppNotificationList';
 export const InAppNotificationPage: FC = () => {
   const { t } = useTranslation('commons');
 
-  const { data: showPageLimitationXL } = useShowPageLimitationXL();
+  const showPageLimitationXL = useAtomValue(showPageLimitationXLAtom);
 
   const limit = showPageLimitationXL != null ? showPageLimitationXL : 20;
 

+ 2 - 2
apps/app/src/client/components/InvitedForm.tsx

@@ -5,7 +5,7 @@ import { useTranslation } from 'next-i18next';
 import { useRouter } from 'next/router';
 
 import { apiv3Post } from '~/client/util/apiv3-client';
-import { useCurrentUser } from '~/stores-universal/context';
+import { useCurrentUser } from '~/states/global';
 
 
 type InvitedFormProps = {
@@ -17,7 +17,7 @@ export const InvitedForm = (props: InvitedFormProps): JSX.Element => {
 
   const { t } = useTranslation();
   const router = useRouter();
-  const { data: user } = useCurrentUser();
+  const user = useCurrentUser();
   const [loginErrors, setLoginErrors] = useState<Error[]>([]);
   const [isLoading, setIsLoading] = useState(false);
 

+ 18 - 16
apps/app/src/client/components/ItemsTree/ItemsTree.tsx

@@ -3,7 +3,6 @@ import React, { useEffect, useCallback, type JSX } from 'react';
 import path from 'path';
 
 import type { IPageToDeleteWithMeta } from '@growi/core';
-import { useGlobalSocket } from '@growi/core/dist/swr';
 import { useTranslation } from 'next-i18next';
 import { useRouter } from 'next/router';
 
@@ -12,14 +11,17 @@ import type { IPageForItem } from '~/interfaces/page';
 import type { OnDuplicatedFunction, OnDeletedFunction } from '~/interfaces/ui';
 import type { UpdateDescCountData, UpdateDescCountRawData } from '~/interfaces/websocket';
 import { SocketEventName } from '~/interfaces/websocket';
-import type { IPageForPageDuplicateModal } from '~/stores/modal';
-import { usePageDuplicateModal, usePageDeleteModal } from '~/stores/modal';
-import { mutateAllPageInfo, useCurrentPagePath, useSWRMUTxCurrentPage } from '~/stores/page';
+import { useCurrentPagePath, useFetchCurrentPage } from '~/states/page';
+import { useGlobalSocket } from '~/states/socket-io';
+import { usePageDeleteModalActions } from '~/states/ui/modal/page-delete';
+import type { IPageForPageDuplicateModal } from '~/states/ui/modal/page-duplicate';
+import { usePageDuplicateModalActions } from '~/states/ui/modal/page-duplicate';
+import { usePageTreeDescCountMapAction } from '~/states/ui/page-tree-desc-count-map';
+import { mutateAllPageInfo } from '~/stores/page';
 import {
   useSWRxRootPage, mutatePageTree, mutatePageList,
 } from '~/stores/page-listing';
 import { mutateSearching } from '~/stores/search';
-import { usePageTreeDescCountMap } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 
 import { ItemNode, type TreeItemProps } from '../TreeItem';
@@ -54,15 +56,15 @@ export const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
   const router = useRouter();
 
   const { data: rootPageResult, error } = useSWRxRootPage({ suspense: true });
-  const { data: currentPagePath } = useCurrentPagePath();
-  const { open: openDuplicateModal } = usePageDuplicateModal();
-  const { open: openDeleteModal } = usePageDeleteModal();
+  const currentPagePath = useCurrentPagePath();
+  const { open: openDuplicateModal } = usePageDuplicateModalActions();
+  const { open: openDeleteModal } = usePageDeleteModalActions();
 
-  const { data: socket } = useGlobalSocket();
-  const { data: ptDescCountMap, update: updatePtDescCountMap } = usePageTreeDescCountMap();
+  const socket = useGlobalSocket();
+  const { update: updatePtDescCountMap } = usePageTreeDescCountMapAction();
 
   // for mutation
-  const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
+  const { fetchCurrentPage } = useFetchCurrentPage();
 
   useEffect(() => {
     if (socket == null) {
@@ -78,7 +80,7 @@ export const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
 
     return () => { socket.off(SocketEventName.UpdateDescCount) };
 
-  }, [socket, ptDescCountMap, updatePtDescCountMap]);
+  }, [socket, updatePtDescCountMap]);
 
   const onRenamed = useCallback((fromPath: string | undefined, toPath: string) => {
     mutatePageTree();
@@ -86,9 +88,9 @@ export const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
     mutatePageList();
 
     if (currentPagePath === fromPath || currentPagePath === toPath) {
-      mutateCurrentPage();
+      fetchCurrentPage({ force: true });
     }
-  }, [currentPagePath, mutateCurrentPage]);
+  }, [currentPagePath, fetchCurrentPage]);
 
   const onClickDuplicateMenuItem = useCallback((pageToDuplicate: IPageForPageDuplicateModal) => {
     // eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -122,13 +124,13 @@ export const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
       mutateAllPageInfo();
 
       if (currentPagePath === pathOrPathsToDelete) {
-        mutateCurrentPage();
+        fetchCurrentPage({ force: true });
         router.push(isCompletely ? path.dirname(pathOrPathsToDelete) : `/trash${pathOrPathsToDelete}`);
       }
     };
 
     openDeleteModal([pageToDelete], { onDeleted: onDeletedHandler });
-  }, [currentPagePath, mutateCurrentPage, openDeleteModal, router, t]);
+  }, [currentPagePath, fetchCurrentPage, openDeleteModal, router, t]);
 
 
   if (error != null) {

+ 5 - 10
apps/app/src/client/components/Maintenance/Maintenance.tsx

@@ -1,21 +1,16 @@
 import type { JSX } from 'react';
 
-import type { IUserHasId } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { toastError } from '~/client/util/toastr';
-import { useCurrentUser } from '~/stores-universal/context';
+import { useCurrentUser } from '~/states/global';
 
 
-type Props = {
-  currentUser: IUserHasId,
-};
-
-export const Maintenance = (props: Props): JSX.Element => {
+export const Maintenance = (): JSX.Element => {
   const { t } = useTranslation();
 
-  useCurrentUser(props.currentUser ?? null);
+  const currentUser = useCurrentUser();
 
   const logoutHandler = async() => {
     try {
@@ -34,14 +29,14 @@ export const Maintenance = (props: Props): JSX.Element => {
       <h3>{ t('maintenance_mode.growi_is_under_maintenance') }</h3>
       <hr />
       <div className="text-start">
-        {props.currentUser?.admin
+        {currentUser?.admin
               && (
                 <p>
                   <span className="material-symbols-outlined">arrow_circle_right</span>
                   <a className="btn btn-link" href="/admin">{ t('maintenance_mode.admin_page') }</a>
                 </p>
               )}
-        {props.currentUser != null
+        {currentUser != null
           ? (
             <p>
               <span className="material-symbols-outlined">arrow_circle_right</span>

+ 2 - 2
apps/app/src/client/components/Me/AccessTokenScopeList.tsx

@@ -4,7 +4,7 @@ import type { Scope } from '@growi/core/dist/interfaces';
 import { useTranslation } from 'next-i18next';
 import type { UseFormRegisterReturn } from 'react-hook-form';
 
-import { useIsDeviceLargerThanMd } from '~/stores/ui';
+import { useDeviceLargerThanMd } from '~/states/ui/device';
 
 
 import styles from './AccessTokenScopeList.module.scss';
@@ -32,7 +32,7 @@ export const AccessTokenScopeList: React.FC<AccessTokenScopeListProps> = ({
   level = 1,
 }) => {
 
-  const { data: isDeviceLargerThanMd } = useIsDeviceLargerThanMd();
+  const [isDeviceLargerThanMd] = useDeviceLargerThanMd();
 
   // Convert object into an array to determine "first vs. non-first" elements
   const entries = Object.entries(scopeObject);

+ 2 - 2
apps/app/src/client/components/Me/AccessTokenScopeSelect.tsx

@@ -5,7 +5,7 @@ import { SCOPE } from '@growi/core/dist/interfaces';
 import type { UseFormRegisterReturn } from 'react-hook-form';
 
 import { extractScopes, getDisabledScopes, parseScopes } from '~/client/util/scope-util';
-import { useIsAdmin } from '~/stores-universal/context';
+import { useIsAdmin } from '~/states/context';
 
 import { AccessTokenScopeList } from './AccessTokenScopeList';
 
@@ -23,7 +23,7 @@ type AccessTokenScopeSelectProps = {
  */
 export const AccessTokenScopeSelect: React.FC<AccessTokenScopeSelectProps> = ({ register, selectedScopes }) => {
   const [disabledScopes, setDisabledScopes] = useState<Set<Scope>>(new Set());
-  const { data: isAdmin } = useIsAdmin();
+  const isAdmin = useIsAdmin();
 
   const ScopesMap = useMemo(() => parseScopes({ scopes: SCOPE, isAdmin }), [isAdmin]);
   const extractedScopes = useMemo(() => extractScopes(ScopesMap), [ScopesMap]);

+ 3 - 3
apps/app/src/client/components/Me/ApiSettings.tsx

@@ -2,7 +2,7 @@ import React from 'react';
 
 import { useTranslation } from 'next-i18next';
 
-import { useCurrentUser } from '~/stores-universal/context';
+import { useCurrentUser } from '~/states/global';
 
 import { AccessTokenSettings } from './AccessTokenSettings';
 import { ApiTokenSettings } from './ApiTokenSettings';
@@ -11,9 +11,9 @@ import { ApiTokenSettings } from './ApiTokenSettings';
 const ApiSettings = React.memo((): JSX.Element => {
 
   const { t } = useTranslation();
-  const { data: currentUser, isLoading: isLoadingCurrentUserData } = useCurrentUser();
+  const currentUser = useCurrentUser();
 
-  const shouldHideAccessTokenSettings = isLoadingCurrentUserData || !currentUser?.readOnly;
+  const shouldHideAccessTokenSettings = currentUser == null || !currentUser?.readOnly;
 
   return (
     <>

+ 2 - 3
apps/app/src/client/components/Me/ApiTokenSettings.tsx

@@ -6,14 +6,13 @@ import {
   apiv3Put,
 } from '~/client/util/apiv3-client';
 import { toastSuccess, toastError } from '~/client/util/toastr';
-import { usePersonalSettings, useSWRxPersonalSettings } from '~/stores/personal-settings';
+import { useSWRxPersonalSettings } from '~/stores/personal-settings';
 
 
 export const ApiTokenSettings = React.memo((): JSX.Element => {
 
   const { t } = useTranslation();
-  const { mutate: mutateDatabaseData } = useSWRxPersonalSettings();
-  const { data: personalSettingsData } = usePersonalSettings();
+  const { data: personalSettingsData, mutate: mutateDatabaseData } = useSWRxPersonalSettings();
 
   const submitHandler = useCallback(async() => {
 

Некоторые файлы не были показаны из-за большого количества измененных файлов