Yuki Takei 5 месяцев назад
Родитель
Сommit
c6e10d185e

+ 16 - 6
.serena/memories/apps-app-modal-list-for-v3.md

@@ -1,5 +1,13 @@
 # モーダル一覧 - V3動的ロード対象
 
+## V3進捗状況
+
+**実装完了**: 2/46モーダル (2025-10-15)
+- ✅ PageAccessoriesModal
+- ✅ ShortcutsModal
+
+---
+
 ## V2完了モーダル (46個) - V3動的ロード候補
 
 ### 高頻度使用 - 動的ロード非推奨 (2個)
@@ -8,12 +16,12 @@
 2. PageCreateModal.tsx - ページ作成 (重要機能)
 
 ### 中頻度使用 - 動的ロードを検討 (6個)
-- PageAccessoriesModal.tsx
-- PageDeleteModal.tsx
-- PageRenameModal.tsx
-- PageDuplicateModal.tsx
-- DescendantsPageListModal.tsx
-- ShortcutsModal.tsx
+- PageAccessoriesModal.tsx
+- ✅ ShortcutsModal.tsx
+- [ ] PageDeleteModal.tsx
+- [ ] PageRenameModal.tsx
+- [ ] PageDuplicateModal.tsx
+- [ ] DescendantsPageListModal.tsx
 
 ### 低頻度使用 - 動的ロード確定 (38個)
 - PageBulkExportSelectModal.tsx
@@ -46,6 +54,8 @@
 - PageSelectModal.tsx
 - その他9個
 
+---
+
 ## Container-Presentation構造 (V2成果)
 
 多くのモーダルは以下の構造:

+ 426 - 60
.serena/memories/apps-app-modal-performance-optimization-v3.md

@@ -37,6 +37,7 @@
 1. **useLazyLoader**: 汎用的な動的ローディングフック (コンポーネントのアクティブ/非アクティブ状態に応じて動的ロード)
 2. **グローバルキャッシュ**: 同じimportの重複実行防止
 3. **責務の分離**: モーダルロジックと動的ローディングロジックの分離
+4. **Named Export**: コード可読性とメンテナンス性のため、named exportを標準とする
 
 ## 実装
 
@@ -50,9 +51,12 @@ import { useState, useEffect, useCallback } from 'react';
 // 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 (!componentCache.has(key)) {
     componentCache.set(key, importFn());
@@ -60,6 +64,19 @@ const getCachedImport = <T>(
   return componentCache.get(key)!;
 };
 
+/**
+ * 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
  * 
@@ -80,19 +97,29 @@ const getCachedImport = <T>(
  * // For conditional panels
  * const AdminPanel = useLazyLoader('admin-panel', () => import('./AdminPanel'), isAdmin);
  */
-export const useLazyLoader = <T extends {}>(
+export const useLazyLoader = <T extends Record<string, unknown>>(
   importKey: string,
   importFn: () => Promise<{ default: React.ComponentType<T> }>,
-  isActive: boolean
-) => {
+  isActive: boolean,
+): React.ComponentType<T> | null => {
   const [Component, setComponent] = useState<React.ComponentType<T> | null>(null);
 
   const memoizedImportFn = useCallback(importFn, [importKey]);
 
   useEffect(() => {
-    if (isActive && !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);
+        });
     }
   }, [isActive, Component, importKey, memoizedImportFn]);
 
@@ -100,131 +127,470 @@ export const useLazyLoader = <T extends {}>(
 };
 ```
 
+**テスト**: `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)
 ```
 
-## リファクタリング手順
+### 3. Named Exportベストプラクティス
 
-### ステップ 1: ディレクトリ構造の変更
+**原則**: 全てのモーダルコンポーネントでnamed exportを使用する
 
-既存の単一ファイルを以下のように分割:
+**理由**:
+- コード可読性の向上(importで何をインポートしているか明確)
+- IDE/エディタのサポート向上(auto-import、リファクタリング)
+- 一貫性の維持(プロジェクト全体で統一されたパターン)
 
+**実装例**:
+```tsx
+// ❌ Default Export (非推奨)
+export default ShortcutsModal;
+
+// ✅ Named Export (推奨)
+export const ShortcutsModal = () => { /* ... */ };
+
+// dynamic.tsx
+export const ShortcutsModalDynamic = () => {
+  const Modal = useLazyLoader(
+    'shortcuts-modal',
+    () => import('./ShortcutsModal').then(mod => ({ default: mod.ShortcutsModal })),
+    isOpened,
+  );
+  return Modal ? <Modal /> : <></>;
+};
+
+// index.ts
+export { ShortcutsModalDynamic as ShortcutsModal } from './dynamic';
+
+// BasicLayout.tsx
+import { ShortcutsModal } from '~/client/components/ShortcutsModal';
 ```
-Before:  TemplateModal/
-        ├── index.ts
-        └── TemplateModal.ts
+
+---
+
+## リファクタリング手順: 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 TemplateModalDynamic = (): 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 { TemplateModalDynamic as TemplateModal } 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 { TemplateModal } from '~/components/TemplateModal';
+```
 
-**ファイル**: `TemplateModal/dynamic.ts`
+---
 
+### ケースB: Container-Presentation分離済み、但しContainerに`<Modal>`外枠なし
+
+**現状**: `Substance`と`Container`があるが、Containerは早期returnのみで`<Modal>`を持たない
+
+**例**:
 ```tsx
-import React from 'react';
-import { Modal } from 'reactstrap';
-import { useLazyLoader } from '~/client/util/use-lazy-loader';
-import { useTemplateModal } from '~/hooks/useTemplateModal';
+const TemplateModalSubstance = () => { /* 全ての実装 + <Modal> */ };
 
-export const TemplateModalDynamic = (): JSX.Element => {
-  const { data: templateModalStatus, close } = useTemplateModal();
-  
-  const TemplateModal = useLazyLoader(
-    '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>`外枠あり ⭐
 
-**ファイル**: `TemplateModal/index.ts`
+**現状**: 既にV2で理想的な構造になっている(最も簡単なケース)
 
+**例**:
 ```tsx
-export { TemplateModalDynamic as TemplateModal } from './dynamic';
+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
+
+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 ShortcutsModalDynamic = (): 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 { ShortcutsModalDynamic as ShortcutsModal } from './dynamic';
 ```
 
+4. **BasicLayout.tsx**
+```tsx
+// Before
+const ShortcutsModal = dynamic(() => import('~/client/components/ShortcutsModal'), { ssr: false });
+
+// After
+import { ShortcutsModal } from '~/client/components/ShortcutsModal';
+```
+
+**作業時間**: 約5分(ケースCは非常に高速)
+
+---
+
 ## チェックリスト
 
 ### 実装確認項目
+- [ ] **ケース判定完了**: モーダルがA/B/Cのどのケースか確認
 - [ ] `useLazyLoader` フックが作成済み
-- [ ] モーダルディレクトリが作成済み(index.ts, [Modal].ts, dynamic.ts)
-- [ ] 実際のモーダルコンポーネントが分離済み
+- [ ] モーダルディレクトリが作成済み(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エラーが発生しない
 
+---
+
 ## 注意点
 
 ### パフォーマンス
 - グローバルキャッシュにより同じ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を実行できる
 
-## 他のモーダルへの適用
+---
 
-同じパターンを、使用頻度が高いとはいえないモーダルに関して適用する
+## 他のモーダルへの適用優先度
 
+### 高優先度(低頻度使用モーダル)
 1. LinkEditModal
 2. TagEditModal
 3. ConflictDiffModal
-4. その他の使用頻度が高いとはいえないモーダルコンポーネント
+4. 管理者専用モーダル群
+
+### 中優先度(中頻度使用モーダル)
+- PageAccessoriesModal ✅ (完了)
+- ShortcutsModal ✅ (完了)
+- PageDuplicateModal
+- PageRenameModal
+- PageDeleteModal
+
+### 低優先度(高頻度使用モーダル)
+- PageCreateModal(使用頻度が非常に高いため保留)
+- SearchModal(使用頻度が非常に高いため保留)
+
+各モーダルで `importKey` を一意にし、適切な状態管理フックを使用することで同様の効果を得られる。
+
+---
+
+## 最短経路での指示テンプレート
+
+### ケース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)
 
-各モーダルで `importKey` を一意にし、適切な状態管理フックを使用することで同様の効果を得られる。
+【変更】[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>)
+
+【手順】最短経路(所要時間: 約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)
+
+【変更】[Modal].tsx本体はnamed export化のみ(実装は変更なし)
+【達成】動的ロード効果を即座に獲得
+```