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

Merge pull request #10391 from growilabs/imprv/modal-optimization

imprv: Modal optimization
Yuki Takei 5 месяцев назад
Родитель
Сommit
11d6a8baef
34 измененных файлов с 1338 добавлено и 1197 удалено
  1. 74 0
      .serena/memories/apps-app-modal-list-for-v3.md
  2. 0 124
      .serena/memories/apps-app-modal-performance-optimization-progress-tracker.md
  3. 84 0
      .serena/memories/apps-app-modal-performance-optimization-v2-completion-summary.md
  4. 0 406
      .serena/memories/apps-app-modal-performance-optimization-v2.md
  5. 16 2
      .serena/memories/apps-app-modal-performance-optimization-v3.md
  6. 56 39
      apps/app/src/client/components/Admin/SlackIntegration/DeleteSlackBotSettingsModal.tsx
  7. 65 61
      apps/app/src/client/components/Common/ImageCropModal.tsx
  8. 25 23
      apps/app/src/client/components/CreateTemplateModal.tsx
  9. 38 13
      apps/app/src/client/components/DeleteBookmarkFolderModal.tsx
  10. 72 37
      apps/app/src/client/components/DescendantsPageListModal.tsx
  11. 47 16
      apps/app/src/client/components/EmptyTrashModal.tsx
  12. 42 12
      apps/app/src/client/components/GrantedGroupsInheritanceSelectModal.tsx
  13. 35 12
      apps/app/src/client/components/Me/AssociateModal.tsx
  14. 34 10
      apps/app/src/client/components/Me/DisassociateModal.tsx
  15. 53 17
      apps/app/src/client/components/PageAttachment/DeleteAttachmentModal.tsx
  16. 87 58
      apps/app/src/client/components/PageComment/DeleteCommentModal.tsx
  17. 30 28
      apps/app/src/client/components/PageCreateModal.tsx
  18. 30 13
      apps/app/src/client/components/PageDuplicateModal.tsx
  19. 52 22
      apps/app/src/client/components/PageEditor/ConflictDiffModal.tsx
  20. 0 10
      apps/app/src/client/components/PageEditor/DrawioModal.tsx
  21. 0 7
      apps/app/src/client/components/PageEditor/HandsontableModal.tsx
  22. 43 28
      apps/app/src/client/components/PageEditor/LinkEditModal.tsx
  23. 36 16
      apps/app/src/client/components/PagePresentationModal.tsx
  24. 28 10
      apps/app/src/client/components/PageRenameModal.tsx
  25. 53 20
      apps/app/src/client/components/PrivateLegacyPagesMigrationModal.tsx
  26. 85 68
      apps/app/src/client/components/PutbackPageModal.jsx
  27. 32 22
      apps/app/src/client/components/ShortcutsModal.tsx
  28. 43 12
      apps/app/src/features/growi-plugin/client/Admin/components/PluginsExtensionPageContents/PluginDeleteModal.tsx
  29. 33 19
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/SelectUserGroupModal.tsx
  30. 56 20
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/ShareScopeWarningModal.tsx
  31. 58 40
      apps/app/src/features/openai/client/components/AiAssistant/Sidebar/DeleteAiAssistantModal.tsx
  32. 2 7
      apps/app/src/features/page-bulk-export/client/components/PageBulkExportSelectModal.tsx
  33. 1 6
      apps/app/src/features/search/client/components/SearchModal.tsx
  34. 28 19
      apps/app/src/features/search/client/components/SearchPage/SearchOptionModal.tsx

+ 74 - 0
.serena/memories/apps-app-modal-list-for-v3.md

@@ -0,0 +1,74 @@
+# モーダル一覧 - V3動的ロード対象
+
+## V2完了モーダル (46個) - V3動的ロード候補
+
+### 高頻度使用 - 動的ロード非推奨 (6個)
+初期ロードを維持すべきモーダル:
+1. SearchModal.tsx - 検索機能 (頻繁に使用)
+2. PageCreateModal.tsx - ページ作成 (重要機能)
+3. PageDeleteModal.tsx - ページ削除 (重要機能)
+4. TagEditModal.tsx - タグ編集
+5. PageAccessoriesModal.tsx - ページアクセサリ
+6. UserGroupModal.tsx - ユーザーグループ管理
+
+### 中頻度使用 - 動的ロード推奨 (15個)
+状況に応じて動的ロード:
+- PageRenameModal.tsx
+- PageDuplicateModal.tsx
+- PageBulkExportSelectModal.tsx
+- LinkEditModal.tsx
+- CreateTemplateModal.tsx
+- SearchOptionModal.tsx
+- ImageCropModal.tsx
+- DeleteCommentModal.tsx
+- AssociateModal.tsx
+- DisassociateModal.tsx
+- EmptyTrashModal.tsx
+- DeleteBookmarkFolderModal.tsx
+- GrantedGroupsInheritanceSelectModal.tsx
+- SelectUserGroupModal.tsx
+- DescendantsPageListModal.tsx
+
+### 低頻度使用 - 動的ロード強く推奨 (25個)
+必ず動的ロード対象:
+- PagePresentationModal.tsx - プレゼンテーション
+- ConflictDiffModal.tsx - 競合解決
+- HandsontableModal.tsx - 表編集
+- DrawioModal.tsx - Drawio編集
+- TemplateModal.tsx
+- DeleteAiAssistantModal.tsx
+- ShareScopeWarningModal.tsx
+- ShortcutsModal.tsx
+- DeleteAttachmentModal.tsx
+- PrivateLegacyPagesMigrationModal.tsx
+- PluginDeleteModal.tsx
+- ShowShortcutsModal.tsx
+- PutbackPageModal.jsx
+- DeleteSlackBotSettingsModal.tsx
+- AiAssistantManagementModal.tsx
+- PageSelectModal.tsx
+- その他9個
+
+## V3実装方針
+
+### Phase 1: 低頻度モーダル (25個)
+動的ロード実装で最大効果
+
+### Phase 2: 中頻度モーダル (15個)
+使用状況を見て判断
+
+### Phase 3: 測定・検証
+- Bundle size削減効果測定
+- 初回ロード時間改善確認
+- ユーザー体験への影響評価
+
+## Container-Presentation構造 (V2成果)
+
+全46モーダルは以下の構造:
+```
+Modal/
+  ├── Container (6-15行) - Modal wrapper
+  └── Substance (全ロジック) - 動的ロード対象
+```
+
+**V3での利点**: Substanceのみ動的ロード可能

+ 0 - 124
.serena/memories/apps-app-modal-performance-optimization-progress-tracker.md

@@ -1,124 +0,0 @@
-# モーダル最適化 - 進捗管理
-
-## 📊 **最適化進捗状況**
-
-### ✅ **完了済み (11個)** - Phase 1+2 完了
-1. **SearchModal.tsx** ✅ (検索機能)
-2. **PageBulkExportSelectModal.tsx** ✅ (一括エクスポート)
-3. **PageSelectModal.tsx** ✅ (ページ選択)
-4. **TemplateModal.tsx** ✅ (テンプレート)
-5. **PageAccessoriesModal.tsx** ✅ (ページアクセサリ)
-6. **DrawioModal.tsx** ✅ (Drawio iframe)
-7. **HandsontableModal.tsx** ✅ (表編集)
-8. **AiAssistantManagementModal.tsx** ✅ (AI アシスタント管理)
-9. **UserGroupModal.tsx** ✅ (ユーザーグループ管理)
-10. **TagEditModal.tsx** ✅ (タグ編集)
-11. **PageDeleteModal.tsx** ✅ (ページ削除) - **完全最適化完了**
-
----
-
-### 🔄 **未完了 - 高優先度 (16個)**
-
-#### **📝 Page操作系 (6個)**
-- [ ] **PageCreateModal.tsx** - ページ作成
-- [ ] **PageRenameModal.tsx** - ページリネーム  
-- [ ] **PageDuplicateModal.tsx** - ページ複製
-- [ ] **ConflictDiffModal.tsx** - 競合差分表示
-- [ ] **LinkEditModal.tsx** - リンク編集
-- [ ] **PagePresentationModal.tsx** - プレゼンテーション
-
-#### **👥 Admin/ユーザー管理系 (5個)**
-- [ ] **UserGroupDeleteModal.tsx** - ユーザーグループ削除
-- [ ] **UserGroupUserModal.tsx** - ユーザーグループユーザー管理
-- [ ] **UpdateParentConfirmModal.tsx** - 親グループ更新確認
-- [ ] **SelectCollectionsModal.tsx** - コレクション選択
-- [ ] **ConfirmModal.tsx** - 汎用確認
-
-#### **🔧 機能系 (5個)**
-- [ ] **CreateTemplateModal.tsx** - テンプレート作成
-- [ ] **SearchOptionModal.tsx** - 検索オプション
-- [ ] **DescendantsPageListModal.tsx** - 子ページリスト
-- [ ] **GrantedGroupsInheritanceSelectModal.tsx** - 権限グループ継承選択  
-- [ ] **ImageCropModal.tsx** - 画像クロップ
-
----
-
-### 🔄 **未完了 - 中優先度 (15個)**
-
-#### **💬 コメント/添付ファイル系 (2個)**
-- [ ] **DeleteCommentModal.tsx** - コメント削除
-- [ ] **DeleteAttachmentModal.tsx** - 添付ファイル削除
-
-#### **🔌 機能統合系 (4個)**  
-- [ ] **AssociateModal.tsx** - アカウント連携
-- [ ] **DisassociateModal.tsx** - アカウント連携解除
-- [ ] **DeleteSlackBotSettingsModal.tsx** - Slack Bot設定削除
-- [ ] **PrivateLegacyPagesMigrationModal.tsx** - プライベートページ移行
-
-#### **🤖 AI機能系 (3個)**
-- [ ] **DeleteAiAssistantModal.tsx** - AI アシスタント削除
-- [ ] **ShareScopeWarningModal.tsx** - 共有スコープ警告
-- [ ] **SelectUserGroupModal.tsx** - ユーザーグループ選択
-
-#### **🎨 UI/UX系 (4個)**
-- [ ] **ShortcutsModal.tsx** - ショートカット表示
-- [ ] **ShowShortcutsModal.tsx** - ショートカット表示(Hotkeys)
-- [ ] **EmptyTrashModal.tsx** - ゴミ箱を空にする
-- [ ] **DeleteBookmarkFolderModal.tsx** - ブックマークフォルダ削除
-
-#### **🔌 プラグイン系 (2個)**
-- [ ] **PluginDeleteModal.tsx** - プラグイン削除
-- [ ] **TreeItemForModal.tsx** - ツリーアイテム (サブコンポーネント)
-
----
-
-### 🔄 **未完了 - 低優先度** 
-(対応は任意 - 軽量なモーダルが多数)
-
----
-
-## 📈 **統計情報**
-
-- **完了済み**: 11モーダル (21%)
-- **高優先度**: 16モーダル (30%)
-- **中優先度**: 15モーダル (28%)
-- **低優先度**: 11モーダル (21%)
-- **総計**: 53モーダル
-
----
-
-## 🎯 **次のアクション**
-
-### 推奨実装順序
-1. **Page操作系** (6個) → ユーザー体験への直接影響大
-2. **Admin系** (5個) → データ処理の複雑性
-3. **機能系** (5個) → 重要機能の安定性
-
-### 最適化効果 (完了済み11モーダルから)
-- 🚀 初期読み込み時間短縮
-- 💾 メモリ使用量削減  
-- ⚡ レンダリング回数削減
-- 🎯 不要な再計算防止
-- 📦 コード分割による効率的な読み込み
-
-### PageDeleteModal 完全最適化詳細
-✅ **7つのフェーズ完了**:
-1. 配列処理のメモ化 (notOperatablePages, notOperatablePageIds)
-2. injectedPagesのメモ化
-3. メインハンドラーメモ化 (deletePage)
-4. サポートハンドラーメモ化 (各種イベントハンドラー)
-5. レンダリング関数メモ化 (フォーム表示関数)
-6. ページパス表示最適化
-7. コンテンツ関数メモ化 (headerContent, bodyContent, footerContent)
-
-✅ **特別最適化**: JSX.Element依存関係問題の解決
-- React.JSX.Element型のdependency参照による不要な再計算を防止
-- 参照型依存関係を原始値に変更してメモ化効率を改善
-
----
-
-## 📝 **更新履歴**
-
-- 2025-09-11: 初回作成、10モーダル完了済みを反映
-- 2025-09-11: PageDeleteModal完全最適化完了、11モーダル完了に更新
-- 未完了46モーダルを優先度別に分類、高優先度16モーダルを次の対象として設定

+ 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でさらなる最適化を実現**

+ 0 - 406
.serena/memories/apps-app-modal-performance-optimization-v2.md

@@ -1,406 +0,0 @@
-# モーダル系コンポーネント パフォーマンス最適化ガイド Version2
-
-## 概要
-
-モーダル系コンポーネントは通常時は非表示状態であるにも関わらず、不適切な実装により常時リソースを消費している場合があります。本ドキュメントは、モーダルコンポーネントのパフォーマンス最適化の指針と具体的な手法を提供します。
-
-## 最適化の要否の評価基準
-
-### 高優先度(必須対応)
-
-以下の条件に**複数該当**する場合は、必ず最適化を実施してください:
-
-- ✅ **重い計算処理**: `useMemo`を使わずに毎レンダリングで配列操作(filter、map、reduce等)を実行
-- ✅ **外部データフェッチ**: SWRやカスタムフックによるAPI呼び出しが常時発生
-- ✅ **多数のフック**: useState、useEffect、useCallbackが5個以上存在
-- ✅ **インライン関数**: イベントハンドラーが関数宣言で定義されている
-- ✅ **複雑な条件分岐**: レンダリング時に重い条件判定を実行
-
-### 中優先度(推奨対応)
-
-以下の条件に該当する場合は、開発工数と効果を検討して対応を判断:
-
-- 🔶 **軽微な計算処理**: 単純な文字列操作や数値計算を毎レンダリングで実行
-- 🔶 **少数のフック**: useState、useEffect、useCallbackが2-4個存在
-- 🔶 **モーダル表示頻度**: ユーザーが頻繁に開閉するモーダル
-
-### 低優先度(対応不要)
-
-以下のような単純なモーダルは最適化不要:
-
-- ⚪ **静的コンテンツのみ**: テキストやボタンのみで計算処理なし
-- ⚪ **最小限のフック**: useState 1-2個程度のシンプルな実装
-- ⚪ **使用頻度が低い**: エラー表示など限定的な用途
-
-## 主要なパフォーマンス問題パターン
-
-### 1. コンテナ・プレゼンテーション分離の欠如
-
-**❌ 問題のあるパターン**
-```typescript
-const MyModal: FC = () => {
-  const { isOpen } = useModalStatus();
-  
-  // モーダルが閉じていても以下が常に実行される
-  const heavyData = useMemo(() => processHeavyData(data), [data]);
-  const { data: apiData } = useSWR('/api/data');
-  
-  return (
-    <Modal isOpen={isOpen}>
-      {/* 内容 */}
-    </Modal>
-  );
-};
-```
-
-**✅ 最適化されたパターン**
-```typescript
-const MyModalSubstance: FC = () => {
-  // 重い処理はモーダルが開いている時のみ実行される
-  const heavyData = useMemo(() => processHeavyData(data), [data]);
-  const { data: apiData } = useSWR('/api/data');
-  
-  return (
-    <>
-      <ModalHeader>...</ModalHeader>
-      <ModalBody>...</ModalBody>
-      <ModalFooter>...</ModalFooter>
-    </>
-  );
-};
-
-const MyModal: FC = () => {
-  const { isOpen } = useModalStatus();
-  
-  if (!isOpen) {
-    return <></>;  // 早期リターンで不要な処理を完全回避
-  }
-  
-  return (
-    <Modal isOpen={isOpen}>
-      <MyModalSubstance />
-    </Modal>
-  );
-};
-```
-
-### 2. 計算処理の最適化不足
-
-**❌ 問題のあるパターン**
-```typescript
-// 毎レンダリングで実行される重い計算
-const filteredItems = items.filter(item => item.status === 'active');
-const processedData = filteredItems.map(item => transformItem(item));
-const targetPath = clickedItem?.path || defaultPath;
-```
-
-**✅ 最適化されたパターン**
-```typescript
-const filteredItems = useMemo(() => 
-  items.filter(item => item.status === 'active'), 
-  [items]
-);
-
-const processedData = useMemo(() => 
-  filteredItems.map(item => transformItem(item)), 
-  [filteredItems]
-);
-
-const targetPath = useMemo(() => 
-  clickedItem?.path || defaultPath, 
-  [clickedItem, defaultPath]
-);
-```
-
-### 3. イベントハンドラーの最適化不足
-
-**❌ 問題のあるパターン**
-```typescript
-// 毎レンダリングで新しい関数が作成される
-function handleSubmit() {
-  // 処理
-}
-
-function handleCancel() {
-  closeModal();
-}
-```
-
-**✅ 最適化されたパターン**
-```typescript
-const handleSubmit = useCallback(() => {
-  // 処理
-}, [dependencies]);
-
-const handleCancel = useCallback(() => {
-  closeModal();
-}, [closeModal]);
-```
-
-### 4. レンダリング関数の最適化不足
-
-**❌ 問題のあるパターン**
-```typescript
-const renderItems = () => {
-  const displayItems = items.length > 0 ? items : defaultItems;
-  return displayItems.map(item => <ItemComponent key={item.id} item={item} />);
-};
-
-return (
-  <Modal>
-    {renderItems()}
-  </Modal>
-);
-```
-
-**✅ 最適化されたパターン**
-```typescript
-const displayItems = useMemo(() => 
-  items.length > 0 ? items : defaultItems, 
-  [items, defaultItems]
-);
-
-const renderedItems = useMemo(() =>
-  displayItems.map(item => <ItemComponent key={item.id} item={item} />),
-  [displayItems]
-);
-
-return (
-  <Modal>
-    {renderedItems}
-  </Modal>
-);
-```
-
-### 5. JSX.Element依存関係の問題
-
-**❌ 問題のあるパターン**
-```typescript
-const renderContent = useMemo(() => (
-  <div>{processData(data)}</div>
-), [data]);
-
-const bodyContent = useMemo(() => (
-  <ModalBody>
-    {renderContent}  // JSX.Element型をdependencyに使用
-  </ModalBody>
-), [renderContent]); // 毎回新しい参照で無効化される
-```
-
-**✅ 最適化されたパターン**
-```typescript
-const bodyContent = useMemo(() => {
-  // 内部で直接処理を統合
-  const processedContent = processData(data);
-  return (
-    <ModalBody>
-      <div>{processedContent}</div>
-    </ModalBody>
-  );
-}, [data]); // 原始値による正確なメモ化判定
-```
-
-### **重要** メモ化の判断基準
-
-ただし、過度なメモ化は避ける
-
-- ✅ 重い計算処理: useMemoで保護
-- ✅ 複雑なオブジェクト構築: useMemoで保護
-- ✅ 外部依存のあるハンドラ: useCallbackで保護
-- ✅ **JSX.Element型の統合**: 分離せずに内部で直接処理
-- ❌ 単純な条件分岐: メモ化不要
-- ❌ 軽量なsetter関数: useCallback不要
-- ❌ **JSX.Element型のdependency**: 参照型による無効化を避ける
-
-
-
-## 最適化チェックリスト
-
-### 🔍 診断フェーズ
-
-- [ ] モーダルが閉じている状態でのフック実行数を確認
-- [ ] React DevTools Profilerでレンダリング回数を測定
-- [ ] Network タブで不要なAPI呼び出しがないか確認
-- [ ] コンソールで不要なログ出力がないか確認
-
-### 🚀 実装フェーズ
-
-#### 必須対応項目
-- [ ] **コンテナ・プレゼンテーション分離**: 早期リターンによる完全な処理回避を実装
-- [ ] **計算処理のメモ化**: 配列操作や文字列処理のうち、メモ化すべきと判断したものを `useMemo` でラップ
-- [ ] **イベントハンドラーのメモ化**: イベントハンドラーのうち、メモ化すべきと判断したものを `useCallback` でラップ
-- [ ] **外部依存関数の安定化**: 親から渡される関数の依存関係を明確化
-- [ ] **JSX.Element依存関係の排除**: 参照型dependency回避のため内部統合を実装
-
-#### 推奨対応項目
-- [ ] **レンダリング関数の最適化**: 条件付きレンダリングを事前計算
-- [ ] **状態の適切な初期化**: useEffect での状態リセット処理を最適化
-- [ ] **型安全性の向上**: 分離後のコンポーネントでのnon-null assertionを活用
-
-### 🧪 検証フェーズ
-
-- [ ] **レンダリング回数の削減**: React DevTools Profilerで改善を確認
-- [ ] **メモリ使用量の削減**: 閉じた状態でのヒープ使用量を確認
-- [ ] **ネットワークリクエストの削減**: 不要なAPI呼び出しの停止を確認
-- [ ] **ユーザー体験の向上**: モーダル開閉速度の改善を確認
-
-## 実装テンプレート
-
-### 基本的なモーダル分離パターン
-
-```typescript
-// Substance: 実際のモーダル内容(重い処理を含む)
-const MyModalSubstance: FC = () => {
-  const { data, opts } = useMyModalStatus()!; // 非null確定
-  const { close } = useMyModalActions();
-  
-  // 重い処理はここで実行(モーダルが開いている時のみ)
-  const processedData = useMemo(() => 
-    heavyProcessing(data), 
-    [data]
-  );
-  
-  const handleAction = useCallback((item: Item) => {
-    // アクション処理
-    opts?.onAction?.(item);
-    close();
-  }, [opts?.onAction, close]);
-  
-  return (
-    <>
-      <ModalHeader toggle={close}>タイトル</ModalHeader>
-      <ModalBody>
-        {processedData.map(item => (
-          <ItemComponent key={item.id} item={item} onAction={handleAction} />
-        ))}
-      </ModalBody>
-      <ModalFooter>
-        <Button onClick={close}>キャンセル</Button>
-      </ModalFooter>
-    </>
-  );
-};
-
-// Container: モーダルの表示制御のみ
-export const MyModal: FC = () => {
-  const { isOpen } = useMyModalStatus() ?? {};
-  const { close } = useMyModalActions();
-  
-  if (!isOpen) {
-    return <></>;
-  }
-  
-  return (
-    <Modal isOpen={isOpen} toggle={close}>
-      <MyModalSubstance />
-    </Modal>
-  );
-};
-```
-
-### 複雑なデータ処理を含むモーダルパターン
-
-```typescript
-const ComplexModalSubstance: FC = () => {
-  const { items, filters, opts } = useComplexModalStatus()!;
-  const { close } = useComplexModalActions();
-  
-  // Step 1: 基本フィルタリング
-  const filteredItems = useMemo(() => 
-    items.filter(item => applyFilters(item, filters)),
-    [items, filters]
-  );
-  
-  // Step 2: 権限チェック
-  const accessibleItems = useMemo(() =>
-    filteredItems.filter(item => hasPermission(item)),
-    [filteredItems]
-  );
-  
-  // Step 3: ソート・グループ化
-  const organizedItems = useMemo(() => 
-    groupAndSort(accessibleItems),
-    [accessibleItems]
-  );
-  
-  // イベントハンドラー群
-  const handleSelect = useCallback((item: Item) => {
-    opts?.onSelect?.(item);
-    close();
-  }, [opts?.onSelect, close]);
-  
-  const handleBulkAction = useCallback((action: string) => {
-    const selectedItems = organizedItems.filter(item => item.selected);
-    opts?.onBulkAction?.(action, selectedItems);
-    close();
-  }, [organizedItems, opts?.onBulkAction, close]);
-  
-  return (
-    <>
-      <ModalHeader toggle={close}>複雑なモーダル</ModalHeader>
-      <ModalBody>
-        <ComplexContent 
-          items={organizedItems}
-          onSelect={handleSelect}
-          onBulkAction={handleBulkAction}
-        />
-      </ModalBody>
-      <ModalFooter>
-        <ActionButtons onBulkAction={handleBulkAction} />
-      </ModalFooter>
-    </>
-  );
-};
-```
-
-## 測定・監視のベストプラクティス
-
-### 開発時の測定
-```bash
-# React DevTools Profilerを使用
-# 1. モーダル閉じた状態でのベースライン測定
-# 2. モーダル開いた状態での差分測定
-# 3. 最適化前後での比較
-
-# Performance.measureUserTiming API活用例
-performance.mark('modal-render-start');
-// レンダリング処理
-performance.mark('modal-render-end');
-performance.measure('modal-render', 'modal-render-start', 'modal-render-end');
-```
-
-### 本番環境での監視
-- **Core Web Vitals**: FCP、LCP への影響を測定
-- **カスタムメトリクス**: モーダル開閉時間、レンダリング回数
-- **エラー監視**: メモリリークやパフォーマンス劣化の検知
-
-## まとめ
-
-モーダル系コンポーネントのパフォーマンス最適化は、**段階的なアプローチ**が重要です:
-
-### 🏃‍♂️ **Phase 1: 基本最適化(必須・全モーダル対象)**
-- **コンテナ・プレゼンテーション分離**: 早期リターンによる完全な処理回避
-- **適切なメモ化**: useMemo、useCallbackによる不要な再計算・再生成の回避
-    - ただし過度なメモ化をさけるためメモ化するかどうかの判断は適切に
-
-### 🚀 **Phase 2: 高度な最適化(条件次第で効果的)**
-- **Dynamic Import**: バンドル削減と初期読み込み速度向上
-- **プリロード戦略**: UXを損なわずにCode Splittingの効果を最大化
-
-### ⚠️ **実装時の重要な注意点**
-
-1. **技術的判断vs UX判断の区別**
-   - バンドルサイズ・使用頻度:技術的に測定可能
-   - 応答速度の期待値・ユーザー体験:**必ずステークホルダーに確認**
-
-2. **測定による検証**
-   - 最適化前後の具体的な数値比較を実施
-   - React DevTools Profiler、webpack-bundle-analyzerの活用
-
-3. **段階的実装**
-   - 高効果期待の重要モーダルから開始
-   - 効果を測定しながら対象を拡大
-
-特に**Dynamic Importの適用判断**では、技術的メトリクス(バンドルサイズ、使用頻度)は客観的に測定できますが、**ユーザー体験への影響は主観的**です。リファクタ前に必ず関係者との合意形成を行い、適切なフォールバック(プリロード、スケルトン表示等)を実装することを強く推奨します。
-
-最適化実施時は、必ず事前・事後の測定を行い、実際のパフォーマンス改善とユーザー体験の両面を数値で確認することが成功の鍵となります。

+ 16 - 2
.serena/memories/apps-app-modal-performance-optimization-v3.md

@@ -1,10 +1,24 @@
 # モーダル系コンポーネント パフォーマンス最適化ガイド Version3
 
+## 前提: V2完了状況
+
+**完了日**: 2025-10-15  
+**達成**: 46/51モーダル (90%) - Container-Presentation分離完了
+
+### V2の主要成果
+1. **Container超軽量化**: 6-15行 (最大85%削減)
+2. **Fadeout transition修正**: 全25モーダル
+3. **計算処理メモ化**: useMemo/useCallback適用
+
+**詳細**: `apps-app-modal-performance-optimization-v2-completion-summary.md`
+
+---
+
 ## 目的
 
-- 「モーダル系コンポーネント パフォーマンス最適化ガイド Version2」を終えたモーダル群について、更に最適化を行う
+- V2で最適化されたモーダル群について、さらに動的ロード最適化を行う
 - モーダル動的ロードに関するリファクタリングガイドである
-- Pages Routerにおけるモーダルコンポーネントの遅延ロードを実現し、初期ページロード時のバンドルサイズを削減する。
+- Pages Routerにおけるモーダルコンポーネントの遅延ロードを実現し、初期ページロード時のバンドルサイズを削減する
 
 ## 問題点
 

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

+ 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

@@ -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>
   );
 };

+ 38 - 13
apps/app/src/client/components/DeleteBookmarkFolderModal.tsx

@@ -10,39 +10,45 @@ import {
 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 DeleteBookmarkFolderModal: FC = () => {
+const DeleteBookmarkFolderModalSubstance = ({
+  bookmarkFolder,
+  onDeleted,
+  closeModal,
+}: DeleteBookmarkFolderModalSubstanceProps): React.JSX.Element => {
   const { t } = useTranslation();
 
-  const { isOpened, bookmarkFolder, opts } = useDeleteBookmarkFolderModalStatus();
-  const { close: closeBookmarkFolderDeleteModal } = useDeleteBookmarkFolderModalActions();
-
   const deleteBookmark = useCallback(async() => {
-    if (bookmarkFolder == null) {
-      return;
-    }
     try {
       await deleteBookmarkFolder(bookmarkFolder._id);
-      const onDeleted = opts?.onDeleted;
       if (onDeleted != null) {
         onDeleted(bookmarkFolder._id);
       }
-      closeBookmarkFolderDeleteModal();
+      closeModal();
     }
     catch (err) {
       toastError(err);
     }
-  }, [bookmarkFolder, closeBookmarkFolderDeleteModal, opts?.onDeleted]);
+  }, [bookmarkFolder, onDeleted, closeModal]);
 
   const onClickDeleteButton = useCallback(async() => {
     await deleteBookmark();
   }, [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">
+    <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>
@@ -63,8 +69,27 @@ const DeleteBookmarkFolderModal: FC = () => {
           {t('bookmark_folder.delete_modal.modal_footer_button')}
         </button>
       </ModalFooter>
-    </Modal>
+    </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>
   );
 };
 

+ 72 - 37
apps/app/src/client/components/DescendantsPageListModal.tsx

@@ -1,6 +1,6 @@
 
 import React, {
-  useState, useMemo, useEffect, type JSX,
+  useState, useMemo, useEffect, useCallback,
 } from 'react';
 
 import { useTranslation } from 'next-i18next';
@@ -25,37 +25,45 @@ const DescendantsPageList = dynamic<DescendantsPageListProps>(() => import('./De
 
 const PageTimeline = dynamic(() => import('./PageTimeline').then(mod => mod.PageTimeline), { ssr: false });
 
-export const DescendantsPageListModal = (): JSX.Element => {
+/**
+ * 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 status = useDescendantsPageListModalStatus();
-  const { close } = useDescendantsPageListModalActions();
-
   const { events } = useRouter();
-
   const [isDeviceLargerThanLg] = useDeviceLargerThanLg();
 
   useEffect(() => {
-    events.on('routeChangeStart', close);
+    events.on('routeChangeStart', closeModal);
     return () => {
-      events.off('routeChangeStart', close);
+      events.off('routeChangeStart', closeModal);
     };
-  }, [close, events]);
+  }, [closeModal, events]);
 
   const navTabMapping = useMemo(() => {
     return {
       pagelist: {
         Icon: () => <span className="material-symbols-outlined">subject</span>,
         Content: () => {
-          if (status == null || status.path == null || !status.isOpened) {
+          if (path == null) {
             return <></>;
           }
-          return <DescendantsPageList path={status.path} />;
+          return <DescendantsPageList path={path} />;
         },
         i18n: t('page_list'),
         isLinkEnabled: () => !isSharedUser,
@@ -63,49 +71,45 @@ export const DescendantsPageListModal = (): JSX.Element => {
       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]);
+  }, [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={() => setIsWindowExpanded(true)}
-        contractWindow={() => setIsWindowExpanded(false)}
+        expandWindow={expandWindow}
+        contractWindow={contractWindow}
       />
-      <button type="button" className="btn btn-close ms-2" onClick={close} aria-label="Close"></button>
+      <button type="button" className="btn btn-close ms-2" onClick={closeModal} aria-label="Close"></button>
     </span>
-  ), [close, isWindowExpanded]);
-
-  if (status == null) {
-    return <></>;
-  }
-
-  const { isOpened } = status;
+  ), [closeModal, isWindowExpanded, expandWindow, contractWindow]);
 
   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}>
+    <div>
+      <ModalHeader className={isDeviceLargerThanLg ? 'p-0' : ''} toggle={closeModal} close={buttons}>
         {isDeviceLargerThanLg && (
           <CustomNavTab
             activeTab={activeTab}
             navTabMapping={navTabMapping}
             breakpointToHideInactiveTabsDown="md"
-            onNavSelected={v => setActiveTab(v)}
+            onNavSelected={onNavSelected}
             hideBorderBottom
           />
         )}
@@ -115,7 +119,7 @@ export const DescendantsPageListModal = (): JSX.Element => {
           <CustomNavDropdown
             activeTab={activeTab}
             navTabMapping={navTabMapping}
-            onNavSelected={v => setActiveTab(v)}
+            onNavSelected={onNavSelected}
           />
         )}
         <CustomTabContent
@@ -124,7 +128,38 @@ export const DescendantsPageListModal = (): JSX.Element => {
           additionalClassNames={!isDeviceLargerThanLg ? ['grw-tab-content-style-md-down'] : undefined}
         />
       </ModalBody>
-    </Modal>
+    </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 isOpened = status?.isOpened ?? false;
+
+  const handleExpandedChange = useCallback((isExpanded: boolean) => {
+    setIsWindowExpanded(isExpanded);
+  }, []);
+
+  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' : ''}`}
+    >
+      {isOpened && (
+        <DescendantsPageListModalSubstance
+          path={status?.path}
+          closeModal={close}
+          onExpandedChange={handleExpandedChange}
+        />
+      )}
+    </Modal>
+  );
 };

+ 47 - 16
apps/app/src/client/components/EmptyTrashModal.tsx

@@ -1,6 +1,7 @@
 import type { FC } from 'react';
-import React, { useState, useCallback } 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,
@@ -11,13 +12,23 @@ import { useEmptyTrashModalStatus, useEmptyTrashModalActions } from '~/states/ui
 
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 
-const EmptyTrashModal: FC = () => {
-  const { t } = useTranslation();
-
-  const { isOpened, pages, opts } = useEmptyTrashModalStatus();
-  const { close: closeEmptyTrashModal } = useEmptyTrashModalActions();
+/**
+ * EmptyTrashModalSubstance - Presentation component (all logic here)
+ */
+type EmptyTrashModalSubstanceProps = {
+  pages: IPageToDeleteWithMeta[] | undefined;
+  canDeleteAllPages: boolean;
+  onEmptiedTrash?: () => void;
+  closeModal: () => void;
+};
 
-  const canDeleteAllpages = opts?.canDeleteAllPages ?? false;
+const EmptyTrashModalSubstance = ({
+  pages,
+  canDeleteAllPages,
+  onEmptiedTrash,
+  closeModal,
+}: EmptyTrashModalSubstanceProps): React.JSX.Element => {
+  const { t } = useTranslation();
 
   const [errs, setErrs] = useState<Error[] | null>(null);
 
@@ -28,22 +39,22 @@ const EmptyTrashModal: FC = () => {
 
     try {
       await apiv3Delete('/pages/empty-trash');
-      const onEmptiedTrash = opts?.onEmptiedTrash;
       if (onEmptiedTrash != null) {
         onEmptiedTrash();
       }
-      closeEmptyTrashModal();
+      closeModal();
     }
     catch (err) {
       setErrs([err]);
     }
-  }, [pages, opts?.onEmptiedTrash, closeEmptyTrashModal]);
+  }, [pages, onEmptiedTrash, closeModal]);
 
   const emptyTrashButtonHandler = useCallback(async() => {
     await emptyTrash();
   }, [emptyTrash]);
 
-  const renderPagePaths = useCallback(() => {
+  // Memoize page paths rendering
+  const renderPagePaths = useMemo(() => {
     if (pages != null) {
       return pages.map(page => (
         <p key={page.data._id} className="mb-1">
@@ -55,8 +66,8 @@ const EmptyTrashModal: FC = () => {
   }, [pages]);
 
   return (
-    <Modal size="lg" isOpen={isOpened} toggle={closeEmptyTrashModal} data-testid="page-delete-modal">
-      <ModalHeader tag="h4" toggle={closeEmptyTrashModal} className="text-danger">
+    <div>
+      <ModalHeader tag="h4" toggle={closeModal} className="text-danger">
         <span className="material-symbols-outlined">delete_forever</span>
         {t('modal_empty.empty_the_trash')}
       </ModalHeader>
@@ -64,9 +75,9 @@ const EmptyTrashModal: FC = () => {
         <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()}
+          {renderPagePaths}
         </div>
-        {!canDeleteAllpages && t('modal_empty.not_deletable_notice')}<br />
+        {!canDeleteAllPages && t('modal_empty.not_deletable_notice')}<br />
         {t('modal_empty.notice')}
       </ModalBody>
       <ModalFooter>
@@ -80,8 +91,28 @@ const EmptyTrashModal: FC = () => {
           {t('modal_empty.empty_the_trash_button')}
         </button>
       </ModalFooter>
-    </Modal>
+    </div>
+  );
+};
 
+/**
+ * EmptyTrashModal - Container component (lightweight, always rendered)
+ */
+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>
   );
 };
 

+ 42 - 12
apps/app/src/client/components/GrantedGroupsInheritanceSelectModal.tsx

@@ -1,4 +1,4 @@
-import { useState, type JSX } from 'react';
+import { useState, useCallback } from 'react';
 
 import { useTranslation } from 'react-i18next';
 import {
@@ -9,22 +9,30 @@ import {
   useGrantedGroupsInheritanceSelectModalActions, useGrantedGroupsInheritanceSelectModalStatus,
 } from '~/states/ui/modal/granted-groups-inheritance-select';
 
-const GrantedGroupsInheritanceSelectModal = (): JSX.Element => {
+/**
+ * GrantedGroupsInheritanceSelectModalSubstance - Presentation component (heavy logic, rendered only when isOpen)
+ */
+type GrantedGroupsInheritanceSelectModalSubstanceProps = {
+  onCreateBtnClick: ((onlyInheritUserRelatedGrantedGroups: boolean) => Promise<void>) | undefined;
+  closeModal: () => void;
+};
+
+const GrantedGroupsInheritanceSelectModalSubstance = (props: GrantedGroupsInheritanceSelectModalSubstanceProps): React.JSX.Element => {
+  const { onCreateBtnClick: _onCreateBtnClick, closeModal } = props;
   const { t } = useTranslation();
-  const { isOpened, onCreateBtnClick: _onCreateBtnClick } = useGrantedGroupsInheritanceSelectModalStatus();
-  const { close: closeModal } = useGrantedGroupsInheritanceSelectModalActions();
+
   const [onlyInheritUserRelatedGrantedGroups, setOnlyInheritUserRelatedGrantedGroups] = useState(false);
 
-  const onCreateBtnClick = async() => {
+  const onCreateBtnClick = useCallback(async() => {
     await _onCreateBtnClick?.(onlyInheritUserRelatedGrantedGroups);
     setOnlyInheritUserRelatedGrantedGroups(false); // reset to false after create request
-  };
+  }, [_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>
@@ -37,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')}
@@ -50,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')}
@@ -64,6 +72,28 @@ const GrantedGroupsInheritanceSelectModal = (): JSX.Element => {
           {t('modal_granted_groups_inheritance_select.create_page')}
         </button>
       </ModalFooter>
+    </>
+  );
+};
+
+/**
+ * GrantedGroupsInheritanceSelectModal - Container component (lightweight, always rendered)
+ */
+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>
   );
 };

+ 35 - 12
apps/app/src/client/components/Me/AssociateModal.tsx

@@ -22,13 +22,20 @@ type Props = {
   onClose: () => void,
 }
 
-const AssociateModal = (props: Props): JSX.Element => {
+/**
+ * AssociateModalSubstance - Presentation component (heavy logic, rendered only when isOpen)
+ */
+type AssociateModalSubstanceProps = {
+  onClose: () => void;
+};
+
+const AssociateModalSubstance = (props: AssociateModalSubstanceProps): JSX.Element => {
+  const { onClose } = props;
   const { t } = useTranslation();
   const { mutate: mutatePersonalExternalAccounts } = useSWRxPersonalExternalAccounts();
-  const { trigger: associateLdapAccount, isMutating } = useAssociateLdapAccount();
-  const [activeTab, setActiveTab] = useState(1);
-  const { isOpen, onClose } = props;
+  const { trigger: associateLdapAccount } = useAssociateLdapAccount();
 
+  const [activeTab, setActiveTab] = useState(1);
   const [username, setUsername] = useState('');
   const [password, setPassword] = useState('');
 
@@ -38,7 +45,6 @@ const AssociateModal = (props: Props): JSX.Element => {
     setPassword('');
   }, [onClose]);
 
-
   const clickAddLdapAccountHandler = useCallback(async() => {
     try {
       await associateLdapAccount({ username, password });
@@ -50,12 +56,16 @@ const AssociateModal = (props: Props): JSX.Element => {
     catch (err) {
       toastError(err);
     }
-
   }, [associateLdapAccount, closeModalHandler, mutatePersonalExternalAccounts, password, t, username]);
 
+  const setTabToLdap = useCallback(() => setActiveTab(1), []);
+  const setTabToGithub = useCallback(() => setActiveTab(2), []);
+  const setTabToGoogle = useCallback(() => setActiveTab(3), []);
+  const handleUsernameChange = useCallback((username: string) => setUsername(username), []);
+  const handlePasswordChange = useCallback((password: string) => setPassword(password), []);
 
   return (
-    <Modal isOpen={isOpen} toggle={closeModalHandler} size="lg" data-testid="grw-associate-modal">
+    <>
       <ModalHeader toggle={onClose}>
         { t('admin:user_management.create_external_account') }
       </ModalHeader>
@@ -64,19 +74,19 @@ const AssociateModal = (props: Props): JSX.Element => {
           <Nav tabs className="mb-2">
             <NavLink
               className={`${activeTab === 1 ? 'active' : ''} d-flex gap-1 align-items-center`}
-              onClick={() => setActiveTab(1)}
+              onClick={setTabToLdap}
             >
               <span className="material-symbols-outlined fs-5">network_node</span> LDAP
             </NavLink>
             <NavLink
               className={`${activeTab === 2 ? 'active' : ''} d-flex gap-1 align-items-center`}
-              onClick={() => setActiveTab(2)}
+              onClick={setTabToGithub}
             >
               <span className="growi-custom-icons">github</span> (TBD) GitHub
             </NavLink>
             <NavLink
               className={`${activeTab === 3 ? 'active' : ''} d-flex gap-1 align-items-center`}
-              onClick={() => setActiveTab(3)}
+              onClick={setTabToGoogle}
             >
               <span className="growi-custom-icons">google</span> (TBD) Google OAuth
             </NavLink>
@@ -86,8 +96,8 @@ const AssociateModal = (props: Props): JSX.Element => {
               <LdapAuthTest
                 username={username}
                 password={password}
-                onChangeUsername={username => setUsername(username)}
-                onChangePassword={password => setPassword(password)}
+                onChangeUsername={handleUsernameChange}
+                onChangePassword={handlePasswordChange}
               />
             </TabPane>
             <TabPane tabId={2}>
@@ -111,6 +121,19 @@ const AssociateModal = (props: Props): JSX.Element => {
           {t('add')}
         </button>
       </ModalFooter>
+    </>
+  );
+};
+
+/**
+ * AssociateModal - Container component (lightweight, always rendered)
+ */
+const AssociateModal = (props: Props): JSX.Element => {
+  const { isOpen, onClose } = props;
+
+  return (
+    <Modal isOpen={isOpen} toggle={onClose} size="lg" data-testid="grw-associate-modal">
+      {isOpen && <AssociateModalSubstance onClose={onClose} />}
     </Modal>
   );
 };

+ 34 - 10
apps/app/src/client/components/Me/DisassociateModal.tsx

@@ -1,4 +1,4 @@
-import React, { useCallback, type JSX } from 'react';
+import React, { useCallback } from 'react';
 
 import type { HasObjectId, IExternalAccount } from '@growi/core';
 import { useTranslation } from 'next-i18next';
@@ -19,20 +19,26 @@ type Props = {
   accountForDisassociate: IExternalAccount<IExternalAuthProviderType> & HasObjectId,
 }
 
+/**
+ * DisassociateModalSubstance - Presentation component (heavy logic, rendered only when isOpen)
+ */
+type DisassociateModalSubstanceProps = {
+  onClose: () => void;
+  accountForDisassociate: IExternalAccount<IExternalAuthProviderType> & HasObjectId;
+};
 
-const DisassociateModal = (props: Props): JSX.Element => {
-
+const DisassociateModalSubstance = (props: DisassociateModalSubstanceProps): React.JSX.Element => {
+  const { onClose, accountForDisassociate } = props;
   const { t } = useTranslation();
   const { mutate: mutatePersonalExternalAccounts } = useSWRxPersonalExternalAccounts();
   const { trigger: disassociateLdapAccount } = useDisassociateLdapAccount();
 
-  const { providerType, accountId } = props.accountForDisassociate;
+  const { providerType, accountId } = accountForDisassociate;
 
   const disassociateAccountHandler = useCallback(async() => {
-
     try {
       await disassociateLdapAccount({ providerType, accountId });
-      props.onClose();
+      onClose();
       toastSuccess(t('security_settings.updated_general_security_setting'));
     }
     catch (err) {
@@ -42,11 +48,11 @@ const DisassociateModal = (props: Props): JSX.Element => {
     if (mutatePersonalExternalAccounts != null) {
       mutatePersonalExternalAccounts();
     }
-  }, [accountId, disassociateLdapAccount, mutatePersonalExternalAccounts, props, providerType, t]);
+  }, [accountId, disassociateLdapAccount, mutatePersonalExternalAccounts, onClose, providerType, t]);
 
   return (
-    <Modal isOpen={props.isOpen} toggle={props.onClose}>
-      <ModalHeader className="text-info" toggle={props.onClose}>
+    <>
+      <ModalHeader className="text-info" toggle={onClose}>
         {t('personal_settings.disassociate_external_account')}
       </ModalHeader>
       <ModalBody>
@@ -54,7 +60,7 @@ const DisassociateModal = (props: Props): JSX.Element => {
         <p dangerouslySetInnerHTML={{ __html: t('personal_settings.disassociate_external_account_desc', { providerType, accountId }) }} />
       </ModalBody>
       <ModalFooter>
-        <button type="button" className="btn btn-sm btn-outline-secondary" onClick={props.onClose}>
+        <button type="button" className="btn btn-sm btn-outline-secondary" onClick={onClose}>
           { t('Cancel') }
         </button>
         <button type="button" className="btn btn-sm btn-danger" onClick={disassociateAccountHandler}>
@@ -62,6 +68,24 @@ const DisassociateModal = (props: Props): JSX.Element => {
           { t('Disassociate') }
         </button>
       </ModalFooter>
+    </>
+  );
+};
+
+/**
+ * DisassociateModal - Container component (lightweight, always rendered)
+ */
+const DisassociateModal = (props: Props): React.JSX.Element => {
+  const { isOpen, onClose, accountForDisassociate } = props;
+
+  return (
+    <Modal isOpen={isOpen} toggle={onClose}>
+      {isOpen && (
+        <DisassociateModalSubstance
+          onClose={onClose}
+          accountForDisassociate={accountForDisassociate}
+        />
+      )}
     </Modal>
   );
 };

+ 53 - 17
apps/app/src/client/components/PageAttachment/DeleteAttachmentModal.tsx

@@ -2,6 +2,7 @@ import React, {
   useCallback, useMemo, useState,
 } from 'react';
 
+import type { IAttachmentHasId } from '@growi/core';
 import { UserPicture, LoadingSpinner } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
 import {
@@ -22,22 +23,30 @@ const iconByFormat = (format: string): string => {
   return format.match(/image\/.+/i) ? 'image' : 'description';
 };
 
-export const DeleteAttachmentModal: React.FC = () => {
+/**
+ * DeleteAttachmentModalSubstance - Presentation component (all logic here)
+ */
+type DeleteAttachmentModalSubstanceProps = {
+  attachment: IAttachmentHasId | undefined;
+  remove: ((args: { attachment_id: string }) => Promise<void>) | undefined;
+  closeModal: () => void;
+};
+
+const DeleteAttachmentModalSubstance = ({
+  attachment,
+  remove,
+  closeModal,
+}: DeleteAttachmentModalSubstanceProps): React.JSX.Element => {
   const [deleting, setDeleting] = useState<boolean>(false);
   const [deleteError, setDeleteError] = useState<string>('');
 
   const { t } = useTranslation();
-  const deleteAttachmentModal = useDeleteAttachmentModalStatus();
-  const { close: closeDeleteAttachmentModal } = useDeleteAttachmentModalActions();
-  const isOpen = deleteAttachmentModal?.isOpened;
-  const attachment = deleteAttachmentModal?.attachment;
-  const remove = deleteAttachmentModal?.remove;
 
   const toggleHandler = useCallback(() => {
-    closeDeleteAttachmentModal();
+    closeModal();
     setDeleting(false);
     setDeleteError('');
-  }, [closeDeleteAttachmentModal]);
+  }, [closeModal]);
 
   const onClickDeleteButtonHandler = useCallback(async() => {
     if (remove == null || attachment == null) {
@@ -49,7 +58,7 @@ export const DeleteAttachmentModal: React.FC = () => {
     try {
       await remove({ attachment_id: attachment._id });
       setDeleting(false);
-      closeDeleteAttachmentModal();
+      closeModal();
       toastSuccess(`Delete ${attachment.originalName}`);
     }
     catch (err) {
@@ -58,7 +67,7 @@ export const DeleteAttachmentModal: React.FC = () => {
       toastError(err);
       logger.error(err);
     }
-  }, [attachment, closeDeleteAttachmentModal, remove]);
+  }, [attachment, closeModal, remove]);
 
   const attachmentFileFormat = useMemo(() => {
     if (attachment == null) {
@@ -94,13 +103,7 @@ export const DeleteAttachmentModal: React.FC = () => {
   }, [deleting, deleteError]);
 
   return (
-    <Modal
-      isOpen={isOpen}
-      className={`${styles['attachment-delete-modal']} attachment-delete-modal`}
-      size="lg"
-      aria-labelledby="contained-modal-title-lg"
-      fade={false}
-    >
+    <div>
       <ModalHeader tag="h4" toggle={toggleHandler} className="text-danger">
         <span id="contained-modal-title-lg">{t('delete_attachment_modal.confirm_delete_attachment')}</span>
       </ModalHeader>
@@ -117,7 +120,40 @@ export const DeleteAttachmentModal: React.FC = () => {
           disabled={deleting}
         >{t('commons:Delete')}
         </Button>
+        <Button
+          color="secondary"
+          onClick={toggleHandler}
+        >
+          {t('modal_delete.cancel')}
+        </Button>
       </ModalFooter>
+    </div>
+  );
+};
+
+/**
+ * DeleteAttachmentModal - Container component (lightweight, always rendered)
+ */
+export const DeleteAttachmentModal: React.FC = () => {
+  const deleteAttachmentModal = useDeleteAttachmentModalStatus();
+  const { close: closeModal } = useDeleteAttachmentModalActions();
+  const isOpen = deleteAttachmentModal?.isOpened;
+
+  return (
+    <Modal
+      isOpen={isOpen}
+      className={`${styles['attachment-delete-modal']} attachment-delete-modal`}
+      size="lg"
+      aria-labelledby="contained-modal-title-lg"
+      fade={false}
+    >
+      {isOpen && (
+        <DeleteAttachmentModalSubstance
+          attachment={deleteAttachmentModal?.attachment}
+          remove={deleteAttachmentModal?.remove}
+          closeModal={closeModal}
+        />
+      )}
     </Modal>
   );
 };

+ 87 - 58
apps/app/src/client/components/PageComment/DeleteCommentModal.tsx

@@ -1,4 +1,4 @@
-import React, { type JSX } from 'react';
+import React, { useMemo } from 'react';
 
 import { isPopulated } from '@growi/core';
 import { UserPicture } from '@growi/ui/dist/components';
@@ -22,80 +22,109 @@ export type DeleteCommentModalProps = {
   confirmToDelete: () => void,
 }
 
-export const DeleteCommentModal = (props: DeleteCommentModalProps): JSX.Element => {
+/**
+ * DeleteCommentModalSubstance - Presentation component (heavy logic, rendered only when isOpen)
+ */
+type DeleteCommentModalSubstanceProps = {
+  comment: ICommentHasId,
+  errorMessage: string,
+  cancelToDelete: () => void,
+  confirmToDelete: () => void,
+}
+
+const DeleteCommentModalSubstance = (props: DeleteCommentModalSubstanceProps): React.JSX.Element => {
   const {
-    isShown, comment, errorMessage, cancelToDelete, confirmToDelete,
+    comment, errorMessage, cancelToDelete, confirmToDelete,
   } = props;
 
   const { t } = useTranslation();
 
-  const headerContent = () => {
-    if (comment == null || isShown === false) {
-      return <></>;
-    }
-    return (
-      <span>
-        <span className="material-symbols-outlined">delete_forever</span>
-        {t('page_comment.delete_comment')}
-      </span>
-    );
-  };
-
-  const bodyContent = () => {
-    if (comment == null || isShown === false) {
-      return <></>;
-    }
-
-    // the threshold for omitting body
+  // Memoize formatted date
+  const commentDate = useMemo(() => {
+    if (comment == null) return '';
+    return format(new Date(comment.createdAt), 'yyyy/MM/dd HH:mm');
+  }, [comment]);
+
+  // Memoize creator
+  const creator = useMemo(() => {
+    if (comment == null) return undefined;
+    return isPopulated(comment.creator) ? comment.creator : undefined;
+  }, [comment]);
+
+  // Memoize processed comment body
+  const commentBodyElement = useMemo(() => {
+    if (comment == null) return null;
     const OMIT_BODY_THRES = 400;
-
-    const commentDate = format(new Date(comment.createdAt), 'yyyy/MM/dd HH:mm');
-
-    const creator = isPopulated(comment.creator) ? comment.creator : undefined;
-
     let commentBody = comment.comment;
-    if (commentBody.length > OMIT_BODY_THRES) { // omit
+    if (commentBody.length > OMIT_BODY_THRES) {
       commentBody = `${commentBody.substr(0, OMIT_BODY_THRES)}...`;
     }
-    const commentBodyElement = <span style={{ whiteSpace: 'pre-wrap' }}>{commentBody}</span>;
-
-    return (
-      <>
-        <UserPicture user={creator} size="xs" /> <strong className="me-2"><Username user={creator}></Username></strong>{commentDate}:
-        <div className="card mt-2">
-          <div className="card-body comment-body px-3 py-2">{commentBodyElement}</div>
-        </div>
-      </>
-    );
-  };
-
-  const footerContent = () => {
-    if (comment == null || isShown === false) {
-      return <></>;
-    }
-    return (
-      <>
-        <span className="text-danger">{errorMessage}</span>&nbsp;
-        <Button onClick={cancelToDelete}>{t('Cancel')}</Button>
-        <Button data-testid="delete-comment-button" color="danger" onClick={confirmToDelete}>
-          <span className="material-symbols-outlined">delete_forever</span>
-          {t('Delete')}
-        </Button>
-      </>
-    );
-  };
+    return <span style={{ whiteSpace: 'pre-wrap' }}>{commentBody}</span>;
+  }, [comment]);
+
+  // Memoize header content
+  const headerContent = useMemo(() => (
+    <span>
+      <span className="material-symbols-outlined">delete_forever</span>
+      {t('page_comment.delete_comment')}
+    </span>
+  ), [t]);
+
+  // Memoize body content
+  const bodyContent = useMemo(() => (
+    <>
+      <UserPicture user={creator} size="xs" /> <strong className="me-2"><Username user={creator}></Username></strong>{commentDate}:
+      <div className="card mt-2">
+        <div className="card-body comment-body px-3 py-2">{commentBodyElement}</div>
+      </div>
+    </>
+  ), [creator, commentDate, commentBodyElement]);
+
+  // Memoize footer content
+  const footerContent = useMemo(() => (
+    <>
+      <span className="text-danger">{errorMessage}</span>&nbsp;
+      <Button onClick={cancelToDelete}>{t('Cancel')}</Button>
+      <Button data-testid="delete-comment-button" color="danger" onClick={confirmToDelete}>
+        <span className="material-symbols-outlined">delete_forever</span>
+        {t('Delete')}
+      </Button>
+    </>
+  ), [errorMessage, cancelToDelete, confirmToDelete, t]);
 
   return (
-    <Modal data-testid="page-comment-delete-modal" isOpen={isShown} toggle={cancelToDelete} className={`${styles['page-comment-delete-modal']}`}>
+    <>
       <ModalHeader tag="h4" toggle={cancelToDelete} className="text-danger">
-        {headerContent()}
+        {headerContent}
       </ModalHeader>
       <ModalBody>
-        {bodyContent()}
+        {bodyContent}
       </ModalBody>
       <ModalFooter>
-        {footerContent()}
+        {footerContent}
       </ModalFooter>
+    </>
+  );
+};
+
+/**
+ * DeleteCommentModal - Container component (lightweight, always rendered)
+ */
+export const DeleteCommentModal = (props: DeleteCommentModalProps): React.JSX.Element => {
+  const {
+    isShown, comment, errorMessage, cancelToDelete, confirmToDelete,
+  } = props;
+
+  return (
+    <Modal data-testid="page-comment-delete-modal" isOpen={isShown} toggle={cancelToDelete} className={`${styles['page-comment-delete-modal']}`}>
+      {isShown && comment != null && (
+        <DeleteCommentModalSubstance
+          comment={comment}
+          errorMessage={errorMessage}
+          cancelToDelete={cancelToDelete}
+          confirmToDelete={confirmToDelete}
+        />
+      )}
     </Modal>
   );
 };

+ 30 - 28
apps/app/src/client/components/PageCreateModal.tsx

@@ -43,11 +43,16 @@ const PageCreateModal: React.FC = () => {
   const { createTemplate } = useCreateTemplatePage();
 
   const isReachable = useAtomValue(isSearchServiceReachableAtom);
-  const userHomepagePath = pagePathUtils.userHomepagePath(currentUser);
-  const isCreatable = isCreatablePage(pathname) || isUsersHomepage(pathname);
-  const pageNameInputInitialValue = isCreatable ? pathUtils.addTrailingSlash(pathname) : '/';
-  const now = format(new Date(), 'yyyy/MM/dd');
-  const todaysParentPath = [userHomepagePath, t('create_page_dropdown.todays.memo', { ns: 'commons' }), now].join('/');
+
+  // Memoize computed values
+  const userHomepagePath = useMemo(() => pagePathUtils.userHomepagePath(currentUser), [currentUser]);
+  const isCreatable = useMemo(() => isCreatablePage(pathname) || isUsersHomepage(pathname), [pathname]);
+  const pageNameInputInitialValue = useMemo(() => (isCreatable ? pathUtils.addTrailingSlash(pathname) : '/'), [isCreatable, pathname]);
+  const now = useMemo(() => format(new Date(), 'yyyy/MM/dd'), []);
+  const todaysParentPath = useMemo(
+    () => [userHomepagePath, t('create_page_dropdown.todays.memo', { ns: 'commons' }), now].join('/'),
+    [userHomepagePath, t, now],
+  );
 
   const [todayInput, setTodayInput] = useState('');
   const [pageNameInput, setPageNameInput] = useState(pageNameInputInitialValue);
@@ -55,41 +60,38 @@ const PageCreateModal: React.FC = () => {
   const [isMatchedWithUserHomepagePath, setIsMatchedWithUserHomepagePath] = useState(false);
 
   const checkIsUsersHomepageDebounce = useMemo(() => {
-    const checkIsUsersHomepage = () => {
-      setIsMatchedWithUserHomepagePath(isUsersHomepage(pageNameInput));
-    };
-
-    return debounce(1000, checkIsUsersHomepage);
-  }, [pageNameInput]);
+    return debounce(1000, (input: string) => {
+      setIsMatchedWithUserHomepagePath(isUsersHomepage(input));
+    });
+  }, []);
 
   useEffect(() => {
     if (isOpened) {
-      checkIsUsersHomepageDebounce();
+      checkIsUsersHomepageDebounce(pageNameInput);
     }
   }, [isOpened, checkIsUsersHomepageDebounce, pageNameInput]);
 
-
-  function transitBySubmitEvent(e, transitHandler) {
+  const transitBySubmitEvent = useCallback((e, transitHandler) => {
     // prevent page transition by submit
     e.preventDefault();
     transitHandler();
-  }
+  }, []);
 
   /**
    * change todayInput
    * @param {string} value
    */
-  function onChangeTodayInputHandler(value) {
+  const onChangeTodayInputHandler = useCallback((value) => {
     setTodayInput(value);
-  }
+  }, []);
 
   /**
    * change template
    * @param {string} value
    */
-  function onChangeTemplateHandler(value) {
+  const onChangeTemplateHandler = useCallback((value) => {
     setTemplate(value);
-  }
+  }, []);
 
   /**
    * access today page
@@ -137,7 +139,7 @@ const PageCreateModal: React.FC = () => {
   const createInputPageWithToastr = useToastrOnError(createInputPage);
   const createTemplateWithToastr = useToastrOnError(createTemplatePage);
 
-  function renderCreateTodayForm() {
+  const renderCreateTodayForm = useMemo(() => {
     if (!isOpened) {
       return <></>;
     }
@@ -180,9 +182,9 @@ const PageCreateModal: React.FC = () => {
         </fieldset>
       </div>
     );
-  }
+  }, [isOpened, todaysParentPath, todayInput, t, onChangeTodayInputHandler, transitBySubmitEvent, createTodaysMemoWithToastr]);
 
-  function renderInputPageForm() {
+  const renderInputPageForm = useMemo(() => {
     if (!isOpened) {
       return <></>;
     }
@@ -237,9 +239,9 @@ const PageCreateModal: React.FC = () => {
         </fieldset>
       </div>
     );
-  }
+  }, [isOpened, isReachable, pageNameInputInitialValue, createInputPageWithToastr, pageNameInput, isMatchedWithUserHomepagePath, t, transitBySubmitEvent]);
 
-  function renderTemplatePageForm() {
+  const renderTemplatePageForm = useMemo(() => {
     if (!isOpened) {
       return <></>;
     }
@@ -289,7 +291,7 @@ const PageCreateModal: React.FC = () => {
         </fieldset>
       </div>
     );
-  }
+  }, [isOpened, pathname, template, onChangeTemplateHandler, createTemplateWithToastr, t]);
 
   return (
     <Modal
@@ -304,9 +306,9 @@ const PageCreateModal: React.FC = () => {
         {t('New Page')}
       </ModalHeader>
       <ModalBody>
-        {renderCreateTodayForm()}
-        {renderInputPageForm()}
-        {renderTemplatePageForm()}
+        {renderCreateTodayForm}
+        {renderInputPageForm}
+        {renderTemplatePageForm}
       </ModalBody>
     </Modal>
 

+ 30 - 13
apps/app/src/client/components/PageDuplicateModal.tsx

@@ -1,5 +1,5 @@
 import React, {
-  useState, useEffect, useCallback, useMemo, type JSX,
+  useState, useEffect, useCallback, useMemo,
 } from 'react';
 
 import { useAtomValue } from 'jotai';
@@ -19,8 +19,10 @@ import DuplicatePathsTable from './DuplicatedPathsTable';
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 import PagePathAutoComplete from './PagePathAutoComplete';
 
-
-const PageDuplicateModal = (): JSX.Element => {
+/**
+ * PageDuplicateModalSubstance - Heavy processing component (rendered only when modal is open)
+ */
+const PageDuplicateModalSubstance: React.FC = () => {
   const { t } = useTranslation();
 
   const siteUrl = useSiteUrl();
@@ -39,6 +41,12 @@ const PageDuplicateModal = (): JSX.Element => {
   const [isDuplicateRecursivelyWithoutExistPath, setIsDuplicateRecursivelyWithoutExistPath] = useState(true);
   const [onlyDuplicateUserRelatedResources, setOnlyDuplicateUserRelatedResources] = useState(false);
 
+  // Memoize computed values
+  const isTargetPageDuplicate = useMemo(() => existingPaths.includes(pageNameInput), [existingPaths, pageNameInput]);
+  const submitButtonEnabled = useMemo(() => (
+    existingPaths.length === 0 || (isDuplicateRecursively && isDuplicateRecursivelyWithoutExistPath)
+  ), [existingPaths.length, isDuplicateRecursively, isDuplicateRecursivelyWithoutExistPath]);
+
   const updateSubordinatedList = useCallback(async() => {
     if (page == null) {
       return;
@@ -90,14 +98,14 @@ const PageDuplicateModal = (): JSX.Element => {
    * change pageNameInput
    * @param {string} value
    */
-  function inputChangeHandler(value) {
+  const inputChangeHandler = useCallback((value) => {
     setErrs(null);
     setPageNameInput(value);
-  }
+  }, []);
 
-  function changeIsDuplicateRecursivelyHandler() {
+  const changeIsDuplicateRecursivelyHandler = useCallback(() => {
     setIsDuplicateRecursively(!isDuplicateRecursively);
-  }
+  }, [isDuplicateRecursively]);
 
   useEffect(() => {
     if (page != null && isOpened) {
@@ -156,7 +164,6 @@ const PageDuplicateModal = (): JSX.Element => {
     }
 
     const { path } = page;
-    const isTargetPageDuplicate = existingPaths.includes(pageNameInput);
 
     return (
       <>
@@ -257,9 +264,6 @@ const PageDuplicateModal = (): JSX.Element => {
       return <></>;
     }
 
-    const submitButtonEnabled = existingPaths.length === 0
-    || (isDuplicateRecursively && isDuplicateRecursivelyWithoutExistPath);
-
     return (
       <>
         <ApiErrorMessageList errs={errs} targetPath={pageNameInput} />
@@ -278,7 +282,7 @@ const PageDuplicateModal = (): JSX.Element => {
 
 
   return (
-    <Modal size="lg" isOpen={isOpened} toggle={closeDuplicateModal} data-testid="page-duplicate-modal" className="grw-duplicate-page" autoFocus={false}>
+    <>
       <ModalHeader tag="h4" toggle={closeDuplicateModal}>
         { t('modal_duplicate.label.Duplicate page') }
       </ModalHeader>
@@ -288,9 +292,22 @@ const PageDuplicateModal = (): JSX.Element => {
       <ModalFooter>
         {renderFooterContent()}
       </ModalFooter>
-    </Modal>
+    </>
   );
 };
 
+/**
+ * PageDuplicateModal - Container component (lightweight, always rendered)
+ */
+const PageDuplicateModal = (): React.JSX.Element => {
+  const { isOpened } = usePageDuplicateModalStatus();
+  const { close: closeDuplicateModal } = usePageDuplicateModalActions();
+
+  return (
+    <Modal size="lg" isOpen={isOpened} toggle={closeDuplicateModal} data-testid="page-duplicate-modal" className="grw-duplicate-page" autoFocus={false}>
+      {isOpened && <PageDuplicateModalSubstance />}
+    </Modal>
+  );
+};
 
 export default PageDuplicateModal;

+ 52 - 22
apps/app/src/client/components/PageEditor/ConflictDiffModal.tsx

@@ -1,5 +1,5 @@
 import React, {
-  useState, useEffect, useCallback, useMemo, type JSX,
+  useState, useEffect, useCallback, useMemo,
 } from 'react';
 
 import type { IUser } from '@growi/core';
@@ -32,28 +32,38 @@ type IRevisionOnConflict = {
   user: IUser
 }
 
-type ConflictDiffModalCoreProps = {
+/**
+ * ConflictDiffModalSubstance - Presentation component (heavy logic, rendered only when isOpen)
+ */
+type ConflictDiffModalSubstanceProps = {
   request: IRevisionOnConflict
   latest: IRevisionOnConflict
+  isModalExpanded: boolean
+  setIsModalExpanded: React.Dispatch<React.SetStateAction<boolean>>
 };
 
 const formatedDate = (date: Date): string => {
   return format(date, 'yyyy/MM/dd HH:mm:ss');
 };
 
-const ConflictDiffModalCore = (props: ConflictDiffModalCoreProps): JSX.Element => {
-  const { request, latest } = props;
+const ConflictDiffModalSubstance = (props: ConflictDiffModalSubstanceProps): React.JSX.Element => {
+  const {
+    request, latest, isModalExpanded, setIsModalExpanded,
+  } = props;
 
   const [resolvedRevision, setResolvedRevision] = useState<string>('');
   const [isRevisionselected, setIsRevisionSelected] = useState<boolean>(false);
   const [revisionSelectedToggler, setRevisionSelectedToggler] = useState<boolean>(false);
-  const [isModalExpanded, setIsModalExpanded] = useState<boolean>(false);
 
   const { t } = useTranslation();
   const conflictDiffModalStatus = useConflictDiffModalStatus();
   const { close: closeConflictDiffModal } = useConflictDiffModalActions();
   const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.DIFF);
 
+  // Memoize formatted dates
+  const requestFormattedDate = useMemo(() => formatedDate(request.createdAt), [request.createdAt]);
+  const latestFormattedDate = useMemo(() => formatedDate(latest.createdAt), [latest.createdAt]);
+
   const selectRevisionHandler = useCallback((selectedRevision: string) => {
     setResolvedRevision(selectedRevision);
     setRevisionSelectedToggler(prev => !prev);
@@ -86,11 +96,10 @@ const ConflictDiffModalCore = (props: ConflictDiffModalCoreProps): JSX.Element =
         <span className="material-symbols-outlined">close</span>
       </button>
     </div>
-  ), [closeConflictDiffModal, isModalExpanded]);
+  ), [closeConflictDiffModal, isModalExpanded, setIsModalExpanded]);
 
   return (
-    <Modal isOpen={conflictDiffModalStatus?.isOpened} className={`${styles['conflict-diff-modal']} ${isModalExpanded ? ' grw-modal-expanded' : ''}`} size="xl">
-
+    <>
       <ModalHeader tag="h4" className="d-flex align-items-center" close={headerButtons}>
         <span className="material-symbols-outlined me-1">error</span>{t('modal_resolve_conflict.resolve_conflict')}
       </ModalHeader>
@@ -109,7 +118,7 @@ const ConflictDiffModalCore = (props: ConflictDiffModalCoreProps): JSX.Element =
               </div>
               <div className="ms-3 text-muted">
                 <p className="my-0">updated by {request.user.username}</p>
-                <p className="my-0">{ formatedDate(request.createdAt) }</p>
+                <p className="my-0">{ requestFormattedDate }</p>
               </div>
             </div>
           </div>
@@ -122,7 +131,7 @@ const ConflictDiffModalCore = (props: ConflictDiffModalCoreProps): JSX.Element =
               </div>
               <div className="ms-3 text-muted">
                 <p className="my-0">updated by {latest.user.username}</p>
-                <p className="my-0">{ formatedDate(latest.createdAt) }</p>
+                <p className="my-0">{ latestFormattedDate }</p>
               </div>
             </div>
           </div>
@@ -184,12 +193,14 @@ const ConflictDiffModalCore = (props: ConflictDiffModalCoreProps): JSX.Element =
           {t('modal_resolve_conflict.resolve_and_save')}
         </button>
       </ModalFooter>
-    </Modal>
+    </>
   );
 };
 
-
-export const ConflictDiffModal = (): JSX.Element => {
+/**
+ * ConflictDiffModal - Container component (lightweight, always rendered)
+ */
+export const ConflictDiffModal = (): React.JSX.Element => {
   const currentUser = useCurrentUser();
   const currentPage = useCurrentPageData();
   const conflictDiffModalStatus = useConflictDiffModalStatus();
@@ -202,23 +213,42 @@ export const ConflictDiffModal = (): JSX.Element => {
 
   const isRemotePageDataInappropriate = remoteRevisionId == null || remoteRevisionBody == null || remoteRevisionLastUpdateUser == null;
 
-  if (!conflictDiffModalStatus?.isOpened || currentUser == null || currentPage == null || isRemotePageDataInappropriate) {
-    return <></>;
-  }
+  const [isModalExpanded, setIsModalExpanded] = useState<boolean>(false);
 
-  const currentTime: Date = new Date();
+  // Check if all required data is available
+  const isDataReady = conflictDiffModalStatus?.isOpened
+    && currentUser != null
+    && currentPage != null
+    && !isRemotePageDataInappropriate;
 
-  const request: IRevisionOnConflict = {
+  // Prepare data for Substance
+  const currentTime: Date = new Date();
+  const request: IRevisionOnConflict | null = isDataReady ? {
     revisionBody: conflictDiffModalStatus.requestRevisionBody ?? '',
     createdAt: currentTime,
     user: currentUser,
-  };
+  } : null;
 
-  const latest: IRevisionOnConflict = {
+  const latest: IRevisionOnConflict | null = isDataReady ? {
     revisionBody: remoteRevisionBody,
     createdAt: new Date(remoteRevisionLastUpdatedAt ?? currentTime.toString()),
     user: remoteRevisionLastUpdateUser,
-  };
+  } : null;
 
-  return <ConflictDiffModalCore request={request} latest={latest} />;
+  return (
+    <Modal
+      isOpen={conflictDiffModalStatus?.isOpened ?? false}
+      className={`${styles['conflict-diff-modal']} ${isModalExpanded ? ' grw-modal-expanded' : ''}`}
+      size="xl"
+    >
+      {isDataReady && request != null && latest != null && (
+        <ConflictDiffModalSubstance
+          request={request}
+          latest={latest}
+          isModalExpanded={isModalExpanded}
+          setIsModalExpanded={setIsModalExpanded}
+        />
+      )}
+    </Modal>
+  );
 };

+ 0 - 10
apps/app/src/client/components/PageEditor/DrawioModal.tsx

@@ -185,15 +185,5 @@ const DrawioModalSubstance = (): JSX.Element => {
 };
 
 export const DrawioModal = (): JSX.Element => {
-  const drawioModalData = useDrawioModalStatus();
-  const drawioModalDataInEditor = useDrawioModalForEditorStatus();
-
-  const isOpened = drawioModalData?.isOpened ?? false;
-  const isOpenedInEditor = drawioModalDataInEditor?.isOpened ?? false;
-
-  if (!isOpened && !isOpenedInEditor) {
-    return <></>;
-  }
-
   return <DrawioModalSubstance />;
 };

+ 0 - 7
apps/app/src/client/components/PageEditor/HandsontableModal.tsx

@@ -535,12 +535,5 @@ export const HandsontableModalSubstance = (): JSX.Element => {
 };
 
 export const HandsontableModal = (): JSX.Element => {
-  const handsontableModalData = useHandsontableModalStatus();
-  const isOpened = handsontableModalData?.isOpened ?? false;
-
-  if (!isOpened) {
-    return <></>;
-  }
-
   return <HandsontableModalSubstance />;
 };

+ 43 - 28
apps/app/src/client/components/PageEditor/LinkEditModal.tsx

@@ -1,5 +1,5 @@
 import React, {
-  useEffect, useState, useCallback, type JSX,
+  useEffect, useState, useCallback,
 } from 'react';
 
 import path from 'path';
@@ -32,7 +32,10 @@ import styles from './LinkEditPreview.module.scss';
 
 const logger = loggerFactory('growi:components:LinkEditModal');
 
-export const LinkEditModal = (): JSX.Element => {
+/**
+ * LinkEditModalSubstance - Heavy processing component (rendered only when modal is open)
+ */
+const LinkEditModalSubstance: React.FC = () => {
   const { t } = useTranslation();
   const currentPath = useCurrentPagePath();
   const { data: rendererOptions } = usePreviewOptions();
@@ -106,7 +109,7 @@ export const LinkEditModal = (): JSX.Element => {
 
   }, [linkEditModalStatus, parseLinkAndSetState]);
 
-  const toggleIsUseRelativePath = () => {
+  const toggleIsUseRelativePath = useCallback(() => {
     if (!linkInputValue.startsWith('/') || linkerType === Linker.types.growiLink) {
       return;
     }
@@ -114,9 +117,9 @@ export const LinkEditModal = (): JSX.Element => {
     // User can't use both relativePath and permalink at the same time
     setIsUseRelativePath(!isUseRelativePath);
     setIsUsePermanentLink(false);
-  };
+  }, [linkInputValue, linkerType, isUseRelativePath]);
 
-  const toggleIsUsePamanentLink = () => {
+  const toggleIsUsePamanentLink = useCallback(() => {
     if (permalink === '' || linkerType === Linker.types.growiLink) {
       return;
     }
@@ -124,9 +127,9 @@ export const LinkEditModal = (): JSX.Element => {
     // User can't use both relativePath and permalink at the same time
     setIsUsePermanentLink(!isUsePermanentLink);
     setIsUseRelativePath(false);
-  };
+  }, [permalink, linkerType, isUsePermanentLink]);
 
-  const setMarkdownHandler = async() => {
+  const setMarkdownHandler = useCallback(async() => {
     const path = linkInputValue;
     let markdown = '';
     let pagePath = '';
@@ -155,9 +158,9 @@ export const LinkEditModal = (): JSX.Element => {
     setMarkdown(markdown);
     setPagePath(pagePath);
     setPermalink(permalink);
-  };
+  }, [linkInputValue, t]);
 
-  const generateLink = () => {
+  const generateLink = useCallback(() => {
 
     let reshapedLink = linkInputValue;
     if (isUseRelativePath) {
@@ -170,9 +173,9 @@ export const LinkEditModal = (): JSX.Element => {
     }
 
     return new Linker(linkerType, labelInputValue, reshapedLink);
-  };
+  }, [linkInputValue, isUseRelativePath, getRootPath, linkerType, isUsePermanentLink, permalink, labelInputValue]);
 
-  const renderLinkPreview = (): JSX.Element => {
+  const renderLinkPreview = (): React.JSX.Element => {
     const linker = generateLink();
     return (
       <div className="d-flex justify-content-between mb-3 flex-column flex-sm-row">
@@ -196,7 +199,7 @@ export const LinkEditModal = (): JSX.Element => {
     );
   };
 
-  const handleChangeTypeahead = (selected) => {
+  const handleChangeTypeahead = useCallback((selected) => {
     const pageWithMeta = selected[0];
     if (pageWithMeta != null) {
       const page = pageWithMeta.data;
@@ -204,13 +207,13 @@ export const LinkEditModal = (): JSX.Element => {
       setLinkInputValue(page.path);
       setPermalink(permalink);
     }
-  };
+  }, []);
 
-  const handleChangeLabelInput = (label: string) => {
+  const handleChangeLabelInput = useCallback((label: string) => {
     setLabelInputValue(label);
-  };
+  }, []);
 
-  const handleChangeLinkInput = (link) => {
+  const handleChangeLinkInput = useCallback((link) => {
     let useRelativePath = isUseRelativePath;
     if (!linkInputValue.startsWith('/') || linkerType === Linker.types.growiLink) {
       useRelativePath = false;
@@ -219,9 +222,9 @@ export const LinkEditModal = (): JSX.Element => {
     setIsUseRelativePath(useRelativePath);
     setIsUsePermanentLink(false);
     setPermalink('');
-  };
+  }, [linkInputValue, isUseRelativePath, linkerType]);
 
-  const save = () => {
+  const save = useCallback(() => {
     const linker = generateLink();
 
     if (linkEditModalStatus?.onSave != null) {
@@ -229,17 +232,17 @@ export const LinkEditModal = (): JSX.Element => {
     }
 
     close();
-  };
+  }, [generateLink, linkEditModalStatus, close]);
 
-  const toggleIsPreviewOpen = async() => {
+  const toggleIsPreviewOpen = useCallback(async() => {
     // open popover
     if (!isPreviewOpen) {
       setMarkdownHandler();
     }
     setIsPreviewOpen(!isPreviewOpen);
-  };
+  }, [isPreviewOpen, setMarkdownHandler]);
 
-  const renderLinkAndLabelForm = (): JSX.Element => {
+  const renderLinkAndLabelForm = (): React.JSX.Element => {
     return (
       <>
         <h3 className="grw-modal-head">{t('link_edit.set_link_and_label')}</h3>
@@ -295,7 +298,7 @@ export const LinkEditModal = (): JSX.Element => {
     );
   };
 
-  const renderPathFormatForm = (): JSX.Element => {
+  const renderPathFormatForm = (): React.JSX.Element => {
     return (
       <div className="card custom-card pt-3">
         <form className="mb-0">
@@ -335,12 +338,8 @@ export const LinkEditModal = (): JSX.Element => {
     );
   };
 
-  if (linkEditModalStatus == null) {
-    return <></>;
-  }
-
   return (
-    <Modal className="link-edit-modal" isOpen={linkEditModalStatus.isOpened} toggle={close} size="lg" autoFocus={false}>
+    <>
       <ModalHeader tag="h4" toggle={close}>
         {t('link_edit.edit_link')}
       </ModalHeader>
@@ -368,6 +367,22 @@ export const LinkEditModal = (): JSX.Element => {
           {t('Done')}
         </button>
       </ModalFooter>
+    </>
+  );
+};
+
+/**
+ * LinkEditModal - Container component (lightweight, always rendered)
+ */
+export const LinkEditModal = (): React.JSX.Element => {
+  const linkEditModalStatus = useLinkEditModalStatus();
+  const { close } = useLinkEditModalActions();
+
+  const isOpened = linkEditModalStatus?.isOpened ?? false;
+
+  return (
+    <Modal className="link-edit-modal" isOpen={isOpened} toggle={close} size="lg" autoFocus={false}>
+      {isOpened && <LinkEditModalSubstance />}
     </Modal>
   );
 };

+ 36 - 16
apps/app/src/client/components/PagePresentationModal.tsx

@@ -1,4 +1,4 @@
-import React, { useCallback, type JSX } from 'react';
+import React, { useCallback } from 'react';
 
 import type { PresentationProps } from '@growi/presentation/dist/client';
 import { useSlidesByFrontmatter } from '@growi/presentation/dist/services';
@@ -30,10 +30,11 @@ const Presentation = dynamic<PresentationProps>(() => import('./Presentation/Pre
   ),
 });
 
+/**
+ * PagePresentationModalSubstance - Heavy processing component (rendered only when modal is open)
+ */
+const PagePresentationModalSubstance: React.FC = () => {
 
-const PagePresentationModal = (): JSX.Element => {
-
-  const presentationModalData = usePresentationModalStatus();
   const { close: closePresentationModal } = usePresentationModalActions();
 
   const { isDarkMode } = useNextThemes();
@@ -64,19 +65,8 @@ const PagePresentationModal = (): JSX.Element => {
     closePresentationModal();
   }, [fullscreen, closePresentationModal]);
 
-  const isOpen = presentationModalData?.isOpened ?? false;
-
-  if (!isOpen) {
-    return <></>;
-  }
-
   return (
-    <Modal
-      isOpen={isOpen}
-      toggle={closeHandler}
-      data-testid="page-presentation-modal"
-      className={moduleClass}
-    >
+    <>
       <div className="grw-presentation-controls d-flex">
         <button
           className="btn material-symbols-outlined"
@@ -106,6 +96,36 @@ const PagePresentationModal = (): JSX.Element => {
           </Presentation>
         ) }
       </ModalBody>
+    </>
+  );
+};
+
+/**
+ * PagePresentationModal - Container component (lightweight, always rendered)
+ */
+const PagePresentationModal = (): React.JSX.Element => {
+  const presentationModalData = usePresentationModalStatus();
+  const { close: closePresentationModal } = usePresentationModalActions();
+
+  const fullscreen = useFullScreen();
+
+  const closeHandler = useCallback(() => {
+    if (fullscreen.active) {
+      fullscreen.exit();
+    }
+    closePresentationModal();
+  }, [fullscreen, closePresentationModal]);
+
+  const isOpen = presentationModalData?.isOpened ?? false;
+
+  return (
+    <Modal
+      isOpen={isOpen}
+      toggle={closeHandler}
+      data-testid="page-presentation-modal"
+      className={moduleClass}
+    >
+      {isOpen && <PagePresentationModalSubstance />}
     </Modal>
   );
 };

+ 28 - 10
apps/app/src/client/components/PageRenameModal.tsx

@@ -1,5 +1,5 @@
 import React, {
-  useState, useEffect, useCallback, useMemo, type JSX,
+  useState, useEffect, useCallback, useMemo,
 } from 'react';
 
 import { isIPageInfoForEntity } from '@growi/core';
@@ -26,8 +26,10 @@ const isV5Compatible = (meta: unknown): boolean => {
   return isIPageInfoForEntity(meta) ? meta.isV5Compatible : true;
 };
 
-
-const PageRenameModal = (): JSX.Element => {
+/**
+ * PageRenameModalSubstance - Heavy processing component (rendered only when modal is open)
+ */
+const PageRenameModalSubstance: React.FC = () => {
   const { t } = useTranslation();
 
   const { isUsersHomepage } = pagePathUtils;
@@ -79,15 +81,19 @@ const PageRenameModal = (): JSX.Element => {
     }
   }, [isOpened, page, updateSubordinatedList]);
 
+  // Memoize computed values
+  const isTargetPageDuplicate = useMemo(() => existingPaths.includes(pageNameInput), [existingPaths, pageNameInput]);
+  const isV5CompatiblePage = useMemo(() => (page != null ? isV5Compatible(page.meta) : true), [page]);
+
   const canRename = useMemo(() => {
     if (page == null || isMatchedWithUserHomepagePath || page.data.path === pageNameInput) {
       return false;
     }
-    if (isV5Compatible(page.meta)) {
+    if (isV5CompatiblePage) {
       return existingPaths.length === 0; // v5 data
     }
     return isRenameRecursively; // v4 data
-  }, [existingPaths.length, isMatchedWithUserHomepagePath, isRenameRecursively, page, pageNameInput]);
+  }, [existingPaths.length, isMatchedWithUserHomepagePath, isRenameRecursively, page, pageNameInput, isV5CompatiblePage]);
 
   const rename = useCallback(async() => {
     if (page == null || !canRename) {
@@ -175,10 +181,10 @@ const PageRenameModal = (): JSX.Element => {
    * change pageNameInput
    * @param {string} value
    */
-  function inputChangeHandler(value) {
+  const inputChangeHandler = useCallback((value) => {
     setErrs(null);
     setPageNameInput(value);
-  }
+  }, []);
 
   useEffect(() => {
     if (isOpened || page == null) {
@@ -205,7 +211,6 @@ const PageRenameModal = (): JSX.Element => {
     }
 
     const { path } = page.data;
-    const isTargetPageDuplicate = existingPaths.includes(pageNameInput);
 
     return (
       <>
@@ -348,9 +353,8 @@ const PageRenameModal = (): JSX.Element => {
     );
   };
 
-
   return (
-    <Modal size="lg" isOpen={isOpened} toggle={closeRenameModal} data-testid="page-rename-modal" autoFocus={false}>
+    <>
       <ModalHeader tag="h4" toggle={closeRenameModal}>
         { t('modal_rename.label.Move/Rename page') }
       </ModalHeader>
@@ -360,6 +364,20 @@ const PageRenameModal = (): JSX.Element => {
       <ModalFooter>
         {footerContent()}
       </ModalFooter>
+    </>
+  );
+};
+
+/**
+ * PageRenameModal - Container component (lightweight, always rendered)
+ */
+const PageRenameModal = (): React.JSX.Element => {
+  const { isOpened } = usePageRenameModalStatus();
+  const { close: closeRenameModal } = usePageRenameModalActions();
+
+  return (
+    <Modal size="lg" isOpen={isOpened} toggle={closeRenameModal} data-testid="page-rename-modal" autoFocus={false}>
+      {isOpened && <PageRenameModalSubstance />}
     </Modal>
   );
 };

+ 53 - 20
apps/app/src/client/components/PrivateLegacyPagesMigrationModal.tsx

@@ -1,4 +1,4 @@
-import React, { useState, type JSX } from 'react';
+import React, { useState, useCallback, useMemo } from 'react';
 
 import { useTranslation } from 'next-i18next';
 import {
@@ -6,25 +6,34 @@ import {
 } from 'reactstrap';
 
 import { apiv3Post } from '~/client/util/apiv3-client';
+import type { ILegacyPrivatePage, PrivateLegacyPagesMigrationModalSubmitedHandler } from '~/states/ui/modal/private-legacy-pages-migration';
 import { usePrivateLegacyPagesMigrationModalActions, usePrivateLegacyPagesMigrationModalStatus } from '~/states/ui/modal/private-legacy-pages-migration';
 
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 
 
-export const PrivateLegacyPagesMigrationModal = (): JSX.Element => {
-  const { t } = useTranslation();
-
-  const status = usePrivateLegacyPagesMigrationModalStatus();
-  const { close } = usePrivateLegacyPagesMigrationModalActions();
+/**
+ * PrivateLegacyPagesMigrationModalSubstance - Presentation component (all logic here)
+ */
+type PrivateLegacyPagesMigrationModalSubstanceProps = {
+  status: {
+    isOpened: boolean;
+    pages?: ILegacyPrivatePage[];
+    onSubmit?: PrivateLegacyPagesMigrationModalSubmitedHandler;
+  } | null;
+  close: () => void;
+};
 
-  const isOpened = status?.isOpened ?? false;
+const PrivateLegacyPagesMigrationModalSubstance = ({ status, close }: PrivateLegacyPagesMigrationModalSubstanceProps): React.JSX.Element => {
+  const { t } = useTranslation();
 
   const [isRecursively, setIsRecursively] = useState(true);
 
   // eslint-disable-next-line @typescript-eslint/no-unused-vars
   const [errs, setErrs] = useState<Error[] | null>(null);
 
-  async function submit() {
+  // Memoize submit handler
+  const submit = useCallback(async() => {
     if (status == null || status.pages == null || status.pages.length === 0) {
       return;
     }
@@ -44,9 +53,15 @@ export const PrivateLegacyPagesMigrationModal = (): JSX.Element => {
     catch (err) {
       setErrs([err]);
     }
-  }
+  }, [status, isRecursively]);
+
+  // Memoize checkbox handler
+  const handleRecursivelyChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
+    setIsRecursively(e.target.checked);
+  }, []);
 
-  function renderForm() {
+  // Memoize form rendering
+  const renderForm = useMemo(() => {
     return (
       <div className="form-check form-check-warning">
         <input
@@ -54,9 +69,7 @@ export const PrivateLegacyPagesMigrationModal = (): JSX.Element => {
           id="convertRecursively"
           type="checkbox"
           checked={isRecursively}
-          onChange={(e) => {
-            setIsRecursively(e.target.checked);
-          }}
+          onChange={handleRecursivelyChange}
         />
         <label className="form-label form-check-label" htmlFor="convertRecursively">
           { t('private_legacy_pages.modal.convert_recursively_label') }
@@ -64,17 +77,18 @@ export const PrivateLegacyPagesMigrationModal = (): JSX.Element => {
         </label>
       </div>
     );
-  }
+  }, [isRecursively, handleRecursivelyChange, t]);
 
-  const renderPageIds = () => {
+  // Memoize page IDs rendering
+  const renderPageIds = useMemo(() => {
     if (status != null && status.pages != null) {
       return status.pages.map(page => <div key={page.pageId}><code>{ page.path }</code></div>);
     }
     return <></>;
-  };
+  }, [status]);
 
   return (
-    <Modal size="lg" isOpen={isOpened} toggle={close}>
+    <div>
       <ModalHeader tag="h4" toggle={close}>
         { t('private_legacy_pages.modal.title') }
       </ModalHeader>
@@ -83,9 +97,9 @@ export const PrivateLegacyPagesMigrationModal = (): JSX.Element => {
           <label>{ t('private_legacy_pages.modal.converting_pages') }:</label><br />
           {/* Todo: change the way to show path on modal when too many pages are selected */}
           {/* https://redmine.weseek.co.jp/issues/82787 */}
-          {renderPageIds()}
+          {renderPageIds}
         </div>
-        {renderForm()}
+        {renderForm}
       </ModalBody>
       <ModalFooter>
         <ApiErrorMessageList errs={errs} />
@@ -94,7 +108,26 @@ export const PrivateLegacyPagesMigrationModal = (): JSX.Element => {
           { t('private_legacy_pages.modal.button_label') }
         </button>
       </ModalFooter>
-    </Modal>
+    </div>
+  );
+};
+
+/**
+ * PrivateLegacyPagesMigrationModal - Container component (lightweight, always rendered)
+ */
+export const PrivateLegacyPagesMigrationModal = (): React.JSX.Element => {
+  const status = usePrivateLegacyPagesMigrationModalStatus();
+  const { close } = usePrivateLegacyPagesMigrationModalActions();
+  const isOpened = status?.isOpened ?? false;
 
+  return (
+    <Modal size="lg" isOpen={isOpened} toggle={close}>
+      {isOpened && (
+        <PrivateLegacyPagesMigrationModalSubstance
+          status={status}
+          close={close}
+        />
+      )}
+    </Modal>
   );
 };

+ 85 - 68
apps/app/src/client/components/PutbackPageModal.jsx

@@ -1,7 +1,8 @@
-import React, { useState, useCallback } from 'react';
+import React, { useState, useCallback, useMemo } from 'react';
 
 
 import { useTranslation } from 'next-i18next';
+import PropTypes from 'prop-types';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
@@ -12,12 +13,10 @@ import { mutateAllPageInfo } from '~/stores/page';
 
 import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
 
-const PutBackPageModal = () => {
+const PutBackPageModalSubstance = ({ pageDataToRevert, closePutBackPageModal }) => {
   const { t } = useTranslation();
 
-  const pageDataToRevert = usePutBackPageModalStatus();
-  const { close: closePutBackPageModal } = usePutBackPageModalActions();
-  const { isOpened, page } = pageDataToRevert;
+  const { page } = pageDataToRevert;
   const { pageId, path } = page;
   const onPutBacked = pageDataToRevert.opts?.onPutBacked;
 
@@ -26,11 +25,11 @@ const PutBackPageModal = () => {
 
   const [isPutbackRecursively, setIsPutbackRecursively] = useState(true);
 
-  function changeIsPutbackRecursivelyHandler() {
+  const changeIsPutbackRecursivelyHandler = useCallback(() => {
     setIsPutbackRecursively(!isPutbackRecursively);
-  }
+  }, [isPutbackRecursively]);
 
-  async function putbackPageButtonHandler() {
+  const putbackPageButtonHandler = useCallback(async () => {
     setErrs(null);
 
     try {
@@ -53,81 +52,99 @@ const PutBackPageModal = () => {
       setTargetPath(err.data);
       setErrs([err]);
     }
-  }
-
-  const HeaderContent = () => {
-    if (!isOpened) {
-      return <></>;
-    }
-    return (
-      <>
-        <span className="material-symbols-outlined" aria-hidden="true">undo</span> { t('modal_putback.label.Put Back Page') }
-      </>
-    );
-  };
-
-  const BodyContent = () => {
-    if (!isOpened) {
-      return <></>;
-    }
-    return (
-      <>
-        <div>
-          <label className="form-label">{t('modal_putback.label.Put Back Page')}:</label><br />
-          <code>{path}</code>
-        </div>
-        <div className="form-check form-check-warning">
-          <input
-            className="form-check-input"
-            id="cbPutBackRecursively"
-            type="checkbox"
-            checked={isPutbackRecursively}
-            onChange={changeIsPutbackRecursivelyHandler}
-          />
-          <label htmlFor="cbPutBackRecursively" className="form-label form-check-label">
-            { t('modal_putback.label.recursively') }
-          </label>
-          <p className="form-text text-muted mt-0">
-            <code>{ path }</code>{ t('modal_putback.help.recursively') }
-          </p>
-        </div>
-      </>
-    );
-
-  };
-  const FooterContent = () => {
-    if (!isOpened) {
-      return <></>;
-    }
-    return (
-      <>
-        <ApiErrorMessageList errs={errs} targetPath={targetPath} />
-        <button type="button" className="btn btn-info" onClick={putbackPageButtonHandler} data-testid="put-back-execution-button">
-          <span className="material-symbols-outlined" aria-hidden="true">undo</span> { t('Put Back') }
-        </button>
-      </>
-    );
-  };
+  }, [pageId, isPutbackRecursively, onPutBacked, closePutBackPageModal]);
 
   const closeModalHandler = useCallback(() => {
     closePutBackPageModal();
     setErrs(null);
   }, [closePutBackPageModal]);
 
+  const headerContent = useMemo(() => (
+    <>
+      <span className="material-symbols-outlined" aria-hidden="true">undo</span> { t('modal_putback.label.Put Back Page') }
+    </>
+  ), [t]);
+
+  const bodyContent = useMemo(() => (
+    <>
+      <div>
+        <label className="form-label">{t('modal_putback.label.Put Back Page')}:</label><br />
+        <code>{path}</code>
+      </div>
+      <div className="form-check form-check-warning">
+        <input
+          className="form-check-input"
+          id="cbPutBackRecursively"
+          type="checkbox"
+          checked={isPutbackRecursively}
+          onChange={changeIsPutbackRecursivelyHandler}
+        />
+        <label htmlFor="cbPutBackRecursively" className="form-label form-check-label">
+          { t('modal_putback.label.recursively') }
+        </label>
+        <p className="form-text text-muted mt-0">
+          <code>{ path }</code>{ t('modal_putback.help.recursively') }
+        </p>
+      </div>
+    </>
+  ), [t, path, isPutbackRecursively, changeIsPutbackRecursivelyHandler]);
+
+  const footerContent = useMemo(() => (
+    <>
+      <ApiErrorMessageList errs={errs} targetPath={targetPath} />
+      <button type="button" className="btn btn-info" onClick={putbackPageButtonHandler} data-testid="put-back-execution-button">
+        <span className="material-symbols-outlined" aria-hidden="true">undo</span> { t('Put Back') }
+      </button>
+    </>
+  ), [errs, targetPath, putbackPageButtonHandler, t]);
+
   return (
-    <Modal isOpen={isOpened} toggle={closeModalHandler} data-testid="put-back-page-modal">
+    <>
       <ModalHeader tag="h4" toggle={closeModalHandler} className="text-info">
-        <HeaderContent />
+        {headerContent}
       </ModalHeader>
       <ModalBody>
-        <BodyContent />
+        {bodyContent}
       </ModalBody>
       <ModalFooter>
-        <FooterContent />
+        {footerContent}
       </ModalFooter>
-    </Modal>
+    </>
   );
+};
+
+PutBackPageModalSubstance.propTypes = {
+  pageDataToRevert: PropTypes.shape({
+    page: PropTypes.shape({
+      pageId: PropTypes.string,
+      path: PropTypes.string,
+    }),
+    opts: PropTypes.shape({
+      onPutBacked: PropTypes.func,
+    }),
+  }).isRequired,
+  closePutBackPageModal: PropTypes.func.isRequired,
+};
 
+const PutBackPageModal = () => {
+  const pageDataToRevert = usePutBackPageModalStatus();
+  const { close: closePutBackPageModal } = usePutBackPageModalActions();
+  const { isOpened } = pageDataToRevert;
+
+  const closeModalHandler = useCallback(() => {
+    closePutBackPageModal();
+  }, [closePutBackPageModal]);
+
+  return (
+    <Modal isOpen={isOpened} toggle={closeModalHandler} data-testid="put-back-page-modal">
+      {isOpened && (
+        <PutBackPageModalSubstance
+          pageDataToRevert={pageDataToRevert}
+          closePutBackPageModal={closePutBackPageModal}
+        />
+      )}
+    </Modal>
+  );
 };
 
 export default PutBackPageModal;

+ 32 - 22
apps/app/src/client/components/ShortcutsModal.tsx

@@ -1,4 +1,4 @@
-import React, { type JSX } from 'react';
+import React, { useMemo } from 'react';
 
 import { useTranslation } from 'next-i18next';
 import { Modal, ModalHeader, ModalBody } from 'reactstrap';
@@ -8,22 +8,22 @@ import { useShortcutsModalStatus, useShortcutsModalActions } from '~/states/ui/m
 import styles from './ShortcutsModal.module.scss';
 
 
-const ShortcutsModal = (): JSX.Element => {
+/**
+ * ShortcutsModalSubstance - Presentation component (heavy logic, rendered only when isOpen)
+ */
+const ShortcutsModalSubstance = (): React.JSX.Element => {
   const { t } = useTranslation();
-
-  const status = useShortcutsModalStatus();
   const { close } = useShortcutsModalActions();
 
-  const bodyContent = () => {
-    if (status == null || !status.isOpened) {
-      return <></>;
-    }
-
-    // add classes to cmd-key by OS
+  // Memoize OS-specific class
+  const additionalClassByOs = useMemo(() => {
     const platform = window.navigator.platform.toLowerCase();
     const isMac = (platform.indexOf('mac') > -1);
-    const additionalClassByOs = isMac ? 'mac' : 'win';
+    return isMac ? 'mac' : 'win';
+  }, []);
 
+  // Memoize body content (large static JSX)
+  const bodyContent = useMemo(() => {
     return (
       <div className="container">
         <div className="row">
@@ -399,22 +399,32 @@ const ShortcutsModal = (): JSX.Element => {
         </div>
       </div>
     );
-  };
+  }, [additionalClassByOs, t]);
 
   return (
     <>
-      { status != null && (
-        <Modal id="shortcuts-modal" size="lg" isOpen={status.isOpened} toggle={close} className={`shortcuts-modal ${styles['shortcuts-modal']}`}>
-          <ModalHeader tag="h4" toggle={close} className="px-4">
-            {t('Shortcuts')}
-          </ModalHeader>
-          <ModalBody className="p-md-4 mb-3 grw-modal-body-style overflow-auto">
-            {bodyContent()}
-          </ModalBody>
-        </Modal>
-      ) }
+      <ModalHeader tag="h4" toggle={close} className="px-4">
+        {t('Shortcuts')}
+      </ModalHeader>
+      <ModalBody className="p-md-4 mb-3 grw-modal-body-style overflow-auto">
+        {bodyContent}
+      </ModalBody>
     </>
   );
 };
 
+/**
+ * ShortcutsModal - Container component (lightweight, always rendered)
+ */
+const ShortcutsModal = (): React.JSX.Element => {
+  const status = useShortcutsModalStatus();
+  const { close } = useShortcutsModalActions();
+
+  return (
+    <Modal id="shortcuts-modal" size="lg" isOpen={status?.isOpened ?? false} toggle={close} className={`shortcuts-modal ${styles['shortcuts-modal']}`}>
+      {status?.isOpened && <ShortcutsModalSubstance />}
+    </Modal>
+  );
+};
+
 export default ShortcutsModal;

+ 43 - 12
apps/app/src/features/growi-plugin/client/Admin/components/PluginsExtensionPageContents/PluginDeleteModal.tsx

@@ -13,19 +13,28 @@ import {
 } from '../../states/modal/plugin-delete';
 import { useSWRxAdminPlugins } from '../../stores/admin-plugins';
 
-export const PluginDeleteModal: React.FC = () => {
+/**
+ * PluginDeleteModalSubstance - Presentation component (all logic here)
+ */
+type PluginDeleteModalSubstanceProps = {
+  id: string;
+  name: string;
+  url: string;
+  closeModal: () => void;
+};
+
+const PluginDeleteModalSubstance = ({
+  id,
+  name,
+  url,
+  closeModal,
+}: PluginDeleteModalSubstanceProps): React.JSX.Element => {
   const { t } = useTranslation('admin');
   const { mutate } = useSWRxAdminPlugins();
-  const pluginDeleteModalData = usePluginDeleteModalStatus();
-  const { close: closePluginDeleteModal } = usePluginDeleteModalActions();
-  const isOpen = pluginDeleteModalData.isOpened;
-  const id = pluginDeleteModalData.id;
-  const name = pluginDeleteModalData.name;
-  const url = pluginDeleteModalData.url;
 
   const toggleHandler = useCallback(() => {
-    closePluginDeleteModal();
-  }, [closePluginDeleteModal]);
+    closeModal();
+  }, [closeModal]);
 
   const onClickDeleteButtonHandler = useCallback(async () => {
     const reqUrl = `/plugins/${id}/remove`;
@@ -33,16 +42,16 @@ export const PluginDeleteModal: React.FC = () => {
     try {
       const res = await apiv3Delete(reqUrl);
       const pluginName = res.data.pluginName;
-      closePluginDeleteModal();
+      closeModal();
       toastSuccess(t('toaster.remove_plugin_success', { pluginName }));
       mutate();
     } catch (err) {
       toastError(err);
     }
-  }, [id, closePluginDeleteModal, t, mutate]);
+  }, [id, closeModal, t, mutate]);
 
   return (
-    <Modal isOpen={isOpen} toggle={toggleHandler}>
+    <div>
       <ModalHeader
         tag="h4"
         toggle={toggleHandler}
@@ -67,6 +76,28 @@ export const PluginDeleteModal: React.FC = () => {
           {t('Delete')}
         </Button>
       </ModalFooter>
+    </div>
+  );
+};
+
+/**
+ * PluginDeleteModal - Container component (lightweight, always rendered)
+ */
+export const PluginDeleteModal: React.FC = () => {
+  const pluginDeleteModalData = usePluginDeleteModalStatus();
+  const { close: closeModal } = usePluginDeleteModalActions();
+  const isOpen = pluginDeleteModalData.isOpened;
+
+  return (
+    <Modal isOpen={isOpen} toggle={closeModal}>
+      {isOpen && pluginDeleteModalData.id != null && (
+        <PluginDeleteModalSubstance
+          id={pluginDeleteModalData.id}
+          name={pluginDeleteModalData.name ?? ''}
+          url={pluginDeleteModalData.url ?? ''}
+          closeModal={closeModal}
+        />
+      )}
     </Modal>
   );
 };

+ 33 - 19
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/SelectUserGroupModal.tsx

@@ -1,4 +1,4 @@
-import React, { useCallback } from 'react';
+import React, { useCallback, useMemo } from 'react';
 
 import { GroupType } from '@growi/core';
 import { useTranslation } from 'react-i18next';
@@ -31,21 +31,29 @@ const SelectUserGroupModalSubstance: React.FC<Props> = (props: Props) => {
     return selectedUserGroupIds.includes(targetUserGroup.item._id);
   }, [selectedUserGroups]);
 
+  // Memoize user group list
+  const userGroupList = useMemo(() => {
+    if (userRelatedGroups == null) {
+      return null;
+    }
+    return userRelatedGroups.map(userGroup => (
+      <button
+        className="btn btn-outline-primary d-flex justify-content-start mb-3 mx-4 align-items-center p-3"
+        type="button"
+        key={userGroup.item._id}
+        onClick={() => onSelect(userGroup)}
+      >
+        <input type="checkbox" checked={checked(userGroup)} onChange={() => {}} />
+        <p className="ms-3 mb-0">{userGroup.item.name}</p>
+        {userGroup.type === GroupType.externalUserGroup && <span className="ms-2 badge badge-pill badge-info">{userGroup.item.provider}</span>}
+        {/* TODO: Replace <div className="small">(TBD) List group members</div> */}
+      </button>
+    ));
+  }, [userRelatedGroups, onSelect, checked]);
+
   return (
     <ModalBody className="d-flex flex-column">
-      {userRelatedGroups != null && userRelatedGroups.map(userGroup => (
-        <button
-          className="btn btn-outline-primary d-flex justify-content-start mb-3 mx-4 align-items-center p-3"
-          type="button"
-          key={userGroup.item._id}
-          onClick={() => onSelect(userGroup)}
-        >
-          <input type="checkbox" checked={checked(userGroup)} onChange={() => {}} />
-          <p className="ms-3 mb-0">{userGroup.item.name}</p>
-          {userGroup.type === GroupType.externalUserGroup && <span className="ms-2 badge badge-pill badge-info">{userGroup.item.provider}</span>}
-          {/* TODO: Replace <div className="small">(TBD) List group members</div> */}
-        </button>
-      ))}
+      {userGroupList}
       <button
         type="button"
         className="btn btn-primary mt-2 mx-auto"
@@ -58,17 +66,23 @@ const SelectUserGroupModalSubstance: React.FC<Props> = (props: Props) => {
   );
 };
 
+/**
+ * SelectUserGroupModal - Container component (lightweight, always rendered)
+ */
 export const SelectUserGroupModal: React.FC<Props> = (props) => {
   const { t } = useTranslation();
-
   const { isOpen, closeModal } = props;
 
   return (
     <Modal isOpen={isOpen} toggle={closeModal}>
-      <ModalHeader toggle={closeModal}>
-        {t('user_group.select_group')}
-      </ModalHeader>
-      <SelectUserGroupModalSubstance {...props} />
+      {isOpen && (
+        <>
+          <ModalHeader toggle={closeModal}>
+            {t('user_group.select_group')}
+          </ModalHeader>
+          <SelectUserGroupModalSubstance {...props} />
+        </>
+      )}
     </Modal>
   );
 };

+ 56 - 20
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/ShareScopeWarningModal.tsx

@@ -1,4 +1,4 @@
-import React, { useCallback, type JSX } from 'react';
+import React, { useCallback, useMemo } from 'react';
 
 import { useTranslation } from 'react-i18next';
 import {
@@ -7,20 +7,20 @@ import {
 
 import type { SelectablePage } from '../../../../interfaces/selectable-page';
 
-type Props = {
-  isOpen: boolean,
-  selectedPages: SelectablePage[],
-  closeModal: () => void,
-  onSubmit: () => Promise<void>,
-}
+/**
+ * ShareScopeWarningModalSubstance - Presentation component (all logic here)
+ */
+type ShareScopeWarningModalSubstanceProps = {
+  selectedPages: SelectablePage[];
+  closeModal: () => void;
+  onSubmit: () => Promise<void>;
+};
 
-export const ShareScopeWarningModal = (props: Props): JSX.Element => {
-  const {
-    isOpen,
-    selectedPages,
-    closeModal,
-    onSubmit,
-  } = props;
+const ShareScopeWarningModalSubstance = ({
+  selectedPages,
+  closeModal,
+  onSubmit,
+}: ShareScopeWarningModalSubstanceProps): React.JSX.Element => {
 
   const { t } = useTranslation();
 
@@ -29,8 +29,17 @@ export const ShareScopeWarningModal = (props: Props): JSX.Element => {
     onSubmit();
   }, [closeModal, onSubmit]);
 
+  // Memoize selected pages list
+  const selectedPagesList = useMemo(() => {
+    return selectedPages.map(selectedPage => (
+      <code key={selectedPage.path}>
+        {selectedPage.path}
+      </code>
+    ));
+  }, [selectedPages]);
+
   return (
-    <Modal size="lg" isOpen={isOpen} toggle={closeModal}>
+    <div>
       <ModalHeader toggle={closeModal}>
         <div className="d-flex align-items-center">
           <span className="material-symbols-outlined text-warning me-2 fs-4">warning</span>
@@ -47,11 +56,7 @@ export const ShareScopeWarningModal = (props: Props): JSX.Element => {
 
         <div className="mb-4">
           <p className="mb-2 text-secondary">{t('share_scope_warning_modal.selected_pages_label')}</p>
-          {selectedPages.map(selectedPage => (
-            <code key={selectedPage.path}>
-              {selectedPage.path}
-            </code>
-          ))}
+          {selectedPagesList}
         </div>
 
         <p>
@@ -76,6 +81,37 @@ export const ShareScopeWarningModal = (props: Props): JSX.Element => {
           {t('share_scope_warning_modal.button.proceed')}
         </button>
       </ModalFooter>
+    </div>
+  );
+};
+
+/**
+ * ShareScopeWarningModal - Container component (lightweight, always rendered)
+ */
+type Props = {
+  isOpen: boolean;
+  selectedPages: SelectablePage[];
+  closeModal: () => void;
+  onSubmit: () => Promise<void>;
+};
+
+export const ShareScopeWarningModal = (props: Props): React.JSX.Element => {
+  const {
+    isOpen,
+    selectedPages,
+    closeModal,
+    onSubmit,
+  } = props;
+
+  return (
+    <Modal size="lg" isOpen={isOpen} toggle={closeModal}>
+      {isOpen && (
+        <ShareScopeWarningModalSubstance
+          selectedPages={selectedPages}
+          closeModal={closeModal}
+          onSubmit={onSubmit}
+        />
+      )}
     </Modal>
   );
 };

+ 58 - 40
apps/app/src/features/openai/client/components/AiAssistant/Sidebar/DeleteAiAssistantModal.tsx

@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useMemo } from 'react';
 
 import { useTranslation } from 'next-i18next';
 import {
@@ -15,58 +15,76 @@ export type DeleteAiAssistantModalProps = {
   onConfirm: () => void;
 };
 
-export const DeleteAiAssistantModal: React.FC<DeleteAiAssistantModalProps> = ({
-  isShown, aiAssistant, errorMessage, onCancel, onConfirm,
+/**
+ * DeleteAiAssistantModalSubstance - Presentation component (heavy logic, rendered only when isOpen)
+ */
+type DeleteAiAssistantModalSubstanceProps = {
+  errorMessage?: string;
+  onCancel: () => void;
+  onConfirm: () => void;
+};
+
+const DeleteAiAssistantModalSubstance: React.FC<DeleteAiAssistantModalSubstanceProps> = ({
+  errorMessage, onCancel, onConfirm,
 }) => {
   const { t } = useTranslation();
 
-  const headerContent = () => {
-    if (!isShown || aiAssistant == null) {
-      return null;
-    }
-    return (
-      <>
-        <span className="material-symbols-outlined me-1">delete_forever</span>
-        <span className="fw-bold">{t('ai_assistant_substance.delete_modal.title')}</span>
-      </>
-    );
-  };
+  // Memoize header content
+  const headerContent = useMemo(() => (
+    <>
+      <span className="material-symbols-outlined me-1">delete_forever</span>
+      <span className="fw-bold">{t('ai_assistant_substance.delete_modal.title')}</span>
+    </>
+  ), [t]);
 
-  const bodyContent = () => {
-    if (!isShown || aiAssistant == null) {
-      return null;
-    }
-    return <p className="fw-bold mb-0">{t('ai_assistant_substance.delete_modal.confirm_message')}</p>;
-  };
+  // Memoize body content
+  const bodyContent = useMemo(() => (
+    <p className="fw-bold mb-0">{t('ai_assistant_substance.delete_modal.confirm_message')}</p>
+  ), [t]);
 
-  const footerContent = () => {
-    if (!isShown || aiAssistant == null) {
-      return null;
-    }
-    return (
-      <>
-        {errorMessage && <span className="text-danger">{errorMessage}</span>}
-        <Button color="outline-neutral-secondary" onClick={onCancel}>
-          {t('Cancel')}
-        </Button>
-        <Button color="danger" onClick={onConfirm}>
-          {t('Delete')}
-        </Button>
-      </>
-    );
-  };
+  // Memoize footer content
+  const footerContent = useMemo(() => (
+    <>
+      {errorMessage && <span className="text-danger">{errorMessage}</span>}
+      <Button color="outline-neutral-secondary" onClick={onCancel}>
+        {t('Cancel')}
+      </Button>
+      <Button color="danger" onClick={onConfirm}>
+        {t('Delete')}
+      </Button>
+    </>
+  ), [errorMessage, onCancel, onConfirm, t]);
 
   return (
-    <Modal isOpen={isShown} toggle={onCancel} centered>
+    <>
       <ModalHeader tag="h5" toggle={onCancel} className="text-danger px-4">
-        {headerContent()}
+        {headerContent}
       </ModalHeader>
       <ModalBody className="px-4">
-        {bodyContent()}
+        {bodyContent}
       </ModalBody>
       <ModalFooter className="px-4 gap-2">
-        {footerContent()}
+        {footerContent}
       </ModalFooter>
+    </>
+  );
+};
+
+/**
+ * DeleteAiAssistantModal - Container component (lightweight, always rendered)
+ */
+export const DeleteAiAssistantModal: React.FC<DeleteAiAssistantModalProps> = ({
+  isShown, aiAssistant, errorMessage, onCancel, onConfirm,
+}) => {
+  return (
+    <Modal isOpen={isShown} toggle={onCancel} centered>
+      {isShown && aiAssistant != null && (
+        <DeleteAiAssistantModalSubstance
+          errorMessage={errorMessage}
+          onCancel={onCancel}
+          onConfirm={onConfirm}
+        />
+      )}
     </Modal>
   );
 };

+ 2 - 7
apps/app/src/features/page-bulk-export/client/components/PageBulkExportSelectModal.tsx

@@ -162,14 +162,9 @@ const PageBulkExportSelectModalSubstance = (): JSX.Element => {
 const PageBulkExportSelectModal = (): JSX.Element => {
   const status = usePageBulkExportSelectModalStatus();
 
-  // Early return for performance optimization
-  if (!status?.isOpened) {
-    return <></>;
-  }
-
   return (
-    <Modal isOpen={status.isOpened} size="lg">
-      <PageBulkExportSelectModalSubstance />
+    <Modal isOpen={status?.isOpened ?? false} size="lg">
+      {status?.isOpened && <PageBulkExportSelectModalSubstance />}
     </Modal>
   );
 };

+ 1 - 6
apps/app/src/features/search/client/components/SearchModal.tsx

@@ -177,11 +177,6 @@ const SearchModal = (): JSX.Element => {
     [onSearchOverride, setSearchKeyword],
   );
 
-  // Early return for performance optimization
-  if (!isOpened) {
-    return <></>;
-  }
-
   return (
     <Modal
       size="lg"
@@ -189,7 +184,7 @@ const SearchModal = (): JSX.Element => {
       toggle={closeSearchModal}
       data-testid="search-modal"
     >
-      <SearchModalSubstance onSearch={searchHandler} />
+      {isOpened && <SearchModalSubstance onSearch={searchHandler} />}
     </Modal>
   );
 };

+ 28 - 19
apps/app/src/features/search/client/components/SearchPage/SearchOptionModal.tsx

@@ -1,4 +1,4 @@
-import type { FC } from 'react';
+import { type FC, useCallback } from 'react';
 import { useTranslation } from 'next-i18next';
 import { Modal, ModalBody, ModalHeader } from 'reactstrap';
 
@@ -23,23 +23,30 @@ const SearchOptionModal: FC<Props> = (props: Props) => {
     onIncludeTrashPagesSwitched,
   } = props;
 
-  const onCloseModal = () => {
+  // Memoize event handlers
+  const onCloseModal = useCallback(() => {
     if (onClose != null) {
       onClose();
     }
-  };
+  }, [onClose]);
 
-  const includeUserPagesChangeHandler = (isChecked: boolean) => {
-    if (onIncludeUserPagesSwitched != null) {
-      onIncludeUserPagesSwitched(isChecked);
-    }
-  };
+  const includeUserPagesChangeHandler = useCallback(
+    (isChecked: boolean) => {
+      if (onIncludeUserPagesSwitched != null) {
+        onIncludeUserPagesSwitched(isChecked);
+      }
+    },
+    [onIncludeUserPagesSwitched],
+  );
 
-  const includeTrashPagesChangeHandler = (isChecked: boolean) => {
-    if (onIncludeTrashPagesSwitched != null) {
-      onIncludeTrashPagesSwitched(isChecked);
-    }
-  };
+  const includeTrashPagesChangeHandler = useCallback(
+    (isChecked: boolean) => {
+      if (onIncludeTrashPagesSwitched != null) {
+        onIncludeTrashPagesSwitched(isChecked);
+      }
+    },
+    [onIncludeTrashPagesSwitched],
+  );
 
   return (
     <Modal size="lg" isOpen={isOpen} toggle={onCloseModal} autoFocus={false}>
@@ -53,9 +60,10 @@ const SearchOptionModal: FC<Props> = (props: Props) => {
               <input
                 className="me-2"
                 type="checkbox"
-                onChange={(e) =>
-                  includeUserPagesChangeHandler(e.target.checked)
-                }
+                onChange={useCallback(
+                  (e) => includeUserPagesChangeHandler(e.target.checked),
+                  [includeUserPagesChangeHandler],
+                )}
                 checked={includeUserPages}
               />
               {t('Include Subordinated Target Page', { target: '/user' })}
@@ -66,9 +74,10 @@ const SearchOptionModal: FC<Props> = (props: Props) => {
               <input
                 className="me-2"
                 type="checkbox"
-                onChange={(e) =>
-                  includeTrashPagesChangeHandler(e.target.checked)
-                }
+                onChange={useCallback(
+                  (e) => includeTrashPagesChangeHandler(e.target.checked),
+                  [includeTrashPagesChangeHandler],
+                )}
                 checked={includeTrashPages}
               />
               {t('Include Subordinated Target Page', { target: '/trash' })}