GROWIのPageTreeは、@headless-tree/react と @tanstack/react-virtual を使用したVirtualized Tree実装です。
5000件以上の兄弟ページでも快適に動作するよう設計されています。
src/features/page-tree/
├── index.ts # メインエクスポート
├── components/
│ ├── ItemsTree.tsx # コアvirtualizedツリーコンポーネント
│ ├── ItemsTree.spec.tsx # テスト
│ ├── TreeItemLayout.tsx # 汎用ツリーアイテムレイアウト
│ ├── TreeItemLayout.module.scss
│ ├── SimpleItemContent.tsx # シンプルなアイテムコンテンツ表示
│ ├── SimpleItemContent.module.scss
│ ├── TreeNameInput.tsx # リネーム/新規作成用入力コンポーネント
│ ├── _tree-item-variables.scss # SCSS変数
│ └── index.ts
├── hooks/
│ ├── use-page-rename.tsx # Renameビジネスロジック
│ ├── use-page-create.tsx # Createビジネスロジック
│ ├── use-page-create.spec.tsx
│ ├── use-page-dnd.tsx # Drag & Dropビジネスロジック
│ ├── use-page-dnd.spec.ts
│ ├── use-page-dnd.module.scss # D&D用スタイル
│ ├── use-placeholder-rename-effect.ts # プレースホルダーリネームエフェクト
│ ├── use-socket-update-desc-count.ts # Socket.ioリアルタイム更新フック
│ ├── index.ts
│ └── _inner/
│ ├── use-data-loader.ts # データローダーフック
│ ├── use-data-loader.spec.tsx
│ ├── use-data-loader.integration.spec.tsx
│ ├── use-scroll-to-selected-item.ts # スクロール制御フック
│ ├── use-tree-features.ts # Feature統合フック(checkbox・DnD含む)
│ ├── use-tree-revalidation.ts # ツリー再検証フック
│ ├── use-tree-item-handlers.tsx # アイテムハンドラーフック
│ ├── use-auto-expand-ancestors.ts # 祖先自動展開フック
│ ├── use-auto-expand-ancestors.spec.tsx
│ ├── use-expand-parent-on-create.ts # 作成時親展開フック
│ ├── use-checkbox.ts # チェックボックス状態フック
│ └── index.ts
├── interfaces/
│ └── index.ts # TreeItemProps, TreeItemToolProps
├── states/
│ ├── page-tree-update.ts # ツリー更新状態(Jotai)
│ ├── page-tree-desc-count-map.ts # 子孫カウント状態(Jotai)
│ ├── index.ts
│ └── _inner/
│ ├── page-tree-create.ts # 作成中状態(Jotai)
│ ├── page-tree-create.spec.tsx
│ └── tree-rebuild.ts # ツリー再構築状態
├── services/
│ └── page-tree-children.ts # 子ページ取得サービス
└── constants/
└── _inner.ts # ROOT_PAGE_VIRTUAL_ID
以下は components/Sidebar/PageTreeItem/ に残留:
PageTreeItem.tsx - Sidebar専用の実装CountBadgeForPageTreeItem.tsx - PageTree専用バッジuse-page-item-control.tsx - コンテキストメニュー制御ファイル: features/page-tree/components/ItemsTree.tsx
Virtualizedツリーのコアコンポーネント。@headless-tree/react と @tanstack/react-virtual を統合。
interface ItemsTreeProps {
// 表示対象のターゲットパスまたはID
targetPathOrId: string | null;
// WIPページを表示するか
isWipPageShown?: boolean;
// 仮想スクロール用の親要素
scrollerElem: HTMLElement | null;
// カスタムTreeItemコンポーネント
CustomTreeItem?: React.ComponentType<TreeItemProps<IPageForTreeItem>>;
// チェックボックス機能
enableCheckboxes?: boolean;
initialCheckedItems?: string[];
onCheckedItemsChange?: (checkedItems: IPageForTreeItem[]) => void;
}
asyncDataLoaderFeature - 非同期データローディングselectionFeature - 選択機能renamingFeature - リネーム機能hotkeysCoreFeature - キーボードショートカットcheckboxesFeature - チェックボックス(オプション)dragAndDropFeature - ドラッグ&ドロップ(オプション)use-data-loader.ts で既存API(/page-listing/root, /page-listing/children)を活用@tanstack/react-virtual の useVirtualizer を使用、overscan: 5 で最適化scrollToIndex で選択アイテムまでスクロールファイル: features/page-tree/components/TreeItemLayout.tsx
汎用的なツリーアイテムレイアウト。展開/折りたたみ、アイコン、カスタムコンポーネントを配置。
interface TreeItemLayoutProps {
page: IPageForTreeItem;
level: number;
isOpen: boolean;
isSelected: boolean;
onToggle?: () => void;
onClick?: () => void;
// カスタムコンポーネント
customEndComponents?: React.ReactNode[];
customHoveredEndComponents?: React.ReactNode[];
customAlternativeComponents?: React.ReactNode[];
showAlternativeContent?: boolean;
}
useEffect(() => {
if (isExpanded) return;
const isPathToTarget = page.path != null
&& targetPath.startsWith(addTrailingSlash(page.path))
&& targetPath !== page.path;
if (isPathToTarget) onToggle?.();
}, [targetPath, page.path, isExpanded, onToggle]);
ファイル: components/Sidebar/PageTreeItem/PageTreeItem.tsx
Sidebar用のツリーアイテム実装。TreeItemLayoutを使用し、Rename/Create/Control機能を統合。
実装ファイル:
features/page-tree/hooks/use-page-rename.tsxfeatures/page-tree/components/TreeNameInput.tsxconst { rename, isRenaming, RenameAlternativeComponent } = usePageRename(item);
// TreeItemLayoutに渡す
<TreeItemLayout
showAlternativeContent={isRenaming(item)}
customAlternativeComponents={[RenameAlternativeComponent]}
/>
実装ファイル:
features/page-tree/hooks/use-page-create.tsxfeatures/page-tree/components/TreeNameInput.tsxfeatures/page-tree/states/_inner/page-tree-create.ts// page-tree-create.ts
creatingParentIdAtom: 作成中の親ノードID
useCreatingParentId(): 現在の作成中親ID取得
useIsCreatingChild(parentId): 特定アイテムが作成中か判定
usePageTreeCreateActions(): startCreating, cancelCreating
const { isCreatingChild, CreateInputComponent, startCreating } = usePageCreate(item);
// PageTreeItemで使用
{isCreatingChild() && <CreateInputComponent />}
実装ファイル:
features/page-tree/hooks/use-page-dnd.tsxfeatures/page-tree/hooks/use-page-dnd.module.scssfeatures/page-tree/hooks/_inner/use-tree-features.tsページをドラッグ&ドロップして別のページの子として移動する機能。複数選択D&Dにも対応。
<ItemsTree
enableDragAndDrop={true}
// ...他のprops
/>
usePageDnd(isEnabled): D&Dロジックを提供するフック(UsePageDndPropertiesを返す)
canDrag: ドラッグ可否判定canDrop: ドロップ可否判定onDrop: ドロップ時の処理(APIコール、ツリー更新)renderDragLine: ドラッグライン描画(treeインスタンスを引数に取る)統合方法:
useTreeFeaturesが内部でusePageDndを呼び出し、dndPropertiesとして返すdndProperties.renderDragLine(tree)を呼び出してドラッグライン表示canDrag チェック項目:
pagePathUtils.isUsersProtectedPages(path)がtrueの場合は禁止canDrop チェック項目:
pagePathUtils.isUsersTopPage(targetPath)がtrueの場合は禁止pagePathUtils.canMoveByPath(fromPath, newPath)で検証operation__blockedエラー: 「このページは現在移動できません」トースト表示/pages/renameエンドポイントで各ページを新しいパスに移動mutatePageTree()でページツリーデータを再取得notifyUpdateItems()で親ノードの子リストを無効化targetItem.invalidateItemData()でdescendantCountを再取得targetItem.expand()でドロップ先を展開実装ファイル:
features/page-tree/hooks/use-socket-update-desc-count.tsfeatures/page-tree/states/page-tree-desc-count-map.tsfeatures/page-tree/states/page-tree-update.tsdescendantCountバッジの更新 と ツリー構造の更新 は別々の関心事として分離:
| 更新タイプ | トリガー | 動作 | 対象 |
|---|---|---|---|
| バッジ更新 | Socket.io UpdateDescCount |
数字のみ更新(軽量) | 全祖先 |
| ツリー構造更新 | リロードボタン / 自分の操作後 | 子リスト再取得(重い) | 操作した本人のみ |
この分離の理由:
ItemsTreeコンポーネント内で自動的に有効化されます。
// ItemsTree.tsx内で呼び出し
useSocketUpdateDescCount();
UpdateDescCount: ページの子孫カウント(descendantCount)の更新
export const useSocketUpdateDescCount = (): void => {
const socket = useGlobalSocket();
const { update: updatePtDescCountMap } = usePageTreeDescCountMapAction();
useEffect(() => {
if (socket == null) return;
const handler = (data: UpdateDescCountRawData) => {
// バッジの数字のみ更新(ツリー構造は更新しない)
const newData: UpdateDescCountData = new Map(Object.entries(data));
updatePtDescCountMap(newData);
};
socket.on(SocketEventName.UpdateDescCount, handler);
return () => socket.off(SocketEventName.UpdateDescCount, handler);
}, [socket, updatePtDescCountMap]);
};
ツリー構造(子リスト)の更新は以下のタイミングで行われる:
notifyUpdateAllTrees() を呼び出し、全ツリーを再取得自分の操作後:
notifyUpdateItems([parentId]) を呼び出し操作した親ノードの子リストのみ再取得
// リロードボタンの例
const { notifyUpdateAllTrees } = usePageTreeInformationUpdate();
const handleReload = () => notifyUpdateAllTrees();
// 操作完了後の例(Create, Delete, Move)
const { notifyUpdateItems } = usePageTreeInformationUpdate();
const handleOperationComplete = (parentId: string) => notifyUpdateItems([parentId]);
page-tree-desc-count-map.ts: 子孫カウントを管理するJotai atom
usePageTreeDescCountMap(): カウント取得(バッジ表示用)usePageTreeDescCountMapAction(): カウント更新(Socket.ioから)page-tree-update.ts: ツリー更新を管理するJotai atom
generationAtom: 更新世代番号lastUpdatedItemIdsAtom: 更新対象アイテムID(nullは全体更新)usePageTreeInformationUpdate(): 更新通知(notifyUpdateItems, notifyUpdateAllTrees)usePageTreeRevalidationEffect(): 更新検知と再取得実行使用箇所: AiAssistantManagementPageTreeSelection.tsx
ItemsTreeのcheckboxesオプションを使用。
<ItemsTree
enableCheckboxes={true}
initialCheckedItems={['page-id-1', 'page-id-2']}
onCheckedItemsChange={(checkedItems) => {
// チェック変更時の処理
// ページパスに `/*` を付加して保存
}}
/>
フック構成:
useTreeFeatures: feature設定とチェックボックス・D&D機能を統合管理useCheckbox: チェックボックス状態管理(checkedItemIds, setCheckedItems, createNotifyEffect)createNotifyEffect: 親コンポーネントへの変更通知用ヘルパー関数を提供循環依存の回避:
useTreeFeaturesはtreeインスタンスに依存しないcreateNotifyEffectがtreeインスタンスとコールバックを受け取り、useEffectのコールバック関数を返すuseEffect(createNotifyEffect(tree, onCheckedItemsChange), [createNotifyEffect, tree])を呼び出す設定:
checkboxesFeature を条件付きで追加propagateCheckedState: false で子への伝播を無効化canCheckFolders: true でフォルダもチェック可能GET /page-listing/root
→ ルートページ "/" のデータ
GET /page-listing/children?id={pageId}
→ 指定ページの直下の子のみ
GET /page-listing/item?id={pageId}
→ 単一ページデータ(新規追加)
interface IPageForTreeItem {
_id: string;
path: string;
parent?: string;
descendantCount: number;
revision?: string;
grant: PageGrant;
isEmpty: boolean;
wip: boolean;
processData?: IPageOperationProcessData;
}
useTree<IPageForTreeItem> でカスタム型を指定const tree = useTree<IPageForTreeItem>({
rootItemId: "root",
dataLoader: {
getItem: async (itemId) => await api.fetchItem(itemId),
getChildren: async (itemId) => await api.fetchChildren(itemId),
},
createLoadingItemData: () => ({ /* loading state */ }),
features: [asyncDataLoaderFeature],
});
const item = tree.getItemInstance("item1");
item.invalidateItemData(); // アイテムデータの再取得
item.invalidateChildrenIds(); // 子IDリストの再取得
const items = tree.getItems(); // フラット化されたアイテムリスト
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => scrollElementRef.current,
estimateSize: () => 32,
overscan: 5,
});
tree.getItems(): フラット化されたツリーアイテムのリストtree.getItemInstance(id): IDからアイテムインスタンスを取得tree.getContainerProps(): ツリーコンテナのprops(ホットキー有効化に必須)tree.rebuildTree(): ツリー構造を再構築item.getProps(): アイテム要素のpropsitem.getId(): アイテムIDitem.getItemData(): カスタムペイロード(IPageForTreeItem)item.getItemMeta(): メタデータ(level, indexなど)item.isFolder(): フォルダかどうかitem.isExpanded(): 展開されているかitem.expand() / item.collapse(): 展開/折りたたみitem.startRenaming(): リネームモード開始item.isRenaming(): リネーム中か判定@headless-tree/core の asyncDataLoaderFeature は内部キャッシュを持ち、invalidateChildrenIds() メソッドでキャッシュを無効化できます。
invalidateChildrenIds(optimistic?: boolean) の動作:
// 内部実装(feature.ts より)
invalidateChildrenIds: async ({ tree, itemId }, optimistic) => {
if (!optimistic) {
delete getDataRef(tree).current.childrenIds?.[itemId]; // キャッシュ削除
}
await loadChildrenIds(tree, itemId); // データ再取得
// loadChildrenIds 内で自動的に tree.rebuildTree() が呼ばれる
};
optimistic パラメータの影響:
| パラメータ | 動作 | 用途 |
|---|---|---|
false (デフォルト) |
ローディング状態を更新、再レンダリングをトリガー | 最後の呼び出しに使用 |
true |
ローディング状態を更新しない、古いデータを表示し続ける | バッチ処理の途中に使用 |
パフォーマンス最適化パターン:
// ❌ 非効率: 全アイテムに optimistic=false
items.forEach(item => item.invalidateChildrenIds(false));
// → 各呼び出しで rebuildTree() が実行され、N回の再構築が発生
// ✅ 効率的: 展開済みアイテムのみ対象、最後だけ optimistic=false
const expandedItems = tree.getItems().filter(item => item.isExpanded());
expandedItems.forEach(item => item.invalidateChildrenIds(true)); // 楽観的
rootItem.invalidateChildrenIds(false); // 最後に1回だけ再構築
実際の実装 (page-tree-update.ts):
useEffect(() => {
if (globalGeneration <= generation) return;
const shouldUpdateAll = globalLastUpdatedItemIds == null;
if (shouldUpdateAll) {
// pendingリクエストキャッシュをクリア
invalidatePageTreeChildren();
// 展開済みアイテムのみ楽観的に無効化(rebuildTree回避)
const expandedItems = tree.getItems().filter(item => item.isExpanded());
expandedItems.forEach(item => item.invalidateChildrenIds(true));
// ルートのみ optimistic=false で再構築トリガー
getItemInstance(ROOT_PAGE_VIRTUAL_ID)?.invalidateChildrenIds(false);
} else {
// 部分更新: 指定アイテムのみ
invalidatePageTreeChildren(globalLastUpdatedItemIds);
globalLastUpdatedItemIds.forEach(itemId => {
getItemInstance(itemId)?.invalidateChildrenIds(false);
});
}
onRevalidatedRef.current?.();
}, [globalGeneration, generation, getItemInstance, globalLastUpdatedItemIds, tree]);
overscan: 5 で表示範囲外の先読みestimateSize: 32 でアイテム高さを推定invalidateChildrenIds() で明示的に無効化可能// Jotai atomでツリー更新を通知
const { notifyUpdateItems } = usePageTreeInformationUpdate();
notifyUpdateItems(updatedPages);
// SWRでページデータを再取得
const { mutate: mutatePageTree } = useSWRxPageTree();
await mutatePageTree();
なし(全機能実装済み)
hotkeysCoreFeature と getContainerProps() の組み合わせが必須。
getContainerProps() がないとホットキーが動作しない。
操作完了後は以下を呼び出す:
mutatePageTree() - SWRでデータ再取得notifyUpdateItems() - Jotai atomで更新通知以下のファイルはTypeScriptエラーあり(許容):
ItemsTree.tsx - 旧実装PageTreeItem.tsx - 旧Sidebar用TreeItemForModal.tsx - 旧Modal用