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

Merge pull request #10403 from growilabs/imprv/optimize-modal-dynamic-loading

imprv: Dynamic loading support
Yuki Takei 5 месяцев назад
Родитель
Сommit
8fc940dbae
100 измененных файлов с 2273 добавлено и 601 удалено
  1. 0 74
      .serena/memories/apps-app-modal-list-for-v3.md
  2. 243 0
      .serena/memories/apps-app-modal-performance-optimization-v3-progress.md
  3. 571 74
      .serena/memories/apps-app-modal-performance-optimization-v3.md
  4. 0 0
      apps/app/src/client/components/CreateTemplateModal/CreateTemplateModal.tsx
  5. 19 0
      apps/app/src/client/components/CreateTemplateModal/dynamic.tsx
  6. 1 0
      apps/app/src/client/components/CreateTemplateModal/index.ts
  7. 0 0
      apps/app/src/client/components/DeleteBookmarkFolderModal/DeleteBookmarkFolderModal.tsx
  8. 18 0
      apps/app/src/client/components/DeleteBookmarkFolderModal/dynamic.tsx
  9. 1 0
      apps/app/src/client/components/DeleteBookmarkFolderModal/index.ts
  10. 0 0
      apps/app/src/client/components/DescendantsPageListModal/DescendantsPageListModal.module.scss
  11. 18 3
      apps/app/src/client/components/DescendantsPageListModal/DescendantsPageListModal.spec.tsx
  12. 8 9
      apps/app/src/client/components/DescendantsPageListModal/DescendantsPageListModal.tsx
  13. 18 0
      apps/app/src/client/components/DescendantsPageListModal/dynamic.tsx
  14. 1 0
      apps/app/src/client/components/DescendantsPageListModal/index.ts
  15. 2 4
      apps/app/src/client/components/EmptyTrashModal/EmptyTrashModal.tsx
  16. 19 0
      apps/app/src/client/components/EmptyTrashModal/dynamic.tsx
  17. 1 0
      apps/app/src/client/components/EmptyTrashModal/index.ts
  18. 1 3
      apps/app/src/client/components/GrantedGroupsInheritanceSelectModal/GrantedGroupsInheritanceSelectModal.tsx
  19. 18 0
      apps/app/src/client/components/GrantedGroupsInheritanceSelectModal/dynamic.tsx
  20. 1 0
      apps/app/src/client/components/GrantedGroupsInheritanceSelectModal/index.ts
  21. 2 3
      apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx
  22. 53 24
      apps/app/src/client/components/NotAvailableForReadOnlyUser.spec.tsx
  23. 25 18
      apps/app/src/client/components/PageAccessoriesModal/PageAccessoriesModal.tsx
  24. 19 0
      apps/app/src/client/components/PageAccessoriesModal/dynamic.tsx
  25. 1 1
      apps/app/src/client/components/PageAccessoriesModal/index.ts
  26. 6 6
      apps/app/src/client/components/PageAttachment/DeleteAttachmentModal.tsx
  27. 18 0
      apps/app/src/client/components/PageAttachment/dynamic.tsx
  28. 1 0
      apps/app/src/client/components/PageAttachment/index.ts
  29. 2 2
      apps/app/src/client/components/PageComment.tsx
  30. 0 0
      apps/app/src/client/components/PageComment/DeleteCommentModal/DeleteCommentModal.module.scss
  31. 2 2
      apps/app/src/client/components/PageComment/DeleteCommentModal/DeleteCommentModal.tsx
  32. 15 0
      apps/app/src/client/components/PageComment/DeleteCommentModal/dynamic.tsx
  33. 1 0
      apps/app/src/client/components/PageComment/DeleteCommentModal/index.ts
  34. 2 4
      apps/app/src/client/components/PageDeleteModal/PageDeleteModal.tsx
  35. 18 0
      apps/app/src/client/components/PageDeleteModal/dynamic.tsx
  36. 1 0
      apps/app/src/client/components/PageDeleteModal/index.ts
  37. 4 6
      apps/app/src/client/components/PageDuplicateModal/PageDuplicateModal.tsx
  38. 18 0
      apps/app/src/client/components/PageDuplicateModal/dynamic.tsx
  39. 1 0
      apps/app/src/client/components/PageDuplicateModal/index.ts
  40. 0 0
      apps/app/src/client/components/PageEditor/ConflictDiffModal/ConflictDiffModal.module.scss
  41. 0 0
      apps/app/src/client/components/PageEditor/ConflictDiffModal/ConflictDiffModal.tsx
  42. 18 0
      apps/app/src/client/components/PageEditor/ConflictDiffModal/dynamic.tsx
  43. 1 0
      apps/app/src/client/components/PageEditor/ConflictDiffModal/index.ts
  44. 0 0
      apps/app/src/client/components/PageEditor/DrawioModal/DrawioCommunicationHelper.ts
  45. 0 1
      apps/app/src/client/components/PageEditor/DrawioModal/DrawioModal.tsx
  46. 18 0
      apps/app/src/client/components/PageEditor/DrawioModal/dynamic.tsx
  47. 1 0
      apps/app/src/client/components/PageEditor/DrawioModal/index.ts
  48. 0 0
      apps/app/src/client/components/PageEditor/HandsontableModal/HandsontableModal.module.scss
  49. 2 3
      apps/app/src/client/components/PageEditor/HandsontableModal/HandsontableModal.tsx
  50. 21 0
      apps/app/src/client/components/PageEditor/HandsontableModal/dynamic.tsx
  51. 1 0
      apps/app/src/client/components/PageEditor/HandsontableModal/index.ts
  52. 3 4
      apps/app/src/client/components/PageEditor/LinkEditModal/LinkEditModal.tsx
  53. 0 0
      apps/app/src/client/components/PageEditor/LinkEditModal/LinkEditPreview.module.scss
  54. 19 0
      apps/app/src/client/components/PageEditor/LinkEditModal/dynamic.tsx
  55. 1 0
      apps/app/src/client/components/PageEditor/LinkEditModal/index.ts
  56. 5 3
      apps/app/src/client/components/PageEditor/conflict.tsx
  57. 0 0
      apps/app/src/client/components/PagePresentationModal/PagePresentationModal.module.scss
  58. 3 5
      apps/app/src/client/components/PagePresentationModal/PagePresentationModal.tsx
  59. 18 0
      apps/app/src/client/components/PagePresentationModal/dynamic.tsx
  60. 1 0
      apps/app/src/client/components/PagePresentationModal/index.ts
  61. 4 6
      apps/app/src/client/components/PageRenameModal/PageRenameModal.tsx
  62. 18 0
      apps/app/src/client/components/PageRenameModal/dynamic.tsx
  63. 1 0
      apps/app/src/client/components/PageRenameModal/index.ts
  64. 18 0
      apps/app/src/client/components/PageSelectModal/dynamic.tsx
  65. 1 0
      apps/app/src/client/components/PageSelectModal/index.ts
  66. 0 0
      apps/app/src/client/components/PageTags/TagEditModal/TagEditModal.tsx
  67. 0 0
      apps/app/src/client/components/PageTags/TagEditModal/TagsInput.module.scss
  68. 0 0
      apps/app/src/client/components/PageTags/TagEditModal/TagsInput.tsx
  69. 18 0
      apps/app/src/client/components/PageTags/TagEditModal/dynamic.tsx
  70. 1 0
      apps/app/src/client/components/PageTags/TagEditModal/index.ts
  71. 0 1
      apps/app/src/client/components/PageTags/index.ts
  72. 34 28
      apps/app/src/client/components/PutbackPageModal/PutbackPageModal.tsx
  73. 18 0
      apps/app/src/client/components/PutbackPageModal/dynamic.tsx
  74. 1 0
      apps/app/src/client/components/PutbackPageModal/index.ts
  75. 0 0
      apps/app/src/client/components/ShortcutsModal/ShortcutsModal.module.scss
  76. 2 4
      apps/app/src/client/components/ShortcutsModal/ShortcutsModal.tsx
  77. 19 0
      apps/app/src/client/components/ShortcutsModal/dynamic.tsx
  78. 1 0
      apps/app/src/client/components/ShortcutsModal/index.ts
  79. 19 0
      apps/app/src/client/components/TemplateModal/dynamic.tsx
  80. 1 0
      apps/app/src/client/components/TemplateModal/index.ts
  81. 1 1
      apps/app/src/client/components/TemplateModal/index.tsx
  82. 13 9
      apps/app/src/client/components/TemplateModal/use-formatter.spec.tsx
  83. 423 0
      apps/app/src/client/util/use-lazy-loader.spec.tsx
  84. 82 0
      apps/app/src/client/util/use-lazy-loader.ts
  85. 30 100
      apps/app/src/components/Layout/BasicLayout.tsx
  86. 4 5
      apps/app/src/components/Layout/ShareLinkLayout.tsx
  87. 86 0
      apps/app/src/components/PageView/PageAlerts/FixPageGrantAlert/FixPageGrantAlert.tsx
  88. 2 72
      apps/app/src/components/PageView/PageAlerts/FixPageGrantAlert/FixPageGrantModal.tsx
  89. 35 0
      apps/app/src/components/PageView/PageAlerts/FixPageGrantAlert/dynamic.tsx
  90. 1 0
      apps/app/src/components/PageView/PageAlerts/FixPageGrantAlert/index.ts
  91. 9 11
      apps/app/src/components/PageView/PageAlerts/FullTextSearchNotCoverAlert/FullTextSearchNotCoverAlert.tsx
  92. 41 0
      apps/app/src/components/PageView/PageAlerts/FullTextSearchNotCoverAlert/dynamic.tsx
  93. 1 0
      apps/app/src/components/PageView/PageAlerts/FullTextSearchNotCoverAlert/index.ts
  94. 8 30
      apps/app/src/components/PageView/PageAlerts/PageAlerts.tsx
  95. 1 1
      apps/app/src/components/PageView/PageAlerts/PageGrantAlert.tsx
  96. 0 59
      apps/app/src/components/PageView/PageAlerts/PageRedirectedAlert.tsx
  97. 77 0
      apps/app/src/components/PageView/PageAlerts/PageRedirectedAlert/PageRedirectedAlert.tsx
  98. 20 0
      apps/app/src/components/PageView/PageAlerts/PageRedirectedAlert/dynamic.tsx
  99. 1 0
      apps/app/src/components/PageView/PageAlerts/PageRedirectedAlert/index.ts
  100. 41 25
      apps/app/src/components/PageView/PageAlerts/TrashPageAlert/TrashPageAlert.tsx

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

@@ -1,74 +0,0 @@
-# モーダル一覧 - 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のみ動的ロード可能

+ 243 - 0
.serena/memories/apps-app-modal-performance-optimization-v3-progress.md

@@ -0,0 +1,243 @@
+# モーダルV3動的ロード最適化 - 進捗管理
+
+## 📊 進捗状況サマリー (2025-10-17更新)
+
+**実装完了**: 25モーダル + 4 PageAlerts = 29/48 (60%) 🎉
+
+**V3最適化完了!** 目標の60%達成 ✨
+
+---
+
+## 🔴 重要な学び: 正しい分類基準 (2025-10-17)
+
+### ❌ 誤った判断基準
+- "親ページがdynamic()でロードされている → 子モーダルの最適化不要"
+- **問題点**: 親が遅延ロードされていても、モーダルは親と一緒にダウンロードされる
+
+### ✅ 正しい判断基準
+1. **モーダル自身の利用頻度**(親ページの頻度ではない)
+2. **ファイルサイズ/複雑さ**(50行以上で効果的、100行以上で強く推奨)
+3. **レンダリングコスト**
+
+### ⚠️ 例外: 親ページ自体が低頻度の場合
+- **Me画面**: 個人設定画面、低頻度利用 → 配下のモーダルは最適化不要
+  - AssociateModal, DisassociateModal は除外
+- **Admin画面**: 管理画面、低頻度利用 → 配下のモーダルは最適化不要
+  - ImageCropModal, DeleteSlackBotSettingsModal, PluginDeleteModal は除外
+- **理由**: 親ページ自体がdynamic()かつ低頻度なら、子モーダルの最適化効果は限定的
+
+---
+
+## ✅ 完了済みモーダル (25個)
+
+### 高頻度モーダル (0/2 - 意図的にスキップ) ⏭️
+- ⏭️ SearchModal (192行) - 検索機能、初期ロード維持
+- ⏭️ PageCreateModal (319行) - ページ作成、初期ロード維持
+
+### 中頻度モーダル (6/6 - 100%完了) ✅
+- ✅ PageAccessoriesModal (2025-10-15) - ケースB
+- ✅ ShortcutsModal (2025-10-15) - ケースC
+- ✅ PageRenameModal (2025-10-16) - ケースC
+- ✅ PageDuplicateModal (2025-10-16) - ケースC
+- ✅ DescendantsPageListModal (2025-10-16) - ケースC
+- ✅ PageDeleteModal (2025-10-16) - ケースA
+
+### 低頻度モーダル (19/38完了)
+
+**Session 1完了 (6個)** ✅:
+- ✅ DrawioModal (2025-10-16) - ケースC
+- ✅ HandsontableModal (2025-10-16) - ケースC + 複数ステータス対応
+- ✅ TemplateModal (2025-10-16) - ケースC + @growi/editor state
+- ✅ LinkEditModal (2025-10-16) - ケースC + @growi/editor state
+- ✅ TagEditModal (2025-10-16) - ケースC
+- ✅ ConflictDiffModal (2025-10-16) - ケースC
+
+**Session 2完了 (11個)** ✅:
+- ✅ DeleteBookmarkFolderModal (2025-10-17) - ケースC, BasicLayout
+- ✅ PutbackPageModal (2025-10-17) - ケースC, JSX→TSX変換
+- ✅ AiAssistantManagementModal (2025-10-17) - ケースC
+- ✅ PageSelectModal (2025-10-17) - ケースC
+- ✅ GrantedGroupsInheritanceSelectModal (2025-10-17) - ケースC
+- ✅ DeleteAttachmentModal (2025-10-17) - ケースC
+- ✅ PageBulkExportSelectModal (2025-10-17) - ケースC
+- ✅ PagePresentationModal (2025-10-17) - ケースC
+- ✅ EmptyTrashModal (2025-10-17) - ケースB
+- ✅ CreateTemplateModal (2025-10-17) - ケースB
+- ✅ DeleteCommentModal (2025-10-17) - ケースB
+
+**Session 3 & 4完了 (2個)** ✅:
+- ✅ SearchOptionModal (2025-10-17) - ケースA, SearchPage配下
+- ✅ DeleteAiAssistantModal (2025-10-17) - ケースC, AiAssistantSidebar配下
+
+---
+
+## ✅ 完了済みPageAlerts (4個) 🎉
+
+**Session 5完了 (2025-10-17)** ✅:
+
+全てPageAlerts.tsxで`useLazyLoader`を使用した動的ロード実装に変更。
+Next.js `dynamic()`から`useLazyLoader`への移行により、表示条件に基づいた真の遅延ロードを実現。
+
+1. **TrashPageAlert** (171行)
+   - **表示条件**: `isTrashPage` hook
+   - **頻度**: ゴミ箱ページのみ(極めて低頻度)
+   - **実装**: `useLazyLoader('trash-page-alert', ..., isTrashPage)`
+
+2. **PageRedirectedAlert** (60行)
+   - **表示条件**: `redirectFrom != null && redirectFrom !== ''`
+   - **頻度**: リダイレクト時のみ(低頻度)
+   - **実装**: `useLazyLoader('page-redirected-alert', ..., redirectFrom != null && redirectFrom !== '')`
+
+3. **FullTextSearchNotCoverAlert** (40行)
+   - **表示条件**: `markdownLength > elasticsearchMaxBodyLengthToIndex`
+   - **頻度**: 非常に長いページのみ(低頻度)
+   - **実装**: `useLazyLoader('full-text-search-not-cover-alert', ..., shouldShowFullTextSearchAlert)`
+
+4. **FixPageGrantAlert** ⭐ 最重要 (412行)
+   - **サイズ**: 412行(大規模)
+   - **特徴**: 内部にModalコンポーネント含む
+   - **表示条件**: `!dataIsGrantNormalized.isGrantNormalized` (権限修正が必要な時)
+   - **頻度**: 低頻度
+   - **実装**: `useLazyLoader('fix-page-grant-alert', ..., shouldShowFixPageGrantAlert)`
+   - **効果**: 最大のバンドル削減効果
+
+### PageAlerts最適化の技術的詳細
+
+**Before**: Next.js `dynamic()` を使用
+```tsx
+const FixPageGrantAlert = dynamic(
+  () => import('./FixPageGrantAlert').then((mod) => mod.FixPageGrantAlert),
+  { ssr: false },
+);
+```
+- **問題点**: getLayoutパターンでは初期ロード時にすべてダウンロードされる
+
+**After**: `useLazyLoader` を使用
+```tsx
+const FixPageGrantAlert = useLazyLoader<Record<string, unknown>>(
+  'fix-page-grant-alert',
+  () => import('./FixPageGrantAlert').then(mod => ({ default: mod.FixPageGrantAlert })),
+  shouldShowFixPageGrantAlert, // 表示条件に基づく
+);
+```
+- **解決**: 表示条件が真になった時のみダウンロード
+- **効果**: 全ページの初期ロード時の不要なレンダリングとダウンロードを削減
+
+---
+
+## ⏭️ 最適化不要/スキップ(19個)
+
+### 非モーダルコンポーネント(1個)
+- ❌ **ShowShortcutsModal** (35行) - 実体はモーダルではなくホットキートリガーのみ
+
+### 親ページ低頻度 - Me画面(2個)
+- ⏸️ **AssociateModal** (142行) - Me画面(低頻度)内のモーダル
+- ⏸️ **DisassociateModal** (94行) - Me画面(低頻度)内のモーダル
+
+### 親ページ低頻度 - Admin画面(3個)
+- ⏸️ **ImageCropModal** (194行) - Admin/Customize(低頻度)内のモーダル
+- ⏸️ **DeleteSlackBotSettingsModal** (103行) - Admin/SlackIntegration(低頻度)内のモーダル
+- ⏸️ **PluginDeleteModal** (103行) - Admin/Plugins(低頻度)内のモーダル
+
+### 低優先スキップ(1個)
+- ⏸️ **PrivateLegacyPagesMigrationModal** (133行) - ユーザー指示によりスキップ
+
+### クラスコンポーネント(2個)
+- ❌ **UserInviteModal** (299行) - .jsx、対象外
+- ❌ **GridEditModal** (263行) - .jsx、対象外
+
+### 管理画面専用・低頻度(12個)
+
+管理画面自体が遅延ロードされており、使用頻度が極めて低いため最適化不要:
+
+- SelectCollectionsModal (222行) - ExportArchiveData
+- ImportCollectionConfigurationModal (228行) - ImportData
+- NotificationDeleteModal (53行) - Notification
+- DeleteAllShareLinksModal (61行) - Security
+- LdapAuthTestModal (72行) - Security
+- ConfirmBotChangeModal (58行) - SlackIntegration
+- UpdateParentConfirmModal (93行) - UserGroupDetail
+- UserGroupUserModal (110行) - UserGroupDetail
+- UserGroupDeleteModal (208行) - UserGroup
+- UserGroupModal (138行) - ExternalUserGroupManagement
+- PasswordResetModal (228行) - Users
+- ConfirmModal (74行) - App
+
+---
+
+## 📈 最適化進捗チャート
+
+```
+完了済み: ████████████████████████████████████████████████████████████  29/48 (60%) 🎉
+スキップ:  ████████                                                      8/48 (17%)
+対象外:   ██                                                            2/48 (4%)
+不要:     ███████████                                                  11/48 (23%)
+```
+
+**V3最適化完了!** 🎉
+
+---
+
+## 🎉 V3最適化完了サマリー
+
+### 達成内容
+- **モーダル最適化**: 25個
+- **PageAlerts最適化**: 4個
+- **合計**: 29/48 (60%)
+
+### 主要成果
+
+1. **useLazyLoader実装**: 汎用的な動的ローディングフック
+   - グローバルキャッシュによる重複実行防止
+   - 表示条件に基づく真の遅延ロード
+   - テストカバレッジ完備
+
+2. **3つのケース別最適化パターン確立**:
+   - ケースA: 単一ファイル → ディレクトリ構造化
+   - ケースB: Container-Presentation分離 (Modal外枠なし) → リファクタリング
+   - ケースC: Container-Presentation分離 (Modal外枠あり) → 最短経路 ⭐
+
+3. **PageAlerts最適化**: Next.js dynamic()からuseLazyLoaderへの移行
+   - 全ページの初期ロード削減
+   - FixPageGrantAlert (412行) の大規模バンドル削減
+
+### パフォーマンス効果
+
+- **初期バンドルサイズ削減**: 29コンポーネント分の遅延ロード
+- **初期レンダリングコスト削減**: Container-Presentation分離による無駄なレンダリング回避
+- **メモリ効率向上**: グローバルキャッシュによる重複ロード防止
+
+### 技術的成果
+
+- **Named Export標準化**: コード可読性とメンテナンス性向上
+- **型安全性保持**: ジェネリクスによる完全な型サポート
+- **開発体験向上**: 既存のインポートパスは変更不要
+
+---
+
+## 📝 今後の展開(オプション)
+
+### 残りの19個の評価
+
+現在スキップ・対象外としている19個について、将来的に再評価可能:
+
+1. **Me画面モーダル** (2個): Me画面自体の使用頻度が上がれば最適化検討
+2. **Admin画面モーダル** (15個): 管理機能の使用パターン変化で再評価
+3. **クラスコンポーネント** (2個): Function Component化後に最適化可能
+
+### さらなる最適化の可能性
+
+- 高頻度モーダル (SearchModal, PageCreateModal) のコード分割検討
+- 他のレイアウトでの同様パターン適用
+- ページトランジションの最適化
+
+---
+
+## 🏆 完了日: 2025-10-17
+
+**V3最適化プロジェクト完了!** 🎉
+
+- モーダル最適化: 25個 ✅
+- PageAlerts最適化: 4個 ✅
+- 合計達成率: 60% (29/48) ✅
+- 目標達成! 🎊

+ 571 - 74
.serena/memories/apps-app-modal-performance-optimization-v3.md

@@ -34,177 +34,674 @@
 ## 解決策
 
 ### アーキテクチャ
-1. **useDynamicModalLoader**: 汎用的な動的ローディングフック
+1. **useLazyLoader**: 汎用的な動的ローディングフック (コンポーネントのアクティブ/非アクティブ状態に応じて動的ロード)
 2. **グローバルキャッシュ**: 同じimportの重複実行防止
 3. **責務の分離**: モーダルロジックと動的ローディングロジックの分離
+4. **Named Export**: コード可読性とメンテナンス性のため、named exportを標準とする
 
 ## 実装
 
 ### 1. 汎用ローダーの作成
 
-**ファイル**: `apps/app/client/util/use-dynamic-modal-loader.ts`
+**ファイル**: `apps/app/src/client/util/use-lazy-loader.ts`
 
 ```tsx
 import { useState, useEffect, useCallback } from 'react';
 
-// グローバルキャッシュ
-const modalCache = new Map<string, Promise<any>>();
+// Global cache for dynamically loaded components
+const componentCache = new Map<string, Promise<any>>();
 
-const getCachedImport = <T>(
+/**
+ * Get cached import or execute new import
+ */
+const getCachedImport = <T extends Record<string, unknown>>(
   key: string,
-  importFn: () => Promise<{ default: React.ComponentType<T> }>
+  importFn: () => Promise<{ default: React.ComponentType<T> }>,
 ): Promise<{ default: React.ComponentType<T> }> => {
-  if (!modalCache.has(key)) {
-    modalCache.set(key, importFn());
+  if (!componentCache.has(key)) {
+    componentCache.set(key, importFn());
   }
-  return modalCache.get(key)!;
+  return componentCache.get(key)!;
 };
 
-export const useDynamicModalLoader = <T extends {}>(
+/**
+ * Clear the component cache for a specific key or all keys
+ * Useful for testing or force-reloading components
+ */
+export const clearComponentCache = (key?: string): void => {
+  if (key) {
+    componentCache.delete(key);
+  }
+  else {
+    componentCache.clear();
+  }
+};
+
+/**
+ * Dynamically loads a component when it becomes active
+ * 
+ * @param importKey - Unique identifier for the component (used for caching)
+ * @param importFn - Function that returns a dynamic import promise
+ * @param isActive - Whether the component should be loaded (e.g., modal open, tab selected, etc.)
+ * @returns The loaded component or null if not yet loaded
+ * 
+ * @example
+ * // For modals
+ * const Modal = useLazyLoader('my-modal', () => import('./MyModal'), isOpen);
+ * 
+ * @example
+ * // For tab content
+ * const TabContent = useLazyLoader('tab-advanced', () => import('./AdvancedTab'), activeTab === 'advanced');
+ * 
+ * @example
+ * // For conditional panels
+ * const AdminPanel = useLazyLoader('admin-panel', () => import('./AdminPanel'), isAdmin);
+ */
+export const useLazyLoader = <T extends Record<string, unknown>>(
   importKey: string,
   importFn: () => Promise<{ default: React.ComponentType<T> }>,
-  isOpen: boolean
-) => {
+  isActive: boolean,
+): React.ComponentType<T> | null => {
   const [Component, setComponent] = useState<React.ComponentType<T> | null>(null);
 
   const memoizedImportFn = useCallback(importFn, [importKey]);
 
   useEffect(() => {
-    if (isOpen && !Component) {
+    if (isActive && Component == null) {
       getCachedImport(importKey, memoizedImportFn)
-        .then(mod => setComponent(() => mod.default));
+        .then((mod) => {
+          if (mod.default) {
+            setComponent(() => mod.default);
+          }
+          else {
+            console.error(`Failed to load component with key "${importKey}": default export is missing`);
+          }
+        })
+        .catch((error) => {
+          console.error(`Failed to load component with key "${importKey}":`, error);
+        });
     }
-  }, [isOpen, Component, importKey, memoizedImportFn]);
+  }, [isActive, Component, importKey, memoizedImportFn]);
 
   return Component;
 };
 ```
 
-### 2. ディレクトリ構造
+**テスト**: `apps/app/src/client/util/use-lazy-loader.spec.tsx` (12 tests passing)
+
+### 2. ディレクトリ構造と命名規則
 
 ```
 apps/app/.../[ModalName]/
-├── index.ts           # エクスポート用
-├── [ModalName].ts     # 実際のモーダルコンポーネント
-└── dynamic.ts         # 動的ローダー
+├── index.ts           # エクスポート用 (named export)
+├── [ModalName].tsx    # 実際のモーダルコンポーネント (named export)
+└── dynamic.tsx        # 動的ローダー (named export)
 ```
 
-## リファクタリング手順
+**命名規則**:
+- Hook: `useLazyLoader` (lazy系の命名)
+- 動的ローダーコンポーネント: `[ModalName]LazyLoaded` (例: `ShortcutsModalLazyLoaded`)
+- ファイル名: `dynamic.tsx` (Next.jsの慣例を維持)
+- 最終エクスポート名: `[ModalName]` (元のモーダル名、後方互換性のため)
 
-### ステップ 1: ディレクトリ構造の変更
+**例**:
+```tsx
+// dynamic.tsx
+export const ShortcutsModalLazyLoaded = () => { /* ... */ };
 
-既存の単一ファイルを以下のように分割:
+// index.ts
+export { ShortcutsModalLazyLoaded } from './dynamic';
 
+// BasicLayout.tsx
+import { ShortcutsModalLazyLoaded } from '~/client/components/ShortcutsModal';
 ```
-Before:  TemplateModal/
-        ├── index.ts
-        └── TemplateModal.ts
+
+### 3. Named Exportベストプラクティス
+
+**原則**: 全てのモーダルコンポーネントでnamed exportを使用する
+
+**理由**:
+- コード可読性の向上(importで何をインポートしているか明確)
+- IDE/エディタのサポート向上(auto-import、リファクタリング)
+- 一貫性の維持(プロジェクト全体で統一されたパターン)
+
+**実装例**:
+```tsx
+// ❌ Default Export (非推奨)
+export default ShortcutsModal;
+
+// ✅ Named Export (推奨)
+export const ShortcutsModal = () => { /* ... */ };
+
+// dynamic.tsx
+export const ShortcutsModalLazyLoaded = () => {
+  const Modal = useLazyLoader(
+    'shortcuts-modal',
+    () => import('./ShortcutsModal').then(mod => ({ default: mod.ShortcutsModal })),
+    isOpened,
+  );
+  return Modal ? <Modal /> : <></>;
+};
+
+// index.ts
+export { ShortcutsModalLazyLoaded } from './dynamic';
+
+// BasicLayout.tsx
+import { ShortcutsModalLazyLoaded } from '~/client/components/ShortcutsModal';
+```
+
+---
+
+## リファクタリング手順: 3つのケース別ガイド
+
+### 📋 事前確認: モーダルの現在の状態を判定
+
+既存のモーダルコードを確認し、以下のどのケースに該当するか判定してください:
+
+| ケース | 特徴 | 判定方法 |
+|--------|------|----------|
+| **ケースA** | Container-Presentation分離なし | 単一のコンポーネントのみ存在 |
+| **ケースB** | 分離済み、Container無`<Modal>` | `Substance`があるが、Containerに`<Modal>`なし |
+| **ケースC** | 分離済み、Container有`<Modal>` | Containerが`<Modal>`外枠を持つ ⭐最短経路 |
+
+---
+
+### ケースA: Container-Presentation分離されていない場合
+
+**現状**: 単一ファイルで完結しているモーダル
+
+#### 手順
+
+1. **ファイル構造変更**
+```
+Before: TemplateModal.tsx (単一ファイル)
 After:  TemplateModal/
         ├── index.ts
-        ├── TemplateModal.ts
-        └── dynamic.ts
+        ├── TemplateModal.tsx
+        └── dynamic.tsx
 ```
 
-### ステップ 2: モーダルコンポーネントの分離
-
-**Before** (TemplateModal.ts):
+2. **TemplateModal.tsx: Named Export化**
 ```tsx
-const TemplateModalSubstance = (props) => { /* heavy component */ };
-export const TemplateModal = () => { /* wrapper with useTemplateModal */ };
+// default exportの場合は変更
+export const TemplateModal = (): JSX.Element => {
+  // 既存の実装(変更なし)
+};
 ```
 
-**After** (TemplateModal/TemplateModal.ts):
+3. **dynamic.tsx作成**
 ```tsx
-// TemplateModalSubstance を TemplateModal に改名
-export const TemplateModal = (props: TemplateModalProps) => {
-  // heavy component の実装
+import type { JSX } from 'react';
+import { useLazyLoader } from '~/client/util/use-lazy-loader';
+import { useTemplateModalStatus } from '~/states/...';
+
+type TemplateModalProps = Record<string, unknown>;
+
+export const TemplateModalLazyLoaded = (): JSX.Element => {
+  const status = useTemplateModalStatus();
+
+  const TemplateModal = useLazyLoader<TemplateModalProps>(
+    'template-modal',
+    () => import('./TemplateModal').then(mod => ({ default: mod.TemplateModal })),
+    status?.isOpened ?? false,
+  );
+
+  // TemplateModal handles Modal wrapper and rendering
+  return TemplateModal ? <TemplateModal /> : <></>;
 };
 ```
 
-### ステップ 3: 動的ローダーの作成
+4. **index.ts作成**
+```tsx
+export { TemplateModalLazyLoaded } from './dynamic';
+```
+
+5. **BasicLayout.tsx更新**
+```tsx
+// Before: Next.js dynamic()
+const TemplateModal = dynamic(() => import('~/components/TemplateModal'), { ssr: false });
+
+// After: 直接import (named)
+// eslint-disable-next-line no-restricted-imports
+import { TemplateModalLazyLoaded } from '~/components/TemplateModal';
+```
+
+---
+
+### ケースB: Container-Presentation分離済み、但しContainerに`<Modal>`外枠なし
 
-**ファイル**: `TemplateModal/dynamic.ts`
+**現状**: `Substance`と`Container`があるが、Containerは早期returnのみで`<Modal>`を持たない
 
+**例**:
 ```tsx
-import React from 'react';
-import { Modal } from 'reactstrap';
-import { useDynamicModalLoader } from '~/utils/use-dynamic-modal-loader';
-import { useTemplateModal } from '~/hooks/useTemplateModal';
+const TemplateModalSubstance = () => { /* 全ての実装 + <Modal> */ };
 
-export const TemplateModalDynamic = (): JSX.Element => {
-  const { data: templateModalStatus, close } = useTemplateModal();
-  
-  const TemplateModal = useDynamicModalLoader(
-    'template-modal',
-    () => import('./TemplateModal').then(mod => ({ default: mod.TemplateModal })),
-    templateModalStatus?.isOpened ?? false
+export const TemplateModal = () => {
+  const status = useStatus();
+  if (!status?.isOpened) return <></>;  // 早期return
+  return <TemplateModalSubstance />;
+};
+```
+
+#### 手順
+
+1. **ファイル構造変更** (ケースAと同じ)
+
+2. **TemplateModal.tsxリファクタリング**: Containerに`<Modal>`を追加
+```tsx
+// Substance: <Modal>外枠を削除、<ModalHeader><ModalBody>のみに
+const TemplateModalSubstance = ({ 
+  someProp, 
+  setSomeProp 
+}: TemplateModalSubstanceProps) => {
+  // 重い処理・hooks
+  return (
+    <>
+      <ModalHeader toggle={close}>...</ModalHeader>
+      <ModalBody>...</ModalBody>
+    </>
   );
+};
 
-  if (templateModalStatus == null) {
-    return <></>;
-  }
+// Container: <Modal>外枠を追加、状態管理、named export
+export const TemplateModal = () => {
+  const status = useStatus();
+  const { close } = useActions();
+  const [someProp, setSomeProp] = useState(...);
+
+  if (status == null) return <></>;
 
   return (
     <Modal 
-      className="template-modal" 
-      isOpen={templateModalStatus.isOpened} 
-      toggle={close} 
-      size="xl" 
-      autoFocus={false}
+      isOpen={status.isOpened} 
+      toggle={close}
+      size="xl"
+      className="..."
     >
-      {templateModalStatus.isOpened && TemplateModal && (
-        <TemplateModal templateModalStatus={templateModalStatus} close={close} />
+      {status.isOpened && (
+        <TemplateModalSubstance 
+          someProp={someProp} 
+          setSomeProp={setSomeProp} 
+        />
       )}
     </Modal>
   );
 };
 ```
 
-### ステップ 4: エクスポートファイルの更新
+3. **dynamic.tsx, index.ts作成** (ケースAと同じ)
+
+4. **BasicLayout.tsx更新** (ケースAと同じ)
+
+---
+
+### ケースC: Container-Presentation分離済み、且つContainerに`<Modal>`外枠あり ⭐
+
+**現状**: 既にV2で理想的な構造になっている(最も簡単なケース)
+
+**例**:
+```tsx
+const TemplateModalSubstance = (props) => {
+  // 重い処理
+  return (
+    <>
+      <ModalHeader>...</ModalHeader>
+      <ModalBody>...</ModalBody>
+    </>
+  );
+};
+
+export const TemplateModal = () => {
+  const status = useStatus();
+  const { close } = useActions();
+  
+  if (status == null) return <></>;
+  
+  return (
+    <Modal isOpen={status.isOpened} toggle={close}>
+      {status.isOpened && <TemplateModalSubstance />}
+    </Modal>
+  );
+};
+```
+
+#### 手順
+
+**最短経路**: TemplateModal.tsxの変更は**ほぼ不要**!
+
+1. **ファイル構造変更**
+```
+Before: TemplateModal.tsx (単一ファイル)
+After:  TemplateModal/
+        ├── index.ts
+        ├── TemplateModal.tsx (移動のみ)
+        └── dynamic.tsx (新規)
+```
+
+2. **TemplateModal.tsx: Named Export確認**
+```tsx
+// default exportの場合のみ修正
+// Before: export default TemplateModal;
+// After:  export const TemplateModal = ...;
+```
+
+3. **dynamic.tsx作成** (ケースAと同じ)
+
+4. **index.ts作成** (ケースAと同じ)
+
+5. **BasicLayout.tsx更新** (ケースAと同じ)
+
+**変更内容**: `dynamic.tsx`と`index.ts`の追加、named export化のみ
+
+---
+
+## ケース判定フローチャート
+
+```
+[モーダルコード確認]
+    ↓
+[SubstanceとContainerに分離されている?]
+    ↓ No  → ケースA: シンプル、dynamic.tsx追加 + named export化
+    ↓ Yes
+[Containerに<Modal>外枠がある?]
+    ↓ No  → ケースB: Containerリファクタリング必要
+    ↓ Yes
+    ↓     → ケースC: ⭐最短経路、dynamic.tsx追加 + named export化のみ
+```
+
+---
+
+## 実装例
+
+### 例1: PageAccessoriesModal (ケースB→C変換)
+
+詳細は前述のケースB手順を参照
+
+### 例2: ShortcutsModal (ケースC、最短経路) ⭐
+
+**Before**: 単一ファイル、default export
+```tsx
+// ShortcutsModal.tsx
+const ShortcutsModalSubstance = () => { /* ... */ };
+
+const ShortcutsModal = () => {
+  return (
+    <Modal isOpen={status?.isOpened}>
+      {status?.isOpened && <ShortcutsModalSubstance />}
+    </Modal>
+  );
+};
+
+export default ShortcutsModal; // default export
+```
+
+**After**: ディレクトリ構造、named export
 
-**ファイル**: `TemplateModal/index.ts`
+1. **ShortcutsModal/ShortcutsModal.tsx** (named export化のみ)
+```tsx
+const ShortcutsModalSubstance = () => { /* 変更なし */ };
 
+export const ShortcutsModal = () => { // named export
+  return (
+    <Modal isOpen={status?.isOpened}>
+      {status?.isOpened && <ShortcutsModalSubstance />}
+    </Modal>
+  );
+};
+```
+
+2. **ShortcutsModal/dynamic.tsx** (新規)
+```tsx
+import type { JSX } from 'react';
+import { useLazyLoader } from '~/client/util/use-lazy-loader';
+import { useShortcutsModalStatus } from '~/states/ui/modal/shortcuts';
+
+type ShortcutsModalProps = Record<string, unknown>;
+
+export const ShortcutsModalLazyLoaded = (): JSX.Element => {
+  const status = useShortcutsModalStatus();
+
+  const ShortcutsModal = useLazyLoader<ShortcutsModalProps>(
+    'shortcuts-modal',
+    () => import('./ShortcutsModal').then(mod => ({ default: mod.ShortcutsModal })),
+    status?.isOpened ?? false,
+  );
+
+  return ShortcutsModal ? <ShortcutsModal /> : <></>
+};
+```
+
+3. **ShortcutsModal/index.ts** (新規)
+```tsx
+export { ShortcutsModalLazyLoaded } from './dynamic';
+```
+
+4. **BasicLayout.tsx**
 ```tsx
-export { TemplateModalDynamic as TemplateModal } from './dynamic';
+// Before
+const ShortcutsModal = dynamic(() => import('~/client/components/ShortcutsModal'), { ssr: false });
+
+// After
+import { ShortcutsModalLazyLoaded } from '~/client/components/ShortcutsModal';
+```
+
+**作業時間**: 約5分(ケースCは非常に高速)
+
+---
+
+## 最適化判断基準
+
+### ✅ 最適化すべきモーダル
+
+1. **モーダル自身の利用頻度が低い**(親ページの頻度ではない)
+2. **ファイルサイズが50行以上**(100行以上は強く推奨)
+3. **レンダリングコストが高い**
+
+### 最適化判断フローチャート
+
+```
+1. モーダルは常にレンダリングされるか?
+   YES → 次へ
+   NO → 最適化不要
+
+2. モーダル自身の利用頻度は?
+   高頻度 → スキップ(初期ロード維持)
+   中〜低頻度 → 次へ
+
+3. ファイルサイズは?
+   50行未満 → 効果小、要検討
+   50行以上 → V3最適化推奨
+   100行以上 → V3最適化強く推奨
 ```
 
+### 重要な注意点
+
+**親の遅延ロード ≠ 子の遅延ロード**:
+```
+BasicLayout (常にレンダリング)
+  ├─ HotkeysManager (dynamic()) ← 遅延ロード
+  │    └─ ShowShortcutsModal (静的import) ← ❌ 遅延ロードされない!
+  │
+  ├─ SearchPage (dynamic()) ← 遅延ロード
+  │    └─ SearchOptionModal (静的import) ← ❌ 遅延ロードされない!
+```
+
+**結論**: 親がdynamic()でも、子モーダルは親と一緒にダウンロードされる
+
+---
+
 ## チェックリスト
 
 ### 実装確認項目
-- [ ] `useDynamicModalLoader` フックが作成済み
-- [ ] モーダルディレクトリが作成済み(index.ts, [Modal].ts, dynamic.ts)
-- [ ] 実際のモーダルコンポーネントが分離済み
-- [ ] 動的ローダーが `useDynamicModalLoader` を使用
+- [ ] **ケース判定完了**: モーダルがA/B/Cのどのケースか確認
+- [ ] `useLazyLoader` フックが作成済み
+- [ ] モーダルディレクトリが作成済み(index.ts, [Modal].tsx, dynamic.tsx)
+- [ ] **Named Export化**: `export const [Modal]` に変更済み
+- [ ] **ケースBの場合**: Containerリファクタリング完了(`<Modal>`外枠追加)
+- [ ] 動的ローダーが `useLazyLoader` を使用
 - [ ] エクスポートファイルが正しく設定済み
+- [ ] BasicLayout.tsx/ShareLinkLayout.tsxでNext.js `dynamic()`削除、直接import
 
 ### 動作確認項目
 - [ ] ページ初回ロード時にモーダルchunkがダウンロードされない
 - [ ] モーダルを開いた際に初めてchunkがダウンロードされる
 - [ ] 同じモーダルを再度開いても重複ダウンロードされない
-- [ ] モーダルが正常に表示・動作する
+- [ ] **Fadeout transition正常動作**: モーダルを閉じる際にアニメーションが発生
+- [ ] **Container-Presentation効果**: モーダル閉じている時、Substanceがレンダリングされない
 - [ ] TypeScriptエラーが発生しない
 
+### デグレチェック項目 🚨
+- [ ] **モーダルが開くか**: トリガーボタンを押してモーダルが正しく開くことを確認
+- [ ] **State import パス**: `@growi/editor`パッケージのstateを使用していないか確認
+  - LinkEditModal: `@growi/editor/dist/states/modal/link-edit`
+  - TemplateModal: `@growi/editor`
+  - HandsontableModal (Editor): `@growi/editor` (useHandsontableModalForEditorStatus)
+- [ ] **複数ステータス**: モーダルが複数のステータスプロパティを持っていないか確認
+  - 例: HandsontableModal は `isOpened || isOpendInEditor` の両方をチェック必要
+- [ ] **Export宣言**: モーダルコンポーネントが`export const`で正しくexportされているか
+- [ ] **動的ローダーのtrigger条件**: `status?.isOpened`だけでなく、他のプロパティも必要ないか確認
+
+---
+
+## デバッグガイド 🔧
+
+### モーダルが開かない場合のチェックリスト
+
+1. **State import パスの確認**
+```bash
+# モーダル本体で使用しているstate hookのimport元を確認
+grep -n "useXxxModalStatus" path/to/Modal.tsx
+
+# dynamic.tsxで同じimport元を使用しているか確認
+grep -n "useXxxModalStatus" path/to/dynamic.tsx
+```
+
+**よくある間違い**:
+- ❌ dynamic.tsx: `import { useXxxModalStatus } from '~/states/ui/modal/xxx'`
+- ✅ 本体と同じ: `import { useXxxModalStatus } from '@growi/editor'`
+
+2. **ステータスプロパティの確認**
+```tsx
+// モーダル本体で使用しているプロパティを確認
+<Modal isOpen={status?.isOpened || anotherStatus?.isOpened}>
+
+// dynamic.tsxで同じ条件を使用
+const Component = useLazyLoader(
+  'modal-key',
+  () => import('./Modal'),
+  status?.isOpened || anotherStatus?.isOpened || false, // ⭐すべての条件を含める
+);
+```
+
+3. **Export宣言の確認**
+```tsx
+// ❌ 間違い: default export
+export default MyModal;
+
+// ✅ 正しい: named export
+export const MyModal = () => { ... };
+```
+
+4. **Import パスの確認**
+```tsx
+// dynamic.tsx内
+() => import('./Modal').then(mod => ({ default: mod.MyModal }))
+//                                              ↑ named exportの名前
+```
+
+---
+
 ## 注意点
 
 ### パフォーマンス
 - グローバルキャッシュにより同じimportは1度だけ実行される
 - メモ化により不要な再レンダリングを防ぐ
+- Container-Presentation分離により、モーダル閉じている時の無駄な処理を回避
 
 ### 型安全性
 - ジェネリクスを使用して型安全性を保持
 - 既存のProps型は変更不要
 
 ### 開発体験
+- Named exportによりコード可読性向上
 - 既存のインポートパスは変更不要
 - 各モーダルの状態管理ロジックは維持
+- ケースCの場合、既存のモーダルコードはnamed export化のみ
+
+### Fadeout Transition保証の設計原則
+- **Container**: 常に`<Modal>`をレンダリング(`status == null`のみ早期return)
+- **Substance**: `isOpened && <Substance />`で条件付きレンダリング
+- この設計により、`<Modal isOpen={false}>`が正しくfadeout transitionを実行できる
+
+### Cross-Package State Management 🚨
+エディター関連のモーダルは`@growi/editor`パッケージでstateを管理している場合があります:
+- `~/states`からインポートできると仮定しないこと
+- モーダル本体のimport元を必ず確認すること
+- dynamic.tsxで同じimport元を使用すること
+
+**例**:
+```tsx
+// LinkEditModal.tsx (本体)
+import { useLinkEditModalStatus } from '@growi/editor/dist/states/modal/link-edit';
+
+// dynamic.tsx (同じimport元を使用)
+import { useLinkEditModalStatus } from '@growi/editor/dist/states/modal/link-edit';
+```
+
+---
 
-## 他のモーダルへの適用
+## 最短経路での指示テンプレート
 
-同じパターンを、使用頻度が高いとはいえないモーダルに関して適用する
+### ケースA向け
+```
+[モーダル名]を動的ロード化してください。
+
+【現状】単一ファイル構成(Container-Presentation分離なし)
+
+【手順】
+1. ディレクトリ化: [Modal].tsx → [Modal]/
+2. Named Export化: export const [Modal] = ...
+3. dynamic.tsx作成: useLazyLoaderで[Modal].tsxを動的ロード
+4. index.ts: dynamic.tsxからexport
+5. BasicLayout.tsx: Next.js dynamic()削除、直接import (named)
+
+【変更】[Modal].tsx本体はnamed export化のみ
+```
+
+### ケースB向け
+```
+[モーダル名]を動的ロード化してください。
+
+【現状】Container-Presentation分離済みだが、Containerに<Modal>外枠なし
+
+【手順】
+1. [Modal].tsxリファクタリング:
+   - Containerに<Modal>外枠を追加
+   - Substanceから<Modal>外枠を削除
+   - 必要に応じて状態をContainer→Substanceにpropsで渡す
+   - Container: <Modal>{isOpened && <Substance />}</Modal>
+   - Named Export化: export const [Modal] = ...
+2. dynamic.tsx作成: useLazyLoaderで[Modal]全体を動的ロード
+3. index.ts: dynamic.tsxからexport
+4. BasicLayout.tsx: Next.js dynamic()削除、直接import (named)
+
+【達成】動的ロード + Container-Presentation分離 + Fadeout transition
+```
+
+### ケースC向け ⭐
+```
+[モーダル名]を動的ロード化してください。
+
+【現状】理想的なContainer-Presentation分離済み(Container有<Modal>)
 
-1. LinkEditModal
-2. TagEditModal
-3. ConflictDiffModal
-4. その他の使用頻度が高いとはいえないモーダルコンポーネント
+【手順】最短経路(所要時間: 約5分)
+1. ディレクトリ化: [Modal].tsx → [Modal]/
+2. Named Export確認: export const [Modal] = ... (必要な場合のみ変更)
+3. dynamic.tsx作成: useLazyLoaderで[Modal]全体を動的ロード
+4. index.ts: dynamic.tsxからexport
+5. BasicLayout.tsx: Next.js dynamic()削除、直接import (named)
 
-各モーダルで `importKey` を一意にし、適切な状態管理フックを使用することで同様の効果を得られる。
+【変更】[Modal].tsx本体はnamed export化のみ(実装は変更なし)
+【達成】動的ロード効果を即座に獲得
+【デグレチェック】モーダルが開くか、state import パス、複数ステータス確認
+```

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


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

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

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

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

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


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

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

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

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

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


+ 18 - 3
apps/app/src/client/components/DescendantsPageListModal.spec.tsx → apps/app/src/client/components/DescendantsPageListModal/DescendantsPageListModal.spec.tsx

@@ -14,9 +14,12 @@ vi.mock('next/router', () => ({
   }),
 }));
 
-vi.mock('~/stores/modal', () => ({
-  useDescendantsPageListModal: vi.fn().mockReturnValue({
-    data: { isOpened: true },
+vi.mock('~/states/ui/modal/descendants-page-list', () => ({
+  useDescendantsPageListModalStatus: vi.fn().mockReturnValue({
+    isOpened: true,
+    path: '/test/path',
+  }),
+  useDescendantsPageListModalActions: vi.fn().mockReturnValue({
     close: mockClose,
   }),
 }));
@@ -25,6 +28,18 @@ vi.mock('~/states/ui/device', () => ({
   useDeviceLargerThanLg,
 }));
 
+vi.mock('~/states/context', () => ({
+  useIsSharedUser: vi.fn().mockReturnValue(false),
+}));
+
+vi.mock('../DescendantsPageList', () => ({
+  DescendantsPageList: () => <div data-testid="descendants-page-list">DescendantsPageList</div>,
+}));
+
+vi.mock('../PageTimeline', () => ({
+  PageTimeline: () => <div data-testid="page-timeline">PageTimeline</div>,
+}));
+
 describe('DescendantsPageListModal.tsx', () => {
 
   it('should render the modal when isOpened is true', () => {

+ 8 - 9
apps/app/src/client/components/DescendantsPageListModal.tsx → apps/app/src/client/components/DescendantsPageListModal/DescendantsPageListModal.tsx

@@ -14,16 +14,16 @@ import { useIsSharedUser } from '~/states/context';
 import { useDeviceLargerThanLg } from '~/states/ui/device';
 import { useDescendantsPageListModalActions, useDescendantsPageListModalStatus } from '~/states/ui/modal/descendants-page-list';
 
-import { CustomNavDropdown, CustomNavTab } from './CustomNavigation/CustomNav';
-import CustomTabContent from './CustomNavigation/CustomTabContent';
-import type { DescendantsPageListProps } from './DescendantsPageList';
-import ExpandOrContractButton from './ExpandOrContractButton';
+import { CustomNavDropdown, CustomNavTab } from '../CustomNavigation/CustomNav';
+import CustomTabContent from '../CustomNavigation/CustomTabContent';
+import type { DescendantsPageListProps } from '../DescendantsPageList';
+import ExpandOrContractButton from '../ExpandOrContractButton';
 
 import styles from './DescendantsPageListModal.module.scss';
 
-const DescendantsPageList = dynamic<DescendantsPageListProps>(() => import('./DescendantsPageList').then(mod => mod.DescendantsPageList), { ssr: false });
+const DescendantsPageList = dynamic<DescendantsPageListProps>(() => import('../DescendantsPageList').then(mod => mod.DescendantsPageList), { ssr: false });
 
-const PageTimeline = dynamic(() => import('./PageTimeline').then(mod => mod.PageTimeline), { ssr: false });
+const PageTimeline = dynamic(() => import('../PageTimeline').then(mod => mod.PageTimeline), { ssr: false });
 
 /**
  * DescendantsPageListModalSubstance - Presentation component (all logic here)
@@ -139,7 +139,6 @@ 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);
@@ -148,12 +147,12 @@ export const DescendantsPageListModal = (): React.JSX.Element => {
   return (
     <Modal
       size="xl"
-      isOpen={isOpened}
+      isOpen={status.isOpened}
       toggle={close}
       data-testid="descendants-page-list-modal"
       className={`grw-descendants-page-list-modal ${styles['grw-descendants-page-list-modal']} ${isWindowExpanded ? 'grw-modal-expanded' : ''}`}
     >
-      {isOpened && (
+      {status.isOpened && (
         <DescendantsPageListModalSubstance
           path={status?.path}
           closeModal={close}

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

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

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

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

+ 2 - 4
apps/app/src/client/components/EmptyTrashModal.tsx → apps/app/src/client/components/EmptyTrashModal/EmptyTrashModal.tsx

@@ -10,7 +10,7 @@ import {
 import { apiv3Delete } from '~/client/util/apiv3-client';
 import { useEmptyTrashModalStatus, useEmptyTrashModalActions } from '~/states/ui/modal/empty-trash';
 
-import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
+import ApiErrorMessageList from '../PageManagement/ApiErrorMessageList';
 
 /**
  * EmptyTrashModalSubstance - Presentation component (all logic here)
@@ -98,7 +98,7 @@ const EmptyTrashModalSubstance = ({
 /**
  * EmptyTrashModal - Container component (lightweight, always rendered)
  */
-const EmptyTrashModal: FC = () => {
+export const EmptyTrashModal: FC = () => {
   const { isOpened, pages, opts } = useEmptyTrashModalStatus();
   const { close: closeModal } = useEmptyTrashModalActions();
 
@@ -115,5 +115,3 @@ const EmptyTrashModal: FC = () => {
     </Modal>
   );
 };
-
-export default EmptyTrashModal;

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

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

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

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

+ 1 - 3
apps/app/src/client/components/GrantedGroupsInheritanceSelectModal.tsx → apps/app/src/client/components/GrantedGroupsInheritanceSelectModal/GrantedGroupsInheritanceSelectModal.tsx

@@ -79,7 +79,7 @@ const GrantedGroupsInheritanceSelectModalSubstance = (props: GrantedGroupsInheri
 /**
  * GrantedGroupsInheritanceSelectModal - Container component (lightweight, always rendered)
  */
-const GrantedGroupsInheritanceSelectModal = (): React.JSX.Element => {
+export const GrantedGroupsInheritanceSelectModal = (): React.JSX.Element => {
   const { isOpened, onCreateBtnClick } = useGrantedGroupsInheritanceSelectModalStatus();
   const { close: closeModal } = useGrantedGroupsInheritanceSelectModalActions();
 
@@ -97,5 +97,3 @@ const GrantedGroupsInheritanceSelectModal = (): React.JSX.Element => {
     </Modal>
   );
 };
-
-export default GrantedGroupsInheritanceSelectModal;

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

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

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

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

+ 2 - 3
apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -51,6 +51,7 @@ import {
 } from '~/stores/page';
 import { mutatePageTree, mutateRecentlyUpdated } from '~/stores/page-listing';
 
+import { CreateTemplateModalLazyLoaded } from '../CreateTemplateModal';
 import { NotAvailable } from '../NotAvailable';
 import { Skeleton } from '../Skeleton';
 
@@ -58,8 +59,6 @@ import styles from './GrowiContextualSubNavigation.module.scss';
 import PageEditorModeManagerStyles from './PageEditorModeManager.module.scss';
 
 
-const CreateTemplateModal = dynamic(() => import('../CreateTemplateModal').then(mod => mod.CreateTemplateModal), { ssr: false });
-
 const PageEditorModeManager = dynamic(
   () => import('./PageEditorModeManager').then(mod => mod.PageEditorModeManager),
   { ssr: false, loading: () => <Skeleton additionalClass={`${PageEditorModeManagerStyles['grw-page-editor-mode-manager-skeleton']}`} /> },
@@ -461,7 +460,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
       </Sticky>
 
       {path != null && currentUser != null && !isReadOnlyUser && (
-        <CreateTemplateModal
+        <CreateTemplateModalLazyLoaded
           path={path}
           isOpen={isPageTemplateModalShown}
           onClose={() => setIsPageTempleteModalShown(false)}

+ 53 - 24
apps/app/src/client/components/NotAvailableForReadOnlyUser.spec.tsx

@@ -1,28 +1,48 @@
+import type { ReactNode } from 'react';
+
 import { render, screen } from '@testing-library/react';
+import { Provider } from 'jotai';
+import { useHydrateAtoms } from 'jotai/utils';
 import {
   describe, it, expect, vi,
 } from 'vitest';
 
+import { isRomUserAllowedToCommentAtom } from '~/states/server-configurations';
+
 import { NotAvailableIfReadOnlyUserNotAllowedToComment } from './NotAvailableForReadOnlyUser';
 
-const useIsReadOnlyUser = vi.hoisted(() => vi.fn().mockReturnValue({ data: true }));
-const useIsRomUserAllowedToComment = vi.hoisted(() => vi.fn().mockReturnValue({ data: true }));
+const useIsReadOnlyUser = vi.hoisted(() => vi.fn().mockReturnValue(true));
 
-vi.mock('~/stores-universal/context', () => ({
+vi.mock('~/states/context', () => ({
   useIsReadOnlyUser,
-  useIsRomUserAllowedToComment,
 }));
 
+vi.mock('react-disable', () => ({
+  Disable: ({ children, disabled }: { children: ReactNode; disabled: boolean }) => (
+    <div aria-hidden={disabled ? 'true' : undefined}>
+      {children}
+    </div>
+  ),
+}));
+
+const HydrateAtoms = ({ children, initialValues }: { children: ReactNode; initialValues: Array<[typeof isRomUserAllowedToCommentAtom, boolean]> }) => {
+  useHydrateAtoms(initialValues);
+  return <>{children}</>;
+};
+
 describe('NotAvailableForReadOnlyUser.tsx', () => {
 
   it('renders NotAvailable component as enable when user is read-only and comments by rom users is allowed', async() => {
-    useIsReadOnlyUser.mockReturnValue({ data: true });
-    useIsRomUserAllowedToComment.mockReturnValue({ data: true });
+    useIsReadOnlyUser.mockReturnValue(true);
 
     render(
-      <NotAvailableIfReadOnlyUserNotAllowedToComment>
-        <div data-testid="test-child">Test Child</div>
-      </NotAvailableIfReadOnlyUserNotAllowedToComment>,
+      <Provider>
+        <HydrateAtoms initialValues={[[isRomUserAllowedToCommentAtom, true]]}>
+          <NotAvailableIfReadOnlyUserNotAllowedToComment>
+            <div data-testid="test-child">Test Child</div>
+          </NotAvailableIfReadOnlyUserNotAllowedToComment>
+        </HydrateAtoms>
+      </Provider>,
     );
 
     // when
@@ -34,13 +54,16 @@ describe('NotAvailableForReadOnlyUser.tsx', () => {
   });
 
   it('renders NotAvailable component as disable when user is read-only and comments by rom users is not allowed', async() => {
-    useIsReadOnlyUser.mockReturnValue({ data: true });
-    useIsRomUserAllowedToComment.mockReturnValue({ data: false });
+    useIsReadOnlyUser.mockReturnValue(true);
 
     render(
-      <NotAvailableIfReadOnlyUserNotAllowedToComment>
-        <div data-testid="test-child">Test Child</div>
-      </NotAvailableIfReadOnlyUserNotAllowedToComment>,
+      <Provider>
+        <HydrateAtoms initialValues={[[isRomUserAllowedToCommentAtom, false]]}>
+          <NotAvailableIfReadOnlyUserNotAllowedToComment>
+            <div data-testid="test-child">Test Child</div>
+          </NotAvailableIfReadOnlyUserNotAllowedToComment>
+        </HydrateAtoms>
+      </Provider>,
     );
 
     // when
@@ -52,13 +75,16 @@ describe('NotAvailableForReadOnlyUser.tsx', () => {
   });
 
   it('renders NotAvailable component as enable when user is not read-only and comments by rom users is allowed', async() => {
-    useIsReadOnlyUser.mockReturnValue({ data: false });
-    useIsRomUserAllowedToComment.mockReturnValue({ data: true });
+    useIsReadOnlyUser.mockReturnValue(false);
 
     render(
-      <NotAvailableIfReadOnlyUserNotAllowedToComment>
-        <div data-testid="test-child">Test Child</div>
-      </NotAvailableIfReadOnlyUserNotAllowedToComment>,
+      <Provider>
+        <HydrateAtoms initialValues={[[isRomUserAllowedToCommentAtom, true]]}>
+          <NotAvailableIfReadOnlyUserNotAllowedToComment>
+            <div data-testid="test-child">Test Child</div>
+          </NotAvailableIfReadOnlyUserNotAllowedToComment>
+        </HydrateAtoms>
+      </Provider>,
     );
 
     // when
@@ -70,13 +96,16 @@ describe('NotAvailableForReadOnlyUser.tsx', () => {
   });
 
   it('renders NotAvailable component as enable when user is not read-only and comments by rom users is not allowed', async() => {
-    useIsReadOnlyUser.mockReturnValue({ data: false });
-    useIsRomUserAllowedToComment.mockReturnValue({ data: false });
+    useIsReadOnlyUser.mockReturnValue(false);
 
     render(
-      <NotAvailableIfReadOnlyUserNotAllowedToComment>
-        <div data-testid="test-child">Test Child</div>
-      </NotAvailableIfReadOnlyUserNotAllowedToComment>,
+      <Provider>
+        <HydrateAtoms initialValues={[[isRomUserAllowedToCommentAtom, false]]}>
+          <NotAvailableIfReadOnlyUserNotAllowedToComment>
+            <div data-testid="test-child">Test Child</div>
+          </NotAvailableIfReadOnlyUserNotAllowedToComment>
+        </HydrateAtoms>
+      </Provider>,
     );
 
     // when

+ 25 - 18
apps/app/src/client/components/PageAccessoriesModal/PageAccessoriesModal.tsx

@@ -1,5 +1,5 @@
 import React, {
-  useMemo, useState, useCallback, type JSX,
+  useMemo, useCallback, useState, type JSX,
 } from 'react';
 
 import { useAtomValue } from 'jotai';
@@ -27,11 +27,14 @@ const PageAttachment = dynamic(() => import('./PageAttachment'), { ssr: false })
 const PageHistory = dynamic(() => import('./PageHistory').then(mod => mod.PageHistory), { ssr: false });
 const ShareLink = dynamic(() => import('./ShareLink').then(mod => mod.ShareLink), { ssr: false });
 
-const PageAccessoriesModalSubstance = (): JSX.Element => {
+interface PageAccessoriesModalSubstanceProps {
+  isWindowExpanded: boolean;
+  setIsWindowExpanded: (expanded: boolean) => void;
+}
 
-  const { t } = useTranslation();
+const PageAccessoriesModalSubstance = ({ isWindowExpanded, setIsWindowExpanded }: PageAccessoriesModalSubstanceProps): JSX.Element => {
 
-  const [isWindowExpanded, setIsWindowExpanded] = useState(false);
+  const { t } = useTranslation();
 
   const isSharedUser = useIsSharedUser();
   const isGuestUser = useIsGuestUser();
@@ -74,8 +77,8 @@ const PageAccessoriesModalSubstance = (): JSX.Element => {
   }, [t, close, isGuestUser, isReadOnlyUser, isSharedUser, isLinkSharingDisabled]);
 
   // Memoize expand/contract handlers
-  const expandWindow = useCallback(() => setIsWindowExpanded(true), []);
-  const contractWindow = useCallback(() => setIsWindowExpanded(false), []);
+  const expandWindow = useCallback(() => setIsWindowExpanded(true), [setIsWindowExpanded]);
+  const contractWindow = useCallback(() => setIsWindowExpanded(false), [setIsWindowExpanded]);
 
   const buttons = useMemo(() => (
     <span className="me-3">
@@ -92,16 +95,8 @@ const PageAccessoriesModalSubstance = (): JSX.Element => {
     return <></>;
   }
 
-  const { isOpened } = status;
-
   return (
-    <Modal
-      size="xl"
-      isOpen={isOpened}
-      toggle={close}
-      data-testid="page-accessories-modal"
-      className={`grw-page-accessories-modal ${styles['grw-page-accessories-modal']} ${isWindowExpanded ? 'grw-modal-expanded' : ''} `}
-    >
+    <>
       <ModalHeader className={isDeviceLargerThanLg ? 'p-0' : ''} toggle={close} close={buttons}>
         {isDeviceLargerThanLg && (
           <CustomNavTab
@@ -127,16 +122,28 @@ const PageAccessoriesModalSubstance = (): JSX.Element => {
           additionalClassNames={!isDeviceLargerThanLg ? ['grw-tab-content-style-md-down'] : undefined}
         />
       </ModalBody>
-    </Modal>
+    </>
   );
 };
 
 export const PageAccessoriesModal = (): JSX.Element => {
   const status = usePageAccessoriesModalStatus();
+  const { close } = usePageAccessoriesModalActions();
+  const [isWindowExpanded, setIsWindowExpanded] = useState(false);
 
-  if (status == null || !status.isOpened) {
+  if (status == null) {
     return <></>;
   }
 
-  return <PageAccessoriesModalSubstance />;
+  return (
+    <Modal
+      size="xl"
+      isOpen={status.isOpened}
+      toggle={close}
+      data-testid="page-accessories-modal"
+      className={`grw-page-accessories-modal ${styles['grw-page-accessories-modal']} ${isWindowExpanded ? 'grw-modal-expanded' : ''} `}
+    >
+      {status.isOpened && <PageAccessoriesModalSubstance isWindowExpanded={isWindowExpanded} setIsWindowExpanded={setIsWindowExpanded} />}
+    </Modal>
+  );
 };

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

@@ -0,0 +1,19 @@
+import type { JSX } from 'react';
+
+import { useLazyLoader } from '~/client/util/use-lazy-loader';
+import { usePageAccessoriesModalStatus } from '~/states/ui/modal/page-accessories';
+
+type PageAccessoriesModalProps = Record<string, unknown>;
+
+export const PageAccessoriesModalLazyLoaded = (): JSX.Element => {
+  const status = usePageAccessoriesModalStatus();
+
+  const PageAccessoriesModal = useLazyLoader<PageAccessoriesModalProps>(
+    'page-accessories-modal',
+    () => import('./PageAccessoriesModal').then(mod => ({ default: mod.PageAccessoriesModal })),
+    status?.isOpened ?? false,
+  );
+
+  // PageAccessoriesModal handles early return and fadeout transition internally
+  return PageAccessoriesModal ? <PageAccessoriesModal /> : <></>;
+};

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

@@ -1 +1 @@
-export * from './PageAccessoriesModal';
+export { PageAccessoriesModalLazyLoaded } from './dynamic';

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

@@ -114,18 +114,18 @@ const DeleteAttachmentModalSubstance = ({
         <div className="me-3 d-inline-block">
           {deletingIndicator}
         </div>
+        <Button
+          color="outline-neutral-secondary"
+          onClick={toggleHandler}
+        >
+          {t('commons:Cancel')}
+        </Button>
         <Button
           color="danger"
           onClick={onClickDeleteButtonHandler}
           disabled={deleting}
         >{t('commons:Delete')}
         </Button>
-        <Button
-          color="secondary"
-          onClick={toggleHandler}
-        >
-          {t('modal_delete.cancel')}
-        </Button>
       </ModalFooter>
     </div>
   );

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

@@ -0,0 +1,18 @@
+import type { JSX } from 'react';
+
+import { useLazyLoader } from '~/client/util/use-lazy-loader';
+import { useDeleteAttachmentModalStatus } from '~/states/ui/modal/delete-attachment';
+
+type DeleteAttachmentModalProps = Record<string, unknown>;
+
+export const DeleteAttachmentModalLazyLoaded = (): JSX.Element => {
+  const status = useDeleteAttachmentModalStatus();
+
+  const DeleteAttachmentModal = useLazyLoader<DeleteAttachmentModalProps>(
+    'delete-attachment-modal',
+    () => import('./DeleteAttachmentModal').then(mod => ({ default: mod.DeleteAttachmentModal })),
+    status?.isOpened ?? false,
+  );
+
+  return DeleteAttachmentModal ? <DeleteAttachmentModal /> : <></>;
+};

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

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

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

@@ -23,7 +23,7 @@ import { NotAvailableForGuest } from './NotAvailableForGuest';
 import { NotAvailableIfReadOnlyUserNotAllowedToComment } from './NotAvailableForReadOnlyUser';
 import { Comment } from './PageComment/Comment';
 import { CommentEditor } from './PageComment/CommentEditor';
-import { DeleteCommentModal } from './PageComment/DeleteCommentModal';
+import { DeleteCommentModalLazyLoaded } from './PageComment/DeleteCommentModal';
 import { ReplyComments } from './PageComment/ReplyComments';
 
 import styles from './PageComment.module.scss';
@@ -218,7 +218,7 @@ export const PageComment: FC<PageCommentProps> = memo((props: PageCommentProps):
       </div>
 
       {!isReadOnly && (
-        <DeleteCommentModal
+        <DeleteCommentModalLazyLoaded
           isShown={isDeleteConfirmModalShown}
           comment={commentToBeDeleted}
           errorMessage={errorMessageOnDelete}

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


+ 2 - 2
apps/app/src/client/components/PageComment/DeleteCommentModal.tsx → apps/app/src/client/components/PageComment/DeleteCommentModal/DeleteCommentModal.tsx

@@ -8,8 +8,8 @@ import {
   Button, Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 
-import { Username } from '../../../components/User/Username';
-import type { ICommentHasId } from '../../../interfaces/comment';
+import { Username } from '~/components/User/Username';
+import type { ICommentHasId } from '~/interfaces/comment';
 
 import styles from './DeleteCommentModal.module.scss';
 

+ 15 - 0
apps/app/src/client/components/PageComment/DeleteCommentModal/dynamic.tsx

@@ -0,0 +1,15 @@
+import type { JSX } from 'react';
+
+import { useLazyLoader } from '../../../util/use-lazy-loader';
+
+import type { DeleteCommentModalProps } from './DeleteCommentModal';
+
+export const DeleteCommentModalLazyLoaded = (props: DeleteCommentModalProps): JSX.Element => {
+  const DeleteCommentModal = useLazyLoader<DeleteCommentModalProps>(
+    'delete-comment-modal',
+    () => import('./DeleteCommentModal').then(mod => ({ default: mod.DeleteCommentModal })),
+    props.isShown,
+  );
+
+  return DeleteCommentModal != null ? <DeleteCommentModal {...props} /> : <></>;
+};

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

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

+ 2 - 4
apps/app/src/client/components/PageDeleteModal.tsx → apps/app/src/client/components/PageDeleteModal/PageDeleteModal.tsx

@@ -19,7 +19,7 @@ import { useSWRxPageInfoForList } from '~/stores/page-listing';
 import loggerFactory from '~/utils/logger';
 
 
-import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
+import ApiErrorMessageList from '../PageManagement/ApiErrorMessageList';
 
 const { isTrashPage } = pagePathUtils;
 
@@ -45,7 +45,7 @@ const isIPageInfoForEntityForDeleteModal = (pageInfo: any | undefined): pageInfo
   return pageInfo != null && 'isDeletable' in pageInfo && 'isAbleToDeleteCompletely' in pageInfo;
 };
 
-const PageDeleteModal: FC = () => {
+export const PageDeleteModal: FC = () => {
   const { t } = useTranslation();
   const { isOpened, pages, opts } = usePageDeleteModalStatus() ?? {};
   const { close: closeDeleteModal } = usePageDeleteModalActions();
@@ -328,5 +328,3 @@ const PageDeleteModal: FC = () => {
 
   );
 };
-
-export default PageDeleteModal;

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

@@ -0,0 +1,18 @@
+import type { JSX } from 'react';
+
+import { useLazyLoader } from '~/client/util/use-lazy-loader';
+import { usePageDeleteModalStatus } from '~/states/ui/modal/page-delete';
+
+type PageDeleteModalProps = Record<string, unknown>;
+
+export const PageDeleteModalLazyLoaded = (): JSX.Element => {
+  const status = usePageDeleteModalStatus();
+
+  const PageDeleteModal = useLazyLoader<PageDeleteModalProps>(
+    'page-delete-modal',
+    () => import('./PageDeleteModal').then(mod => ({ default: mod.PageDeleteModal })),
+    status?.isOpened ?? false,
+  );
+
+  return PageDeleteModal ? <PageDeleteModal /> : <></>;
+};

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

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

+ 4 - 6
apps/app/src/client/components/PageDuplicateModal.tsx → apps/app/src/client/components/PageDuplicateModal/PageDuplicateModal.tsx

@@ -15,9 +15,9 @@ import { useSiteUrl } from '~/states/global';
 import { isSearchServiceReachableAtom } from '~/states/server-configurations';
 import { usePageDuplicateModalStatus, usePageDuplicateModalActions } from '~/states/ui/modal/page-duplicate';
 
-import DuplicatePathsTable from './DuplicatedPathsTable';
-import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
-import PagePathAutoComplete from './PagePathAutoComplete';
+import DuplicatePathsTable from '../DuplicatedPathsTable';
+import ApiErrorMessageList from '../PageManagement/ApiErrorMessageList';
+import PagePathAutoComplete from '../PagePathAutoComplete';
 
 /**
  * PageDuplicateModalSubstance - Heavy processing component (rendered only when modal is open)
@@ -299,7 +299,7 @@ const PageDuplicateModalSubstance: React.FC = () => {
 /**
  * PageDuplicateModal - Container component (lightweight, always rendered)
  */
-const PageDuplicateModal = (): React.JSX.Element => {
+export const PageDuplicateModal = (): React.JSX.Element => {
   const { isOpened } = usePageDuplicateModalStatus();
   const { close: closeDuplicateModal } = usePageDuplicateModalActions();
 
@@ -309,5 +309,3 @@ const PageDuplicateModal = (): React.JSX.Element => {
     </Modal>
   );
 };
-
-export default PageDuplicateModal;

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

@@ -0,0 +1,18 @@
+import type { JSX } from 'react';
+
+import { useLazyLoader } from '~/client/util/use-lazy-loader';
+import { usePageDuplicateModalStatus } from '~/states/ui/modal/page-duplicate';
+
+type PageDuplicateModalProps = Record<string, unknown>;
+
+export const PageDuplicateModalLazyLoaded = (): JSX.Element => {
+  const status = usePageDuplicateModalStatus();
+
+  const PageDuplicateModal = useLazyLoader<PageDuplicateModalProps>(
+    'page-duplicate-modal',
+    () => import('./PageDuplicateModal').then(mod => ({ default: mod.PageDuplicateModal })),
+    status?.isOpened ?? false,
+  );
+
+  return PageDuplicateModal ? <PageDuplicateModal /> : <></>;
+};

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

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

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


+ 0 - 0
apps/app/src/client/components/PageEditor/ConflictDiffModal.tsx → apps/app/src/client/components/PageEditor/ConflictDiffModal/ConflictDiffModal.tsx


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

@@ -0,0 +1,18 @@
+import type { JSX } from 'react';
+
+import { useLazyLoader } from '~/client/util/use-lazy-loader';
+import { useConflictDiffModalStatus } from '~/states/ui/modal/conflict-diff';
+
+type ConflictDiffModalProps = Record<string, unknown>;
+
+export const ConflictDiffModalLazyLoaded = (): JSX.Element => {
+  const status = useConflictDiffModalStatus();
+
+  const ConflictDiffModal = useLazyLoader<ConflictDiffModalProps>(
+    'conflict-diff-modal',
+    () => import('./ConflictDiffModal').then(mod => ({ default: mod.ConflictDiffModal })),
+    status?.isOpened ?? false,
+  );
+
+  return ConflictDiffModal ? <ConflictDiffModal /> : <></>;
+};

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

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

+ 0 - 0
apps/app/src/client/components/PageEditor/DrawioCommunicationHelper.ts → apps/app/src/client/components/PageEditor/DrawioModal/DrawioCommunicationHelper.ts


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

@@ -17,7 +17,6 @@ import { useDrawioModalForEditorStatus, useDrawioModalForEditorActions } from '~
 import { useSWRxPersonalSettings } from '~/stores/personal-settings';
 import loggerFactory from '~/utils/logger';
 
-
 import { type DrawioConfig, DrawioCommunicationHelper } from './DrawioCommunicationHelper';
 
 const logger = loggerFactory('growi:components:DrawioModal');

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

@@ -0,0 +1,18 @@
+import type { JSX } from 'react';
+
+import { useLazyLoader } from '~/client/util/use-lazy-loader';
+import { useDrawioModalStatus } from '~/states/ui/modal/drawio';
+
+type DrawioModalProps = Record<string, unknown>;
+
+export const DrawioModalLazyLoaded = (): JSX.Element => {
+  const status = useDrawioModalStatus();
+
+  const DrawioModal = useLazyLoader<DrawioModalProps>(
+    'drawio-modal',
+    () => import('./DrawioModal').then(mod => ({ default: mod.DrawioModal })),
+    status?.isOpened ?? false,
+  );
+
+  return DrawioModal ? <DrawioModal /> : <></>;
+};

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

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

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


+ 2 - 3
apps/app/src/client/components/PageEditor/HandsontableModal.tsx → apps/app/src/client/components/PageEditor/HandsontableModal/HandsontableModal.tsx

@@ -16,9 +16,8 @@ import { debounce } from 'throttle-debounce';
 import { replaceFocusedMarkdownTableWithEditor, getMarkdownTable } from '~/client/components/PageEditor/markdown-table-util-for-editor';
 import { useHandsontableModalActions, useHandsontableModalStatus } from '~/states/ui/modal/handsontable';
 
-import ExpandOrContractButton from '../ExpandOrContractButton';
-
-import { MarkdownTableDataImportForm } from './MarkdownTableDataImportForm';
+import ExpandOrContractButton from '../../ExpandOrContractButton';
+import { MarkdownTableDataImportForm } from '../MarkdownTableDataImportForm';
 
 import styles from './HandsontableModal.module.scss';
 import 'handsontable/dist/handsontable.full.min.css';

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

@@ -0,0 +1,21 @@
+import type { JSX } from 'react';
+
+import { useHandsontableModalForEditorStatus } from '@growi/editor';
+
+import { useLazyLoader } from '~/client/util/use-lazy-loader';
+import { useHandsontableModalStatus } from '~/states/ui/modal/handsontable';
+
+type HandsontableModalProps = Record<string, unknown>;
+
+export const HandsontableModalLazyLoaded = (): JSX.Element => {
+  const status = useHandsontableModalStatus();
+  const statusForEditor = useHandsontableModalForEditorStatus();
+
+  const HandsontableModal = useLazyLoader<HandsontableModalProps>(
+    'handsontable-modal',
+    () => import('./HandsontableModal').then(mod => ({ default: mod.HandsontableModal })),
+    status?.isOpened || statusForEditor?.isOpened || false,
+  );
+
+  return HandsontableModal ? <HandsontableModal /> : <></>;
+};

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

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

+ 3 - 4
apps/app/src/client/components/PageEditor/LinkEditModal.tsx → apps/app/src/client/components/PageEditor/LinkEditModal/LinkEditModal.tsx

@@ -4,7 +4,7 @@ import React, {
 
 import path from 'path';
 
-import { Linker } from '@growi/editor';
+import { Linker } from '@growi/editor/dist/models/linker';
 import { useLinkEditModalStatus, useLinkEditModalActions } from '@growi/editor/dist/states/modal/link-edit';
 import { useTranslation } from 'next-i18next';
 import {
@@ -22,9 +22,8 @@ import { useCurrentPagePath } from '~/states/page';
 import { usePreviewOptions } from '~/stores/renderer';
 import loggerFactory from '~/utils/logger';
 
-import SearchTypeahead from '../SearchTypeahead';
-
-import Preview from './Preview';
+import SearchTypeahead from '../../SearchTypeahead';
+import Preview from '../Preview';
 
 
 import styles from './LinkEditPreview.module.scss';

+ 0 - 0
apps/app/src/client/components/PageEditor/LinkEditPreview.module.scss → apps/app/src/client/components/PageEditor/LinkEditModal/LinkEditPreview.module.scss


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

@@ -0,0 +1,19 @@
+import type { JSX } from 'react';
+
+import { useLinkEditModalStatus } from '@growi/editor/dist/states/modal/link-edit';
+
+import { useLazyLoader } from '~/client/util/use-lazy-loader';
+
+type LinkEditModalProps = Record<string, unknown>;
+
+export const LinkEditModalLazyLoaded = (): JSX.Element => {
+  const status = useLinkEditModalStatus();
+
+  const LinkEditModal = useLazyLoader<LinkEditModalProps>(
+    'link-edit-modal',
+    () => import('./LinkEditModal').then(mod => ({ default: mod.LinkEditModal })),
+    status?.isOpened ?? false,
+  );
+
+  return LinkEditModal ? <LinkEditModal /> : <></>;
+};

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

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

+ 5 - 3
apps/app/src/client/components/PageEditor/conflict.tsx

@@ -5,9 +5,6 @@ import { GlobalCodeMirrorEditorKey } from '@growi/editor';
 import { useCodeMirrorEditorIsolated } from '@growi/editor/dist/client/stores/codemirror-editor';
 import { useTranslation } from 'react-i18next';
 
-import type { Save, SaveOptions } from '~/client/components/PageEditor/PageEditor';
-import { useUpdateStateAfterSave } from '~/client/services/page-operation';
-import { toastSuccess } from '~/client/util/toastr';
 import { SocketEventName } from '~/interfaces/websocket';
 import type { RemoteRevisionData } from '~/states/page';
 import { useCurrentPageData, useCurrentPageId, useSetRemoteLatestPageData } from '~/states/page';
@@ -16,6 +13,11 @@ import { EditorMode, useEditorMode } from '~/states/ui/editor';
 import { useConflictDiffModalActions } from '~/states/ui/modal/conflict-diff';
 import { usePageStatusAlertActions } from '~/states/ui/modal/page-status-alert';
 
+import { useUpdateStateAfterSave } from '../../services/page-operation';
+import { toastSuccess } from '../../util/toastr';
+
+import type { Save, SaveOptions } from './PageEditor';
+
 
 export type ConflictHandler = (
   remoteRevisionData: RemoteRevisionData,

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


+ 3 - 5
apps/app/src/client/components/PagePresentationModal.tsx → apps/app/src/client/components/PagePresentationModal/PagePresentationModal.tsx

@@ -16,14 +16,14 @@ import { usePresentationModalActions, usePresentationModalStatus } from '~/state
 import { useNextThemes } from '~/stores-universal/use-next-themes';
 import { usePresentationViewOptions } from '~/stores/renderer';
 
-import { RendererErrorMessage } from './Common/RendererErrorMessage';
+import { RendererErrorMessage } from '../Common/RendererErrorMessage';
 
 import styles from './PagePresentationModal.module.scss';
 
 const moduleClass = styles['grw-presentation-modal'] ?? '';
 
 
-const Presentation = dynamic<PresentationProps>(() => import('./Presentation/Presentation').then(mod => mod.Presentation), {
+const Presentation = dynamic<PresentationProps>(() => import('../Presentation/Presentation').then(mod => mod.Presentation), {
   ssr: false,
   loading: () => (
     <LoadingSpinner className="text-muted fs-1" />
@@ -103,7 +103,7 @@ const PagePresentationModalSubstance: React.FC = () => {
 /**
  * PagePresentationModal - Container component (lightweight, always rendered)
  */
-const PagePresentationModal = (): React.JSX.Element => {
+export const PagePresentationModal = (): React.JSX.Element => {
   const presentationModalData = usePresentationModalStatus();
   const { close: closePresentationModal } = usePresentationModalActions();
 
@@ -129,5 +129,3 @@ const PagePresentationModal = (): React.JSX.Element => {
     </Modal>
   );
 };
-
-export default PagePresentationModal;

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

@@ -0,0 +1,18 @@
+import type { JSX } from 'react';
+
+import { useLazyLoader } from '~/client/util/use-lazy-loader';
+import { usePresentationModalStatus } from '~/states/ui/modal/page-presentation';
+
+type PagePresentationModalProps = Record<string, unknown>;
+
+export const PagePresentationModalLazyLoaded = (): JSX.Element => {
+  const status = usePresentationModalStatus();
+
+  const PagePresentationModal = useLazyLoader<PagePresentationModalProps>(
+    'page-presentation-modal',
+    () => import('./PagePresentationModal').then(mod => ({ default: mod.PagePresentationModal })),
+    status?.isOpened ?? false,
+  );
+
+  return PagePresentationModal ? <PagePresentationModal /> : <></>;
+};

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

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

+ 4 - 6
apps/app/src/client/components/PageRenameModal.tsx → apps/app/src/client/components/PageRenameModal/PageRenameModal.tsx

@@ -18,9 +18,9 @@ import { isSearchServiceReachableAtom } from '~/states/server-configurations';
 import { usePageRenameModalStatus, usePageRenameModalActions } from '~/states/ui/modal/page-rename';
 import { useSWRxPageInfo } from '~/stores/page';
 
-import DuplicatedPathsTable from './DuplicatedPathsTable';
-import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
-import PagePathAutoComplete from './PagePathAutoComplete';
+import DuplicatedPathsTable from '../DuplicatedPathsTable';
+import ApiErrorMessageList from '../PageManagement/ApiErrorMessageList';
+import PagePathAutoComplete from '../PagePathAutoComplete';
 
 const isV5Compatible = (meta: unknown): boolean => {
   return isIPageInfoForEntity(meta) ? meta.isV5Compatible : true;
@@ -371,7 +371,7 @@ const PageRenameModalSubstance: React.FC = () => {
 /**
  * PageRenameModal - Container component (lightweight, always rendered)
  */
-const PageRenameModal = (): React.JSX.Element => {
+export const PageRenameModal = (): React.JSX.Element => {
   const { isOpened } = usePageRenameModalStatus();
   const { close: closeRenameModal } = usePageRenameModalActions();
 
@@ -381,5 +381,3 @@ const PageRenameModal = (): React.JSX.Element => {
     </Modal>
   );
 };
-
-export default PageRenameModal;

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

@@ -0,0 +1,18 @@
+import type { JSX } from 'react';
+
+import { useLazyLoader } from '~/client/util/use-lazy-loader';
+import { usePageRenameModalStatus } from '~/states/ui/modal/page-rename';
+
+type PageRenameModalProps = Record<string, unknown>;
+
+export const PageRenameModalLazyLoaded = (): JSX.Element => {
+  const status = usePageRenameModalStatus();
+
+  const PageRenameModal = useLazyLoader<PageRenameModalProps>(
+    'page-rename-modal',
+    () => import('./PageRenameModal').then(mod => ({ default: mod.PageRenameModal })),
+    status?.isOpened ?? false,
+  );
+
+  return PageRenameModal ? <PageRenameModal /> : <></>;
+};

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

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

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

@@ -0,0 +1,18 @@
+import type { JSX } from 'react';
+
+import { useLazyLoader } from '~/client/util/use-lazy-loader';
+import { usePageSelectModalStatus } from '~/states/ui/modal/page-select';
+
+type PageSelectModalProps = Record<string, unknown>;
+
+export const PageSelectModalLazyLoaded = (): JSX.Element => {
+  const status = usePageSelectModalStatus();
+
+  const PageSelectModal = useLazyLoader<PageSelectModalProps>(
+    'page-select-modal',
+    () => import('./PageSelectModal').then(mod => ({ default: mod.PageSelectModal })),
+    status?.isOpened ?? false,
+  );
+
+  return PageSelectModal ? <PageSelectModal /> : <></>;
+};

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

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

+ 0 - 0
apps/app/src/client/components/PageTags/TagEditModal.tsx → apps/app/src/client/components/PageTags/TagEditModal/TagEditModal.tsx


+ 0 - 0
apps/app/src/client/components/PageTags/TagsInput.module.scss → apps/app/src/client/components/PageTags/TagEditModal/TagsInput.module.scss


+ 0 - 0
apps/app/src/client/components/PageTags/TagsInput.tsx → apps/app/src/client/components/PageTags/TagEditModal/TagsInput.tsx


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

@@ -0,0 +1,18 @@
+import type { JSX } from 'react';
+
+import { useLazyLoader } from '~/client/util/use-lazy-loader';
+import { useTagEditModalStatus } from '~/states/ui/modal/tag-edit';
+
+type TagEditModalProps = Record<string, unknown>;
+
+export const TagEditModalLazyLoaded = (): JSX.Element => {
+  const status = useTagEditModalStatus();
+
+  const TagEditModal = useLazyLoader<TagEditModalProps>(
+    'tag-edit-modal',
+    () => import('./TagEditModal').then(mod => ({ default: mod.TagEditModal })),
+    status?.isOpen ?? false,
+  );
+
+  return TagEditModal ? <TagEditModal /> : <></>;
+};

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

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

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

@@ -1,2 +1 @@
 export * from './PageTags';
-export * from './TagsInput';

+ 34 - 28
apps/app/src/client/components/PutbackPageModal.jsx → apps/app/src/client/components/PutbackPageModal/PutbackPageModal.tsx

@@ -1,27 +1,42 @@
-import React, { useState, useCallback, useMemo } from 'react';
-
+import type { FC } from 'react';
+import { useState, useCallback, useMemo } from 'react';
 
 import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 
 import { apiPost } from '~/client/util/apiv1-client';
+import type { PutBackPageModalStatus } from '~/states/ui/modal/put-back-page';
 import { usePutBackPageModalActions, usePutBackPageModalStatus } from '~/states/ui/modal/put-back-page';
 import { mutateAllPageInfo } from '~/stores/page';
 
-import ApiErrorMessageList from './PageManagement/ApiErrorMessageList';
+import ApiErrorMessageList from '../PageManagement/ApiErrorMessageList';
+
+type ApiError = {
+  data?: string;
+};
+
+type ApiResponse = {
+  page: {
+    path: string;
+  };
+};
+
+type PutBackPageModalSubstanceProps = {
+  pageDataToRevert: PutBackPageModalStatus & { page: NonNullable<PutBackPageModalStatus['page']> };
+  closePutBackPageModal: () => void;
+};
 
-const PutBackPageModalSubstance = ({ pageDataToRevert, closePutBackPageModal }) => {
+const PutBackPageModalSubstance: FC<PutBackPageModalSubstanceProps> = ({ pageDataToRevert, closePutBackPageModal }) => {
   const { t } = useTranslation();
 
   const { page } = pageDataToRevert;
   const { pageId, path } = page;
   const onPutBacked = pageDataToRevert.opts?.onPutBacked;
 
-  const [errs, setErrs] = useState(null);
-  const [targetPath, setTargetPath] = useState(null);
+  const [errs, setErrs] = useState<ApiError[] | null>(null);
+  const [targetPath, setTargetPath] = useState<string | null>(null);
 
   const [isPutbackRecursively, setIsPutbackRecursively] = useState(true);
 
@@ -29,7 +44,7 @@ const PutBackPageModalSubstance = ({ pageDataToRevert, closePutBackPageModal })
     setIsPutbackRecursively(!isPutbackRecursively);
   }, [isPutbackRecursively]);
 
-  const putbackPageButtonHandler = useCallback(async () => {
+  const putbackPageButtonHandler = useCallback(async() => {
     setErrs(null);
 
     try {
@@ -37,7 +52,7 @@ const PutBackPageModalSubstance = ({ pageDataToRevert, closePutBackPageModal })
       // If is it not true, Request value must be `null`.
       const recursively = isPutbackRecursively ? true : null;
 
-      const response = await apiPost('/pages.revertRemove', {
+      const response = await apiPost<ApiResponse>('/pages.revertRemove', {
         page_id: pageId,
         recursively,
       });
@@ -49,8 +64,8 @@ const PutBackPageModalSubstance = ({ pageDataToRevert, closePutBackPageModal })
       closePutBackPageModal();
     }
     catch (err) {
-      setTargetPath(err.data);
-      setErrs([err]);
+      setTargetPath((err as ApiError).data ?? null);
+      setErrs([err as ApiError]);
     }
   }, [pageId, isPutbackRecursively, onPutBacked, closePutBackPageModal]);
 
@@ -113,33 +128,24 @@ const PutBackPageModalSubstance = ({ pageDataToRevert, closePutBackPageModal })
   );
 };
 
-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 PutBackPageModal: FC = () => {
   const pageDataToRevert = usePutBackPageModalStatus();
   const { close: closePutBackPageModal } = usePutBackPageModalActions();
-  const { isOpened } = pageDataToRevert;
+  const { isOpened, page } = pageDataToRevert;
 
   const closeModalHandler = useCallback(() => {
     closePutBackPageModal();
   }, [closePutBackPageModal]);
 
+  if (page == null) {
+    return <></>;
+  }
+
   return (
     <Modal isOpen={isOpened} toggle={closeModalHandler} data-testid="put-back-page-modal">
       {isOpened && (
         <PutBackPageModalSubstance
-          pageDataToRevert={pageDataToRevert}
+          pageDataToRevert={{ ...pageDataToRevert, page }}
           closePutBackPageModal={closePutBackPageModal}
         />
       )}
@@ -147,4 +153,4 @@ const PutBackPageModal = () => {
   );
 };
 
-export default PutBackPageModal;
+export { PutBackPageModal };

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

@@ -0,0 +1,18 @@
+import type { JSX } from 'react';
+
+import { useLazyLoader } from '~/client/util/use-lazy-loader';
+import { usePutBackPageModalStatus } from '~/states/ui/modal/put-back-page';
+
+type PutBackPageModalProps = Record<string, unknown>;
+
+export const PutBackPageModalLazyLoaded = (): JSX.Element => {
+  const status = usePutBackPageModalStatus();
+
+  const PutBackPageModal = useLazyLoader<PutBackPageModalProps>(
+    'put-back-page-modal',
+    () => import('./PutbackPageModal').then(mod => ({ default: mod.PutBackPageModal })),
+    status?.isOpened ?? false,
+  );
+
+  return PutBackPageModal ? <PutBackPageModal /> : <></>;
+};

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

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

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


+ 2 - 4
apps/app/src/client/components/ShortcutsModal.tsx → apps/app/src/client/components/ShortcutsModal/ShortcutsModal.tsx

@@ -193,7 +193,7 @@ const ShortcutsModalSubstance = (): React.JSX.Element => {
               <li className="d-flex align-items-center p-3 border-bottom">
                 <div className="flex-grow-1">
                   <span
-                  // eslint-disable-next-line react/no-danger
+                    // eslint-disable-next-line react/no-danger
                     dangerouslySetInnerHTML={{ __html: t('modal_shortcuts.editor.Insert Line') }}
                   />
                   <br />
@@ -416,7 +416,7 @@ const ShortcutsModalSubstance = (): React.JSX.Element => {
 /**
  * ShortcutsModal - Container component (lightweight, always rendered)
  */
-const ShortcutsModal = (): React.JSX.Element => {
+export const ShortcutsModal = (): React.JSX.Element => {
   const status = useShortcutsModalStatus();
   const { close } = useShortcutsModalActions();
 
@@ -426,5 +426,3 @@ const ShortcutsModal = (): React.JSX.Element => {
     </Modal>
   );
 };
-
-export default ShortcutsModal;

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

@@ -0,0 +1,19 @@
+import type { JSX } from 'react';
+
+import { useLazyLoader } from '~/client/util/use-lazy-loader';
+import { useShortcutsModalStatus } from '~/states/ui/modal/shortcuts';
+
+type ShortcutsModalProps = Record<string, unknown>;
+
+export const ShortcutsModalLazyLoaded = (): JSX.Element => {
+  const status = useShortcutsModalStatus();
+
+  const ShortcutsModal = useLazyLoader<ShortcutsModalProps>(
+    'shortcuts-modal',
+    () => import('./ShortcutsModal').then(mod => ({ default: mod.ShortcutsModal })),
+    status?.isOpened ?? false,
+  );
+
+  // ShortcutsModal handles early return and fadeout transition internally
+  return ShortcutsModal ? <ShortcutsModal /> : <></>;
+};

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

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

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

@@ -0,0 +1,19 @@
+import type { JSX } from 'react';
+
+import { useTemplateModalStatus } from '@growi/editor';
+
+import { useLazyLoader } from '~/client/util/use-lazy-loader';
+
+type TemplateModalProps = Record<string, unknown>;
+
+export const TemplateModalLazyLoaded = (): JSX.Element => {
+  const status = useTemplateModalStatus();
+
+  const TemplateModal = useLazyLoader<TemplateModalProps>(
+    'template-modal',
+    () => import('./TemplateModal').then(mod => ({ default: mod.TemplateModal })),
+    status?.isOpened ?? false,
+  );
+
+  return TemplateModal ? <TemplateModal /> : <></>;
+};

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

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

+ 1 - 1
apps/app/src/client/components/TemplateModal/index.tsx

@@ -1 +1 @@
-export * from './TemplateModal';
+export * from './dynamic';

+ 13 - 9
apps/app/src/client/components/TemplateModal/use-formatter.spec.tsx

@@ -1,13 +1,15 @@
+import { renderHook } from '@testing-library/react';
+
 import { useFormatter } from './use-formatter';
 
 
 const mocks = vi.hoisted(() => {
   return {
-    useCurrentPagePathMock: vi.fn(() => { return {} }),
+    useCurrentPagePathMock: vi.fn<() => string | undefined>(() => undefined),
   };
 });
 
-vi.mock('~/stores/page', () => {
+vi.mock('~/states/page', () => {
   return { useCurrentPagePath: mocks.useCurrentPagePathMock };
 });
 
@@ -24,7 +26,8 @@ describe('useFormatter', () => {
       vi.doMock('mustache', () => mastacheMock);
 
       // when
-      const { format } = useFormatter();
+      const { result } = renderHook(() => useFormatter());
+      const { format } = result.current;
       // call with undefined
       const markdown = format(undefined);
 
@@ -43,7 +46,8 @@ describe('useFormatter', () => {
     vi.doMock('mustache', () => mastacheMock);
 
     // when
-    const { format } = useFormatter();
+    const { result } = renderHook(() => useFormatter());
+    const { format } = result.current;
     const markdown = 'markdown body';
     const formatted = format(markdown);
 
@@ -53,7 +57,8 @@ describe('useFormatter', () => {
 
   it('returns markdown formatted when currentPagePath is undefined', () => {
     // when
-    const { format } = useFormatter();
+    const { result } = renderHook(() => useFormatter());
+    const { format } = result.current;
     const markdown = `
 title: {{{title}}}{{^title}}(empty){{/title}}
 path: {{{path}}}
@@ -69,14 +74,13 @@ path: /
 
   it('returns markdown formatted', () => {
     // setup
-    mocks.useCurrentPagePathMock.mockImplementation(() => {
-      return { data: '/Sandbox' };
-    });
+    mocks.useCurrentPagePathMock.mockReturnValue('/Sandbox');
     // 2023/5/31 15:01:xx
     vi.setSystemTime(new Date(2023, 4, 31, 15, 1));
 
     // when
-    const { format } = useFormatter();
+    const { result } = renderHook(() => useFormatter());
+    const { format } = result.current;
     const markdown = `
 title: {{{title}}}
 path: {{{path}}}

+ 423 - 0
apps/app/src/client/util/use-lazy-loader.spec.tsx

@@ -0,0 +1,423 @@
+import React from 'react';
+
+import { renderHook, waitFor } from '@testing-library/react';
+import {
+  describe, it, expect, vi, beforeEach,
+} from 'vitest';
+
+import { useLazyLoader, clearComponentCache } from './use-lazy-loader';
+
+describe('useLazyLoader', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    // Clear the global component cache to ensure test isolation
+    clearComponentCache();
+  });
+
+  describe('Basic functionality', () => {
+    it('should load component when isActive is true', async () => {
+      // Arrange
+      const MockComponent = () => <div>Loaded</div>;
+      const mockImport = vi.fn().mockResolvedValue({ default: MockComponent });
+
+      // Act
+      const { result } = renderHook(() => useLazyLoader('test-key', mockImport, true));
+
+      // Assert
+      await waitFor(() => {
+        expect(result.current).toBe(MockComponent);
+      });
+      expect(mockImport).toHaveBeenCalledTimes(1);
+    });
+
+    it('should not load component when isActive is false', () => {
+      // Arrange
+      const mockImport = vi.fn();
+
+      // Act
+      const { result } = renderHook(() => useLazyLoader('test-key', mockImport, false));
+
+      // Assert
+      expect(result.current).toBeNull();
+      expect(mockImport).not.toHaveBeenCalled();
+    });
+
+    it('should return null initially and load component asynchronously', async () => {
+      // Arrange
+      const MockComponent = () => <div>Async Loaded</div>;
+      const mockImport = vi.fn().mockResolvedValue({ default: MockComponent });
+
+      // Act
+      const { result } = renderHook(() => useLazyLoader('async-key', mockImport, true));
+
+      // Assert - Initially null
+      expect(result.current).toBeNull();
+
+      // Assert - After loading
+      await waitFor(() => {
+        expect(result.current).toBe(MockComponent);
+      });
+    });
+  });
+
+  describe('Cache functionality', () => {
+    it('should use cache for the same importKey', async () => {
+      // Arrange
+      const MockComponent = () => <div>Cached</div>;
+      const mockImport = vi.fn().mockResolvedValue({ default: MockComponent });
+
+      // Act - First call
+      const { result: result1 } = renderHook(() => useLazyLoader('cached-key', mockImport, true));
+
+      await waitFor(() => {
+        expect(result1.current).toBe(MockComponent);
+      });
+
+      // Act - Second call with same key
+      const { result: result2 } = renderHook(() => useLazyLoader('cached-key', mockImport, true));
+
+      await waitFor(() => {
+        expect(result2.current).toBe(MockComponent);
+      });
+
+      // Assert - Import should be called only once
+      expect(mockImport).toHaveBeenCalledTimes(1);
+    });
+
+    it('should not use cache for different importKeys', async () => {
+      // Arrange
+      const Component1 = () => <div>Component1</div>;
+      const Component2 = () => <div>Component2</div>;
+      const mockImport1 = vi.fn().mockResolvedValue({ default: Component1 });
+      const mockImport2 = vi.fn().mockResolvedValue({ default: Component2 });
+
+      // Act
+      const { result: result1 } = renderHook(() => useLazyLoader('key1', mockImport1, true));
+
+      const { result: result2 } = renderHook(() => useLazyLoader('key2', mockImport2, true));
+
+      // Assert
+      await waitFor(() => {
+        expect(result1.current).toBe(Component1);
+        expect(result2.current).toBe(Component2);
+      });
+
+      expect(mockImport1).toHaveBeenCalledTimes(1);
+      expect(mockImport2).toHaveBeenCalledTimes(1);
+    });
+  });
+
+  describe('State change functionality', () => {
+    it('should load component when isActive changes from false to true', async () => {
+      // Arrange
+      const MockComponent = () => <div>Dynamic</div>;
+      const mockImport = vi.fn().mockResolvedValue({ default: MockComponent });
+
+      // Act - Initial render with isActive=false
+      const { result, rerender } = renderHook(
+        ({ isActive }) => useLazyLoader('dynamic-key', mockImport, isActive),
+        { initialProps: { isActive: false } },
+      );
+
+      // Assert - Should not load initially
+      expect(result.current).toBeNull();
+      expect(mockImport).not.toHaveBeenCalled();
+
+      // Act - Change isActive to true
+      rerender({ isActive: true });
+
+      // Assert - Should load component
+      await waitFor(() => {
+        expect(result.current).toBe(MockComponent);
+      });
+      expect(mockImport).toHaveBeenCalledTimes(1);
+    });
+
+    it('should not reload component when isActive changes from true to false', async () => {
+      // Arrange
+      const MockComponent = () => <div>Persistent</div>;
+      const mockImport = vi.fn().mockResolvedValue({ default: MockComponent });
+
+      // Act - Initial render with isActive=true
+      const { result, rerender } = renderHook(
+        ({ isActive }) => useLazyLoader('persistent-key', mockImport, isActive),
+        { initialProps: { isActive: true } },
+      );
+
+      await waitFor(() => {
+        expect(result.current).toBe(MockComponent);
+      });
+
+      // Act - Change isActive to false
+      rerender({ isActive: false });
+
+      // Assert - Component should remain loaded
+      expect(result.current).toBe(MockComponent);
+      expect(mockImport).toHaveBeenCalledTimes(1);
+    });
+
+    it('should not reload component on multiple isActive=true rerenders', async () => {
+      // Arrange
+      const MockComponent = () => <div>Stable</div>;
+      const mockImport = vi.fn().mockResolvedValue({ default: MockComponent });
+
+      // Act
+      const { result, rerender } = renderHook(
+        ({ isActive }) => useLazyLoader('stable-key', mockImport, isActive),
+        { initialProps: { isActive: true } },
+      );
+
+      await waitFor(() => {
+        expect(result.current).toBe(MockComponent);
+      });
+
+      // Act - Multiple rerenders
+      rerender({ isActive: true });
+      rerender({ isActive: true });
+
+      // Assert - Import should be called only once
+      expect(mockImport).toHaveBeenCalledTimes(1);
+    });
+  });
+
+  describe('Multiple instances', () => {
+    it('should handle multiple instances with different keys independently', async () => {
+      // Arrange
+      const Component1 = () => <div>Component1</div>;
+      const Component2 = () => <div>Component2</div>;
+      const Component3 = () => <div>Component3</div>;
+      const mockImport1 = vi.fn().mockResolvedValue({ default: Component1 });
+      const mockImport2 = vi.fn().mockResolvedValue({ default: Component2 });
+      const mockImport3 = vi.fn().mockResolvedValue({ default: Component3 });
+
+      // Act
+      const { result: result1 } = renderHook(() => useLazyLoader('multi-key1', mockImport1, true));
+
+      const { result: result2 } = renderHook(() => useLazyLoader('multi-key2', mockImport2, true));
+
+      const { result: result3 } = renderHook(() => useLazyLoader('multi-key3', mockImport3, false));
+
+      // Assert
+      await waitFor(() => {
+        expect(result1.current).toBe(Component1);
+        expect(result2.current).toBe(Component2);
+      });
+
+      expect(result3.current).toBeNull();
+      expect(mockImport1).toHaveBeenCalledTimes(1);
+      expect(mockImport2).toHaveBeenCalledTimes(1);
+      expect(mockImport3).not.toHaveBeenCalled();
+    });
+
+    it('should handle concurrent loads with same key', async () => {
+      // Arrange
+      const MockComponent = () => <div>Concurrent</div>;
+      const mockImport = vi.fn().mockResolvedValue({ default: MockComponent });
+
+      // Act - Render two hooks with same key simultaneously
+      const { result: result1 } = renderHook(() => useLazyLoader('concurrent-key', mockImport, true));
+
+      const { result: result2 } = renderHook(() => useLazyLoader('concurrent-key', mockImport, true));
+
+      // Assert - Both should resolve to same component
+      await waitFor(() => {
+        expect(result1.current).toBe(MockComponent);
+        expect(result2.current).toBe(MockComponent);
+      });
+
+      // Import should be called only once due to caching
+      expect(mockImport).toHaveBeenCalledTimes(1);
+    });
+  });
+
+  describe('Error handling', () => {
+    it('should handle import failure gracefully', async () => {
+      // Arrange
+      const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
+      const mockError = new Error('Import failed');
+      const mockImport = vi.fn().mockRejectedValue(mockError);
+
+      // Act
+      const { result } = renderHook(() => useLazyLoader('error-key', mockImport, true));
+
+      // Assert - Should remain null on error
+      expect(result.current).toBeNull();
+
+      // Wait for error to be processed
+      await waitFor(() => {
+        expect(mockImport).toHaveBeenCalledTimes(1);
+      });
+
+      // Component should still be null after error
+      expect(result.current).toBeNull();
+
+      consoleErrorSpy.mockRestore();
+    });
+  });
+
+  describe('Type safety', () => {
+    it('should work with components with props', async () => {
+      // Arrange
+      type TestProps = Record<string, unknown> & {
+        title: string;
+        count: number;
+      };
+      const MockComponentWithProps = ({ title, count }: TestProps) => (
+        <div>
+          {title}: {count}
+        </div>
+      );
+      const mockImport = vi.fn().mockResolvedValue({ default: MockComponentWithProps });
+
+      // Act
+      const { result } = renderHook(() => useLazyLoader<TestProps>('typed-key', mockImport, true));
+
+      // Assert
+      await waitFor(() => {
+        expect(result.current).toBe(MockComponentWithProps);
+      });
+    });
+  });
+
+  describe('Edge cases and boundary values', () => {
+    it('should handle empty string as importKey', async () => {
+      // Arrange
+      const MockComponent = () => <div>Empty Key</div>;
+      const mockImport = vi.fn().mockResolvedValue({ default: MockComponent });
+
+      // Act
+      const { result } = renderHook(() => useLazyLoader('', mockImport, true));
+
+      // Assert
+      await waitFor(() => {
+        expect(result.current).toBe(MockComponent);
+      });
+      expect(mockImport).toHaveBeenCalledTimes(1);
+    });
+
+    it('should use first importFn when same key is used with different import functions', async () => {
+      // Arrange
+      const Component1 = () => <div>Component1</div>;
+      const Component2 = () => <div>Component2</div>;
+      const mockImport1 = vi.fn().mockResolvedValue({ default: Component1 });
+      const mockImport2 = vi.fn().mockResolvedValue({ default: Component2 });
+
+      // Act - First hook with Component1
+      const { result: result1 } = renderHook(() => useLazyLoader('duplicate-key', mockImport1, true));
+
+      await waitFor(() => {
+        expect(result1.current).toBe(Component1);
+      });
+
+      // Act - Second hook with same key but different import function
+      const { result: result2 } = renderHook(() => useLazyLoader('duplicate-key', mockImport2, true));
+
+      await waitFor(() => {
+        expect(result2.current).toBe(Component1); // Should still get Component1 from cache
+      });
+
+      // Assert - Only first import should be called
+      expect(mockImport1).toHaveBeenCalledTimes(1);
+      expect(mockImport2).not.toHaveBeenCalled();
+    });
+
+    it('should handle import function returning null', async () => {
+      // Arrange
+      const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
+      const mockImport = vi.fn().mockResolvedValue(null);
+
+      // Act
+      const { result } = renderHook(() => useLazyLoader('null-key', mockImport, true));
+
+      // Assert - Should remain null
+      await waitFor(() => {
+        expect(mockImport).toHaveBeenCalledTimes(1);
+      });
+
+      // Wait a bit to ensure state update attempts have been processed
+      await new Promise(resolve => setTimeout(resolve, 50));
+
+      // Component should be null since the import resolved to null
+      expect(result.current).toBeNull();
+      expect(consoleErrorSpy).toHaveBeenCalledWith(
+        expect.stringContaining('Module or default export is missing'),
+      );
+
+      consoleErrorSpy.mockRestore();
+    });
+
+    it('should handle import function returning object without default property', async () => {
+      // Arrange
+      const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
+      const mockImport = vi.fn().mockResolvedValue({ notDefault: () => <div>Wrong</div> });
+
+      // Act
+      const { result } = renderHook(() => useLazyLoader('no-default-key', mockImport, true));
+
+      // Assert - Should remain null since there's no default export
+      await waitFor(() => {
+        expect(mockImport).toHaveBeenCalledTimes(1);
+      });
+
+      // Wait a bit to ensure state update attempts have been processed
+      await new Promise(resolve => setTimeout(resolve, 50));
+
+      expect(result.current).toBeNull();
+      expect(consoleErrorSpy).toHaveBeenCalledWith(
+        expect.stringContaining('Module or default export is missing'),
+      );
+
+      consoleErrorSpy.mockRestore();
+    });
+
+    it('should handle rapid isActive toggling', async () => {
+      // Arrange
+      const MockComponent = () => <div>Toggled</div>;
+      const mockImport = vi.fn().mockResolvedValue({ default: MockComponent });
+
+      // Act
+      const { result, rerender } = renderHook(
+        ({ isActive }) => useLazyLoader('toggle-key', mockImport, isActive),
+        { initialProps: { isActive: false } },
+      );
+
+      // Rapidly toggle isActive
+      rerender({ isActive: true });
+      rerender({ isActive: false });
+      rerender({ isActive: true });
+      rerender({ isActive: false });
+      rerender({ isActive: true });
+
+      // Assert
+      await waitFor(() => {
+        expect(result.current).toBe(MockComponent);
+      });
+
+      // Should only import once despite rapid toggling
+      expect(mockImport).toHaveBeenCalledTimes(1);
+    });
+
+    it('should not call import function when isActive is false initially and remains false', async () => {
+      // Arrange
+      const mockImport = vi.fn().mockResolvedValue({ default: () => <div>Test</div> });
+
+      // Act
+      const { result, rerender } = renderHook(
+        ({ isActive }) => useLazyLoader('inactive-key', mockImport, isActive),
+        { initialProps: { isActive: false } },
+      );
+
+      // Multiple rerenders with isActive=false
+      rerender({ isActive: false });
+      rerender({ isActive: false });
+      rerender({ isActive: false });
+
+      // Wait a bit to ensure no async operations are triggered
+      await new Promise(resolve => setTimeout(resolve, 100));
+
+      // Assert
+      expect(result.current).toBeNull();
+      expect(mockImport).not.toHaveBeenCalled();
+    });
+  });
+});

+ 82 - 0
apps/app/src/client/util/use-lazy-loader.ts

@@ -0,0 +1,82 @@
+import type React from 'react';
+import { useState, useEffect, useCallback } from 'react';
+
+type ComponentModule<T> = { default: React.ComponentType<T> };
+
+// Global cache for dynamically loaded components
+const componentCache = new Map<string, Promise<ComponentModule<unknown>>>();
+
+const getCachedImport = <T>(
+  key: string,
+  importFn: () => Promise<ComponentModule<T>>,
+): Promise<ComponentModule<T>> => {
+  if (!componentCache.has(key)) {
+    componentCache.set(key, importFn() as Promise<ComponentModule<unknown>>);
+  }
+  const cached = componentCache.get(key);
+  if (cached == null) {
+    throw new Error(`Failed to retrieve cached import for key: ${key}`);
+  }
+  return cached as Promise<ComponentModule<T>>;
+};
+
+/**
+ * Clears the component cache. This is primarily intended for testing purposes.
+ * In production, the cache persists for the lifetime of the application.
+ *
+ * @internal
+ */
+export const clearComponentCache = (): void => {
+  componentCache.clear();
+};
+
+/**
+ * Dynamically loads a component when it becomes active
+ *
+ * @param importKey - Unique identifier for the component (used for caching)
+ * @param importFn - Function that returns a dynamic import promise
+ * @param isActive - Whether the component should be loaded (e.g., modal open, tab selected, etc.)
+ * @returns The loaded component or null if not yet loaded
+ *
+ * @example
+ * // For modals
+ * const Modal = useLazyLoader('my-modal', () => import('./MyModal'), isOpen);
+ *
+ * @example
+ * // For tab content
+ * const TabContent = useLazyLoader('tab-advanced', () => import('./AdvancedTab'), activeTab === 'advanced');
+ *
+ * @example
+ * // For conditional panels
+ * const AdminPanel = useLazyLoader('admin-panel', () => import('./AdminPanel'), isAdmin);
+ */
+export const useLazyLoader = <T extends Record<string, unknown>>(
+  importKey: string,
+  importFn: () => Promise<{ default: React.ComponentType<T> }>,
+  isActive: boolean,
+): React.ComponentType<T> | null => {
+  const [Component, setComponent] = useState<React.ComponentType<T> | null>(null);
+
+  const memoizedImportFn = useCallback(importFn, [importFn, importKey]);
+
+  useEffect(() => {
+    if (isActive && !Component) {
+      getCachedImport(importKey, memoizedImportFn)
+        .then((mod) => {
+          if (mod?.default) {
+            setComponent(() => mod.default);
+          }
+          else {
+            // eslint-disable-next-line no-console
+            console.error(`Failed to load component with key "${importKey}": Module or default export is missing`);
+          }
+        })
+        .catch((error) => {
+          // eslint-disable-next-line no-console
+          console.error(`Failed to load component with key "${importKey}":`, error);
+        });
+    }
+  }, [isActive, Component, importKey, memoizedImportFn]);
+
+  return Component;
+};

+ 30 - 100
apps/app/src/components/Layout/BasicLayout.tsx

@@ -1,18 +1,26 @@
 import type { JSX, ReactNode } from 'react';
 import dynamic from 'next/dynamic';
 
+import { AlertSiteUrlUndefined } from '~/client/components/AlertSiteUrlUndefined';
+import { DeleteBookmarkFolderModalLazyLoaded } from '~/client/components/DeleteBookmarkFolderModal';
+import { GrantedGroupsInheritanceSelectModalLazyLoaded } from '~/client/components/GrantedGroupsInheritanceSelectModal';
+import { PageAccessoriesModalLazyLoaded } from '~/client/components/PageAccessoriesModal';
+import { DeleteAttachmentModalLazyLoaded } from '~/client/components/PageAttachment';
+import { PageDeleteModalLazyLoaded } from '~/client/components/PageDeleteModal';
+import { PageDuplicateModalLazyLoaded } from '~/client/components/PageDuplicateModal';
+import { PagePresentationModalLazyLoaded } from '~/client/components/PagePresentationModal';
+import { PageRenameModalLazyLoaded } from '~/client/components/PageRenameModal';
+import { PageSelectModalLazyLoaded } from '~/client/components/PageSelectModal';
+import { PutBackPageModalLazyLoaded } from '~/client/components/PutbackPageModal';
+import { ShortcutsModalLazyLoaded } from '~/client/components/ShortcutsModal';
+import { AiAssistantManagementModalLazyLoaded } from '~/features/openai/client/components/AiAssistant/AiAssistantManagementModal';
+import { AiAssistantSidebarLazyLoaded } from '~/features/openai/client/components/AiAssistant/AiAssistantSidebar/dynamic';
+import { PageBulkExportSelectModalLazyLoaded } from '~/features/page-bulk-export/client/components';
+
 import { RawLayout } from './RawLayout';
 
 import styles from './BasicLayout.module.scss';
 
-const AiAssistantSidebar = dynamic(
-  () =>
-    import(
-      '~/features/openai/client/components/AiAssistant/AiAssistantSidebar/AiAssistantSidebar'
-    ).then((mod) => mod.AiAssistantSidebar),
-  { ssr: false },
-);
-
 const moduleClass = styles['grw-basic-layout'] ?? '';
 
 const Sidebar = dynamic(
@@ -20,20 +28,6 @@ const Sidebar = dynamic(
   { ssr: false },
 );
 
-const AlertSiteUrlUndefined = dynamic(
-  () =>
-    import('~/client/components/AlertSiteUrlUndefined').then(
-      (mod) => mod.AlertSiteUrlUndefined,
-    ),
-  { ssr: false },
-);
-const DeleteAttachmentModal = dynamic(
-  () =>
-    import('~/client/components/PageAttachment/DeleteAttachmentModal').then(
-      (mod) => mod.DeleteAttachmentModal,
-    ),
-  { ssr: false },
-);
 const HotkeysManager = dynamic(
   () => import('~/client/components/Hotkeys/HotkeysManager'),
   { ssr: false },
@@ -45,83 +39,19 @@ const GrowiNavbarBottom = dynamic(
     ),
   { ssr: false },
 );
-const ShortcutsModal = dynamic(
-  () => import('~/client/components/ShortcutsModal'),
-  { ssr: false },
-);
 const SystemVersion = dynamic(
   () => import('~/client/components/SystemVersion'),
   { ssr: false },
 );
-const PutbackPageModal = dynamic(
-  () => import('~/client/components/PutbackPageModal'),
-  { ssr: false },
-);
 // Page modals
 const PageCreateModal = dynamic(
   () => import('~/client/components/PageCreateModal'),
   { ssr: false },
 );
-const PageDuplicateModal = dynamic(
-  () => import('~/client/components/PageDuplicateModal'),
-  { ssr: false },
-);
-const PageDeleteModal = dynamic(
-  () => import('~/client/components/PageDeleteModal'),
-  { ssr: false },
-);
-const PageRenameModal = dynamic(
-  () => import('~/client/components/PageRenameModal'),
-  { ssr: false },
-);
-const PagePresentationModal = dynamic(
-  () => import('~/client/components/PagePresentationModal'),
-  { ssr: false },
-);
-const PageAccessoriesModal = dynamic(
-  () =>
-    import('~/client/components/PageAccessoriesModal').then(
-      (mod) => mod.PageAccessoriesModal,
-    ),
-  { ssr: false },
-);
-const GrantedGroupsInheritanceSelectModal = dynamic(
-  () => import('~/client/components/GrantedGroupsInheritanceSelectModal'),
-  { ssr: false },
-);
-const DeleteBookmarkFolderModal = dynamic(
-  () =>
-    import('~/client/components/DeleteBookmarkFolderModal').then(
-      (mod) => mod.DeleteBookmarkFolderModal,
-    ),
-  { ssr: false },
-);
 const SearchModal = dynamic(
   () => import('~/features/search/client/components/SearchModal'),
   { ssr: false },
 );
-const PageBulkExportSelectModal = dynamic(
-  () =>
-    import(
-      '~/features/page-bulk-export/client/components/PageBulkExportSelectModal'
-    ),
-  { ssr: false },
-);
-
-const AiAssistantManagementModal = dynamic(
-  () =>
-    import(
-      '~/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementModal'
-    ).then((mod) => mod.AiAssistantManagementModal),
-  { ssr: false },
-);
-const PageSelectModal = dynamic(
-  () =>
-    import('~/client/components/PageSelectModal/PageSelectModal').then(
-      (mod) => mod.PageSelectModal,
-    ),
-  { ssr: false },
-);
 
 type Props = {
   children?: ReactNode;
@@ -142,29 +72,29 @@ export const BasicLayout = ({ children, className }: Props): JSX.Element => {
           {children}
         </div>
 
-        <AiAssistantSidebar />
+        <AiAssistantSidebarLazyLoaded />
       </div>
 
       <GrowiNavbarBottom />
       <SearchModal />
 
       <PageCreateModal />
-      <PageDuplicateModal />
-      <PageDeleteModal />
-      <PageRenameModal />
-      <PageAccessoriesModal />
-      <DeleteAttachmentModal />
-      <DeleteBookmarkFolderModal />
-      <PutbackPageModal />
-      <PageSelectModal />
-      <AiAssistantManagementModal />
+      <PageDuplicateModalLazyLoaded />
+      <PageDeleteModalLazyLoaded />
+      <PageRenameModalLazyLoaded />
+      <PageAccessoriesModalLazyLoaded />
+      <DeleteAttachmentModalLazyLoaded />
+      <DeleteBookmarkFolderModalLazyLoaded />
+      <PutBackPageModalLazyLoaded />
+      <PageSelectModalLazyLoaded />
+      <AiAssistantManagementModalLazyLoaded />
 
-      <PagePresentationModal />
+      <PagePresentationModalLazyLoaded />
       <HotkeysManager />
 
-      <ShortcutsModal />
-      <PageBulkExportSelectModal />
-      <GrantedGroupsInheritanceSelectModal />
+      <ShortcutsModalLazyLoaded />
+      <PageBulkExportSelectModalLazyLoaded />
+      <GrantedGroupsInheritanceSelectModalLazyLoaded />
       <SystemVersion showShortcutsButton />
     </RawLayout>
   );

+ 4 - 5
apps/app/src/components/Layout/ShareLinkLayout.tsx

@@ -2,6 +2,9 @@ import type { JSX, ReactNode } from 'react';
 import React from 'react';
 import dynamic from 'next/dynamic';
 
+// eslint-disable-next-line no-restricted-imports
+import { ShortcutsModalLazyLoaded } from '~/client/components/ShortcutsModal';
+
 import { RawLayout } from './RawLayout';
 
 const PageCreateModal = dynamic(
@@ -15,10 +18,6 @@ const GrowiNavbarBottom = dynamic(
     ),
   { ssr: false },
 );
-const ShortcutsModal = dynamic(
-  () => import('~/client/components/ShortcutsModal'),
-  { ssr: false },
-);
 const SystemVersion = dynamic(
   () => import('~/client/components/SystemVersion'),
   { ssr: false },
@@ -35,7 +34,7 @@ export const ShareLinkLayout = ({ children }: Props): JSX.Element => {
 
       <GrowiNavbarBottom />
 
-      <ShortcutsModal />
+      <ShortcutsModalLazyLoaded />
       <PageCreateModal />
       <SystemVersion showShortcutsButton />
     </RawLayout>

+ 86 - 0
apps/app/src/components/PageView/PageAlerts/FixPageGrantAlert/FixPageGrantAlert.tsx

@@ -0,0 +1,86 @@
+import { type JSX, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+
+import type { IResGrantData } from '~/interfaces/page-grant';
+import { useCurrentUser } from '~/states/global';
+import { useCurrentPageData } from '~/states/page';
+import { useSWRxApplicableGrant, useSWRxCurrentGrantData } from '~/stores/page';
+
+import { FixPageGrantModal } from './FixPageGrantModal';
+
+type SubstanceProps = {
+  pageId: string;
+  currentAndParentPageGrantData: IResGrantData;
+};
+
+const FixPageGrantAlertSubstance = (props: SubstanceProps): JSX.Element => {
+  const { t } = useTranslation();
+  const { pageId, currentAndParentPageGrantData } = props;
+
+  const { data: dataApplicableGrant } = useSWRxApplicableGrant(pageId);
+
+  const [isOpen, setOpen] = useState<boolean>(false);
+
+  if (dataApplicableGrant == null) {
+    // biome-ignore lint/complexity/noUselessFragments: ignore
+    return <></>;
+  }
+
+  return (
+    <>
+      <div className="alert alert-warning py-3 ps-4 d-flex flex-column flex-lg-row">
+        <div className="flex-grow-1 d-flex align-items-center">
+          <span className="material-symbols-outlined mx-1" aria-hidden="true">
+            error
+          </span>
+          {t('fix_page_grant.alert.description')}
+        </div>
+        <div className="d-flex align-items-end align-items-lg-center">
+          <button
+            type="button"
+            className="btn btn-info btn-sm rounded-pill px-3"
+            onClick={() => setOpen(true)}
+          >
+            {t('fix_page_grant.alert.btn_label')}
+          </button>
+        </div>
+      </div>
+
+      <FixPageGrantModal
+        isOpen={isOpen}
+        pageId={pageId}
+        dataApplicableGrant={dataApplicableGrant}
+        currentAndParentPageGrantData={currentAndParentPageGrantData}
+        close={() => setOpen(false)}
+      />
+    </>
+  );
+};
+
+export const FixPageGrantAlert = (): JSX.Element => {
+  const currentUser = useCurrentUser();
+  const pageData = useCurrentPageData();
+
+  const hasParent = pageData?.parent != null ?? false;
+  const pageId = pageData?._id;
+
+  const { data: dataIsGrantNormalized } = useSWRxCurrentGrantData(
+    currentUser != null ? pageId : null,
+  );
+
+  if (
+    pageId == null ||
+    !hasParent ||
+    !dataIsGrantNormalized?.isGrantNormalized
+  ) {
+    // biome-ignore lint/complexity/noUselessFragments: ignore
+    return <></>;
+  }
+
+  return (
+    <FixPageGrantAlertSubstance
+      pageId={pageId}
+      currentAndParentPageGrantData={dataIsGrantNormalized.grantData}
+    />
+  );
+};

+ 2 - 72
apps/app/src/components/PageView/PageAlerts/FixPageGrantAlert.tsx → apps/app/src/components/PageView/PageAlerts/FixPageGrantAlert/FixPageGrantModal.tsx

@@ -12,9 +12,6 @@ import type {
   IResGrantData,
   PopulatedGrantedGroup,
 } from '~/interfaces/page-grant';
-import { useCurrentUser } from '~/states/global';
-import { useCurrentPageData } from '~/states/page';
-import { useSWRxApplicableGrant, useSWRxCurrentGrantData } from '~/stores/page';
 
 type ModalProps = {
   isOpen: boolean;
@@ -24,7 +21,7 @@ type ModalProps = {
   close(): void;
 };
 
-const FixPageGrantModal = (props: ModalProps): JSX.Element => {
+export const FixPageGrantModal = (props: ModalProps): JSX.Element => {
   const { t } = useTranslation();
 
   const {
@@ -95,7 +92,7 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
 
       const toastSuccess = (await import('~/client/util/toastr')).toastSuccess;
       toastSuccess(t('Successfully updated'));
-    } catch (err) {
+    } catch {
       const toastError = (await import('~/client/util/toastr')).toastError;
       toastError(t('Failed to update'));
     }
@@ -342,70 +339,3 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
     </>
   );
 };
-
-export const FixPageGrantAlert = (): JSX.Element => {
-  const { t } = useTranslation();
-
-  const currentUser = useCurrentUser();
-  const pageData = useCurrentPageData();
-  const hasParent = pageData != null ? pageData.parent != null : false;
-  const pageId = pageData?._id;
-
-  const [isOpen, setOpen] = useState<boolean>(false);
-
-  const { data: dataIsGrantNormalized } = useSWRxCurrentGrantData(
-    currentUser != null ? pageId : null,
-  );
-  const { data: dataApplicableGrant } = useSWRxApplicableGrant(
-    currentUser != null ? pageId : null,
-  );
-
-  // Dependencies
-  if (pageData == null) {
-    // biome-ignore lint/complexity/noUselessFragments: ignore
-    return <></>;
-  }
-
-  if (!hasParent) {
-    // biome-ignore lint/complexity/noUselessFragments: ignore
-    return <></>;
-  }
-  if (
-    dataIsGrantNormalized?.isGrantNormalized == null ||
-    dataIsGrantNormalized.isGrantNormalized
-  ) {
-    return <></>;
-  }
-
-  return (
-    <>
-      <div className="alert alert-warning py-3 ps-4 d-flex flex-column flex-lg-row">
-        <div className="flex-grow-1 d-flex align-items-center">
-          <span className="material-symbols-outlined mx-1" aria-hidden="true">
-            error
-          </span>
-          {t('fix_page_grant.alert.description')}
-        </div>
-        <div className="d-flex align-items-end align-items-lg-center">
-          <button
-            type="button"
-            className="btn btn-info btn-sm rounded-pill px-3"
-            onClick={() => setOpen(true)}
-          >
-            {t('fix_page_grant.alert.btn_label')}
-          </button>
-        </div>
-      </div>
-
-      {pageId != null && dataApplicableGrant != null && (
-        <FixPageGrantModal
-          isOpen={isOpen}
-          pageId={pageId}
-          dataApplicableGrant={dataApplicableGrant}
-          currentAndParentPageGrantData={dataIsGrantNormalized.grantData}
-          close={() => setOpen(false)}
-        />
-      )}
-    </>
-  );
-};

+ 35 - 0
apps/app/src/components/PageView/PageAlerts/FixPageGrantAlert/dynamic.tsx

@@ -0,0 +1,35 @@
+import type { JSX } from 'react';
+
+import { useLazyLoader } from '~/client/util/use-lazy-loader';
+import { useCurrentUser } from '~/states/global';
+import { useCurrentPageData, usePageNotFound } from '~/states/page';
+import { useSWRxCurrentGrantData } from '~/stores/page';
+
+export const FixPageGrantAlertLazyLoaded = (): JSX.Element => {
+  const isNotFound = usePageNotFound();
+  const currentUser = useCurrentUser();
+  const pageData = useCurrentPageData();
+  const pageId = pageData?._id;
+
+  const hasParent = pageData != null ? pageData.parent != null : false;
+  const { data: dataIsGrantNormalized } = useSWRxCurrentGrantData(
+    currentUser != null ? pageId : null,
+  );
+
+  const isActive =
+    !isNotFound &&
+    hasParent &&
+    dataIsGrantNormalized?.isGrantNormalized != null &&
+    !dataIsGrantNormalized.isGrantNormalized;
+
+  const FixPageGrantAlert = useLazyLoader<Record<string, unknown>>(
+    'fix-page-grant-alert',
+    () =>
+      import('./FixPageGrantAlert').then((mod) => ({
+        default: mod.FixPageGrantAlert,
+      })),
+    isActive,
+  );
+
+  return FixPageGrantAlert ? <FixPageGrantAlert /> : <></>;
+};

+ 1 - 0
apps/app/src/components/PageView/PageAlerts/FixPageGrantAlert/index.ts

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

+ 9 - 11
apps/app/src/components/PageView/PageAlerts/FullTextSearchNotCoverAlert.tsx → apps/app/src/components/PageView/PageAlerts/FullTextSearchNotCoverAlert/FullTextSearchNotCoverAlert.tsx

@@ -2,24 +2,22 @@ import type { JSX } from 'react';
 import { useAtomValue } from 'jotai';
 import { useTranslation } from 'react-i18next';
 
-import { useCurrentPageData } from '~/states/page';
 import { elasticsearchMaxBodyLengthToIndexAtom } from '~/states/server-configurations';
 
-export const FullTextSearchNotCoverAlert = (): JSX.Element => {
-  const { t } = useTranslation();
+export type FullTextSearchNotCoverAlertProps = {
+  isActive: boolean;
+};
 
+export const FullTextSearchNotCoverAlert = ({
+  isActive,
+}: FullTextSearchNotCoverAlertProps): JSX.Element => {
+  const { t } = useTranslation();
   const elasticsearchMaxBodyLengthToIndex = useAtomValue(
     elasticsearchMaxBodyLengthToIndexAtom,
   );
-  const data = useCurrentPageData();
-
-  const markdownLength = data?.revision?.body?.length;
 
-  if (
-    markdownLength == null ||
-    elasticsearchMaxBodyLengthToIndex == null ||
-    markdownLength <= elasticsearchMaxBodyLengthToIndex
-  ) {
+  // Display condition is controlled by the isActive prop from dynamic.tsx
+  if (!isActive) {
     // biome-ignore lint/complexity/noUselessFragments: ignore
     return <></>;
   }

+ 41 - 0
apps/app/src/components/PageView/PageAlerts/FullTextSearchNotCoverAlert/dynamic.tsx

@@ -0,0 +1,41 @@
+import type { JSX } from 'react';
+import { useAtomValue } from 'jotai';
+
+import { useLazyLoader } from '~/client/util/use-lazy-loader';
+import { useCurrentPageData } from '~/states/page';
+import { elasticsearchMaxBodyLengthToIndexAtom } from '~/states/server-configurations';
+
+import type { FullTextSearchNotCoverAlertProps } from './FullTextSearchNotCoverAlert';
+
+export const FullTextSearchNotCoverAlertLazyLoaded = (): JSX.Element => {
+  const pageData = useCurrentPageData();
+  const elasticsearchMaxBodyLengthToIndex = useAtomValue(
+    elasticsearchMaxBodyLengthToIndexAtom,
+  );
+
+  const markdownLength = pageData?.revision?.body?.length;
+
+  // Calculate whether the alert should be shown
+  const shouldShow =
+    markdownLength != null &&
+    elasticsearchMaxBodyLengthToIndex != null &&
+    markdownLength > elasticsearchMaxBodyLengthToIndex;
+
+  // Load component when it should be shown (loads once and stays cached)
+  const FullTextSearchNotCoverAlert =
+    useLazyLoader<FullTextSearchNotCoverAlertProps>(
+      'full-text-search-not-cover-alert',
+      () =>
+        import('./FullTextSearchNotCoverAlert').then((mod) => ({
+          default: mod.FullTextSearchNotCoverAlert,
+        })),
+      shouldShow,
+    );
+
+  // Pass active state to control visibility
+  return FullTextSearchNotCoverAlert ? (
+    <FullTextSearchNotCoverAlert isActive={shouldShow} />
+  ) : (
+    <></>
+  );
+};

+ 1 - 0
apps/app/src/components/PageView/PageAlerts/FullTextSearchNotCoverAlert/index.ts

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

+ 8 - 30
apps/app/src/components/PageView/PageAlerts/PageAlerts.tsx

@@ -1,48 +1,26 @@
 import type { JSX } from 'react';
-import dynamic from 'next/dynamic';
-
-import { usePageNotFound } from '~/states/page';
 
+import { FixPageGrantAlertLazyLoaded } from './FixPageGrantAlert';
+import { FullTextSearchNotCoverAlertLazyLoaded } from './FullTextSearchNotCoverAlert';
 import { OldRevisionAlert } from './OldRevisionAlert';
 import { PageGrantAlert } from './PageGrantAlert';
+import { PageRedirectedAlertLazyLoaded } from './PageRedirectedAlert';
 import { PageStaleAlert } from './PageStaleAlert';
+import { TrashPageAlertLazyLoaded } from './TrashPageAlert';
 import { WipPageAlert } from './WipPageAlert';
 
-const FullTextSearchNotCoverAlert = dynamic(
-  () =>
-    import('./FullTextSearchNotCoverAlert').then(
-      (mod) => mod.FullTextSearchNotCoverAlert,
-    ),
-  { ssr: false },
-);
-const PageRedirectedAlert = dynamic(
-  () => import('./PageRedirectedAlert').then((mod) => mod.PageRedirectedAlert),
-  { ssr: false },
-);
-const FixPageGrantAlert = dynamic(
-  () => import('./FixPageGrantAlert').then((mod) => mod.FixPageGrantAlert),
-  { ssr: false },
-);
-const TrashPageAlert = dynamic(
-  () => import('./TrashPageAlert').then((mod) => mod.TrashPageAlert),
-  { ssr: false },
-);
-
 export const PageAlerts = (): JSX.Element => {
-  const isNotFound = usePageNotFound();
-
   return (
     <div className="row d-edit-none">
       <div className="col-sm-12">
-        {/* alerts */}
-        {!isNotFound && <FixPageGrantAlert />}
-        <FullTextSearchNotCoverAlert />
         <WipPageAlert />
         <PageGrantAlert />
-        <TrashPageAlert />
         <PageStaleAlert />
         <OldRevisionAlert />
-        <PageRedirectedAlert />
+        <FixPageGrantAlertLazyLoaded />
+        <FullTextSearchNotCoverAlertLazyLoaded />
+        <TrashPageAlertLazyLoaded />
+        <PageRedirectedAlertLazyLoaded />
       </div>
     </div>
   );

+ 1 - 1
apps/app/src/components/PageView/PageAlerts/PageGrantAlert.tsx

@@ -1,4 +1,4 @@
-import React, { type JSX } from 'react';
+import type { JSX } from 'react';
 import { isPopulated } from '@growi/core';
 import { useTranslation } from 'react-i18next';
 

+ 0 - 59
apps/app/src/components/PageView/PageAlerts/PageRedirectedAlert.tsx

@@ -1,59 +0,0 @@
-import React, { type JSX, useCallback, useState } from 'react';
-import { useTranslation } from 'next-i18next';
-
-import { useCurrentPagePath, useRedirectFrom } from '~/states/page';
-
-export const PageRedirectedAlert = React.memo((): JSX.Element => {
-  const { t } = useTranslation();
-  const currentPagePath = useCurrentPagePath();
-  const redirectFrom = useRedirectFrom();
-
-  const [isUnlinked, setIsUnlinked] = useState(false);
-
-  const unlinkButtonClickHandler = useCallback(async () => {
-    if (currentPagePath == null) {
-      return;
-    }
-    try {
-      const unlink = (await import('~/client/services/page-operation')).unlink;
-      await unlink(currentPagePath);
-      setIsUnlinked(true);
-    } catch (err) {
-      const toastError = (await import('~/client/util/toastr')).toastError;
-      toastError(err);
-    }
-  }, [currentPagePath]);
-
-  if (redirectFrom == null || redirectFrom === '') {
-    return <></>;
-  }
-
-  if (isUnlinked) {
-    return (
-      <div className="alert alert-info d-edit-none py-3 px-4">
-        <strong>{t('Unlinked')}: </strong> {t('page_page.notice.unlinked')}
-      </div>
-    );
-  }
-
-  return (
-    <div className="alert alert-pink d-edit-none py-3 px-4 d-flex align-items-center justify-content-between">
-      <span>
-        <strong>{t('Redirected')}:</strong> {t('page_page.notice.redirected')}{' '}
-        <code>{redirectFrom}</code> {t('page_page.notice.redirected_period')}
-      </span>
-      <button
-        type="button"
-        id="unlink-page-button"
-        onClick={unlinkButtonClickHandler}
-        className="btn btn-outline-dark btn-sm float-end"
-      >
-        <span className="material-symbols-outlined" aria-hidden="true">
-          link_off
-        </span>
-        {t('unlink_redirection')}
-      </button>
-    </div>
-  );
-});
-PageRedirectedAlert.displayName = 'PageRedirectedAlert';

+ 77 - 0
apps/app/src/components/PageView/PageAlerts/PageRedirectedAlert/PageRedirectedAlert.tsx

@@ -0,0 +1,77 @@
+import React, { type JSX, useCallback, useState } from 'react';
+import { useTranslation } from 'next-i18next';
+
+import { useCurrentPagePath, useRedirectFrom } from '~/states/page';
+
+type SubstanceProps = {
+  redirectFrom: string;
+};
+
+const PageRedirectedAlertSubstance = React.memo(
+  (props: SubstanceProps): JSX.Element => {
+    const { t } = useTranslation();
+    const { redirectFrom } = props;
+    const currentPagePath = useCurrentPagePath();
+
+    const [isUnlinked, setIsUnlinked] = useState(false);
+
+    const unlinkButtonClickHandler = useCallback(async () => {
+      if (currentPagePath == null) {
+        return;
+      }
+      try {
+        const unlink = (await import('~/client/services/page-operation'))
+          .unlink;
+        await unlink(currentPagePath);
+        setIsUnlinked(true);
+      } catch (err) {
+        const toastError = (await import('~/client/util/toastr')).toastError;
+        toastError(err);
+      }
+    }, [currentPagePath]);
+
+    if (isUnlinked) {
+      return (
+        <div className="alert alert-info d-edit-none py-3 px-4">
+          <strong>{t('Unlinked')}: </strong> {t('page_page.notice.unlinked')}
+        </div>
+      );
+    }
+
+    return (
+      <div className="alert alert-pink d-edit-none py-3 px-4 d-flex align-items-center justify-content-between">
+        <span>
+          <strong>{t('Redirected')}:</strong> {t('page_page.notice.redirected')}{' '}
+          <code>{redirectFrom}</code> {t('page_page.notice.redirected_period')}
+        </span>
+        <button
+          type="button"
+          id="unlink-page-button"
+          onClick={unlinkButtonClickHandler}
+          className="btn btn-outline-dark btn-sm float-end"
+        >
+          <span className="material-symbols-outlined" aria-hidden="true">
+            link_off
+          </span>
+          {t('unlink_redirection')}
+        </button>
+      </div>
+    );
+  },
+);
+PageRedirectedAlertSubstance.displayName = 'PageRedirectedAlertSubstance';
+
+export const PageRedirectedAlert = React.memo((): JSX.Element => {
+  const redirectFrom = useRedirectFrom();
+
+  // Lightweight condition check in Container
+  if (redirectFrom == null || redirectFrom === '') {
+    // biome-ignore lint/complexity/noUselessFragments: ignore
+    return <></>;
+  }
+
+  // Render Substance only when redirectFrom exists
+  // Dynamic imports will only be executed when the unlink button is clicked
+  return <PageRedirectedAlertSubstance redirectFrom={redirectFrom} />;
+});
+PageRedirectedAlert.displayName = 'PageRedirectedAlert';

+ 20 - 0
apps/app/src/components/PageView/PageAlerts/PageRedirectedAlert/dynamic.tsx

@@ -0,0 +1,20 @@
+import type { JSX } from 'react';
+
+import { useLazyLoader } from '~/client/util/use-lazy-loader';
+import { useRedirectFrom } from '~/states/page';
+
+export const PageRedirectedAlertLazyLoaded = (): JSX.Element => {
+  const redirectFrom = useRedirectFrom();
+  const isActive = redirectFrom != null && redirectFrom !== '';
+
+  const PageRedirectedAlert = useLazyLoader<Record<string, unknown>>(
+    'page-redirected-alert',
+    () =>
+      import('./PageRedirectedAlert').then((mod) => ({
+        default: mod.PageRedirectedAlert,
+      })),
+    isActive,
+  );
+
+  return PageRedirectedAlert ? <PageRedirectedAlert /> : <></>;
+};

+ 1 - 0
apps/app/src/components/PageView/PageAlerts/PageRedirectedAlert/index.ts

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

+ 41 - 25
apps/app/src/components/PageView/PageAlerts/TrashPageAlert.tsx → apps/app/src/components/PageView/PageAlerts/TrashPageAlert/TrashPageAlert.tsx

@@ -24,17 +24,23 @@ const onDeletedHandler = (pathOrPathsToDelete) => {
   window.location.href = '/';
 };
 
-export const TrashPageAlert = (): JSX.Element => {
+type SubstanceProps = {
+  pageId: string;
+  pagePath: string;
+  revisionId: string;
+};
+
+const TrashPageAlertSubstance = (props: SubstanceProps): JSX.Element => {
   const { t } = useTranslation();
   const router = useRouter();
+  const { pageId, pagePath, revisionId } = props;
 
+  const pageData = useCurrentPageData();
   const isAbleToShowTrashPageManagementButtons =
     useIsAbleToShowTrashPageManagementButtons();
-  const pageData = useCurrentPageData();
-  const isTrashPage = useIsTrashPage();
-  const pageId = pageData?._id;
-  const pagePath = pageData?.path;
-  const { data: pageInfo } = useSWRxPageInfo(pageId ?? null);
+
+  // useSWRxPageInfo is executed only when Substance is rendered
+  const { data: pageInfo } = useSWRxPageInfo(pageId);
 
   const { open: openDeleteModal } = usePageDeleteModalActions();
   const { open: openPutBackPageModal } = usePutBackPageModalActions();
@@ -44,16 +50,10 @@ export const TrashPageAlert = (): JSX.Element => {
 
   const deleteUser = pageData?.deleteUser;
   const deletedAt = pageData?.deletedAt
-    ? format(new Date(pageData?.deletedAt), 'yyyy/MM/dd HH:mm')
+    ? format(new Date(pageData.deletedAt), 'yyyy/MM/dd HH:mm')
     : '';
-  const revisionId = pageData?.revision?._id;
-  const isEmptyPage = pageId == null || revisionId == null || pagePath == null;
 
   const openPutbackPageModalHandler = useCallback(() => {
-    // User cannot operate empty page.
-    if (isEmptyPage) {
-      return;
-    }
     const putBackedHandler = async () => {
       if (currentPagePath == null) {
         return;
@@ -76,7 +76,6 @@ export const TrashPageAlert = (): JSX.Element => {
       { onPutBacked: putBackedHandler },
     );
   }, [
-    isEmptyPage,
     openPutBackPageModal,
     pageId,
     pagePath,
@@ -86,10 +85,6 @@ export const TrashPageAlert = (): JSX.Element => {
   ]);
 
   const openPageDeleteModalHandler = useCallback(() => {
-    // User cannot operate empty page.
-    if (isEmptyPage) {
-      return;
-    }
     const pageToDelete = {
       data: {
         _id: pageId,
@@ -99,7 +94,7 @@ export const TrashPageAlert = (): JSX.Element => {
       meta: pageInfo,
     };
     openDeleteModal([pageToDelete], { onDeleted: onDeletedHandler });
-  }, [openDeleteModal, pageId, pageInfo, pagePath, revisionId, isEmptyPage]);
+  }, [openDeleteModal, pageId, pageInfo, pagePath, revisionId]);
 
   const renderTrashPageManagementButtons = useCallback(() => {
     return (
@@ -135,12 +130,6 @@ export const TrashPageAlert = (): JSX.Element => {
     t,
   ]);
 
-  // Show this alert only for non-empty pages in trash.
-  if (!isTrashPage || isEmptyPage) {
-    // biome-ignore lint/complexity/noUselessFragments: ignore
-    return <></>;
-  }
-
   return (
     <div
       className="alert alert-warning py-3 ps-4 d-flex flex-column flex-lg-row"
@@ -168,3 +157,30 @@ export const TrashPageAlert = (): JSX.Element => {
     </div>
   );
 };
+
+export const TrashPageAlert = (): JSX.Element => {
+  const pageData = useCurrentPageData();
+  const isTrashPage = useIsTrashPage();
+  const pageId = pageData?._id;
+  const pagePath = pageData?.path;
+  const revisionId = pageData?.revision?._id;
+
+  // Lightweight condition checks in Container
+  const isEmptyPage = pageId == null || revisionId == null || pagePath == null;
+
+  // Show this alert only for non-empty pages in trash.
+  if (!isTrashPage || isEmptyPage) {
+    // biome-ignore lint/complexity/noUselessFragments: ignore
+    return <></>;
+  }
+
+  // Render Substance only when conditions are met
+  // useSWRxPageInfo will be executed only here
+  return (
+    <TrashPageAlertSubstance
+      pageId={pageId}
+      pagePath={pagePath}
+      revisionId={revisionId}
+    />
+  );
+};

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