完了日: 2025-10-20
プロジェクト期間: 2025-10-15 〜 2025-10-20
最終成果: 34コンポーネント最適化完了 🎉
| カテゴリ | 完了数 | 詳細 |
|---|---|---|
| モーダル | 25個 | useLazyLoader動的ロード |
| PageAlerts | 4個 | Container-Presentation分離 + 条件付きレンダリング |
| Sidebar | 1個 | AiAssistantSidebar (useLazyLoader + SWR最適化) |
| その他 | 4個 | 既存のLazyLoaded実装 |
| 合計 | 34個 | 全体最適化達成 ✨ |
useLazyLoader実装: 汎用的な動的ローディングフック
3つのケース別最適化パターン確立:
PageAlerts最適化: Next.js dynamic()からuseLazyLoaderへの移行
Sidebar最適化: AiAssistantSidebar
ファイル: apps/app/src/client/util/use-lazy-loader.ts
特徴:
基本的な使い方:
const Component = useLazyLoader(
'unique-key', // グローバルキャッシュ用の一意なキー
() => import('./Component'), // dynamic import
isActive, // ロードトリガー条件
);
return Component ? <Component /> : null;
テスト: 12 tests passing
apps/app/.../[ComponentName]/
├── index.ts # エクスポート用 (named export)
├── [ComponentName].tsx # 実際のコンポーネント (named export)
└── dynamic.tsx # 動的ローダー (named export)
命名規則:
useLazyLoader[ComponentName]LazyLoadeddynamic.tsxケースA: 単一ファイル
ケースB: Container無Modal
<Modal> なし<Modal> 外枠追加 + リファクタリングケースC: Container有Modal ⭐
dynamic.tsx:
import type { JSX } from 'react';
import { useLazyLoader } from '~/components/utils/use-lazy-loader';
import { useShortcutsModalStatus } from '~/states/ui/modal/shortcuts';
export const ShortcutsModalLazyLoaded = (): JSX.Element => {
const status = useShortcutsModalStatus();
const ShortcutsModal = useLazyLoader(
'shortcuts-modal',
() => import('./ShortcutsModal').then(mod => ({ default: mod.ShortcutsModal })),
status?.isOpened ?? false,
);
return ShortcutsModal ? <ShortcutsModal /> : <></>;
};
index.ts:
export { ShortcutsModalLazyLoaded } from './dynamic';
BasicLayout.tsx:
// Before: Next.js dynamic()
const ShortcutsModal = dynamic(() => import('~/client/components/ShortcutsModal'), { ssr: false });
// After: 直接import (named)
import { ShortcutsModalLazyLoaded } from '~/client/components/ShortcutsModal';
特徴:
構造:
FixPageGrantAlert/
├── FixPageGrantModal.tsx (新規) - 342行のモーダルコンポーネント
├── FixPageGrantAlert.tsx (リファクタリング済み)
│ ├── FixPageGrantAlert (Container) - ~35行、簡素化
│ └── FixPageGrantAlertSubstance (Presentation) - ~30行
└── dynamic.tsx (useLazyLoader パターン)
Container (~35行):
export const FixPageGrantAlert = (): JSX.Element => {
const currentUser = useCurrentUser();
const pageData = useCurrentPageData();
const hasParent = pageData != null ? pageData.parent != null : false;
const pageId = pageData?._id;
const { data: dataIsGrantNormalized } = useSWRxCurrentGrantData(
currentUser != null ? pageId : null,
);
const { data: dataApplicableGrant } = useSWRxApplicableGrant(
currentUser != null ? pageId : null,
);
// Early returns for invalid states
if (pageData == null) return <></>;
if (!hasParent) return <></>;
if (dataIsGrantNormalized?.isGrantNormalized == null || dataIsGrantNormalized.isGrantNormalized) {
return <></>;
}
// Render Substance only when all conditions are met
if (pageId != null && dataApplicableGrant != null) {
return (
<FixPageGrantAlertSubstance
pageId={pageId}
dataApplicableGrant={dataApplicableGrant}
currentAndParentPageGrantData={dataIsGrantNormalized.grantData}
/>
);
}
return <></>;
};
効果:
特徴:
Container (~20行):
export const TrashPageAlert = (): JSX.Element => {
const pageData = useCurrentPageData();
const isTrashPage = useIsTrashPage();
const pageId = pageData?._id;
const pagePath = pageData?.path;
const revisionId = pageData?.revision?._id;
// Lightweight condition checks in Container
const isEmptyPage = pageId == null || revisionId == null || pagePath == null;
// Show this alert only for non-empty pages in trash.
if (!isTrashPage || isEmptyPage) {
return <></>;
}
// Render Substance only when conditions are met
// useSWRxPageInfo will be executed only here
return (
<TrashPageAlertSubstance
pageId={pageId}
pagePath={pagePath}
revisionId={revisionId}
/>
);
};
Substance (~130行):
const TrashPageAlertSubstance = (props: SubstanceProps): JSX.Element => {
const { pageId, pagePath, revisionId } = props;
const pageData = useCurrentPageData();
// useSWRxPageInfo is executed only when Substance is rendered
const { data: pageInfo } = useSWRxPageInfo(pageId);
// ... UI レンダリング + モーダル操作
};
効果:
useSWRxPageInfo が常に実行されるuseSWRxPageInfo が実行される構造:
AiAssistantSidebar/
├── dynamic.tsx (新規) - useLazyLoader パターン
├── AiAssistantSidebar.tsx (リファクタリング済み)
│ ├── AiAssistantSidebar (Container) - 簡素化、~30行
│ └── AiAssistantSidebarSubstance (Presentation) - 複雑なロジック、~500行
└── (その他のサブコンポーネント)
dynamic.tsx:
import type { FC } from 'react';
import { memo } from 'react';
import { useLazyLoader } from '~/components/utils/use-lazy-loader';
import { useAiAssistantSidebarStatus } from '../../../states';
export const AiAssistantSidebarLazyLoaded: FC = memo(() => {
const aiAssistantSidebarData = useAiAssistantSidebarStatus();
const isOpened = aiAssistantSidebarData?.isOpened ?? false;
const ComponentToRender = useLazyLoader(
'ai-assistant-sidebar',
() => import('./AiAssistantSidebar').then(mod => ({ default: mod.AiAssistantSidebar })),
isOpened,
);
if (ComponentToRender == null) {
return null;
}
return <ComponentToRender />;
});
Container の軽量化:
export const AiAssistantSidebar: FC = memo((): JSX.Element => {
const aiAssistantSidebarData = useAiAssistantSidebarStatus();
const { close: closeAiAssistantSidebar } = useAiAssistantSidebarActions();
const { disable: disableUnifiedMergeView } = useUnifiedMergeViewActions();
const aiAssistantData = aiAssistantSidebarData?.aiAssistantData;
const threadData = aiAssistantSidebarData?.threadData;
const isOpened = aiAssistantSidebarData?.isOpened;
const isEditorAssistant = aiAssistantSidebarData?.isEditorAssistant ?? false;
// useSWRxThreads を削除(Substance に移動)
useEffect(() => {
if (!aiAssistantSidebarData?.isOpened) {
disableUnifiedMergeView();
}
}, [aiAssistantSidebarData?.isOpened, disableUnifiedMergeView]);
if (!isOpened) {
return <></>;
}
return (
<div className="...">
<AiAssistantSidebarSubstance
isEditorAssistant={isEditorAssistant}
threadData={threadData}
aiAssistantData={aiAssistantData}
onCloseButtonClicked={closeAiAssistantSidebar}
/>
</div>
);
});
Substance に useSWRxThreads を移動:
const AiAssistantSidebarSubstance: React.FC<Props> = (props) => {
// useSWRxThreads is executed only when Substance is rendered
const { data: threads, mutate: mutateThreads } = useSWRxThreads(aiAssistantData?._id);
const { refreshThreadData } = useAiAssistantSidebarActions();
// refresh thread data when the data is changed
useEffect(() => {
if (threads == null) return;
const currentThread = threads.find(t => t.threadId === threadData?.threadId);
if (currentThread != null) {
refreshThreadData(currentThread);
}
}, [threads, refreshThreadData, threadData?.threadId]);
// ... UI レンダリング
};
効果:
useSWRxThreads が実行される(isOpened が false でも)useSWRxThreads が実行されるSession 1完了 (6個) ✅:
Session 2完了 (11個) ✅:
Session 3 & 4完了 (2個) ✅:
Session 5完了 (2025-10-17) ✅:
全てPageAlerts.tsxでuseLazyLoaderを使用した動的ロード実装に変更。
TrashPageAlert (171行)
isTrashPagePageRedirectedAlert (60行)
redirectFrom != null && redirectFrom !== ''FullTextSearchNotCoverAlert (40行)
markdownLength > elasticsearchMaxBodyLengthToIndexFixPageGrantAlert ⭐ 最重要 (412行)
!dataIsGrantNormalized.isGrantNormalizedSession 6完了 (2025-10-20) ✅:
AiAssistantSidebar (約600行)
既にuseLazyLoaderパターンで実装済み:
管理画面自体が遅延ロードされており、使用頻度が極めて低いため最適化不要:
完了済み: ████████████████████████████████████████████████████████████ 34/53 (64%) 🎉
スキップ: ████████ 8/53 (15%)
対象外: ██ 2/53 (4%)
不要: ███████████ 11/53 (21%)
V3最適化完了! 🎉
useLazyLoader実装: 汎用的な動的ローディングフック
3つのケース別最適化パターン確立:
PageAlerts最適化: Next.js dynamic()からuseLazyLoaderへの移行
Sidebar最適化: AiAssistantSidebar
現在スキップ・対象外としている19個について、将来的に再評価可能:
V3最適化プロジェクト完了! 🎉
apps-app-modal-performance-optimization-v2-completion-summary.mdapps/app/src/client/util/use-lazy-loader.tsapps/app/src/client/util/use-lazy-loader.spec.tsx正しい判断基準:
親の遅延ロード ≠ 子の遅延ロード:
Container-Presentation分離の効果: