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

Merge pull request #10581 from growilabs/feat/page-tree-virtualization

feat: PageTree Virtualization
Yuki Takei 4 месяцев назад
Родитель
Сommit
d7e8c01aa6
85 измененных файлов с 6263 добавлено и 1636 удалено
  1. 67 6
      .serena/memories/apps-app-jotai-directory-structure.md
  2. 683 0
      .serena/memories/apps-app-page-tree-specification.md
  3. 0 186
      .serena/memories/apps-app-pagetree-performance-refactor-plan.md
  4. 1 13
      apps/app/.eslintrc.js
  5. 3 0
      apps/app/package.json
  6. 0 1
      apps/app/src/client/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  7. 0 18
      apps/app/src/client/components/ItemsTree/ItemNode.ts
  8. 0 4
      apps/app/src/client/components/ItemsTree/ItemsTree.module.scss
  9. 0 164
      apps/app/src/client/components/ItemsTree/ItemsTree.tsx
  10. 0 2
      apps/app/src/client/components/ItemsTree/index.ts
  11. 40 35
      apps/app/src/client/components/PageSelectModal/PageSelectModal.tsx
  12. 25 13
      apps/app/src/client/components/PageSelectModal/TreeItemForModal.tsx
  13. 13 67
      apps/app/src/client/components/Sidebar/PageTree/PageTreeSubstance.tsx
  14. 4 3
      apps/app/src/client/components/Sidebar/PageTreeItem/CountBadgeForPageTreeItem.tsx
  15. 108 162
      apps/app/src/client/components/Sidebar/PageTreeItem/PageTreeItem.tsx
  16. 10 116
      apps/app/src/client/components/Sidebar/PageTreeItem/use-page-item-control.tsx
  17. 1 3
      apps/app/src/client/components/Sidebar/SidebarHeaderReloadButton.tsx
  18. 0 1
      apps/app/src/client/components/TemplateModal/TemplateModal.tsx
  19. 0 18
      apps/app/src/client/components/TreeItem/ItemNode.ts
  20. 0 37
      apps/app/src/client/components/TreeItem/NewPageInput/NewPageCreateButton.tsx
  21. 0 6
      apps/app/src/client/components/TreeItem/NewPageInput/NewPageInput.module.scss
  22. 0 1
      apps/app/src/client/components/TreeItem/NewPageInput/index.ts
  23. 0 180
      apps/app/src/client/components/TreeItem/NewPageInput/use-new-page-input.tsx
  24. 0 233
      apps/app/src/client/components/TreeItem/TreeItemLayout.tsx
  25. 0 5
      apps/app/src/client/components/TreeItem/index.ts
  26. 0 38
      apps/app/src/client/components/TreeItem/interfaces/index.ts
  27. 5 4
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementPageTreeSelection.module.scss
  28. 21 133
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementPageTreeSelection.tsx
  29. 51 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/PageTreeSelectionTree.tsx
  30. 40 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/SelectedPagesPanel.tsx
  31. 10 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/TreeItemWithCheckbox.module.scss
  32. 62 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/TreeItemWithCheckbox.tsx
  33. 88 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/hooks/use-page-tree-selection.ts
  34. 18 18
      apps/app/src/features/opentelemetry/server/anonymization/handlers/page-listing-api-handler.spec.ts
  35. 2 2
      apps/app/src/features/opentelemetry/server/anonymization/handlers/page-listing-api-handler.ts
  36. 659 0
      apps/app/src/features/page-tree/components/ItemsTree.spec.tsx
  37. 230 0
      apps/app/src/features/page-tree/components/ItemsTree.tsx
  38. 0 0
      apps/app/src/features/page-tree/components/SimpleItemContent.module.scss
  39. 28 14
      apps/app/src/features/page-tree/components/SimpleItemContent.tsx
  40. 7 0
      apps/app/src/features/page-tree/components/TreeItemLayout.module.scss
  41. 163 0
      apps/app/src/features/page-tree/components/TreeItemLayout.tsx
  42. 105 0
      apps/app/src/features/page-tree/components/TreeNameInput.tsx
  43. 1 0
      apps/app/src/features/page-tree/components/_tree-item-variables.scss
  44. 4 0
      apps/app/src/features/page-tree/components/index.ts
  45. 2 0
      apps/app/src/features/page-tree/constants/_inner.ts
  46. 8 0
      apps/app/src/features/page-tree/hooks/_inner/index.ts
  47. 294 0
      apps/app/src/features/page-tree/hooks/_inner/use-auto-expand-ancestors.spec.tsx
  48. 107 0
      apps/app/src/features/page-tree/hooks/_inner/use-auto-expand-ancestors.ts
  49. 81 0
      apps/app/src/features/page-tree/hooks/_inner/use-checkbox.ts
  50. 346 0
      apps/app/src/features/page-tree/hooks/_inner/use-data-loader.integration.spec.tsx
  51. 490 0
      apps/app/src/features/page-tree/hooks/_inner/use-data-loader.spec.tsx
  52. 120 0
      apps/app/src/features/page-tree/hooks/_inner/use-data-loader.ts
  53. 59 0
      apps/app/src/features/page-tree/hooks/_inner/use-expand-parent-on-create.ts
  54. 37 0
      apps/app/src/features/page-tree/hooks/_inner/use-scroll-to-selected-item.ts
  55. 84 0
      apps/app/src/features/page-tree/hooks/_inner/use-tree-features.ts
  56. 125 0
      apps/app/src/features/page-tree/hooks/_inner/use-tree-item-handlers.tsx
  57. 51 0
      apps/app/src/features/page-tree/hooks/_inner/use-tree-revalidation.ts
  58. 5 0
      apps/app/src/features/page-tree/hooks/index.ts
  59. 219 0
      apps/app/src/features/page-tree/hooks/use-page-create.spec.tsx
  60. 281 0
      apps/app/src/features/page-tree/hooks/use-page-create.tsx
  61. 7 0
      apps/app/src/features/page-tree/hooks/use-page-dnd.module.scss
  62. 97 0
      apps/app/src/features/page-tree/hooks/use-page-dnd.spec.ts
  63. 270 0
      apps/app/src/features/page-tree/hooks/use-page-dnd.tsx
  64. 124 0
      apps/app/src/features/page-tree/hooks/use-page-rename.tsx
  65. 265 0
      apps/app/src/features/page-tree/hooks/use-placeholder-rename-effect.spec.tsx
  66. 59 0
      apps/app/src/features/page-tree/hooks/use-placeholder-rename-effect.ts
  67. 39 0
      apps/app/src/features/page-tree/hooks/use-socket-update-desc-count.ts
  68. 3 0
      apps/app/src/features/page-tree/index.ts
  69. 45 0
      apps/app/src/features/page-tree/interfaces/index.ts
  70. 1 0
      apps/app/src/features/page-tree/services/index.ts
  71. 53 0
      apps/app/src/features/page-tree/services/page-tree-children.ts
  72. 2 0
      apps/app/src/features/page-tree/states/_inner/index.ts
  73. 170 0
      apps/app/src/features/page-tree/states/_inner/page-tree-create.spec.tsx
  74. 107 0
      apps/app/src/features/page-tree/states/_inner/page-tree-create.ts
  75. 31 0
      apps/app/src/features/page-tree/states/_inner/tree-rebuild.ts
  76. 2 0
      apps/app/src/features/page-tree/states/index.ts
  77. 0 0
      apps/app/src/features/page-tree/states/page-tree-desc-count-map.ts
  78. 98 0
      apps/app/src/features/page-tree/states/page-tree-update.ts
  79. 1 1
      apps/app/src/interfaces/page.ts
  80. 39 116
      apps/app/src/server/routes/apiv3/page-listing.ts
  81. 0 11
      apps/app/src/server/service/page-listing/page-listing.integ.ts
  82. 34 2
      apps/app/src/states/ui/modal/page-select.ts
  83. 10 10
      apps/app/src/stores/page-listing.tsx
  84. 1 2
      package.json
  85. 47 11
      pnpm-lock.yaml

+ 67 - 6
.serena/memories/apps-app-jotai-directory-structure.md

@@ -13,7 +13,6 @@ states/
 │   ├── untitled-page.ts            # 無題ページ状態 ✅
 │   ├── page-abilities.ts           # ページ権限判定状態 ✅ DERIVED ATOM!
 │   ├── unsaved-warning.ts          # 未保存警告状態 ✅ JOTAI PATTERN!
-│   ├── page-tree-desc-count-map.ts # ページツリー子孫カウント ✅ JOTAI PATTERN!
 │   └── modal/                      # 個別モーダルファイル ✅
 │       ├── page-create.ts          # ページ作成モーダル ✅
 │       ├── page-delete.ts          # ページ削除モーダル ✅
@@ -45,6 +44,25 @@ states/
             └── states/             # OpenAI専用状態 ✅
                 ├── index.ts        # exports ✅
                 └── unified-merge-view.ts # UnifiedMergeView状態 ✅
+
+features/                           # Feature Directory Pattern ✅
+└── page-tree/                      # ページツリー機能 ✅ (NEW!)
+    ├── index.ts                    # メインエクスポート
+    ├── client/
+    │   ├── components/             # 汎用UIコンポーネント
+    │   │   ├── SimplifiedItemsTree.tsx
+    │   │   ├── TreeItemLayout.tsx
+    │   │   └── SimpleItemContent.tsx
+    │   ├── hooks/                  # 汎用フック
+    │   │   ├── use-data-loader.ts
+    │   │   └── use-scroll-to-selected-item.ts
+    │   ├── interfaces/             # インターフェース定義
+    │   │   └── index.ts            # TreeItemProps, TreeItemToolProps
+    │   └── states/                 # Jotai状態 ✅
+    │       ├── page-tree-update.ts # ツリー更新状態
+    │       └── page-tree-desc-count-map.ts # 子孫カウント状態
+    └── constants/
+        └── index.ts                # ROOT_PAGE_VIRTUAL_ID
 ```
 
 ## 📋 ファイル配置ルール
@@ -60,9 +78,36 @@ states/
 - **グローバル状態**: `global/` ディレクトリ
 - **通信系**: `socket-io/` ディレクトリ
 
-### 機能別専用states (`states/features/`)
-- **OpenAI機能**: `features/openai/client/states/`
-- **将来の機能**: `features/{feature-name}/client/states/`
+### 機能別専用states (`states/features/` および `features/`)
+
+**OpenAI機能**: `states/features/openai/client/states/`
+**ページツリー機能**: `features/page-tree/client/states/` ✅ (Feature Directory Pattern)
+
+### Feature Directory Pattern (新パターン) ✅
+
+`features/{feature-name}/` パターンは、特定機能に関連するコンポーネント、フック、状態、定数をすべて一箇所に集約する構造。
+
+**適用例**: `features/page-tree/`
+```
+features/page-tree/
+├── index.ts           # 全エクスポートの集約
+├── client/
+│   ├── components/    # UIコンポーネント
+│   ├── hooks/         # カスタムフック
+│   ├── interfaces/    # 型定義
+│   └── states/        # Jotai状態
+└── constants/         # 定数
+```
+
+**インポート方法**:
+```typescript
+import { 
+  SimplifiedItemsTree,
+  TreeItemLayout,
+  usePageTreeInformationUpdate,
+  ROOT_PAGE_VIRTUAL_ID 
+} from '~/features/page-tree';
+```
 
 ## 🏷️ ファイル命名規則
 
@@ -119,13 +164,29 @@ states/context.ts → _atomsForDerivedAbilities
 ## 🎯 今後の拡張指針
 
 ### 新規機能追加時
-1. **機能専用度評価**: 汎用 → `states/ui/`、専用 → `states/features/`
+1. **機能専用度評価**: 汎用 → `states/ui/`、専用 → `features/{feature-name}/client/states/`
 2. **複雑度評価**: シンプル → 単一ファイル、複雑 → ディレクトリ
 3. **依存関係確認**: 既存atomの活用可能性
 4. **命名規則遵守**: 確立された命名パターンに従う
+5. **Feature Directory Pattern検討**: 複数のコンポーネント・フック・状態が関連する場合は `features/` 配下に集約
 
 ### ディレクトリ構造維持
 - **責務単一原則**: 1ファイル = 1機能・責務
 - **依存関係最小化**: 循環参照の回避
 - **拡張性**: 将来の機能追加を考慮した構造
-- **検索性**: ファイル名から機能が推測できる命名
+- **検索性**: ファイル名から機能が推測できる命名
+
+### Feature Directory Pattern 採用基準
+以下の条件を満たす場合は `features/` 配下に配置:
+- 複数のUIコンポーネントが関連している
+- 専用のカスタムフックがある
+- 専用のJotai状態がある
+- 機能として独立性が高い
+
+**例**: `features/page-tree/` は SimplifiedItemsTree, TreeItemLayout, useDataLoader, page-tree-update.ts などが密接に関連
+
+---
+
+## 📝 最終更新日
+
+2025-11-28 (Feature Directory Pattern 追加)

+ 683 - 0
.serena/memories/apps-app-page-tree-specification.md

@@ -0,0 +1,683 @@
+# PageTree 仕様書
+
+## 概要
+
+GROWIのPageTreeは、`@headless-tree/react` と `@tanstack/react-virtual` を使用したVirtualized Tree実装です。
+5000件以上の兄弟ページでも快適に動作するよう設計されています。
+
+---
+
+## 1. アーキテクチャ
+
+### 1.1 ディレクトリ構成
+
+```
+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
+```
+
+### 1.2 Sidebar専用コンポーネント(移動しなかったファイル)
+
+以下は `components/Sidebar/PageTreeItem/` に残留:
+
+- `PageTreeItem.tsx` - Sidebar専用の実装
+- `CountBadgeForPageTreeItem.tsx` - PageTree専用バッジ
+- `use-page-item-control.tsx` - コンテキストメニュー制御
+
+---
+
+## 2. 主要コンポーネント
+
+### 2.1 ItemsTree
+
+**ファイル**: `features/page-tree/components/ItemsTree.tsx`
+
+Virtualizedツリーのコアコンポーネント。`@headless-tree/react` と `@tanstack/react-virtual` を統合。
+
+#### Props
+
+```typescript
+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;
+}
+```
+
+#### 使用している @headless-tree/core Features
+
+- `asyncDataLoaderFeature` - 非同期データローディング
+- `selectionFeature` - 選択機能
+- `renamingFeature` - リネーム機能
+- `hotkeysCoreFeature` - キーボードショートカット
+- `checkboxesFeature` - チェックボックス(オプション)
+- `dragAndDropFeature` - ドラッグ&ドロップ(オプション)
+
+#### 重要な実装詳細
+
+1. **データローダー**: `use-data-loader.ts` で既存API(`/page-listing/root`, `/page-listing/children`)を活用
+2. **Virtualization**: `@tanstack/react-virtual` の `useVirtualizer` を使用、`overscan: 5` で最適化
+3. **初期スクロール**: `scrollToIndex` で選択アイテムまでスクロール
+
+### 2.2 TreeItemLayout
+
+**ファイル**: `features/page-tree/components/TreeItemLayout.tsx`
+
+汎用的なツリーアイテムレイアウト。展開/折りたたみ、アイコン、カスタムコンポーネントを配置。
+
+#### Props
+
+```typescript
+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;
+}
+```
+
+#### 自動展開ロジック
+
+```typescript
+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]);
+```
+
+### 2.3 PageTreeItem
+
+**ファイル**: `components/Sidebar/PageTreeItem/PageTreeItem.tsx`
+
+Sidebar用のツリーアイテム実装。TreeItemLayoutを使用し、Rename/Create/Control機能を統合。
+
+#### 機能
+
+- WIPページフィルター
+- descendantCountバッジ
+- hover時の操作ボタン(duplicate/delete/rename/create)
+- リネームモード表示
+- 新規作成入力表示(子として)
+
+---
+
+## 3. 機能実装
+
+### 3.1 Rename(ページ名変更)
+
+**実装ファイル**:
+- `features/page-tree/hooks/use-page-rename.tsx`
+- `features/page-tree/components/TreeNameInput.tsx`
+
+#### 使用方法
+
+```typescript
+const { rename, isRenaming, RenameAlternativeComponent } = usePageRename(item);
+
+// TreeItemLayoutに渡す
+<TreeItemLayout
+  showAlternativeContent={isRenaming(item)}
+  customAlternativeComponents={[RenameAlternativeComponent]}
+/>
+```
+
+#### 操作方法
+
+- **開始**: F2キー or コンテキストメニュー
+- **確定**: Enter
+- **キャンセル**: Escape
+
+### 3.2 Create(ページ新規作成)
+
+**実装ファイル**:
+- `features/page-tree/hooks/use-page-create.tsx`
+- `features/page-tree/components/TreeNameInput.tsx`
+- `features/page-tree/states/_inner/page-tree-create.ts`
+
+#### 状態管理(Jotai)
+
+```typescript
+// page-tree-create.ts
+creatingParentIdAtom: 作成中の親ノードID
+useCreatingParentId(): 現在の作成中親ID取得
+useIsCreatingChild(parentId): 特定アイテムが作成中か判定
+usePageTreeCreateActions(): startCreating, cancelCreating
+```
+
+#### 使用方法
+
+```typescript
+const { isCreatingChild, CreateInputComponent, startCreating } = usePageCreate(item);
+
+// PageTreeItemで使用
+{isCreatingChild() && <CreateInputComponent />}
+```
+
+#### 操作方法
+
+- **開始**: コンテキストメニューから「作成」を選択
+- **確定**: Enter → POST /page API → 新規ページに遷移
+- **キャンセル**: Escape or ブラー
+
+### 3.3 Drag and Drop(ページ移動)
+
+**実装ファイル**:
+- `features/page-tree/hooks/use-page-dnd.tsx`
+- `features/page-tree/hooks/use-page-dnd.module.scss`
+- `features/page-tree/hooks/_inner/use-tree-features.ts`
+
+#### 機能概要
+
+ページをドラッグ&ドロップして別のページの子として移動する機能。複数選択D&Dにも対応。
+
+#### 使用方法
+
+```typescript
+<ItemsTree
+  enableDragAndDrop={true}
+  // ...他のprops
+/>
+```
+
+#### 主要コンポーネント
+
+- `usePageDnd(isEnabled)`: D&Dロジックを提供するフック(`UsePageDndProperties`を返す)
+  - `canDrag`: ドラッグ可否判定
+  - `canDrop`: ドロップ可否判定
+  - `onDrop`: ドロップ時の処理(APIコール、ツリー更新)
+  - `renderDragLine`: ドラッグライン描画(treeインスタンスを引数に取る)
+
+**統合方法**:
+- `useTreeFeatures`が内部で`usePageDnd`を呼び出し、`dndProperties`として返す
+- ItemsTree側で`dndProperties.renderDragLine(tree)`を呼び出してドラッグライン表示
+
+#### バリデーションロジック
+
+**canDrag チェック項目**:
+1. 祖先-子孫関係チェック: 選択されたアイテム間に祖先-子孫関係がある場合は禁止
+2. 保護ページチェック: `pagePathUtils.isUsersProtectedPages(path)`が`true`の場合は禁止
+
+**canDrop チェック項目**:
+1. ユーザートップページチェック: `pagePathUtils.isUsersTopPage(targetPath)`が`true`の場合は禁止
+2. 移動可否チェック: `pagePathUtils.canMoveByPath(fromPath, newPath)`で検証
+
+#### エラーハンドリング
+
+- `operation__blocked`エラー: 「このページは現在移動できません」トースト表示
+- その他のエラー: 「ページの移動に失敗しました」トースト表示
+
+#### ドロップ処理の流れ
+
+1. 移動APIコール: `/pages/rename`エンドポイントで各ページを新しいパスに移動
+2. SWRキャッシュ更新: `mutatePageTree()`でページツリーデータを再取得
+3. headless-tree更新: `notifyUpdateItems()`で親ノードの子リストを無効化
+4. ターゲット更新: `targetItem.invalidateItemData()`でdescendantCountを再取得
+5. 自動展開: `targetItem.expand()`でドロップ先を展開
+
+#### 制限事項
+
+- 並び替え(Reorder)は無効(子として追加のみ)
+- キーボードD&Dは非対応
+
+### 3.4 リアルタイム更新(Socket.io統合)
+
+**実装ファイル**:
+- `features/page-tree/hooks/use-socket-update-desc-count.ts`
+- `features/page-tree/states/page-tree-desc-count-map.ts`
+- `features/page-tree/states/page-tree-update.ts`
+
+#### 設計方針
+
+**descendantCountバッジの更新** と **ツリー構造の更新** は別々の関心事として分離:
+
+| 更新タイプ | トリガー | 動作 | 対象 |
+|-----------|---------|------|------|
+| バッジ更新 | Socket.io `UpdateDescCount` | 数字のみ更新(軽量) | 全祖先 |
+| ツリー構造更新 | リロードボタン / 自分の操作後 | 子リスト再取得(重い) | 操作した本人のみ |
+
+**この分離の理由:**
+- 大規模環境で多くのユーザーが同時に操作する場合、全員のツリーが頻繁に再構築されるとパフォーマンス問題が発生
+- バッジ(数字)の更新は軽量なので全員にリアルタイム反映してもOK
+- ツリー構造の変更は操作した本人のウィンドウのみで即時反映し、他ユーザーはリロードボタンで対応
+
+#### 使用方法
+
+`ItemsTree`コンポーネント内で自動的に有効化されます。
+
+```typescript
+// ItemsTree.tsx内で呼び出し
+useSocketUpdateDescCount();
+```
+
+#### 受信イベント
+
+- `UpdateDescCount`: ページの子孫カウント(descendantCount)の更新
+  - サーバーからページ作成/削除/移動時に発行される
+  - 受信データ(Record形式)をMap形式に変換してJotai stateに保存
+  - **バッジ表示のみ更新、ツリー構造は更新しない**
+
+#### 実装詳細
+
+```typescript
+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]);
+};
+```
+
+#### ツリー構造の更新
+
+ツリー構造(子リスト)の更新は以下のタイミングで行われる:
+
+1. **リロードボタン**: `notifyUpdateAllTrees()` を呼び出し、全ツリーを再取得
+2. **自分の操作後**: 
+   - Create/Delete/Move操作の完了コールバックで `notifyUpdateItems([parentId])` を呼び出し
+   - 操作した親ノードの子リストのみ再取得
+
+```typescript
+// リロードボタンの例
+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()`: 更新検知と再取得実行
+
+### 3.5 Checkboxes(AI Assistant用)
+
+**使用箇所**: `AiAssistantManagementPageTreeSelection.tsx`
+
+ItemsTreeのcheckboxesオプションを使用。
+
+#### Props
+
+```typescript
+<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のコールバック関数を返す
+- ItemsTree側で`useEffect(createNotifyEffect(tree, onCheckedItemsChange), [createNotifyEffect, tree])`を呼び出す
+
+**設定**:
+- `checkboxesFeature` を条件付きで追加
+- `propagateCheckedState: false` で子への伝播を無効化
+- `canCheckFolders: true` でフォルダもチェック可能
+
+---
+
+## 4. バックエンドAPI
+
+### 4.1 使用エンドポイント
+
+```
+GET /page-listing/root
+→ ルートページ "/" のデータ
+
+GET /page-listing/children?id={pageId}
+→ 指定ページの直下の子のみ
+
+GET /page-listing/item?id={pageId}
+→ 単一ページデータ(新規追加)
+```
+
+### 4.2 IPageForTreeItem インターフェース
+
+```typescript
+interface IPageForTreeItem {
+  _id: string;
+  path: string;
+  parent?: string;
+  descendantCount: number;
+  revision?: string;
+  grant: PageGrant;
+  isEmpty: boolean;
+  wip: boolean;
+  processData?: IPageOperationProcessData;
+}
+```
+
+---
+
+## 5. @headless-tree/react 基礎知識
+
+### 5.1 データ構造
+
+- **IDベースの参照**: ツリーアイテムは文字列IDで識別
+- **フラット構造を推奨**: dataLoaderで親子関係を定義
+- **ジェネリック型対応**: `useTree<IPageForTreeItem>` でカスタム型を指定
+
+### 5.2 非同期データローダー
+
+```typescript
+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],
+});
+```
+
+#### キャッシュの無効化
+
+```typescript
+const item = tree.getItemInstance("item1");
+item.invalidateItemData();      // アイテムデータの再取得
+item.invalidateChildrenIds();   // 子IDリストの再取得
+```
+
+### 5.3 Virtualization統合
+
+```typescript
+const items = tree.getItems(); // フラット化されたアイテムリスト
+
+const virtualizer = useVirtualizer({
+  count: items.length,
+  getScrollElement: () => scrollElementRef.current,
+  estimateSize: () => 32,
+  overscan: 5,
+});
+```
+
+### 5.4 主要API
+
+#### Tree インスタンス
+- `tree.getItems()`: フラット化されたツリーアイテムのリスト
+- `tree.getItemInstance(id)`: IDからアイテムインスタンスを取得
+- `tree.getContainerProps()`: ツリーコンテナのprops(ホットキー有効化に必須)
+- `tree.rebuildTree()`: ツリー構造を再構築
+
+#### Item インスタンス
+- `item.getProps()`: アイテム要素のprops
+- `item.getId()`: アイテムID
+- `item.getItemData()`: カスタムペイロード(IPageForTreeItem)
+- `item.getItemMeta()`: メタデータ(level, indexなど)
+- `item.isFolder()`: フォルダかどうか
+- `item.isExpanded()`: 展開されているか
+- `item.expand()` / `item.collapse()`: 展開/折りたたみ
+- `item.startRenaming()`: リネームモード開始
+- `item.isRenaming()`: リネーム中か判定
+
+---
+
+## 6. パフォーマンス最適化
+
+### 6.1 headless-tree のキャッシュ無効化と再取得
+
+#### 重要な知見
+
+`@headless-tree/core` の `asyncDataLoaderFeature` は内部キャッシュを持ち、`invalidateChildrenIds()` メソッドでキャッシュを無効化できます。
+
+**invalidateChildrenIds(optimistic?: boolean) の動作:**
+
+```typescript
+// 内部実装(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` | ローディング状態を更新しない、古いデータを表示し続ける | バッチ処理の途中に使用 |
+
+**パフォーマンス最適化パターン:**
+
+```typescript
+// ❌ 非効率: 全アイテムに 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):**
+
+```typescript
+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]);
+```
+
+#### 注意事項
+
+1. **invalidateChildrenIds は async 関数** - Promise を返すが、await しなくても動作する
+2. **loadChildrenIds 完了後に自動で rebuildTree()** - 明示的な呼び出し不要
+3. **optimistic=true でもデータは再取得される** - ただしローディングUIは表示されない
+4. **tree.getItems() は表示中のアイテムのみ** - 折りたたまれた子は含まれない
+
+### 6.2 Virtualization
+
+- **100k+アイテムでテスト済み**
+- `overscan: 5` で表示範囲外の先読み
+- `estimateSize: 32` でアイテム高さを推定
+
+### 6.3 非同期データローダーのキャッシング
+
+- asyncDataLoaderFeatureが自動キャッシング
+- 展開済みアイテムは再取得なし
+- `invalidateChildrenIds()` で明示的に無効化可能
+
+### 6.4 ツリー更新
+
+```typescript
+// Jotai atomでツリー更新を通知
+const { notifyUpdateItems } = usePageTreeInformationUpdate();
+notifyUpdateItems(updatedPages);
+
+// SWRでページデータを再取得
+const { mutate: mutatePageTree } = useSWRxPageTree();
+await mutatePageTree();
+```
+
+---
+
+## 7. 実装済み機能
+
+- ✅ Virtualizedツリー表示
+- ✅ 展開/折りたたみ
+- ✅ ページ遷移(クリック)
+- ✅ 選択状態表示
+- ✅ WIPページフィルター
+- ✅ descendantCountバッジ
+- ✅ hover時の操作ボタン
+- ✅ 選択ページまでの自動展開
+- ✅ 選択ページへの初期スクロール
+- ✅ Rename(F2、コンテキストメニュー)
+- ✅ Create(コンテキストメニュー)
+- ✅ Duplicate(hover時ボタン)
+- ✅ Delete(hover時ボタン)
+- ✅ Checkboxes(AI Assistant用)
+- ✅ Drag and Drop(ページ移動)
+- ✅ リアルタイム更新(Socket.io統合)
+
+---
+
+## 8. 未実装機能
+
+なし(全機能実装済み)
+
+---
+
+## 9. 参考リンク
+
+- @headless-tree/react 公式ドキュメント: https://headless-tree.lukasbach.com/
+- GitHub: https://github.com/lukasbach/headless-tree
+- @tanstack/react-virtual: https://tanstack.com/virtual/latest
+
+---
+
+## 10. 改修時の注意点
+
+### 10.1 ホットキーサポート
+
+`hotkeysCoreFeature` と `getContainerProps()` の組み合わせが必須。
+`getContainerProps()` がないとホットキーが動作しない。
+
+### 10.2 ツリー更新の通知
+
+操作完了後は以下を呼び出す:
+1. `mutatePageTree()` - SWRでデータ再取得
+2. `notifyUpdateItems()` - Jotai atomで更新通知
+
+### 10.3 旧実装について
+
+以下のファイルはTypeScriptエラーあり(許容):
+- `ItemsTree.tsx` - 旧実装
+- `PageTreeItem.tsx` - 旧Sidebar用
+- `TreeItemForModal.tsx` - 旧Modal用
+
+---
+
+## 更新履歴
+
+- 2025-11-10: 初版作成(Virtualization計画)
+- 2025-11-28: Rename/Create実装完了、ディレクトリ再編成
+- 2025-12-05: 仕様書として統合
+- 2025-12-08: Drag and Drop実装完了、ディレクトリ構成更新
+- 2025-12-08: リアルタイム更新(Socket.io統合)実装完了
+- 2025-12-08: headless-tree キャッシュ無効化の知見を追加(invalidateChildrenIds の optimistic パラメータ)
+- 2025-12-08: Socket.io更新の設計方針を明確化(バッジ更新とツリー構造更新の分離)
+- 2025-12-09: useTreeFeaturesリファクタリング完了(checkboxとDnD機能を統合、循環依存を回避)

+ 0 - 186
.serena/memories/apps-app-pagetree-performance-refactor-plan.md

@@ -1,186 +0,0 @@
-# PageTree パフォーマンス改善リファクタ計画 - 現実的戦略
-
-## 🎯 目標
-現在のパフォーマンス問題を解決:
-- **問題**: 5000件の兄弟ページで初期レンダリングが重い
-- **目標**: 表示速度を10-20倍改善、UX維持
-
-## ✅ 戦略2: API軽量化 - **完了済み**
-
-### 実装済み内容
-- **ファイル**: `apps/app/src/server/service/page-listing/page-listing.ts:77`
-- **変更内容**: `.select('_id path parent descendantCount grant isEmpty createdAt updatedAt wip')` を追加
-- **型定義**: `apps/app/src/interfaces/page.ts` の `IPageForTreeItem` 型も対応済み
-- **追加改善**: 計画にはなかった `wip` フィールドも最適化対象に含める
-
-### 実現できた効果
-- **データサイズ**: 推定 500バイト → 約100バイト(5倍軽量化)
-- **ネットワーク転送**: 5000ページ時 2.5MB → 500KB程度に削減
-- **状況**: **実装完了・効果発現中**
-
----
-
-## 🚀 戦略1: 既存アーキテクチャ活用 + headless-tree部分導入 - **現実的戦略**
-
-### 前回のreact-window失敗原因
-1. **動的itemCount**: ツリー展開時にアイテム数が変化→react-windowの前提と衝突
-2. **非同期ローディング**: APIレスポンス待ちでフラット化不可
-3. **複雑な状態管理**: SWRとreact-windowの状態同期が困難
-
-### 現実的制約の認識
-**ItemsTree/TreeItemLayoutは廃止困難**:
-- **CustomTreeItemの出し分け**: `PageTreeItem` vs `TreeItemForModal`  
-- **共通副作用処理**: rename/duplicate/delete時のmutation処理
-- **多箇所からの利用**: PageTree, PageSelectModal, AiAssistant等
-
-## 📋 修正された実装戦略: **ハイブリッドアプローチ**
-
-### **核心アプローチ**: ItemsTreeを**dataProvider**として活用
-
-**既存の責務は保持しつつ、内部実装のみheadless-tree化**:
-
-1. **ItemsTree**: UIロジック・副作用処理はそのまま
-2. **TreeItemLayout**: 個別アイテムレンダリングはそのまま  
-3. **データ管理**: 内部でheadless-treeを使用(SWR → headless-tree)
-4. **Virtualization**: ItemsTree内部にreact-virtualを導入
-
-### **実装計画: 段階的移行**
-
-#### **Phase 1: データ層のheadless-tree化**
-
-**ファイル**: `ItemsTree.tsx`
-```typescript
-// Before: 複雑なSWR + 子コンポーネント管理
-const tree = useTree<IPageForTreeItem>({
-  rootItemId: initialItemNode.page._id,
-  dataLoader: {
-    getItem: async (itemId) => {
-      const response = await apiv3Get('/page-listing/item', { id: itemId });
-      return response.data;
-    },
-    getChildren: async (itemId) => {
-      const response = await apiv3Get('/page-listing/children', { id: itemId });
-      return response.data.children.map(child => child._id);
-    },
-  },
-  features: [asyncDataLoaderFeature],
-});
-
-// 既存のCustomTreeItemに渡すためのアダプター
-const adaptedNodes = tree.getItems().map(item => 
-  new ItemNode(item.getItemData())
-);
-
-return (
-  <ul className={`${moduleClass} list-group`}>
-    {adaptedNodes.map(node => (
-      <CustomTreeItem
-        key={node.page._id}
-        itemNode={node}
-        // ... 既存のpropsをそのまま渡す
-        onRenamed={onRenamed}
-        onClickDuplicateMenuItem={onClickDuplicateMenuItem}
-        onClickDeleteMenuItem={onClickDeleteMenuItem}
-      />
-    ))}
-  </ul>
-);
-```
-
-#### **Phase 2: Virtualization導入**
-
-**ファイル**: `ItemsTree.tsx` (Phase1をベースに拡張)
-```typescript
-const virtualizer = useVirtualizer({
-  count: adaptedNodes.length,
-  getScrollElement: () => containerRef.current,
-  estimateSize: () => 40,
-});
-
-return (
-  <div ref={containerRef} className="tree-container">
-    <div style={{ height: virtualizer.getTotalSize() }}>
-      {virtualizer.getVirtualItems().map(virtualItem => {
-        const node = adaptedNodes[virtualItem.index];
-        return (
-          <div
-            key={node.page._id}
-            style={{
-              position: 'absolute',
-              top: virtualItem.start,
-              height: virtualItem.size,
-              width: '100%',
-            }}
-          >
-            <CustomTreeItem
-              itemNode={node}
-              // ... 既存props
-            />
-          </div>
-        );
-      })}
-    </div>
-  </div>
-);
-```
-
-#### **Phase 3 (将来): 完全なheadless-tree移行**
-
-最終的にはdrag&drop、selection等のUI機能もheadless-treeに移行可能ですが、**今回のスコープ外**。
-
-## 📁 現実的なファイル変更まとめ
-
-| アクション | ファイル | 内容 | スコープ |
-|---------|---------|------|------|
-| ✅ **完了** | **apps/app/src/server/service/page-listing/page-listing.ts** | selectクエリ追加 | API軽量化 |
-| ✅ **完了** | **apps/app/src/interfaces/page.ts** | IPageForTreeItem型定義 | API軽量化 |
-| 🔄 **修正** | **src/client/components/ItemsTree/ItemsTree.tsx** | headless-tree + virtualization導入 | **今回の核心** |
-| 🆕 **新規** | **src/client/components/ItemsTree/usePageTreeDataLoader.ts** | データローダー分離 | 保守性向上 |
-| ⚠️ **保持** | **src/client/components/TreeItem/TreeItemLayout.tsx** | 既存のまま(後方互換) | 既存責務保持 |
-| ⚠️ **部分削除** | **src/stores/page-listing.tsx** | useSWRxPageChildren削除 | 重複排除 |
-
-**新規ファイル**: 1個(データローダー分離のみ)  
-**変更ファイル**: 2個(ItemsTree改修 + store整理)  
-**削除ファイル**: 0個(既存アーキテクチャ尊重)
-
----
-
-## 🎯 実装優先順位
-
-**✅ Phase 1**: API軽量化(低リスク・即効性) - **完了**
-
-**📋 Phase 2-A**: ItemsTree内部のheadless-tree化
-- **工数**: 2-3日
-- **リスク**: 低(外部IF変更なし)
-- **効果**: 非同期ローディング最適化、キャッシュ改善
-
-**📋 Phase 2-B**: Virtualization導入  
-- **工数**: 2-3日
-- **リスク**: 低(内部実装のみ)
-- **効果**: レンダリング性能10-20倍改善
-
-**現在の効果**: API軽量化により 5倍のデータ転送量削減済み  
-**Phase 2完了時の予想効果**: 初期表示速度 20-50倍改善
-
----
-
-## 🏗️ 実装方針: **既存アーキテクチャ尊重**
-
-**基本方針**:
-- **既存のCustomTreeItem責務**は保持(rename/duplicate/delete等)
-- **データ管理層のみ**をheadless-tree化  
-- **外部インターフェース**は変更せず、内部最適化に集中
-- **段階的移行**で低リスク実装
-
-**今回のスコープ**:
-- ✅ 非同期データローディング最適化
-- ✅ Virtualizationによる大量要素対応  
-- ❌ drag&drop/selection(将来フェーズ)
-- ❌ 既存アーキテクチャの破壊的変更
-
----
-
-## 技術的参考資料
-- **headless-tree**: https://headless-tree.lukasbach.com/ (データ管理層のみ利用)
-- **react-virtual**: @tanstack/react-virtualを使用  
-- **アプローチ**: 既存ItemsTree内部でheadless-tree + virtualizationをハイブリッド活用

+ 1 - 13
apps/app/.eslintrc.js

@@ -32,19 +32,7 @@ module.exports = {
     'src/linter-checker/**',
     'src/migrations/**',
     'src/models/**',
-    'src/features/callout/**',
-    'src/features/collaborative-editor/**',
-    'src/features/comment/**',
-    'src/features/templates/**',
-    'src/features/mermaid/**',
-    'src/features/search/**',
-    'src/features/plantuml/**',
-    'src/features/external-user-group/**',
-    'src/features/page-bulk-export/**',
-    'src/features/growi-plugin/**',
-    'src/features/opentelemetry/**',
-    'src/features/openai/**',
-    'src/features/rate-limiter/**',
+    'src/features/**',
     'src/stores-universal/**',
     'src/interfaces/**',
     'src/utils/**',

+ 3 - 0
apps/app/package.json

@@ -271,10 +271,13 @@
     "@growi/editor": "workspace:^",
     "@growi/ui": "workspace:^",
     "@handsontable/react": "=2.1.0",
+    "@headless-tree/core": "^1.5.1",
+    "@headless-tree/react": "^1.5.1",
     "@next/bundle-analyzer": "^14.1.3",
     "@popperjs/core": "^2.11.8",
     "@swc-node/jest": "^1.8.1",
     "@swc/jest": "^0.2.36",
+    "@tanstack/react-virtual": "^3.13.12",
     "@testing-library/jest-dom": "^6.5.0",
     "@testing-library/user-event": "^14.5.2",
     "@types/archiver": "^6.0.2",

+ 0 - 1
apps/app/src/client/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx

@@ -366,7 +366,6 @@ const UserGroupDetailPage = (props: Props): JSX.Element => {
               <li
                 key={ancestorUserGroup._id}
                 className={`breadcrumb-item ${ancestorUserGroup._id === currentUserGroupId ? 'active' : ''}`}
-                aria-current="page"
               >
                 { ancestorUserGroup._id === currentUserGroupId ? (
                   <span>{ancestorUserGroup.name}</span>

+ 0 - 18
apps/app/src/client/components/ItemsTree/ItemNode.ts

@@ -1,18 +0,0 @@
-import type { IPageForItem } from '../../../interfaces/page';
-
-export class ItemNode {
-
-  page: IPageForItem;
-
-  children: ItemNode[];
-
-  constructor(page: IPageForItem, children: ItemNode[] = []) {
-    this.page = page;
-    this.children = children;
-  }
-
-  static generateNodesFromPages(pages: IPageForItem[]): ItemNode[] {
-    return pages.map(page => new ItemNode(page));
-  }
-
-}

+ 0 - 4
apps/app/src/client/components/ItemsTree/ItemsTree.module.scss

@@ -1,4 +0,0 @@
-/* stylelint-disable-next-line block-no-empty */
-.items-tree :global {
-
-}

+ 0 - 164
apps/app/src/client/components/ItemsTree/ItemsTree.tsx

@@ -1,164 +0,0 @@
-import React, { useEffect, useCallback, type JSX } from 'react';
-
-import path from 'path';
-
-import type { IPageToDeleteWithMeta } from '@growi/core';
-import { useTranslation } from 'next-i18next';
-import { useRouter } from 'next/router';
-
-import { toastError, toastSuccess } from '~/client/util/toastr';
-import type { IPageForItem } from '~/interfaces/page';
-import type { OnDuplicatedFunction, OnDeletedFunction } from '~/interfaces/ui';
-import type { UpdateDescCountData, UpdateDescCountRawData } from '~/interfaces/websocket';
-import { SocketEventName } from '~/interfaces/websocket';
-import { useCurrentPagePath, useFetchCurrentPage } from '~/states/page';
-import { useGlobalSocket } from '~/states/socket-io';
-import { usePageDeleteModalActions } from '~/states/ui/modal/page-delete';
-import type { IPageForPageDuplicateModal } from '~/states/ui/modal/page-duplicate';
-import { usePageDuplicateModalActions } from '~/states/ui/modal/page-duplicate';
-import { usePageTreeDescCountMapAction } from '~/states/ui/page-tree-desc-count-map';
-import { mutateAllPageInfo } from '~/stores/page';
-import {
-  useSWRxRootPage, mutatePageTree, mutatePageList,
-} from '~/stores/page-listing';
-import { mutateSearching } from '~/stores/search';
-import loggerFactory from '~/utils/logger';
-
-import { ItemNode, type TreeItemProps } from '../TreeItem';
-
-import ItemsTreeContentSkeleton from './ItemsTreeContentSkeleton';
-
-import styles from './ItemsTree.module.scss';
-
-const moduleClass = styles['items-tree'] ?? '';
-
-const logger = loggerFactory('growi:cli:ItemsTree');
-
-type ItemsTreeProps = {
-  isEnableActions: boolean
-  isReadOnlyUser: boolean
-  isWipPageShown?: boolean
-  targetPath: string
-  targetPathOrId?: string,
-  CustomTreeItem: React.FunctionComponent<TreeItemProps>
-  onClickTreeItem?: (page: IPageForItem) => void;
-}
-
-/*
- * ItemsTree
- */
-export const ItemsTree = (props: ItemsTreeProps): JSX.Element => {
-  const {
-    targetPath, targetPathOrId, isEnableActions, isReadOnlyUser, isWipPageShown, CustomTreeItem, onClickTreeItem,
-  } = props;
-
-  const { t } = useTranslation();
-  const router = useRouter();
-
-  const { data: rootPageResult, error } = useSWRxRootPage({ suspense: true });
-  const currentPagePath = useCurrentPagePath();
-  const { open: openDuplicateModal } = usePageDuplicateModalActions();
-  const { open: openDeleteModal } = usePageDeleteModalActions();
-
-  const socket = useGlobalSocket();
-  const { update: updatePtDescCountMap } = usePageTreeDescCountMapAction();
-
-  // for mutation
-  const { fetchCurrentPage } = useFetchCurrentPage();
-
-  useEffect(() => {
-    if (socket == null) {
-      return;
-    }
-
-    socket.on(SocketEventName.UpdateDescCount, (data: UpdateDescCountRawData) => {
-      // save to global state
-      const newData: UpdateDescCountData = new Map(Object.entries(data));
-
-      updatePtDescCountMap(newData);
-    });
-
-    return () => { socket.off(SocketEventName.UpdateDescCount) };
-
-  }, [socket, updatePtDescCountMap]);
-
-  const onRenamed = useCallback((fromPath: string | undefined, toPath: string) => {
-    mutatePageTree();
-    mutateSearching();
-    mutatePageList();
-
-    if (currentPagePath === fromPath || currentPagePath === toPath) {
-      fetchCurrentPage({ force: true });
-    }
-  }, [currentPagePath, fetchCurrentPage]);
-
-  const onClickDuplicateMenuItem = useCallback((pageToDuplicate: IPageForPageDuplicateModal) => {
-    // eslint-disable-next-line @typescript-eslint/no-unused-vars
-    const duplicatedHandler: OnDuplicatedFunction = (fromPath, toPath) => {
-      toastSuccess(t('duplicated_pages', { fromPath }));
-
-      mutatePageTree();
-      mutateSearching();
-      mutatePageList();
-    };
-
-    openDuplicateModal(pageToDuplicate, { onDuplicated: duplicatedHandler });
-  }, [openDuplicateModal, t]);
-
-  const onClickDeleteMenuItem = useCallback((pageToDelete: IPageToDeleteWithMeta) => {
-    const onDeletedHandler: OnDeletedFunction = (pathOrPathsToDelete, isRecursively, isCompletely) => {
-      if (typeof pathOrPathsToDelete !== 'string') {
-        return;
-      }
-
-      if (isCompletely) {
-        toastSuccess(t('deleted_pages_completely', { path: pathOrPathsToDelete }));
-      }
-      else {
-        toastSuccess(t('deleted_pages', { path: pathOrPathsToDelete }));
-      }
-
-      mutatePageTree();
-      mutateSearching();
-      mutatePageList();
-      mutateAllPageInfo();
-
-      if (currentPagePath === pathOrPathsToDelete) {
-        fetchCurrentPage({ force: true });
-        router.push(isCompletely ? path.dirname(pathOrPathsToDelete) : `/trash${pathOrPathsToDelete}`);
-      }
-    };
-
-    openDeleteModal([pageToDelete], { onDeleted: onDeletedHandler });
-  }, [currentPagePath, fetchCurrentPage, openDeleteModal, router, t]);
-
-
-  if (error != null) {
-    toastError(t('pagetree.error_retrieving_the_pagetree'));
-    return <></>;
-  }
-
-  const initialItemNode = rootPageResult ? new ItemNode(rootPageResult.rootPage) : null;
-  if (initialItemNode != null) {
-    return (
-      <ul className={`${moduleClass} list-group`}>
-        <CustomTreeItem
-          key={initialItemNode.page.path}
-          targetPath={targetPath}
-          targetPathOrId={targetPathOrId}
-          itemNode={initialItemNode}
-          isOpen
-          isEnableActions={isEnableActions}
-          isWipPageShown={isWipPageShown}
-          isReadOnlyUser={isReadOnlyUser}
-          onRenamed={onRenamed}
-          onClickDuplicateMenuItem={onClickDuplicateMenuItem}
-          onClickDeleteMenuItem={onClickDeleteMenuItem}
-          onClick={onClickTreeItem}
-        />
-      </ul>
-    );
-  }
-
-  return <ItemsTreeContentSkeleton />;
-};

+ 0 - 2
apps/app/src/client/components/ItemsTree/index.ts

@@ -1,2 +0,0 @@
-export { ItemNode } from './ItemNode';
-export * from './ItemsTree';

+ 40 - 35
apps/app/src/client/components/PageSelectModal/PageSelectModal.tsx

@@ -3,30 +3,35 @@ import {
   Suspense, useState, useCallback, useMemo,
 } from 'react';
 
-import nodePath from 'path';
-
-import { pathUtils } from '@growi/core/dist/utils';
 import { useTranslation } from 'next-i18next';
+import { dirname } from 'pathe';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter, Button,
 } from 'reactstrap';
-import SimpleBar from 'simplebar-react';
 
-import type { IPageForItem } from '~/interfaces/page';
+import { ItemsTree } from '~/features/page-tree/components';
 import { useIsGuestUser, useIsReadOnlyUser } from '~/states/context';
 import { useCurrentPageData } from '~/states/page';
-import { usePageSelectModalStatus, usePageSelectModalActions } from '~/states/ui/modal/page-select';
+import {
+  usePageSelectModalStatus,
+  usePageSelectModalActions,
+  useSelectedPageInModal,
+} from '~/states/ui/modal/page-select';
 
-import { ItemsTree } from '../ItemsTree';
 import ItemsTreeContentSkeleton from '../ItemsTree/ItemsTreeContentSkeleton';
 
-import { TreeItemForModal } from './TreeItemForModal';
+import { TreeItemForModal, treeItemForModalSize } from './TreeItemForModal';
 
 const PageSelectModalSubstance: FC = () => {
   const { close: closeModal } = usePageSelectModalActions();
 
-  const [clickedParentPage, setClickedParentPage] = useState<IPageForItem | null>(null);
   const [isIncludeSubPage, setIsIncludeSubPage] = useState(true);
+  const [scrollerElem, setScrollerElem] = useState<HTMLDivElement | null>(null);
+
+  // Callback ref to capture the scroller element and trigger re-render
+  const scrollerRefCallback = useCallback((node: HTMLDivElement | null) => {
+    setScrollerElem(node);
+  }, []);
 
   const { t } = useTranslation();
 
@@ -35,41 +40,35 @@ const PageSelectModalSubstance: FC = () => {
   const currentPage = useCurrentPageData();
   const { opts } = usePageSelectModalStatus();
 
-  const isHierarchicalSelectionMode = opts?.isHierarchicalSelectionMode ?? false;
-
-  const onClickTreeItem = useCallback((page: IPageForItem) => {
-    const parentPagePath = page.path;
+  // Get selected page from atom
+  const selectedPage = useSelectedPageInModal();
 
-    if (parentPagePath == null) {
-      return;
-    }
-
-    setClickedParentPage(page);
-  }, []);
+  const isHierarchicalSelectionMode = opts?.isHierarchicalSelectionMode ?? false;
 
   const onClickCancel = useCallback(() => {
-    setClickedParentPage(null);
     closeModal();
   }, [closeModal]);
 
   const { onSelected } = opts ?? {};
   const onClickDone = useCallback(() => {
-    if (clickedParentPage != null) {
-      onSelected?.(clickedParentPage, isIncludeSubPage);
+    if (selectedPage != null) {
+      onSelected?.(selectedPage, isIncludeSubPage);
     }
 
     closeModal();
-  }, [clickedParentPage, closeModal, isIncludeSubPage, onSelected]);
+  }, [selectedPage, closeModal, isIncludeSubPage, onSelected]);
 
-  // Memoize heavy calculation
-  const parentPagePath = useMemo(() => (
-    pathUtils.addTrailingSlash(nodePath.dirname(currentPage?.path ?? ''))
-  ), [currentPage?.path]);
+  // Memoize heavy calculation - parent page path without trailing slash for matching
+  const parentPagePath = useMemo(() => {
+    const dn = dirname(currentPage?.path ?? '');
+    // Ensure root path is '/' not ''
+    return dn === '' ? '/' : dn;
+  }, [currentPage?.path]);
 
-  // Memoize target path calculation (avoid duplication)
+  // Memoize target path calculation
   const targetPath = useMemo(() => (
-    clickedParentPage?.path || parentPagePath
-  ), [clickedParentPage?.path, parentPagePath]);
+    selectedPage?.path || parentPagePath
+  ), [selectedPage?.path, parentPagePath]);
 
   // Memoize checkbox handler
   const handleIncludeSubPageChange = useCallback(() => {
@@ -85,18 +84,24 @@ const PageSelectModalSubstance: FC = () => {
       <ModalHeader toggle={closeModal}>{t('page_select_modal.select_page_location')}</ModalHeader>
       <ModalBody className="p-0">
         <Suspense fallback={<ItemsTreeContentSkeleton />}>
-          <SimpleBar style={{ maxHeight: 'calc(85vh - 133px)' }}> {/* 133px = 63px(ModalHeader) + 70px(ModalFooter) */}
-            <div className="p-3">
+          {/* 133px = 63px(ModalHeader) + 70px(ModalFooter) */}
+          <div
+            ref={scrollerRefCallback}
+            className="p-3"
+            style={{ maxHeight: 'calc(85vh - 133px)', overflowY: 'auto' }}
+          >
+            {scrollerElem && (
               <ItemsTree
                 CustomTreeItem={TreeItemForModal}
                 isEnableActions={!isGuestUser}
                 isReadOnlyUser={!!isReadOnlyUser}
                 targetPath={targetPath}
                 targetPathOrId={targetPath}
-                onClickTreeItem={onClickTreeItem}
+                estimateTreeItemSize={() => treeItemForModalSize}
+                scrollerElem={scrollerElem}
               />
-            </div>
-          </SimpleBar>
+            )}
+          </div>
         </Suspense>
       </ModalBody>
       <ModalFooter className="border-top d-flex flex-column">

+ 25 - 13
apps/app/src/client/components/PageSelectModal/TreeItemForModal.tsx

@@ -1,27 +1,40 @@
 import type { FC } from 'react';
+import { useCallback, useMemo } from 'react';
 
-import {
-  TreeItemLayout, useNewPageInput, type TreeItemProps,
-} from '../TreeItem';
-
+import type { TreeItemProps } from '~/features/page-tree';
+import { TreeItemLayout } from '~/features/page-tree/components';
+import type { IPageForItem } from '~/interfaces/page';
+import { useSelectPageInModal } from '~/states/ui/modal/page-select';
 
 import styles from './TreeItemForModal.module.scss';
 
-const moduleClass = styles['tree-item-for-modal'];
+const moduleClass = styles['tree-item-for-modal'] ?? '';
 
+export const treeItemForModalSize = 36; // in px
 
 type TreeItemForModalProps = TreeItemProps & {
-  key?: React.Key | null,
+  key?: React.Key | null;
 };
 
 export const TreeItemForModal: FC<TreeItemForModalProps> = (props) => {
+  const {
+    item,
+    targetPathOrId,
+    onToggle,
+  } = props;
 
-  const { itemNode, targetPathOrId } = props;
-  const { page } = itemNode;
+  const page = item.getItemData();
+  const selectPage = useSelectPageInModal();
 
-  const { Input: NewPageInput, CreateButton: NewPageCreateButton } = useNewPageInput();
+  // Determine if this item is selected
+  const isSelected = useMemo(() => {
+    return page._id === targetPathOrId || page.path === targetPathOrId;
+  }, [page._id, page.path, targetPathOrId]);
 
-  const isSelected = page._id === targetPathOrId || page.path === targetPathOrId;
+  // Handle click to select this page
+  const handleClick = useCallback((selectedPage: IPageForItem) => {
+    selectPage(selectedPage);
+  }, [selectPage]);
 
   const itemClassNames = [
     isSelected ? 'active' : '',
@@ -31,10 +44,9 @@ export const TreeItemForModal: FC<TreeItemForModalProps> = (props) => {
     <TreeItemLayout
       {...props}
       className={moduleClass}
-      itemClass={TreeItemForModal}
       itemClassName={itemClassNames.join(' ')}
-      customHeadOfChildrenComponents={[NewPageInput]}
-      customHoveredEndComponents={[NewPageCreateButton]}
+      onClick={handleClick}
+      onToggle={onToggle}
     />
   );
 };

+ 13 - 67
apps/app/src/client/components/Sidebar/PageTree/PageTreeSubstance.tsx

@@ -1,10 +1,10 @@
 import React, {
-  memo, useCallback, useEffect, useMemo, useRef, useState,
+  memo, useCallback,
 } from 'react';
 
 import { useTranslation } from 'next-i18next';
-import { debounce } from 'throttle-debounce';
 
+import { usePageTreeInformationUpdate } from '~/features/page-tree/states/page-tree-update';
 import { useIsGuestUser, useIsReadOnlyUser } from '~/states/context';
 import { useCurrentPageId, useCurrentPagePath } from '~/states/page';
 import { useSidebarScrollerElem } from '~/states/ui/sidebar';
@@ -13,8 +13,8 @@ import {
 } from '~/stores/page-listing';
 import loggerFactory from '~/utils/logger';
 
-import { ItemsTree } from '../../ItemsTree/ItemsTree';
-import { PageTreeItem } from '../PageTreeItem';
+import { ItemsTree } from '~/features/page-tree/components';
+import { PageTreeItem, pageTreeItemSize } from '../PageTreeItem';
 import { SidebarHeaderReloadButton } from '../SidebarHeaderReloadButton';
 
 import { PrivateLegacyPagesLink } from './PrivateLegacyPagesLink';
@@ -31,12 +31,15 @@ export const PageTreeHeader = memo(({ isWipPageShown, onWipPageShownChange }: He
 
   const { mutate: mutateRootPage } = useSWRxRootPage({ suspense: true });
   useSWRxV5MigrationStatus({ suspense: true });
+  const { notifyUpdateAllTrees } = usePageTreeInformationUpdate();
 
   const mutate = useCallback(() => {
     mutateRootPage();
     mutatePageTree();
     mutateRecentlyUpdated();
-  }, [mutateRootPage]);
+    // Notify headless-tree to rebuild with fresh data
+    notifyUpdateAllTrees();
+  }, [mutateRootPage, notifyUpdateAllTrees]);
 
   return (
     <>
@@ -103,68 +106,7 @@ export const PageTreeContent = memo(({ isWipPageShown }: PageTreeContentProps) =
   const targetPathOrId = targetId || currentPath;
   const path = currentPath || '/';
 
-  const { data: rootPageResult } = useSWRxRootPage({ suspense: true });
   const sidebarScrollerElem = useSidebarScrollerElem();
-  const [isInitialScrollCompleted, setIsInitialScrollCompleted] = useState(false);
-
-  const rootElemRef = useRef<HTMLDivElement>(null);
-
-  // ***************************  Scroll on init ***************************
-  const scrollOnInit = useCallback(() => {
-    const rootElement = rootElemRef.current;
-    const scrollElement = sidebarScrollerElem;
-
-    if (rootElement == null || scrollElement == null) {
-      return;
-    }
-
-    const scrollTargetElement = rootElement.querySelector<HTMLElement>('[aria-current]');
-
-    if (scrollTargetElement == null) {
-      return;
-    }
-
-    logger.debug('scrollOnInit has invoked');
-
-
-    // NOTE: could not use scrollIntoView
-    //  https://stackoverflow.com/questions/11039885/scrollintoview-causing-the-whole-page-to-move
-
-    // calculate the center point
-    const scrollTop = scrollTargetElement.offsetTop - scrollElement.getBoundingClientRect().height / 2;
-    scrollElement.scrollTo({ top: scrollTop });
-
-    setIsInitialScrollCompleted(true);
-  }, [sidebarScrollerElem]);
-
-  const scrollOnInitDebounced = useMemo(() => debounce(500, scrollOnInit), [scrollOnInit]);
-
-  useEffect(() => {
-    if (isInitialScrollCompleted || rootPageResult == null) {
-      return;
-    }
-
-    const rootElement = rootElemRef.current as HTMLElement | null;
-    if (rootElement == null) {
-      return;
-    }
-
-    const observerCallback = (mutationRecords: MutationRecord[]) => {
-      mutationRecords.forEach(() => scrollOnInitDebounced());
-    };
-
-    const observer = new MutationObserver(observerCallback);
-    observer.observe(rootElement, { childList: true, subtree: true });
-
-    // first call for the situation that all rendering is complete at this point
-    scrollOnInitDebounced();
-
-    return () => {
-      observer.disconnect();
-    };
-  }, [isInitialScrollCompleted, scrollOnInitDebounced, rootPageResult]);
-  // *******************************  end  *******************************
-
 
   if (!migrationStatus?.isV5Compatible) {
     return <PageTreeUnavailable />;
@@ -178,14 +120,18 @@ export const PageTreeContent = memo(({ isWipPageShown }: PageTreeContentProps) =
   }
 
   return (
-    <div ref={rootElemRef} className="pt-4">
+    <div className="pt-4">
       <ItemsTree
+        enableRenaming
+        enableDragAndDrop
         isEnableActions={!isGuestUser}
         isReadOnlyUser={!!isReadOnlyUser}
         isWipPageShown={isWipPageShown}
         targetPath={path}
         targetPathOrId={targetPathOrId}
         CustomTreeItem={PageTreeItem}
+        estimateTreeItemSize={() => pageTreeItemSize}
+        scrollerElem={sidebarScrollerElem}
       />
 
       {!isGuestUser && !isReadOnlyUser && migrationStatus?.migratablePagesCount != null && migrationStatus.migratablePagesCount !== 0 && (

+ 4 - 3
apps/app/src/client/components/Sidebar/PageTreeItem/CountBadgeForPageTreeItem.tsx

@@ -1,14 +1,15 @@
 import type { JSX } from 'react';
 
 import CountBadge from '~/client/components/Common/CountBadge';
-import type { TreeItemToolProps } from '~/client/components/TreeItem';
-import { usePageTreeDescCountMap } from '~/states/ui/page-tree-desc-count-map';
+import type { TreeItemToolProps } from '~/features/page-tree/interfaces';
+import { usePageTreeDescCountMap } from '~/features/page-tree/states';
 
 
 export const CountBadgeForPageTreeItem = (props: TreeItemToolProps): JSX.Element => {
   const { getDescCount } = usePageTreeDescCountMap();
 
-  const { page } = props.itemNode;
+  const { item } = props;
+  const page = item.getItemData();
 
   const descendantCount = getDescCount(page._id) || page.descendantCount || 0;
 

+ 108 - 162
apps/app/src/client/components/Sidebar/PageTreeItem/PageTreeItem.tsx

@@ -1,211 +1,157 @@
-import React, {
-  useCallback, useState, type JSX,
-} from 'react';
+import type { FC } from 'react';
+import { useCallback } from 'react';
 
-import nodePath from 'path';
+import path from 'path';
 
-import type { IPageHasId } from '@growi/core';
-import { pagePathUtils, pathUtils } from '@growi/core/dist/utils';
+import type { IPageToDeleteWithMeta } from '@growi/core/dist/interfaces';
+import { getIdStringForRef } from '@growi/core/dist/interfaces';
+import { pathUtils } from '@growi/core/dist/utils';
 import { useTranslation } from 'next-i18next';
 import { useRouter } from 'next/router';
-import { useDrag, useDrop } from 'react-dnd';
 
-import { apiv3Put } from '~/client/util/apiv3-client';
-import { toastWarning, toastError } from '~/client/util/toastr';
+import { toastSuccess } from '~/client/util/toastr';
+import type { TreeItemProps } from '~/features/page-tree';
+import {
+  usePageTreeInformationUpdate, usePageRename, usePageCreate,
+  usePlaceholderRenameEffect,
+} from '~/features/page-tree';
+import { TreeNameInput, TreeItemLayout } from '~/features/page-tree/components';
 import type { IPageForItem } from '~/interfaces/page';
-import { mutatePageTree, useSWRxPageChildren } from '~/stores/page-listing';
-import loggerFactory from '~/utils/logger';
+import type { OnDeletedFunction, OnDuplicatedFunction } from '~/interfaces/ui';
+import { useCurrentPagePath, useFetchCurrentPage } from '~/states/page';
+import { usePageDeleteModalActions } from '~/states/ui/modal/page-delete';
+import type { IPageForPageDuplicateModal } from '~/states/ui/modal/page-duplicate';
+import { usePageDuplicateModalActions } from '~/states/ui/modal/page-duplicate';
+import { mutateAllPageInfo } from '~/stores/page';
+import { mutatePageTree, mutatePageList } from '~/stores/page-listing';
+import { mutateSearching } from '~/stores/search';
 
-import type { ItemNode } from '../../TreeItem';
-import {
-  TreeItemLayout, useNewPageInput, type TreeItemProps,
-} from '../../TreeItem';
 
 import { CountBadgeForPageTreeItem } from './CountBadgeForPageTreeItem';
-import { CreatingNewPageSpinner } from './CreatingNewPageSpinner';
 import { usePageItemControl } from './use-page-item-control';
 
-
 import styles from './PageTreeItem.module.scss';
 
 const moduleClass = styles['page-tree-item'] ?? '';
 
 
-const logger = loggerFactory('growi:cli:Item');
+export const pageTreeItemSize = 40; // in px
 
-export const PageTreeItem = (props:TreeItemProps): JSX.Element => {
+
+export const PageTreeItem: FC<TreeItemProps> = ({
+  item,
+  targetPath,
+  targetPathOrId,
+  isWipPageShown,
+  isEnableActions = false,
+  isReadOnlyUser = false,
+  onToggle,
+}) => {
+  const { t } = useTranslation();
   const router = useRouter();
 
-  const getNewPathAfterMoved = (droppedPagePath: string, newParentPagePath: string): string => {
-    const pageTitle = nodePath.basename(droppedPagePath);
-    return nodePath.join(newParentPagePath, pageTitle);
-  };
+  const itemData = item.getItemData();
+
+  const currentPagePath = useCurrentPagePath();
+  const { fetchCurrentPage } = useFetchCurrentPage();
+
+  const { open: openDuplicateModal } = usePageDuplicateModalActions();
+  const { open: openDeleteModal } = usePageDeleteModalActions();
+  const { notifyUpdateItems } = usePageTreeInformationUpdate();
+
+  const onClickDuplicateMenuItem = useCallback((page: IPageForPageDuplicateModal) => {
+    const duplicatedHandler: OnDuplicatedFunction = (fromPath) => {
+      toastSuccess(t('duplicated_pages', { fromPath }));
+
+      mutatePageTree();
+      mutateSearching();
+      mutatePageList();
 
-  const isDroppable = (fromPage?: Partial<IPageHasId>, newParentPage?: Partial<IPageHasId>, printLog = false): boolean => {
-    if (fromPage == null || newParentPage == null || fromPage.path == null || newParentPage.path == null) {
-      if (printLog) {
-        logger.warn('Any of page, page.path or droppedPage.path is null');
+      // Notify headless-tree update
+      const parentIds = itemData.parent != null ? [getIdStringForRef(itemData.parent)] : undefined;
+      notifyUpdateItems(parentIds);
+    };
+
+    openDuplicateModal(page, { onDuplicated: duplicatedHandler });
+  }, [openDuplicateModal, t, notifyUpdateItems, itemData.parent]);
+
+  const onClickDeleteMenuItem = useCallback((page: IPageToDeleteWithMeta) => {
+    const onDeletedHandler: OnDeletedFunction = (pathOrPathsToDelete, isRecursively, isCompletely) => {
+      if (typeof pathOrPathsToDelete !== 'string') {
+        return;
+      }
+
+      if (isCompletely) {
+        toastSuccess(t('deleted_pages_completely', { path: pathOrPathsToDelete }));
+      }
+      else {
+        toastSuccess(t('deleted_pages', { path: pathOrPathsToDelete }));
       }
-      return false;
-    }
 
-    const newPathAfterMoved = getNewPathAfterMoved(fromPage.path, newParentPage.path);
-    return pagePathUtils.canMoveByPath(fromPage.path, newPathAfterMoved) && !pagePathUtils.isUsersTopPage(newParentPage.path);
-  };
+      mutatePageTree();
+      mutateSearching();
+      mutatePageList();
+      mutateAllPageInfo();
 
-  const { t } = useTranslation();
+      if (currentPagePath === pathOrPathsToDelete) {
+        fetchCurrentPage({ force: true });
+        router.push(isCompletely ? path.dirname(pathOrPathsToDelete) : `/trash${pathOrPathsToDelete}`);
+      }
 
-  const {
-    itemNode, targetPathOrId, isOpen: _isOpen = false, onRenamed,
-  } = props;
+      // Notify headless-tree update
+      const parentIds = itemData.parent != null ? [getIdStringForRef(itemData.parent)] : undefined;
+      notifyUpdateItems(parentIds);
+    };
 
-  const { page } = itemNode;
-  const [isOpen, setIsOpen] = useState(_isOpen);
+    openDeleteModal([page], { onDeleted: onDeletedHandler });
+  }, [openDeleteModal, t, currentPagePath, fetchCurrentPage, router, itemData.parent, notifyUpdateItems]);
 
-  const { mutate: mutateChildren } = useSWRxPageChildren(isOpen ? page._id : null);
+  const { Control } = usePageItemControl();
 
-  const {
-    showRenameInput, Control, RenameInput,
-  } = usePageItemControl();
-  const { isProcessingSubmission, Input: NewPageInput, CreateButton: NewPageCreateButton } = useNewPageInput();
+  // Rename feature from usePageRename hook
+  const { isRenaming } = usePageRename();
+
+  // Page create feature
+  const { cancelCreating, CreateButton, isCreatingPlaceholder } = usePageCreate();
+
+  // Manage placeholder renaming mode (auto-start, track, and cancel on Esc)
+  usePlaceholderRenameEffect({
+    item,
+    onCancelCreate: cancelCreating,
+  });
 
   const itemSelectedHandler = useCallback((page: IPageForItem) => {
-    if (page.path == null || page._id == null) {
-      return;
-    }
+    if (page.path == null || page._id == null) return;
 
     const link = pathUtils.returnPathForURL(page.path, page._id);
-
     router.push(link);
   }, [router]);
 
   const itemSelectedByWheelClickHandler = useCallback((page: IPageForItem) => {
-    if (page.path == null || page._id == null) {
-      return;
-    }
+    if (page.path == null || page._id == null) return;
 
     const url = pathUtils.returnPathForURL(page.path, page._id);
-
     window.open(url, '_blank');
   }, []);
 
-  const [, drag] = useDrag({
-    type: 'PAGE_TREE',
-    item: { page },
-    canDrag: () => {
-      if (page.path == null) {
-        return false;
-      }
-      return !pagePathUtils.isUsersProtectedPages(page.path);
-    },
-    end: (item, monitor) => {
-      // in order to set d-none to dropped Item
-      const dropResult = monitor.getDropResult();
-    },
-    collect: monitor => ({
-      isDragging: monitor.isDragging(),
-      canDrag: monitor.canDrag(),
-    }),
-  });
-
-  const pageItemDropHandler = async(item: ItemNode) => {
-    const { page: droppedPage } = item;
-    if (!isDroppable(droppedPage, page, true)) {
-      return;
-    }
-    if (droppedPage.path == null || page.path == null) {
-      return;
-    }
-    const newPagePath = getNewPathAfterMoved(droppedPage.path, page.path);
-    try {
-      await apiv3Put('/pages/rename', {
-        pageId: droppedPage._id,
-        revisionId: droppedPage.revision,
-        newPagePath,
-        isRenameRedirect: false,
-        updateMetadata: true,
-      });
-      await mutatePageTree();
-      await mutateChildren();
-      if (onRenamed != null) {
-        onRenamed(page.path, newPagePath);
-      }
-      // force open
-      setIsOpen(true);
-    }
-    catch (err) {
-      if (err.code === 'operation__blocked') {
-        toastWarning(t('pagetree.you_cannot_move_this_page_now'));
-      }
-      else {
-        toastError(t('pagetree.something_went_wrong_with_moving_page'));
-      }
-    }
-  };
-
-  const [{ isOver }, drop] = useDrop<ItemNode, Promise<void>, { isOver: boolean }>(
-    () => ({
-      accept: 'PAGE_TREE',
-      drop: pageItemDropHandler,
-      hover: (item, monitor) => {
-        // when a drag item is overlapped more than 1 sec, the drop target item will be opened.
-        if (monitor.isOver()) {
-          setTimeout(() => {
-            if (monitor.isOver()) {
-              setIsOpen(true);
-            }
-          }, 600);
-        }
-      },
-      canDrop: (item) => {
-        const { page: droppedPage } = item;
-        return isDroppable(droppedPage, page);
-      },
-      collect: monitor => ({
-        isOver: monitor.isOver(),
-      }),
-    }),
-    [page],
-  );
-
-  const itemRef = (c) => {
-    // do not apply when RenameInput is shown
-    if (showRenameInput) return;
-
-    drag(c);
-    drop(c);
-  };
-
-  const isSelected = page._id === targetPathOrId || page.path === targetPathOrId;
-  const itemClassNames = [
-    isOver ? 'drag-over' : '',
-    page.path !== '/' && isSelected ? 'active' : '', // set 'active' except the root page
-  ];
-
   return (
     <TreeItemLayout
       className={moduleClass}
-      targetPath={props.targetPath}
-      targetPathOrId={props.targetPathOrId}
-      itemLevel={props.itemLevel}
-      itemNode={props.itemNode}
-      isOpen={isOpen}
-      isEnableActions={props.isEnableActions}
-      isReadOnlyUser={props.isReadOnlyUser}
-      isWipPageShown={props.isWipPageShown}
+      item={item}
+      targetPath={targetPath}
+      targetPathOrId={targetPathOrId ?? undefined}
+      isWipPageShown={isWipPageShown}
+      isEnableActions={isEnableActions}
+      isReadOnlyUser={isReadOnlyUser}
       onClick={itemSelectedHandler}
-      onClickDuplicateMenuItem={props.onClickDuplicateMenuItem}
-      onClickDeleteMenuItem={props.onClickDeleteMenuItem}
       onWheelClick={itemSelectedByWheelClickHandler}
-      onRenamed={props.onRenamed}
-      itemRef={itemRef}
-      itemClass={PageTreeItem}
-      itemClassName={itemClassNames.join(' ')}
+      onToggle={onToggle}
+      onClickDuplicateMenuItem={onClickDuplicateMenuItem}
+      onClickDeleteMenuItem={onClickDeleteMenuItem}
       customEndComponents={[CountBadgeForPageTreeItem]}
-      customHoveredEndComponents={[Control, NewPageCreateButton]}
-      customHeadOfChildrenComponents={[NewPageInput, () => <CreatingNewPageSpinner show={isProcessingSubmission} />]}
-      showAlternativeContent={showRenameInput}
-      customAlternativeComponents={[RenameInput]}
+      customHoveredEndComponents={[Control, CreateButton]}
+      showAlternativeContent={isRenaming(item) || isCreatingPlaceholder(item)}
+      customAlternativeComponents={[TreeNameInput]}
     />
   );
 };

+ 10 - 116
apps/app/src/client/components/Sidebar/PageTreeItem/use-page-item-control.tsx

@@ -1,50 +1,37 @@
-import type { ChangeEvent, FC } from 'react';
-import React, {
-  useCallback, useRef, useState,
-} from 'react';
-
-import nodePath from 'path';
+import type { FC } from 'react';
+import React, { useCallback } from 'react';
 
 import type { IPageInfoExt, IPageToDeleteWithMeta } from '@growi/core';
-import { pathUtils } from '@growi/core/dist/utils';
-import { useRect } from '@growi/ui/dist/utils';
+import { getIdStringForRef } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { DropdownToggle } from 'reactstrap';
-import { debounce } from 'throttle-debounce';
 
-import { AutosizeSubmittableInput, getAdjustedMaxWidthForAutosizeInput } from '~/client/components/Common/SubmittableInput';
 import { NotAvailableForGuest } from '~/client/components/NotAvailableForGuest';
 import { bookmark, unbookmark, resumeRenameOperation } from '~/client/services/page-operation';
-import { apiv3Put } from '~/client/util/apiv3-client';
 import { toastError, toastSuccess } from '~/client/util/toastr';
-import { ValidationTarget, useInputValidator, type InputValidationResult } from '~/client/util/use-input-validator';
 import { useSWRMUTxCurrentUserBookmarks } from '~/stores/bookmark';
 import { useSWRMUTxPageInfo } from '~/stores/page';
 
 import { PageItemControl } from '../../Common/Dropdown/PageItemControl';
-import type { TreeItemToolProps } from '../../TreeItem';
+import type { TreeItemToolProps } from '~/features/page-tree/interfaces';
 
 
 type UsePageItemControl = {
   Control: FC<TreeItemToolProps>,
-  RenameInput: FC<TreeItemToolProps>,
-  showRenameInput: boolean,
 }
 
 export const usePageItemControl = (): UsePageItemControl => {
   const { t } = useTranslation();
 
-  const [showRenameInput, setShowRenameInput] = useState(false);
-
 
   const Control: FC<TreeItemToolProps> = (props) => {
     const {
-      itemNode,
+      item,
       isEnableActions,
       isReadOnlyUser,
       onClickDuplicateMenuItem, onClickDeleteMenuItem,
     } = props;
-    const { page } = itemNode;
+    const page = item.getItemData();
 
     const { trigger: mutateCurrentUserBookmarks } = useSWRMUTxCurrentUserBookmarks();
     const { trigger: mutatePageInfo } = useSWRMUTxPageInfo(page._id ?? null);
@@ -73,8 +60,9 @@ export const usePageItemControl = (): UsePageItemControl => {
     }, [onClickDuplicateMenuItem, page]);
 
     const renameMenuItemClickHandler = useCallback(() => {
-      setShowRenameInput(true);
-    }, []);
+      // Use headless-tree's renamingFeature
+      item.startRenaming();
+    }, [item]);
 
     const deleteMenuItemClickHandler = useCallback(async(_pageId: string, pageInfo: IPageInfoExt | undefined): Promise<void> => {
       if (onClickDeleteMenuItem == null) {
@@ -88,7 +76,7 @@ export const usePageItemControl = (): UsePageItemControl => {
       const pageToDelete: IPageToDeleteWithMeta = {
         data: {
           _id: page._id,
-          revision: page.revision as string,
+          revision: page.revision != null ? getIdStringForRef(page.revision) : null,
           path: page.path,
         },
         meta: pageInfo,
@@ -134,102 +122,8 @@ export const usePageItemControl = (): UsePageItemControl => {
   };
 
 
-  const RenameInput: FC<TreeItemToolProps> = (props) => {
-    const { itemNode, onRenamed } = props;
-    const { page } = itemNode;
-
-    const parentRef = useRef<HTMLDivElement>(null);
-    const [parentRect] = useRect(parentRef);
-
-    const [validationResult, setValidationResult] = useState<InputValidationResult>();
-
-
-    const inputValidator = useInputValidator(ValidationTarget.PAGE);
-
-    const changeHandler = useCallback(async(e: ChangeEvent<HTMLInputElement>) => {
-      const validationResult = inputValidator(e.target.value);
-      setValidationResult(validationResult ?? undefined);
-    }, [inputValidator]);
-    const changeHandlerDebounced = debounce(300, changeHandler);
-
-    const cancel = useCallback(() => {
-      setValidationResult(undefined);
-      setShowRenameInput(false);
-    }, []);
-
-    const rename = useCallback(async(inputText) => {
-      if (inputText.trim() === '') {
-        return cancel();
-      }
-
-      const parentPath = pathUtils.addTrailingSlash(nodePath.dirname(page.path ?? ''));
-      const newPagePath = nodePath.resolve(parentPath, inputText);
-
-      if (newPagePath === page.path) {
-        setValidationResult(undefined);
-        setShowRenameInput(false);
-        return;
-      }
-
-      try {
-        await apiv3Put('/pages/rename', {
-          pageId: page._id,
-          revisionId: page.revision,
-          newPagePath,
-        });
-
-        onRenamed?.(page.path, newPagePath);
-        setShowRenameInput(false);
-
-        toastSuccess(t('renamed_pages', { path: page.path }));
-      }
-      catch (err) {
-        toastError(err);
-      }
-      finally {
-        setValidationResult(undefined);
-      }
-
-    }, [cancel, onRenamed, page._id, page.path, page.revision]);
-
-
-    if (!showRenameInput) {
-      return <></>;
-    }
-
-    const isInvalid = validationResult != null;
-
-    const maxWidth = parentRect != null
-      ? getAdjustedMaxWidthForAutosizeInput(parentRect.width, 'sm', validationResult != null ? false : undefined)
-      : undefined;
-
-    return (
-      <div ref={parentRef} className="flex-fill">
-        <AutosizeSubmittableInput
-          value={nodePath.basename(page.path ?? '')}
-          inputClassName={`form-control ${isInvalid ? 'is-invalid' : ''}`}
-          inputStyle={{ maxWidth }}
-          placeholder={t('Input page name')}
-          aria-describedby={isInvalid ? 'rename-feedback' : undefined}
-          onChange={changeHandlerDebounced}
-          onSubmit={rename}
-          onCancel={cancel}
-          autoFocus
-        />
-        { isInvalid && (
-          <div id="rename-feedback" className="invalid-feedback d-block my-1">
-            {validationResult.message}
-          </div>
-        ) }
-      </div>
-    );
-  };
-
-
   return {
     Control,
-    RenameInput,
-    showRenameInput,
   };
 
 };

+ 1 - 3
apps/app/src/client/components/Sidebar/SidebarHeaderReloadButton.tsx

@@ -1,10 +1,8 @@
-import React from 'react';
-
 type Props = {
   onClick: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void
 };
 
-export const SidebarHeaderReloadButton = ({ onClick }: Props) => {
+export const SidebarHeaderReloadButton = ({ onClick }: Props): JSX.Element => {
 
   return (
     <button type="button" className="btn btn-sm ms-auto py-0 grw-btn-reload" onClick={onClick}>

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

@@ -67,7 +67,6 @@ const TemplateListGroupItem: React.FC<TemplateSummaryItemProps> = ({
     <a
       className={`list-group-item list-group-item-action ${isSelected ? 'active' : ''}`}
       onClick={onClick}
-      aria-current="true"
     >
       <h4 className="mb-1 d-flex">
         <span className="d-inline-block text-truncate">{localizedTemplate.title}</span>

+ 0 - 18
apps/app/src/client/components/TreeItem/ItemNode.ts

@@ -1,18 +0,0 @@
-import type { IPageForItem } from '../../../interfaces/page';
-
-export class ItemNode {
-
-  page: IPageForItem;
-
-  children: ItemNode[];
-
-  constructor(page: IPageForItem, children: ItemNode[] = []) {
-    this.page = page;
-    this.children = children;
-  }
-
-  static generateNodesFromPages(pages: IPageForItem[]): ItemNode[] {
-    return pages.map(page => new ItemNode(page));
-  }
-
-}

+ 0 - 37
apps/app/src/client/components/TreeItem/NewPageInput/NewPageCreateButton.tsx

@@ -1,37 +0,0 @@
-import React, { type FC } from 'react';
-
-import { pagePathUtils } from '@growi/core/dist/utils';
-
-import { NotAvailableForGuest } from '~/client/components/NotAvailableForGuest';
-import { NotAvailableForReadOnlyUser } from '~/client/components/NotAvailableForReadOnlyUser';
-import type { IPageForItem } from '~/interfaces/page';
-
-type NewPageCreateButtonProps = {
-  page: IPageForItem,
-  onClick?: () => void,
-};
-
-export const NewPageCreateButton: FC<NewPageCreateButtonProps> = (props) => {
-  const {
-    page, onClick,
-  } = props;
-
-  return (
-    <>
-      {!pagePathUtils.isUsersTopPage(page.path ?? '') && (
-        <NotAvailableForGuest>
-          <NotAvailableForReadOnlyUser>
-            <button
-              id="page-create-button-in-page-tree"
-              type="button"
-              className="border-0 rounded btn btn-page-item-control p-0"
-              onClick={onClick}
-            >
-              <span className="material-symbols-outlined p-0">add_circle</span>
-            </button>
-          </NotAvailableForReadOnlyUser>
-        </NotAvailableForGuest>
-      )}
-    </>
-  );
-};

+ 0 - 6
apps/app/src/client/components/TreeItem/NewPageInput/NewPageInput.module.scss

@@ -1,6 +0,0 @@
-@use '../tree-item-variables';
-
-.new-page-input-container {
-  width: calc(100% - tree-item-variables.$btn-triangle-min-width);
-  padding-left: 24px;
-}

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

@@ -1 +0,0 @@
-export * from './use-new-page-input';

+ 0 - 180
apps/app/src/client/components/TreeItem/NewPageInput/use-new-page-input.tsx

@@ -1,180 +0,0 @@
-import type { ChangeEvent } from 'react';
-import React, {
-  useState, type FC, useCallback, useRef,
-} from 'react';
-
-import nodePath from 'path';
-
-import { Origin } from '@growi/core';
-import { pathUtils, pagePathUtils } from '@growi/core/dist/utils';
-import { useRect } from '@growi/ui/dist/utils';
-import { useTranslation } from 'next-i18next';
-import { debounce } from 'throttle-debounce';
-
-import { AutosizeSubmittableInput, getAdjustedMaxWidthForAutosizeInput } from '~/client/components/Common/SubmittableInput';
-import { useCreatePage } from '~/client/services/create-page';
-import { toastWarning, toastError, toastSuccess } from '~/client/util/toastr';
-import type { InputValidationResult } from '~/client/util/use-input-validator';
-import { ValidationTarget, useInputValidator } from '~/client/util/use-input-validator';
-import { usePageTreeDescCountMap } from '~/states/ui/page-tree-desc-count-map';
-import { mutatePageTree, mutateRecentlyUpdated } from '~/stores/page-listing';
-
-import { shouldCreateWipPage } from '../../../../utils/should-create-wip-page';
-import type { TreeItemToolProps } from '../interfaces';
-
-import { NewPageCreateButton } from './NewPageCreateButton';
-
-
-import newPageInputStyles from './NewPageInput.module.scss';
-
-
-type UseNewPageInput = {
-  Input: FC<TreeItemToolProps>,
-  CreateButton: FC<TreeItemToolProps>,
-  isProcessingSubmission: boolean,
-}
-
-export const useNewPageInput = (): UseNewPageInput => {
-
-  const [showInput, setShowInput] = useState(false);
-  const [isProcessingSubmission, setProcessingSubmission] = useState(false);
-
-  const CreateButton: FC<TreeItemToolProps> = (props) => {
-
-    const { itemNode, stateHandlers } = props;
-    const { page } = itemNode;
-
-    const onClick = useCallback(() => {
-      setShowInput(true);
-      stateHandlers?.setIsOpen(true);
-    }, [stateHandlers]);
-
-    return (
-      <NewPageCreateButton
-        page={page}
-        onClick={onClick}
-      />
-    );
-  };
-
-  const Input: FC<TreeItemToolProps> = (props) => {
-
-    const { t } = useTranslation();
-    const { create: createPage } = useCreatePage();
-
-    const { itemNode, stateHandlers, isEnableActions } = props;
-    const { page, children } = itemNode;
-
-    const { getDescCount } = usePageTreeDescCountMap();
-    const descendantCount = getDescCount(page._id) || page.descendantCount || 0;
-
-    const isChildrenLoaded = children?.length > 0;
-    const hasDescendants = descendantCount > 0 || isChildrenLoaded;
-
-    const parentRef = useRef<HTMLDivElement>(null);
-    const [parentRect] = useRect(parentRef);
-
-    const [validationResult, setValidationResult] = useState<InputValidationResult>();
-
-    const inputValidator = useInputValidator(ValidationTarget.PAGE);
-
-    const changeHandler = useCallback(async (e: ChangeEvent<HTMLInputElement>) => {
-      const validationResult = inputValidator(e.target.value);
-      setValidationResult(validationResult ?? undefined);
-    }, [inputValidator]);
-    const changeHandlerDebounced = debounce(300, changeHandler);
-
-    const cancel = useCallback(() => {
-      setValidationResult(undefined);
-      setShowInput(false);
-    }, []);
-
-    const create = useCallback(async (inputText) => {
-      if (inputText.trim() === '') {
-        return cancel();
-      }
-
-      const parentPath = pathUtils.addTrailingSlash(page.path as string);
-      const newPagePath = nodePath.resolve(parentPath, inputText);
-      const isCreatable = pagePathUtils.isCreatablePage(newPagePath);
-
-      if (!isCreatable) {
-        toastWarning(t('you_can_not_create_page_with_this_name_or_hierarchy'));
-        return;
-      }
-
-      setProcessingSubmission(true);
-
-      setShowInput(false);
-
-      try {
-        await createPage(
-          {
-            path: newPagePath,
-            parentPath,
-            body: undefined,
-            // keep grant info undefined to inherit from parent
-            grant: undefined,
-            grantUserGroupIds: undefined,
-            origin: Origin.View,
-            wip: shouldCreateWipPage(newPagePath),
-          },
-          {
-            skipTransition: true,
-            onCreated: () => {
-              mutatePageTree();
-              mutateRecentlyUpdated();
-
-              if (!hasDescendants) {
-                stateHandlers?.setIsOpen(true);
-              }
-
-              toastSuccess(t('successfully_saved_the_page'));
-            },
-          },
-        );
-      }
-      catch (err) {
-        toastError(err);
-      }
-      finally {
-        setProcessingSubmission(false);
-      }
-    }, [cancel, hasDescendants, page.path, stateHandlers, t, createPage]);
-
-    const inputContainerClass = newPageInputStyles['new-page-input-container'] ?? '';
-    const isInvalid = validationResult != null;
-
-    const maxWidth = parentRect != null
-      ? getAdjustedMaxWidthForAutosizeInput(parentRect.width, 'sm', validationResult != null ? false : undefined)
-      : undefined;
-
-    return isEnableActions && showInput
-      ? (
-        <div ref={parentRef} className={inputContainerClass}>
-          <AutosizeSubmittableInput
-            inputClassName={`form-control ${isInvalid ? 'is-invalid' : ''}`}
-            inputStyle={{ maxWidth }}
-            placeholder={t('Input page name')}
-            aria-describedby={isInvalid ? 'new-page-input-feedback' : undefined}
-            onChange={changeHandlerDebounced}
-            onSubmit={create}
-            onCancel={cancel}
-            autoFocus
-          />
-          {isInvalid && (
-            <div id="new-page-input" className="invalid-feedback d-block my-1">
-              {validationResult.message}
-            </div>
-          )}
-        </div>
-      )
-      : <></>;
-  };
-
-  return {
-    Input,
-    CreateButton,
-    isProcessingSubmission,
-  };
-};

+ 0 - 233
apps/app/src/client/components/TreeItem/TreeItemLayout.tsx

@@ -1,233 +0,0 @@
-import React, {
-  useCallback,
-  useState,
-  useEffect,
-  useMemo,
-  type RefObject,
-  type RefCallback,
-  type MouseEvent,
-  type JSX,
-} from 'react';
-
-import { addTrailingSlash } from '@growi/core/dist/utils/path-utils';
-
-import { usePageTreeDescCountMap } from '~/states/ui/page-tree-desc-count-map';
-import { useSWRxPageChildren } from '~/stores/page-listing';
-
-import { ItemNode } from './ItemNode';
-import { SimpleItemContent } from './SimpleItemContent';
-import type { TreeItemProps, TreeItemToolProps } from './interfaces';
-
-
-import styles from './TreeItemLayout.module.scss';
-
-const moduleClass = styles['tree-item-layout'] ?? '';
-
-
-type TreeItemLayoutProps = TreeItemProps & {
-  className?: string,
-  itemRef?: RefObject<any> | RefCallback<any>,
-  indentSize?: number,
-}
-
-export const TreeItemLayout = (props: TreeItemLayoutProps): JSX.Element => {
-  const {
-    className, itemClassName,
-    indentSize = 10,
-    itemLevel: baseItemLevel = 1,
-    itemNode, targetPath, targetPathOrId, isOpen: _isOpen = false,
-    onRenamed, onClick, onClickDuplicateMenuItem, onClickDeleteMenuItem, onWheelClick,
-    isEnableActions, isReadOnlyUser, isWipPageShown = true,
-    itemRef, itemClass,
-    showAlternativeContent,
-  } = props;
-
-  const { page } = itemNode;
-
-  const [currentChildren, setCurrentChildren] = useState<ItemNode[]>([]);
-  const [isOpen, setIsOpen] = useState(_isOpen);
-
-  const { data } = useSWRxPageChildren(isOpen ? page._id : null);
-
-
-  const itemClickHandler = useCallback((e: MouseEvent) => {
-    // DO NOT handle the event when e.currentTarget and e.target is different
-    if (e.target !== e.currentTarget) {
-      return;
-    }
-
-    onClick?.(page);
-
-  }, [onClick, page]);
-
-  const itemMouseupHandler = useCallback((e: MouseEvent) => {
-    // DO NOT handle the event when e.currentTarget and e.target is different
-    if (e.target !== e.currentTarget) {
-      return;
-    }
-
-    if (e.button === 1) {
-      e.preventDefault();
-      onWheelClick?.(page);
-    }
-
-  }, [onWheelClick, page]);
-
-
-  // descendantCount
-  const { getDescCount } = usePageTreeDescCountMap();
-  const descendantCount = getDescCount(page._id) || page.descendantCount || 0;
-
-  // hasDescendants flag
-  const isChildrenLoaded = currentChildren?.length > 0;
-  const hasDescendants = descendantCount > 0 || isChildrenLoaded;
-
-  const hasChildren = useCallback((): boolean => {
-    return currentChildren != null && currentChildren.length > 0;
-  }, [currentChildren]);
-
-  const onClickLoadChildren = useCallback(() => {
-    setIsOpen(!isOpen);
-  }, [isOpen]);
-
-  useEffect(() => {
-    const isPathToTarget = page.path != null
-      && targetPath.startsWith(addTrailingSlash(page.path))
-      && targetPath !== page.path; // Target Page does not need to be opened
-    if (isPathToTarget) setIsOpen(true);
-  }, [targetPath, page.path]);
-
-  /*
-   * When swr fetch succeeded
-   */
-  useEffect(() => {
-    if (isOpen && data != null) {
-      const newChildren = ItemNode.generateNodesFromPages(data.children);
-      setCurrentChildren(newChildren);
-    }
-  }, [data, isOpen, targetPathOrId]);
-
-  const isSelected = useMemo(() => {
-    return page._id === targetPathOrId || page.path === targetPathOrId;
-  }, [page, targetPathOrId]);
-
-  const ItemClassFixed = itemClass ?? TreeItemLayout;
-
-  const baseProps: Omit<TreeItemProps, 'itemLevel' | 'itemNode'> = {
-    isEnableActions,
-    isReadOnlyUser,
-    isOpen: false,
-    isWipPageShown,
-    targetPath,
-    targetPathOrId,
-    onRenamed,
-    onClickDuplicateMenuItem,
-    onClickDeleteMenuItem,
-  };
-
-  const toolProps: TreeItemToolProps = {
-    ...baseProps,
-    itemLevel: baseItemLevel,
-    itemNode,
-    stateHandlers: {
-      setIsOpen,
-    },
-  };
-
-  const EndComponents = props.customEndComponents;
-  const HoveredEndComponents = props.customHoveredEndComponents;
-  const HeadObChildrenComponents = props.customHeadOfChildrenComponents;
-  const AlternativeComponents = props.customAlternativeComponents;
-
-  if (!isWipPageShown && page.wip) {
-    return <></>;
-  }
-
-  return (
-    <div
-      id={`tree-item-layout-${page._id}`}
-      data-testid="grw-pagetree-item-container"
-      className={`${moduleClass} ${className} level-${baseItemLevel}`}
-      style={{ paddingLeft: `${baseItemLevel > 1 ? indentSize : 0}px` }}
-    >
-      <li
-        ref={itemRef}
-        role="button"
-        className={`list-group-item list-group-item-action ${itemClassName}
-          border-0 py-0 ps-0 d-flex align-items-center rounded-1`}
-        id={`grw-pagetree-list-${page._id}`}
-        onClick={itemClickHandler}
-        onMouseUp={itemMouseupHandler}
-        aria-current={isSelected ? true : undefined}
-      >
-
-        <div className="btn-triangle-container d-flex justify-content-center">
-          {hasDescendants && (
-            <button
-              type="button"
-              className={`btn btn-triangle p-0 ${isOpen ? 'open' : ''}`}
-              onClick={onClickLoadChildren}
-            >
-              <div className="d-flex justify-content-center">
-                <span className="material-symbols-outlined fs-5">arrow_right</span>
-              </div>
-            </button>
-          )}
-        </div>
-
-        {showAlternativeContent && AlternativeComponents != null
-          ? (
-            AlternativeComponents.map((AlternativeContent, index) => (
-              // eslint-disable-next-line react/no-array-index-key
-              (<AlternativeContent key={index} {...toolProps} />)
-            ))
-          )
-          : (
-            <>
-              <SimpleItemContent page={page} />
-              <div className="d-hover-none">
-                {EndComponents?.map((EndComponent, index) => (
-                  // eslint-disable-next-line react/no-array-index-key
-                  (<EndComponent key={index} {...toolProps} />)
-                ))}
-              </div>
-              <div className="d-none d-hover-flex">
-                {HoveredEndComponents?.map((HoveredEndContent, index) => (
-                  // eslint-disable-next-line react/no-array-index-key
-                  (<HoveredEndContent key={index} {...toolProps} />)
-                ))}
-              </div>
-            </>
-          )
-        }
-
-      </li>
-      {isOpen && (
-        <div className={`tree-item-layout-children level-${baseItemLevel + 1}`}>
-
-          {HeadObChildrenComponents?.map((HeadObChildrenContents, index) => (
-            // eslint-disable-next-line react/no-array-index-key
-            (<HeadObChildrenContents key={index} {...toolProps} itemLevel={baseItemLevel + 1} />)
-          ))}
-
-          {hasChildren() && currentChildren.map((node) => {
-            const itemProps = {
-              ...baseProps,
-              className,
-              itemLevel: baseItemLevel + 1,
-              itemNode: node,
-              itemClass,
-              itemClassName,
-              onClick,
-            };
-
-            return (
-              <ItemClassFixed key={node.page._id} {...itemProps} />
-            );
-          })}
-
-        </div>
-      )}
-    </div>
-  );
-};

+ 0 - 5
apps/app/src/client/components/TreeItem/index.ts

@@ -1,5 +0,0 @@
-export * from './interfaces';
-
-export * from './NewPageInput';
-export * from './ItemNode';
-export * from './TreeItemLayout';

+ 0 - 38
apps/app/src/client/components/TreeItem/interfaces/index.ts

@@ -1,38 +0,0 @@
-import type { IPageToDeleteWithMeta } from '@growi/core';
-
-import type { IPageForItem } from '~/interfaces/page';
-import type { IPageForPageDuplicateModal } from '~/states/ui/modal/page-duplicate';
-
-import type { ItemNode } from '../ItemNode';
-
-type TreeItemBaseProps = {
-  itemLevel?: number,
-  itemNode: ItemNode,
-  isEnableActions: boolean,
-  isReadOnlyUser: boolean,
-  onClickDuplicateMenuItem?(pageToDuplicate: IPageForPageDuplicateModal): void,
-  onClickDeleteMenuItem?(pageToDelete: IPageToDeleteWithMeta): void,
-  onRenamed?(fromPath: string | undefined, toPath: string): void,
-}
-
-export type TreeItemToolProps = TreeItemBaseProps & {
-  stateHandlers?: {
-    setIsOpen: React.Dispatch<React.SetStateAction<boolean>>,
-  },
-};
-
-export type TreeItemProps = TreeItemBaseProps & {
-  targetPath: string,
-  targetPathOrId?:string,
-  isOpen?: boolean,
-  isWipPageShown?: boolean,
-  itemClass?: React.FunctionComponent<TreeItemProps>,
-  itemClassName?: string,
-  customEndComponents?: Array<React.FunctionComponent<TreeItemToolProps>>,
-  customHoveredEndComponents?: Array<React.FunctionComponent<TreeItemToolProps>>,
-  customHeadOfChildrenComponents?: Array<React.FunctionComponent<TreeItemToolProps>>,
-  showAlternativeContent?: boolean,
-  customAlternativeComponents?: Array<React.FunctionComponent<TreeItemToolProps>>,
-  onClick?(page: IPageForItem): void,
-  onWheelClick?(page: IPageForItem): void,
-};

+ 5 - 4
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementPageTreeSelection.module.scss

@@ -3,9 +3,10 @@
     width: 30%;
   }
 
-  .page-tree-item {
-    .list-group-item {
-      padding: 0.4rem 1rem !important;
-    }
+  .page-tree-container {
+    min-height: 200px;
+    max-height: 50vh;
+    overflow-y: auto;
   }
 }
+

+ 21 - 133
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/AiAssistantManagementPageTreeSelection.tsx

@@ -1,103 +1,25 @@
-import React, { memo, Suspense, useCallback } from 'react';
+import { useCallback } from 'react';
 import { useTranslation } from 'react-i18next';
 import { ModalBody } from 'reactstrap';
-import SimpleBar from 'simplebar-react';
 
-import { ItemsTree } from '~/client/components/ItemsTree';
-import ItemsTreeContentSkeleton from '~/client/components/ItemsTree/ItemsTreeContentSkeleton';
-import type { TreeItemProps } from '~/client/components/TreeItem';
-import { TreeItemLayout } from '~/client/components/TreeItem';
-import type { IPageForItem } from '~/interfaces/page';
 import { useIsGuestUser, useIsReadOnlyUser } from '~/states/context';
 
-import {
-  isSelectablePage,
-  type SelectablePage,
-} from '../../../../interfaces/selectable-page';
-import { useSelectedPages } from '../../../services/use-selected-pages';
+import type { SelectablePage } from '../../../../interfaces/selectable-page';
 import {
   AiAssistantManagementModalPageMode,
   useAiAssistantManagementModalActions,
   useAiAssistantManagementModalStatus,
 } from '../../../states/modal/ai-assistant-management';
 import { AiAssistantManagementHeader } from './AiAssistantManagementHeader';
-import { SelectablePageList } from './SelectablePageList';
+import { usePageTreeSelection } from './hooks/use-page-tree-selection';
+import { PageTreeSelectionTree } from './PageTreeSelectionTree';
+import { SelectedPagesPanel } from './SelectedPagesPanel';
 
 import styles from './AiAssistantManagementPageTreeSelection.module.scss';
 
 const moduleClass =
   styles['grw-ai-assistant-management-page-tree-selection'] ?? '';
 
-const SelectablePageTree = memo(
-  (props: { onClickAddPageButton: (page: SelectablePage) => void }) => {
-    const { onClickAddPageButton } = props;
-
-    const isGuestUser = useIsGuestUser();
-    const isReadOnlyUser = useIsReadOnlyUser();
-
-    const pageTreeItemClickHandler = useCallback(
-      (page: IPageForItem) => {
-        if (!isSelectablePage(page)) {
-          return;
-        }
-
-        onClickAddPageButton(page);
-      },
-      [onClickAddPageButton],
-    );
-
-    const SelectPageButton = useCallback(
-      ({ page }: { page: IPageForItem }) => {
-        return (
-          <button
-            type="button"
-            className="border-0 rounded btn p-0"
-            onClick={(e) => {
-              e.stopPropagation();
-              pageTreeItemClickHandler(page);
-            }}
-          >
-            <span className="material-symbols-outlined p-0 me-2 text-primary">
-              add_circle
-            </span>
-          </button>
-        );
-      },
-      [pageTreeItemClickHandler],
-    );
-
-    const PageTreeItem = useCallback(
-      (props: TreeItemProps) => {
-        const { itemNode } = props;
-        const { page } = itemNode;
-
-        return (
-          <TreeItemLayout
-            {...props}
-            itemClass={PageTreeItem}
-            className="text-muted"
-            customHoveredEndComponents={[
-              () => <SelectPageButton page={page} />,
-            ]}
-          />
-        );
-      },
-      [SelectPageButton],
-    );
-
-    return (
-      <div className="page-tree-item">
-        <ItemsTree
-          targetPath="/"
-          isEnableActions={!isGuestUser}
-          isReadOnlyUser={!!isReadOnlyUser}
-          CustomTreeItem={PageTreeItem}
-        />
-      </div>
-    );
-  },
-);
-
 type Props = {
   baseSelectedPages: SelectablePage[];
   updateBaseSelectedPages: (pages: SelectablePage[]) => void;
@@ -109,6 +31,8 @@ export const AiAssistantManagementPageTreeSelection = (
   const { baseSelectedPages, updateBaseSelectedPages } = props;
 
   const { t } = useTranslation();
+  const isGuestUser = useIsGuestUser();
+  const isReadOnlyUser = useIsReadOnlyUser();
   const aiAssistantManagementModalData = useAiAssistantManagementModalStatus();
   const { changePageMode } = useAiAssistantManagementModalActions();
   const isNewAiAssistant =
@@ -116,32 +40,11 @@ export const AiAssistantManagementPageTreeSelection = (
 
   const {
     selectedPages,
-    selectedPagesRef,
     selectedPagesArray,
-    addPage,
+    initialCheckedItems,
+    handleCheckedItemsChange,
     removePage,
-  } = useSelectedPages(baseSelectedPages);
-
-  const addPageButtonClickHandler = useCallback(
-    (page: SelectablePage) => {
-      const pagePathWithGlob = `${page.path}/*`;
-      if (
-        selectedPagesRef.current == null ||
-        selectedPagesRef.current.has(pagePathWithGlob)
-      ) {
-        return;
-      }
-
-      const clonedPage = { ...page };
-      clonedPage.path = pagePathWithGlob;
-
-      addPage(clonedPage);
-    },
-    [
-      addPage,
-      selectedPagesRef, // Prevent flickering (use ref to avoid method recreation)
-    ],
-  );
+  } = usePageTreeSelection(baseSelectedPages);
 
   const nextButtonClickHandler = useCallback(() => {
     updateBaseSelectedPages(Array.from(selectedPages.values()));
@@ -178,35 +81,20 @@ export const AiAssistantManagementPageTreeSelection = (
           {t('modal_ai_assistant.search_reference_pages_by_keyword')}
         </h4>
 
-        <Suspense fallback={<ItemsTreeContentSkeleton />}>
-          <div className="px-4">
-            <SelectablePageTree
-              onClickAddPageButton={addPageButtonClickHandler}
-            />
-          </div>
-        </Suspense>
-
-        <h4 className="text-center fw-bold mb-3 mt-4">
-          {t('modal_ai_assistant.reference_pages')}
-        </h4>
-
         <div className="px-4">
-          <SimpleBar
-            className="page-list-container"
-            style={{ maxHeight: '300px' }}
-          >
-            <SelectablePageList
-              method="remove"
-              methodButtonPosition="right"
-              pages={selectedPagesArray}
-              onClickMethodButton={removePage}
-            />
-          </SimpleBar>
-          <span className="form-text text-muted mt-2">
-            {t('modal_ai_assistant.can_add_later')}
-          </span>
+          <PageTreeSelectionTree
+            isEnableActions={!isGuestUser}
+            isReadOnlyUser={!!isReadOnlyUser}
+            initialCheckedItems={initialCheckedItems}
+            onCheckedItemsChange={handleCheckedItemsChange}
+          />
         </div>
 
+        <SelectedPagesPanel
+          pages={selectedPagesArray}
+          onRemovePage={removePage}
+        />
+
         <div className="d-flex justify-content-center mt-4">
           <button
             type="button"

+ 51 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/PageTreeSelectionTree.tsx

@@ -0,0 +1,51 @@
+import { Suspense, useCallback, useState } from 'react';
+
+import ItemsTreeContentSkeleton from '~/client/components/ItemsTree/ItemsTreeContentSkeleton';
+import { ItemsTree } from '~/features/page-tree/components';
+import type { IPageForTreeItem } from '~/interfaces/page';
+
+import {
+  TreeItemWithCheckbox,
+  treeItemWithCheckboxSize,
+} from './TreeItemWithCheckbox';
+
+type Props = {
+  isEnableActions: boolean;
+  isReadOnlyUser: boolean;
+  initialCheckedItems: string[];
+  onCheckedItemsChange: (checkedPages: IPageForTreeItem[]) => void;
+};
+
+export const PageTreeSelectionTree = (props: Props): JSX.Element => {
+  const {
+    isEnableActions,
+    isReadOnlyUser,
+    initialCheckedItems,
+    onCheckedItemsChange,
+  } = props;
+
+  // Scroll container for virtualization
+  const [scrollerElem, setScrollerElem] = useState<HTMLElement | null>(null);
+
+  const estimateTreeItemSize = useCallback(() => treeItemWithCheckboxSize, []);
+
+  return (
+    <div className="page-tree-container" ref={setScrollerElem}>
+      {scrollerElem != null && (
+        <Suspense fallback={<ItemsTreeContentSkeleton />}>
+          <ItemsTree
+            targetPath="/"
+            isEnableActions={isEnableActions}
+            isReadOnlyUser={isReadOnlyUser}
+            CustomTreeItem={TreeItemWithCheckbox}
+            estimateTreeItemSize={estimateTreeItemSize}
+            scrollerElem={scrollerElem}
+            enableCheckboxes
+            initialCheckedItems={initialCheckedItems}
+            onCheckedItemsChange={onCheckedItemsChange}
+          />
+        </Suspense>
+      )}
+    </div>
+  );
+};

+ 40 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/SelectedPagesPanel.tsx

@@ -0,0 +1,40 @@
+import { useTranslation } from 'react-i18next';
+import SimpleBar from 'simplebar-react';
+
+import type { SelectablePage } from '../../../../interfaces/selectable-page';
+import { SelectablePageList } from './SelectablePageList';
+
+type Props = {
+  pages: SelectablePage[];
+  onRemovePage: (page: SelectablePage) => void;
+};
+
+export const SelectedPagesPanel = (props: Props): JSX.Element => {
+  const { pages, onRemovePage } = props;
+  const { t } = useTranslation();
+
+  return (
+    <>
+      <h4 className="text-center fw-bold mb-3 mt-4">
+        {t('modal_ai_assistant.reference_pages')}
+      </h4>
+
+      <div className="px-4">
+        <SimpleBar
+          className="page-list-container"
+          style={{ maxHeight: '300px' }}
+        >
+          <SelectablePageList
+            method="remove"
+            methodButtonPosition="right"
+            pages={pages}
+            onClickMethodButton={onRemovePage}
+          />
+        </SimpleBar>
+        <span className="form-text text-muted mt-2">
+          {t('modal_ai_assistant.can_add_later')}
+        </span>
+      </div>
+    </>
+  );
+};

+ 10 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/TreeItemWithCheckbox.module.scss

@@ -0,0 +1,10 @@
+.page-tree-item :global {
+  li {
+    min-height: 36px;
+  }
+
+  input[type="checkbox"] {
+    margin-left: auto;
+    cursor: pointer;
+  }
+}

+ 62 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/TreeItemWithCheckbox.tsx

@@ -0,0 +1,62 @@
+import type { FC } from 'react';
+
+import type {
+  TreeItemProps,
+  TreeItemWithCheckboxToolProps,
+} from '~/features/page-tree';
+import { TreeItemLayout } from '~/features/page-tree/components';
+
+import styles from './TreeItemWithCheckbox.module.scss';
+
+const moduleClass = styles['page-tree-item'] ?? '';
+
+export const treeItemWithCheckboxSize = 36; // in px
+
+// Checkbox component to be used as customEndComponents
+const TreeItemCheckbox: FC<TreeItemWithCheckboxToolProps> = ({ item }) => {
+  const checkboxProps = item.getCheckboxProps();
+
+  const handleCheckboxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+    e.stopPropagation();
+    item.toggleCheckedState();
+  };
+
+  // Prevent click events from bubbling up to the li element
+  const handleClick = (e: React.MouseEvent) => {
+    e.stopPropagation();
+  };
+
+  return (
+    // biome-ignore lint/a11y/useKeyWithClickEvents: click handler only prevents propagation
+    // biome-ignore lint/a11y/noStaticElementInteractions: wrapper div to stop click propagation
+    <div
+      onClick={handleClick}
+      className="form-check form-switch d-flex align-items-center"
+    >
+      <input
+        className="form-check-input"
+        type="checkbox"
+        role="switch"
+        aria-checked={checkboxProps.checked ?? false}
+        aria-label="Toggle selection"
+        checked={checkboxProps.checked ?? false}
+        onChange={handleCheckboxChange}
+      />
+    </div>
+  );
+};
+
+type TreeItemWithCheckboxProps = TreeItemProps & {
+  key?: React.Key | null;
+};
+
+export const TreeItemWithCheckbox: FC<TreeItemWithCheckboxProps> = (props) => {
+  return (
+    <TreeItemLayout
+      {...props}
+      className={moduleClass}
+      customEndComponents={[TreeItemCheckbox]}
+      customHoveredEndComponents={[TreeItemCheckbox]}
+    />
+  );
+};

+ 88 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantManagementModal/hooks/use-page-tree-selection.ts

@@ -0,0 +1,88 @@
+import { useCallback, useMemo } from 'react';
+
+import type { IPageForTreeItem } from '~/interfaces/page';
+
+import {
+  isSelectablePage,
+  type SelectablePage,
+} from '../../../../../interfaces/selectable-page';
+import { useSelectedPages } from '../../../../services/use-selected-pages';
+
+/**
+ * Convert a page path to a glob pattern for selecting descendants.
+ * Handles the root page case where '//*' should become '/*'.
+ */
+export const toPagePathGlob = (path: string): string => {
+  if (path === '/') {
+    return '/*';
+  }
+  return `${path}/*`;
+};
+
+type UsePageTreeSelectionReturn = {
+  selectedPages: Map<string, SelectablePage>;
+  selectedPagesArray: SelectablePage[];
+  initialCheckedItems: string[];
+  handleCheckedItemsChange: (checkedPages: IPageForTreeItem[]) => void;
+  addPage: (page: SelectablePage) => void;
+  removePage: (page: SelectablePage) => void;
+};
+
+export const usePageTreeSelection = (
+  baseSelectedPages: SelectablePage[],
+): UsePageTreeSelectionReturn => {
+  const { selectedPages, selectedPagesArray, addPage, removePage } =
+    useSelectedPages(baseSelectedPages);
+
+  // Calculate initial checked items from baseSelectedPages
+  // Remove the /* suffix to match with page IDs
+  const initialCheckedItems = useMemo(() => {
+    return baseSelectedPages
+      .filter((page) => page._id != null)
+      .map((page) => page._id as string);
+  }, [baseSelectedPages]);
+
+  // Handle checked items change from tree
+  const handleCheckedItemsChange = useCallback(
+    (checkedPages: IPageForTreeItem[]) => {
+      // Get current checked page IDs (with /* suffix paths)
+      const currentCheckedPaths = new Set(
+        checkedPages
+          .filter((page) => isSelectablePage(page) && page.path != null)
+          .map((page) => toPagePathGlob(page.path as string)),
+      );
+
+      // Get currently selected page paths
+      const currentSelectedPaths = new Set(selectedPages.keys());
+
+      // Add newly checked pages
+      checkedPages.forEach((page) => {
+        if (!isSelectablePage(page) || page.path == null) {
+          return;
+        }
+        const pagePathWithGlob = toPagePathGlob(page.path);
+        if (!currentSelectedPaths.has(pagePathWithGlob)) {
+          const clonedPage = { ...page, path: pagePathWithGlob };
+          addPage(clonedPage as SelectablePage);
+        }
+      });
+
+      // Remove unchecked pages
+      selectedPagesArray.forEach((page) => {
+        if (page.path != null && !currentCheckedPaths.has(page.path)) {
+          removePage(page);
+        }
+      });
+    },
+    [selectedPages, selectedPagesArray, addPage, removePage],
+  );
+
+  return {
+    selectedPages,
+    selectedPagesArray,
+    initialCheckedItems,
+    handleCheckedItemsChange,
+    addPage,
+    removePage,
+  };
+};

+ 18 - 18
apps/app/src/features/opentelemetry/server/anonymization/handlers/page-listing-api-handler.spec.ts

@@ -12,20 +12,20 @@ describe('pageListingApiModule', () => {
 
   describe('canHandle', () => {
     it.each`
-      description                           | url                                                  | expected
-      ${'ancestors-children endpoint'}      | ${'/_api/v3/page-listing/ancestors-children?path=/'} | ${true}
-      ${'children endpoint'}                | ${'/_api/v3/page-listing/children?path=/docs'}       | ${true}
-      ${'info endpoint'}                    | ${'/_api/v3/page-listing/info?path=/wiki'}           | ${true}
-      ${'ancestors-children without query'} | ${'/_api/v3/page-listing/ancestors-children'}        | ${true}
-      ${'children without query'}           | ${'/_api/v3/page-listing/children'}                  | ${true}
-      ${'info without query'}               | ${'/_api/v3/page-listing/info'}                      | ${true}
-      ${'other page-listing endpoint'}      | ${'/_api/v3/page-listing/other'}                     | ${false}
-      ${'different API version'}            | ${'/_api/v2/page-listing/children'}                  | ${false}
-      ${'non-page-listing API'}             | ${'/_api/v3/pages/list'}                             | ${false}
-      ${'regular page path'}                | ${'/page/path'}                                      | ${false}
-      ${'root path'}                        | ${'/'}                                               | ${false}
-      ${'empty URL'}                        | ${''}                                                | ${false}
-      ${'partial match'}                    | ${'/_api/v3/page-listing-other/children'}            | ${false}
+      description                           | url                                                         | expected
+      ${'ancestors-children endpoint'}      | ${'/_api/v3/page-listing/ancestors-children?path=/'}        | ${true}
+      ${'children endpoint'}                | ${'/_api/v3/page-listing/children?path=/docs'}              | ${true}
+      ${'item endpoint'}                    | ${'/_api/v3/page-listing/item?id=68b686d3984fce462ecc7c05'} | ${true}
+      ${'ancestors-children without query'} | ${'/_api/v3/page-listing/ancestors-children'}               | ${true}
+      ${'children without query'}           | ${'/_api/v3/page-listing/children'}                         | ${true}
+      ${'item without query'}               | ${'/_api/v3/page-listing/item'}                             | ${true}
+      ${'other page-listing endpoint'}      | ${'/_api/v3/page-listing/other'}                            | ${false}
+      ${'different API version'}            | ${'/_api/v2/page-listing/children'}                         | ${false}
+      ${'non-page-listing API'}             | ${'/_api/v3/pages/list'}                                    | ${false}
+      ${'regular page path'}                | ${'/page/path'}                                             | ${false}
+      ${'root path'}                        | ${'/'}                                                      | ${false}
+      ${'empty URL'}                        | ${''}                                                       | ${false}
+      ${'partial match'}                    | ${'/_api/v3/page-listing-other/children'}                   | ${false}
     `('should return $expected for $description: $url', ({ url, expected }) => {
       const result = pageListingApiModule.canHandle(url);
       expect(result).toBe(expected);
@@ -120,10 +120,10 @@ describe('pageListingApiModule', () => {
       });
     });
 
-    describe('info endpoint', () => {
+    describe('item endpoint', () => {
       it('should anonymize path parameter when present', () => {
         const originalUrl =
-          '/_api/v3/page-listing/info?path=/wiki/documentation';
+          '/_api/v3/page-listing/item?path=/wiki/documentation';
 
         // Ensure canHandle returns true for this URL
         expect(pageListingApiModule.canHandle(originalUrl)).toBe(true);
@@ -131,12 +131,12 @@ describe('pageListingApiModule', () => {
         const result = pageListingApiModule.handle(mockRequest, originalUrl);
 
         expect(result).toEqual({
-          'http.target': '/_api/v3/page-listing/info?path=%5BANONYMIZED%5D',
+          'http.target': '/_api/v3/page-listing/item?path=%5BANONYMIZED%5D',
         });
       });
 
       it('should return null when no path parameter is present', () => {
-        const originalUrl = '/_api/v3/page-listing/info';
+        const originalUrl = '/_api/v3/page-listing/item';
 
         // Ensure canHandle returns true for this URL
         expect(pageListingApiModule.canHandle(originalUrl)).toBe(true);

+ 2 - 2
apps/app/src/features/opentelemetry/server/anonymization/handlers/page-listing-api-handler.ts

@@ -20,7 +20,7 @@ export const pageListingApiModule: AnonymizationModule = {
     return (
       url.includes('/_api/v3/page-listing/ancestors-children') ||
       url.includes('/_api/v3/page-listing/children') ||
-      url.includes('/_api/v3/page-listing/info')
+      url.includes('/_api/v3/page-listing/item')
     );
     // Add other page-listing endpoints here as needed
   },
@@ -39,7 +39,7 @@ export const pageListingApiModule: AnonymizationModule = {
     if (
       url.includes('/_api/v3/page-listing/ancestors-children') ||
       url.includes('/_api/v3/page-listing/children') ||
-      url.includes('/_api/v3/page-listing/info')
+      url.includes('/_api/v3/page-listing/item')
     ) {
       const anonymizedUrl = anonymizeQueryParams(url, ['path']);
       // Only set attributes if the URL was actually modified

+ 659 - 0
apps/app/src/features/page-tree/components/ItemsTree.spec.tsx

@@ -0,0 +1,659 @@
+import type React from 'react';
+import type { FC } from 'react';
+import { Suspense } from 'react';
+import { render, waitFor } from '@testing-library/react';
+
+import type { IPageForTreeItem } from '~/interfaces/page';
+
+import type { TreeItemProps } from '../interfaces';
+import { invalidatePageTreeChildren } from '../services';
+import { ItemsTree } from './ItemsTree';
+
+// Mock the apiv3Get function
+const mockApiv3Get = vi.fn();
+vi.mock('~/client/util/apiv3-client', () => ({
+  apiv3Get: (...args: unknown[]) => mockApiv3Get(...args),
+}));
+
+// Mock useSWRxRootPage
+const mockRootPage: IPageForTreeItem = {
+  _id: 'root-page-id',
+  path: '/',
+  parent: null,
+  descendantCount: 10,
+  grant: 1,
+  isEmpty: false,
+  wip: false,
+};
+
+vi.mock('~/stores/page-listing', () => ({
+  useSWRxRootPage: () => ({
+    data: { rootPage: mockRootPage },
+  }),
+}));
+
+// Mock page-tree-create state hooks
+// These will be overridden in specific tests
+let mockCreatingParentId: string | null = null;
+let mockCreatingParentPath: string | null = null;
+
+vi.mock('../states/page-tree-create', async () => {
+  const actual = await vi.importActual('../states/page-tree-create');
+  return {
+    ...actual,
+    useCreatingParentId: () => mockCreatingParentId,
+    useCreatingParentPath: () => mockCreatingParentPath,
+  };
+});
+
+// Mock page-tree-update hooks
+vi.mock('../states/page-tree-update', () => ({
+  usePageTreeInformationGeneration: () => 1,
+  usePageTreeRevalidationEffect: () => {},
+  usePageTreeInformationUpdate: () => ({
+    notifyUpdateItems: vi.fn(),
+    notifyUpdateAllTrees: vi.fn(),
+  }),
+}));
+
+// Mock usePageRename
+vi.mock('../hooks/use-page-rename', () => ({
+  usePageRename: () => ({
+    rename: vi.fn(),
+    getPageName: (item: { getItemData: () => IPageForTreeItem }) => {
+      const data = item.getItemData();
+      const parts = data.path?.split('/') ?? [];
+      return parts[parts.length - 1] || '/';
+    },
+  }),
+}));
+
+// Mock usePageCreate
+vi.mock('../hooks/use-page-create', () => ({
+  usePageCreate: () => ({
+    createFromPlaceholder: vi.fn(),
+    isCreatingPlaceholder: () => false,
+    cancelCreating: vi.fn(),
+  }),
+}));
+
+// Mock useScrollToSelectedItem
+vi.mock('../hooks/use-scroll-to-selected-item', () => ({
+  useScrollToSelectedItem: () => {},
+}));
+
+/**
+ * Create mock page data for testing
+ */
+const createMockPage = (
+  id: string,
+  path: string,
+  options: Partial<IPageForTreeItem> = {},
+): IPageForTreeItem => ({
+  _id: id,
+  path,
+  parent: null,
+  descendantCount: 0,
+  grant: 1,
+  isEmpty: false,
+  wip: false,
+  ...options,
+});
+
+/**
+ * Simple TreeItem component for testing
+ */
+const TestTreeItem: FC<TreeItemProps> = ({ item }) => {
+  const itemData = item.getItemData();
+  return <div data-testid={`tree-item-${itemData._id}`}>{itemData.path}</div>;
+};
+
+/**
+ * Wrapper component with Suspense for testing
+ */
+const TestWrapper: FC<{ children: React.ReactNode }> = ({ children }) => (
+  <Suspense fallback={<div>Loading...</div>}>{children}</Suspense>
+);
+
+describe('ItemsTree', () => {
+  beforeEach(() => {
+    // Clear cache before each test
+    invalidatePageTreeChildren();
+    // Reset mock
+    mockApiv3Get.mockReset();
+    // Reset creating state
+    mockCreatingParentId = null;
+    mockCreatingParentPath = null;
+  });
+
+  describe('API call optimization', () => {
+    test('should only fetch children for expanded nodes, not for all visible nodes', async () => {
+      // Setup: Root page has 3 children, each with descendantCount > 0 (folders)
+      // but none are expanded initially except root
+      const rootChildren = [
+        createMockPage('child-1', '/Page1', { descendantCount: 5 }),
+        createMockPage('child-2', '/Page2', { descendantCount: 3 }),
+        createMockPage('child-3', '/Page3', { descendantCount: 0 }), // leaf node
+      ];
+
+      // Mock API responses
+      mockApiv3Get.mockImplementation(
+        (endpoint: string, params: { id: string }) => {
+          if (endpoint === '/page-listing/children') {
+            if (params.id === 'root-page-id') {
+              return Promise.resolve({ data: { children: rootChildren } });
+            }
+            // Return empty children for other nodes (they shouldn't be called if not expanded)
+            return Promise.resolve({ data: { children: [] } });
+          }
+          // Handle /page-listing/item endpoint
+          if (endpoint === '/page-listing/item') {
+            return Promise.resolve({
+              data: { item: createMockPage(params.id, `/${params.id}`) },
+            });
+          }
+          return Promise.reject(new Error(`Unexpected endpoint: ${endpoint}`));
+        },
+      );
+
+      const scrollerElem = document.createElement('div');
+      scrollerElem.style.height = '500px';
+      scrollerElem.style.overflow = 'auto';
+      document.body.appendChild(scrollerElem);
+
+      render(
+        <TestWrapper>
+          <ItemsTree
+            targetPath="/"
+            isEnableActions={false}
+            CustomTreeItem={TestTreeItem}
+            estimateTreeItemSize={() => 32}
+            scrollerElem={scrollerElem}
+          />
+        </TestWrapper>,
+      );
+
+      // Wait for initial render and API calls to complete
+      await waitFor(() => {
+        expect(mockApiv3Get).toHaveBeenCalled();
+      });
+
+      // Give time for any additional API calls that might happen
+      await new Promise((resolve) => setTimeout(resolve, 100));
+
+      // Key assertion: API should only be called for:
+      // 1. root-page-id (the only expanded node by default)
+      // NOT for child-1, child-2, child-3 even though they are visible
+      const childrenApiCalls = mockApiv3Get.mock.calls.filter(
+        (call) => call[0] === '/page-listing/children',
+      );
+
+      // Should only have 1 call for root-page-id
+      expect(childrenApiCalls).toHaveLength(1);
+      expect(childrenApiCalls[0][1]).toEqual({ id: 'root-page-id' });
+
+      // Cleanup
+      document.body.removeChild(scrollerElem);
+    });
+
+    test('should not call API for nodes that have descendantCount of 0 (leaf nodes)', async () => {
+      // Setup: All children are leaf nodes (descendantCount = 0)
+      const rootChildren = [
+        createMockPage('leaf-1', '/Leaf1', { descendantCount: 0 }),
+        createMockPage('leaf-2', '/Leaf2', { descendantCount: 0 }),
+        createMockPage('leaf-3', '/Leaf3', { descendantCount: 0 }),
+      ];
+
+      mockApiv3Get.mockImplementation(
+        (endpoint: string, params: { id: string }) => {
+          if (endpoint === '/page-listing/children') {
+            if (params.id === 'root-page-id') {
+              return Promise.resolve({ data: { children: rootChildren } });
+            }
+            return Promise.resolve({ data: { children: [] } });
+          }
+          // Handle /page-listing/item endpoint
+          if (endpoint === '/page-listing/item') {
+            return Promise.resolve({
+              data: { item: createMockPage(params.id, `/${params.id}`) },
+            });
+          }
+          return Promise.resolve({ data: { children: [] } });
+        },
+      );
+
+      const scrollerElem = document.createElement('div');
+      scrollerElem.style.height = '500px';
+      document.body.appendChild(scrollerElem);
+
+      render(
+        <TestWrapper>
+          <ItemsTree
+            targetPath="/"
+            CustomTreeItem={TestTreeItem}
+            estimateTreeItemSize={() => 32}
+            scrollerElem={scrollerElem}
+          />
+        </TestWrapper>,
+      );
+
+      await waitFor(() => {
+        expect(mockApiv3Get).toHaveBeenCalled();
+      });
+
+      await new Promise((resolve) => setTimeout(resolve, 100));
+
+      const childrenApiCalls = mockApiv3Get.mock.calls.filter(
+        (call) => call[0] === '/page-listing/children',
+      );
+
+      // Only root should have children fetched
+      expect(childrenApiCalls).toHaveLength(1);
+      expect(childrenApiCalls[0][1]).toEqual({ id: 'root-page-id' });
+
+      document.body.removeChild(scrollerElem);
+    });
+
+    test('isItemFolder should use descendantCount instead of calling getChildren()', async () => {
+      // This test verifies the fix for the bug where isItemFolder called getChildren()
+      // which triggered API calls for ALL visible nodes
+
+      const rootChildren = [
+        createMockPage('folder-1', '/Folder1', { descendantCount: 5 }),
+        createMockPage('folder-2', '/Folder2', { descendantCount: 10 }),
+        createMockPage('leaf-1', '/Leaf1', { descendantCount: 0 }),
+      ];
+
+      // Track which IDs have their children fetched
+      const fetchedChildrenIds: string[] = [];
+
+      mockApiv3Get.mockImplementation(
+        (endpoint: string, params: { id: string }) => {
+          if (endpoint === '/page-listing/children') {
+            fetchedChildrenIds.push(params.id);
+            if (params.id === 'root-page-id') {
+              return Promise.resolve({ data: { children: rootChildren } });
+            }
+            return Promise.resolve({ data: { children: [] } });
+          }
+          // Handle /page-listing/item endpoint
+          if (endpoint === '/page-listing/item') {
+            return Promise.resolve({
+              data: { item: createMockPage(params.id, `/${params.id}`) },
+            });
+          }
+          return Promise.reject(new Error(`Unexpected endpoint: ${endpoint}`));
+        },
+      );
+
+      const scrollerElem = document.createElement('div');
+      scrollerElem.style.height = '500px';
+      document.body.appendChild(scrollerElem);
+
+      render(
+        <TestWrapper>
+          <ItemsTree
+            targetPath="/"
+            CustomTreeItem={TestTreeItem}
+            estimateTreeItemSize={() => 32}
+            scrollerElem={scrollerElem}
+          />
+        </TestWrapper>,
+      );
+
+      await waitFor(() => {
+        expect(mockApiv3Get).toHaveBeenCalled();
+      });
+
+      // Wait for any potential additional API calls
+      await new Promise((resolve) => setTimeout(resolve, 200));
+
+      // Critical assertion: Only root-page-id should have children fetched
+      // folder-1 and folder-2 should NOT be fetched even though they are folders (descendantCount > 0)
+      // This verifies that isItemFolder doesn't call getChildren()
+      expect(fetchedChildrenIds).toEqual(['root-page-id']);
+      expect(fetchedChildrenIds).not.toContain('folder-1');
+      expect(fetchedChildrenIds).not.toContain('folder-2');
+
+      document.body.removeChild(scrollerElem);
+    });
+  });
+
+  describe('auto-expand ancestors', () => {
+    test('should fetch children only for ancestors of targetPath', async () => {
+      // Setup: Deep nested structure
+      // / (root)
+      //   /Sandbox (expanded because it's ancestor of target)
+      //     /Sandbox/Test (target)
+      //   /Other (NOT expanded)
+
+      const rootChildren = [
+        createMockPage('sandbox-id', '/Sandbox', { descendantCount: 5 }),
+        createMockPage('other-id', '/Other', { descendantCount: 3 }),
+      ];
+
+      const sandboxChildren = [
+        createMockPage('test-id', '/Sandbox/Test', { descendantCount: 0 }),
+      ];
+
+      const fetchedChildrenIds: string[] = [];
+
+      mockApiv3Get.mockImplementation(
+        (endpoint: string, params: { id: string }) => {
+          if (endpoint === '/page-listing/children') {
+            fetchedChildrenIds.push(params.id);
+
+            if (params.id === 'root-page-id') {
+              return Promise.resolve({ data: { children: rootChildren } });
+            }
+            if (params.id === 'sandbox-id') {
+              return Promise.resolve({ data: { children: sandboxChildren } });
+            }
+            return Promise.resolve({ data: { children: [] } });
+          }
+          // Handle /page-listing/item endpoint
+          if (endpoint === '/page-listing/item') {
+            return Promise.resolve({
+              data: { item: createMockPage(params.id, `/${params.id}`) },
+            });
+          }
+          return Promise.reject(new Error(`Unexpected endpoint: ${endpoint}`));
+        },
+      );
+
+      const scrollerElem = document.createElement('div');
+      scrollerElem.style.height = '500px';
+      document.body.appendChild(scrollerElem);
+
+      render(
+        <TestWrapper>
+          <ItemsTree
+            targetPath="/Sandbox/Test"
+            CustomTreeItem={TestTreeItem}
+            estimateTreeItemSize={() => 32}
+            scrollerElem={scrollerElem}
+          />
+        </TestWrapper>,
+      );
+
+      // Wait for auto-expand to complete
+      await waitFor(
+        () => {
+          expect(fetchedChildrenIds).toContain('sandbox-id');
+        },
+        { timeout: 1000 },
+      );
+
+      // Give some extra time for any unwanted calls
+      await new Promise((resolve) => setTimeout(resolve, 200));
+
+      // Should fetch: root-page-id (initial), sandbox-id (ancestor of target)
+      // Should NOT fetch: other-id (not an ancestor of target)
+      expect(fetchedChildrenIds).toContain('root-page-id');
+      expect(fetchedChildrenIds).toContain('sandbox-id');
+      expect(fetchedChildrenIds).not.toContain('other-id');
+
+      document.body.removeChild(scrollerElem);
+    });
+  });
+
+  // NOTE: Page creation placeholder tests are covered in use-data-loader.spec.tsx
+  // The dataLoader is responsible for prepending placeholder nodes when creatingParentId is set
+
+  describe('page creation (creatingParentId)', () => {
+    test('should not cause infinite API requests when creatingParentId is set', async () => {
+      // This test verifies the fix for the infinite request loop bug
+      // When creatingParentId is set, the component should:
+      // 1. Invalidate and refetch children for that parent ONCE
+      // 2. NOT continuously refetch in an infinite loop
+
+      const rootChildren = [
+        createMockPage('parent-1', '/Parent1', { descendantCount: 2 }),
+        createMockPage('parent-2', '/Parent2', { descendantCount: 0 }),
+      ];
+
+      const parent1Children = [
+        createMockPage('child-1', '/Parent1/Child1', { descendantCount: 0 }),
+      ];
+
+      // Track API call count per ID
+      const apiCallCounts: Record<string, number> = {};
+
+      mockApiv3Get.mockImplementation(
+        (endpoint: string, params: { id: string }) => {
+          if (endpoint === '/page-listing/children') {
+            apiCallCounts[params.id] = (apiCallCounts[params.id] || 0) + 1;
+
+            if (params.id === 'root-page-id') {
+              return Promise.resolve({ data: { children: rootChildren } });
+            }
+            if (params.id === 'parent-1') {
+              return Promise.resolve({ data: { children: parent1Children } });
+            }
+            return Promise.resolve({ data: { children: [] } });
+          }
+          // Handle /page-listing/item endpoint for individual item fetches
+          if (endpoint === '/page-listing/item') {
+            return Promise.resolve({
+              data: { item: createMockPage(params.id, `/${params.id}`) },
+            });
+          }
+          return Promise.reject(new Error(`Unexpected endpoint: ${endpoint}`));
+        },
+      );
+
+      const scrollerElem = document.createElement('div');
+      scrollerElem.style.height = '500px';
+      document.body.appendChild(scrollerElem);
+
+      // Set creatingParentId BEFORE rendering to simulate the user clicking create button
+      mockCreatingParentId = 'parent-1';
+      mockCreatingParentPath = '/Parent1';
+
+      const { rerender } = render(
+        <TestWrapper>
+          <ItemsTree
+            targetPath="/"
+            isEnableActions={true}
+            CustomTreeItem={TestTreeItem}
+            estimateTreeItemSize={() => 32}
+            scrollerElem={scrollerElem}
+          />
+        </TestWrapper>,
+      );
+
+      // Wait for initial data fetch
+      await waitFor(() => {
+        expect(mockApiv3Get).toHaveBeenCalled();
+      });
+
+      // Wait a reasonable amount of time to detect infinite loops
+      // If there's an infinite loop, we'd see many API calls within this time
+      await new Promise((resolve) => setTimeout(resolve, 500));
+
+      // Force re-render to simulate React re-renders that could trigger the loop
+      rerender(
+        <TestWrapper>
+          <ItemsTree
+            targetPath="/"
+            isEnableActions={true}
+            CustomTreeItem={TestTreeItem}
+            estimateTreeItemSize={() => 32}
+            scrollerElem={scrollerElem}
+          />
+        </TestWrapper>,
+      );
+
+      // Wait more time for potential infinite loop to manifest
+      await new Promise((resolve) => setTimeout(resolve, 500));
+
+      // Key assertion: API calls for parent-1 should be bounded
+      // An infinite loop would cause this count to be very high (100+)
+      // Normal behavior: 1-3 calls (initial load + invalidation)
+      const parent1CallCount = apiCallCounts['parent-1'] || 0;
+      expect(parent1CallCount).toBeLessThanOrEqual(5);
+
+      // Total API calls should also be bounded
+      const totalCalls = Object.values(apiCallCounts).reduce(
+        (sum, count) => sum + count,
+        0,
+      );
+      expect(totalCalls).toBeLessThanOrEqual(10);
+
+      document.body.removeChild(scrollerElem);
+    });
+
+    test('should handle creatingParentId change without infinite loop', async () => {
+      // Test that changing creatingParentId from one value to another
+      // doesn't cause infinite requests
+
+      const rootChildren = [
+        createMockPage('parent-1', '/Parent1', { descendantCount: 1 }),
+        createMockPage('parent-2', '/Parent2', { descendantCount: 1 }),
+      ];
+
+      let totalApiCalls = 0;
+
+      mockApiv3Get.mockImplementation(
+        (endpoint: string, params: { id: string }) => {
+          if (endpoint === '/page-listing/children') {
+            totalApiCalls++;
+
+            if (params.id === 'root-page-id') {
+              return Promise.resolve({ data: { children: rootChildren } });
+            }
+            return Promise.resolve({ data: { children: [] } });
+          }
+          // Handle /page-listing/item endpoint for individual item fetches
+          if (endpoint === '/page-listing/item') {
+            return Promise.resolve({
+              data: { item: createMockPage(params.id, `/${params.id}`) },
+            });
+          }
+          return Promise.reject(new Error(`Unexpected endpoint: ${endpoint}`));
+        },
+      );
+
+      const scrollerElem = document.createElement('div');
+      scrollerElem.style.height = '500px';
+      document.body.appendChild(scrollerElem);
+
+      // Initial render without creating state
+      render(
+        <TestWrapper>
+          <ItemsTree
+            targetPath="/"
+            isEnableActions={true}
+            CustomTreeItem={TestTreeItem}
+            estimateTreeItemSize={() => 32}
+            scrollerElem={scrollerElem}
+          />
+        </TestWrapper>,
+      );
+
+      // Wait for initial load
+      await waitFor(() => {
+        expect(mockApiv3Get).toHaveBeenCalled();
+      });
+      await new Promise((resolve) => setTimeout(resolve, 200));
+
+      const callsAfterInitialLoad = totalApiCalls;
+
+      // Simulate setting creatingParentId (user clicks create button)
+      // Note: Since we can't easily change the mock mid-test in this setup,
+      // we're mainly testing the initial render with creatingParentId set
+
+      // Wait to ensure no more calls happen
+      await new Promise((resolve) => setTimeout(resolve, 500));
+
+      // Verify API calls stabilized
+      expect(totalApiCalls).toBeLessThanOrEqual(callsAfterInitialLoad + 2);
+
+      document.body.removeChild(scrollerElem);
+    });
+
+    test('should stop fetching when creatingParentId becomes null', async () => {
+      // Verify that resetting creatingParentId to null doesn't cause issues
+
+      const rootChildren = [
+        createMockPage('parent-1', '/Parent1', { descendantCount: 1 }),
+      ];
+
+      let apiCallCount = 0;
+
+      mockApiv3Get.mockImplementation(
+        (endpoint: string, params: { id: string }) => {
+          if (endpoint === '/page-listing/children') {
+            apiCallCount++;
+
+            if (params.id === 'root-page-id') {
+              return Promise.resolve({ data: { children: rootChildren } });
+            }
+            return Promise.resolve({ data: { children: [] } });
+          }
+          // Handle /page-listing/item endpoint for individual item fetches
+          if (endpoint === '/page-listing/item') {
+            return Promise.resolve({
+              data: { item: createMockPage(params.id, `/${params.id}`) },
+            });
+          }
+          return Promise.reject(new Error(`Unexpected endpoint: ${endpoint}`));
+        },
+      );
+
+      const scrollerElem = document.createElement('div');
+      scrollerElem.style.height = '500px';
+      document.body.appendChild(scrollerElem);
+
+      // Start with creatingParentId set
+      mockCreatingParentId = 'parent-1';
+      mockCreatingParentPath = '/Parent1';
+
+      const { unmount } = render(
+        <TestWrapper>
+          <ItemsTree
+            targetPath="/"
+            isEnableActions={true}
+            CustomTreeItem={TestTreeItem}
+            estimateTreeItemSize={() => 32}
+            scrollerElem={scrollerElem}
+          />
+        </TestWrapper>,
+      );
+
+      await waitFor(() => {
+        expect(mockApiv3Get).toHaveBeenCalled();
+      });
+      await new Promise((resolve) => setTimeout(resolve, 300));
+
+      const callsBeforeReset = apiCallCount;
+
+      // Reset creating state (simulating cancel or completion)
+      mockCreatingParentId = null;
+      mockCreatingParentPath = null;
+
+      // Unmount and remount to apply the null state
+      unmount();
+
+      render(
+        <TestWrapper>
+          <ItemsTree
+            targetPath="/"
+            isEnableActions={true}
+            CustomTreeItem={TestTreeItem}
+            estimateTreeItemSize={() => 32}
+            scrollerElem={scrollerElem}
+          />
+        </TestWrapper>,
+      );
+
+      await new Promise((resolve) => setTimeout(resolve, 500));
+
+      // API calls should be bounded even after state changes
+      // The difference should be minimal (just the initial load after remount)
+      expect(apiCallCount - callsBeforeReset).toBeLessThanOrEqual(3);
+
+      document.body.removeChild(scrollerElem);
+    });
+  });
+});

+ 230 - 0
apps/app/src/features/page-tree/components/ItemsTree.tsx

@@ -0,0 +1,230 @@
+import type { FC } from 'react';
+import { useCallback, useEffect, useMemo } from 'react';
+import { useTree } from '@headless-tree/react';
+import { useVirtualizer } from '@tanstack/react-virtual';
+import { useTranslation } from 'next-i18next';
+
+import { toastError, toastWarning } from '~/client/util/toastr';
+import type { IPageForTreeItem } from '~/interfaces/page';
+import { useSWRxRootPage } from '~/stores/page-listing';
+
+import { ROOT_PAGE_VIRTUAL_ID } from '../constants/_inner';
+import {
+  useAutoExpandAncestors,
+  useDataLoader,
+  useExpandParentOnCreate,
+  useScrollToSelectedItem,
+  useTreeFeatures,
+  useTreeItemHandlers,
+  useTreeRevalidation,
+} from '../hooks/_inner';
+import { useSocketUpdateDescCount } from '../hooks/use-socket-update-desc-count';
+import type { TreeItemProps } from '../interfaces';
+import { useTriggerTreeRebuild } from '../states/_inner';
+
+// Stable createLoadingItemData function
+const createLoadingItemData = (): IPageForTreeItem => ({
+  _id: '',
+  path: 'Loading...',
+  parent: '',
+  descendantCount: 0,
+  grant: 1,
+  isEmpty: false,
+  wip: false,
+});
+
+type Props = {
+  targetPath: string;
+  targetPathOrId?: string;
+  isWipPageShown?: boolean;
+  isEnableActions?: boolean;
+  isReadOnlyUser?: boolean;
+  CustomTreeItem: React.FunctionComponent<TreeItemProps>;
+  estimateTreeItemSize: () => number;
+  scrollerElem?: HTMLElement | null;
+  // Feature options
+  enableRenaming?: boolean;
+  enableCheckboxes?: boolean;
+  enableDragAndDrop?: boolean;
+  initialCheckedItems?: string[];
+  onCheckedItemsChange?: (checkedItems: IPageForTreeItem[]) => void;
+};
+
+export const ItemsTree: FC<Props> = (props: Props) => {
+  const {
+    targetPath,
+    targetPathOrId,
+    isWipPageShown = true,
+    isEnableActions = false,
+    isReadOnlyUser = false,
+    CustomTreeItem,
+    estimateTreeItemSize,
+    scrollerElem,
+    enableRenaming = false,
+    enableCheckboxes = false,
+    enableDragAndDrop = false,
+    initialCheckedItems = [],
+    onCheckedItemsChange,
+  } = props;
+
+  const { t } = useTranslation();
+  const triggerTreeRebuild = useTriggerTreeRebuild();
+
+  const { data: rootPageResult } = useSWRxRootPage({ suspense: true });
+  const rootPage = rootPageResult?.rootPage;
+  const rootPageId = rootPage?._id ?? ROOT_PAGE_VIRTUAL_ID;
+  const allPagesCount = rootPage?.descendantCount ?? 0;
+
+  const dataLoader = useDataLoader(rootPageId, allPagesCount);
+
+  // Tree item handlers (rename, create, etc.) with stable callbacks for headless-tree
+  const { getItemName, isItemFolder, handleRename, creatingParentId } =
+    useTreeItemHandlers(triggerTreeRebuild);
+
+  // Configure tree features and get checkbox state and D&D handlers
+  const { features, checkboxProperties, dndProperties } = useTreeFeatures({
+    enableRenaming,
+    enableCheckboxes,
+    enableDragAndDrop,
+    initialCheckedItems,
+  });
+
+  const { setCheckedItems, createNotifyEffect } = checkboxProperties;
+  const { canDrag, canDrop, onDrop, renderDragLine } = dndProperties;
+
+  // Wrap onDrop to show toast notifications
+  const handleDrop = useCallback(
+    async (...args: Parameters<typeof onDrop>) => {
+      const result = await onDrop(...args);
+      if (!result.success) {
+        if (result.errorType === 'operation_blocked') {
+          toastWarning(t('page_tree.move_blocked'));
+        } else {
+          toastError(t('page_tree.move_failed'));
+        }
+      }
+    },
+    [onDrop, t],
+  );
+
+  // Stable initial state
+  // biome-ignore lint/correctness/useExhaustiveDependencies: initialCheckedItems is intentionally not in deps to avoid reinitializing on every change
+  const initialState = useMemo(
+    () => ({
+      expandedItems: [ROOT_PAGE_VIRTUAL_ID],
+      ...(enableCheckboxes ? { checkedItems: initialCheckedItems } : {}),
+    }),
+    [enableCheckboxes],
+  );
+
+  const tree = useTree<IPageForTreeItem>({
+    rootItemId: ROOT_PAGE_VIRTUAL_ID,
+    getItemName,
+    initialState,
+    isItemFolder,
+    createLoadingItemData,
+    dataLoader,
+    onRename: handleRename,
+    features,
+    // Checkbox configuration
+    canCheckFolders: enableCheckboxes,
+    propagateCheckedState: false,
+    setCheckedItems,
+    // Drag and drop configuration (only when enabled)
+    ...(enableDragAndDrop && {
+      canDrag,
+      canDrop,
+      onDrop: handleDrop,
+      canDropInbetween: false,
+    }),
+  });
+
+  // Notify parent when checked items change
+  // biome-ignore lint/correctness/useExhaustiveDependencies: createNotifyEffect already includes checkedItemIds in its closure
+  useEffect(createNotifyEffect(tree, onCheckedItemsChange), [
+    createNotifyEffect,
+    tree,
+  ]);
+
+  // Subscribe to Socket.io UpdateDescCount events
+  useSocketUpdateDescCount();
+
+  // Handle tree revalidation and items count tracking
+  useTreeRevalidation({ tree, triggerTreeRebuild });
+
+  // Expand parent item when page creation is initiated
+  useExpandParentOnCreate({
+    tree,
+    creatingParentId,
+    onTreeUpdated: triggerTreeRebuild,
+  });
+
+  const items = tree.getItems();
+
+  // Auto-expand items that are ancestors of targetPath
+  useAutoExpandAncestors({
+    items,
+    targetPath,
+    onExpanded: triggerTreeRebuild,
+  });
+
+  const virtualizer = useVirtualizer({
+    count: items.length,
+    getScrollElement: () => scrollerElem ?? null,
+    estimateSize: estimateTreeItemSize,
+    overscan: 5,
+  });
+
+  // Scroll to selected item on mount or when targetPathOrId changes
+  useScrollToSelectedItem({ targetPathOrId, items, virtualizer });
+
+  return (
+    <div {...tree.getContainerProps()} className="list-group">
+      {virtualizer.getVirtualItems().map((virtualItem) => {
+        const item = items[virtualItem.index];
+        const itemData = item.getItemData();
+
+        // Skip rendering virtual root
+        if (itemData._id === ROOT_PAGE_VIRTUAL_ID) {
+          return null;
+        }
+
+        // Skip rendering WIP pages if not shown
+        if (!isWipPageShown && itemData.wip) {
+          return null;
+        }
+
+        const { ref: itemRef, ...itemProps } = item.getProps();
+        // Exclude onClick from itemProps to prevent conflicts
+        const { onClick: _onClick, ...itemPropsWithoutOnClick } = itemProps;
+
+        return (
+          <div
+            key={virtualItem.key}
+            data-index={virtualItem.index}
+            ref={(node) => {
+              virtualizer.measureElement(node);
+              if (node && itemRef) {
+                (itemRef as (node: HTMLElement) => void)(node);
+              }
+            }}
+            // Apply props
+            {...itemPropsWithoutOnClick}
+          >
+            <CustomTreeItem
+              item={item}
+              targetPath={targetPath}
+              targetPathOrId={targetPathOrId}
+              isWipPageShown={isWipPageShown}
+              isEnableActions={isEnableActions}
+              isReadOnlyUser={isReadOnlyUser}
+              onToggle={triggerTreeRebuild}
+            />
+          </div>
+        );
+      })}
+      {/* Drag line indicator (rendered by dndProperties when D&D is enabled) */}
+      {renderDragLine(tree)}
+    </div>
+  );
+};

+ 0 - 0
apps/app/src/client/components/TreeItem/SimpleItemContent.module.scss → apps/app/src/features/page-tree/components/SimpleItemContent.module.scss


+ 28 - 14
apps/app/src/client/components/TreeItem/SimpleItemContent.tsx → apps/app/src/features/page-tree/components/SimpleItemContent.tsx

@@ -1,8 +1,6 @@
-import type { JSX } from 'react';
-
-import nodePath from 'path';
-
+import { useId } from 'react';
 import { useTranslation } from 'next-i18next';
+import path from 'pathe';
 import { UncontrolledTooltip } from 'reactstrap';
 
 import type { IPageForItem } from '~/interfaces/page';
@@ -10,16 +8,21 @@ import { shouldRecoverPagePaths } from '~/utils/page-operation';
 
 import styles from './SimpleItemContent.module.scss';
 
-
 const moduleClass = styles['simple-item-content'] ?? '';
 
-
-export const SimpleItemContent = ({ page }: { page: IPageForItem }): JSX.Element => {
+export const SimpleItemContent = ({
+  page,
+}: {
+  page: IPageForItem;
+}): JSX.Element => {
   const { t } = useTranslation();
 
-  const pageName = nodePath.basename(page.path ?? '') || '/';
+  const pageName = path.basename(page.path ?? '') || '/';
+
+  const shouldShowAttentionIcon =
+    page.processData != null ? shouldRecoverPagePaths(page.processData) : false;
 
-  const shouldShowAttentionIcon = page.processData != null ? shouldRecoverPagePaths(page.processData) : false;
+  const spanId = `path-recovery-${useId()}`;
 
   return (
     <div
@@ -28,8 +31,13 @@ export const SimpleItemContent = ({ page }: { page: IPageForItem }): JSX.Element
     >
       {shouldShowAttentionIcon && (
         <>
-          <span id="path-recovery" className="material-symbols-outlined mr-2 text-warning">warning</span>
-          <UncontrolledTooltip placement="top" target="path-recovery" fade={false}>
+          <span
+            id={spanId}
+            className="material-symbols-outlined mr-2 text-warning"
+          >
+            warning
+          </span>
+          <UncontrolledTooltip placement="top" target={spanId} fade={false}>
             {t('tooltip.operation.attention.rename')}
           </UncontrolledTooltip>
         </>
@@ -37,9 +45,15 @@ export const SimpleItemContent = ({ page }: { page: IPageForItem }): JSX.Element
       {page != null && page.path != null && page._id != null && (
         <div className="grw-page-title-anchor flex-grow-1">
           <div className="d-flex align-items-center">
-            <span className={`text-truncate me-1 ${page.isEmpty && 'opacity-75'}`}>{pageName}</span>
-            { page.wip && (
-              <span className="wip-page-badge badge rounded-pill me-1 text-bg-secondary">WIP</span>
+            <span
+              className={`text-truncate me-1 ${page.isEmpty && 'opacity-75'}`}
+            >
+              {pageName}
+            </span>
+            {page.wip && (
+              <span className="wip-page-badge badge rounded-pill me-1 text-bg-secondary">
+                WIP
+              </span>
             )}
           </div>
         </div>

+ 7 - 0
apps/app/src/client/components/TreeItem/TreeItemLayout.module.scss → apps/app/src/features/page-tree/components/TreeItemLayout.module.scss

@@ -12,6 +12,13 @@
           display: flex !important;
         }
       }
+
+      // Drag target state styling
+      &.drag-target {
+        background-color: var(--bs-list-group-active-bg) !important;
+        outline: 3px dashed var(--bs-list-group-active-border-color);
+        outline-offset: -4px;
+      }
     }
   }
 }

+ 163 - 0
apps/app/src/features/page-tree/components/TreeItemLayout.tsx

@@ -0,0 +1,163 @@
+import type { JSX, MouseEvent } from 'react';
+import { useCallback, useMemo } from 'react';
+
+import type { TreeItemProps, TreeItemToolProps } from '../interfaces';
+import { SimpleItemContent } from './SimpleItemContent';
+
+import styles from './TreeItemLayout.module.scss';
+
+const moduleClass = styles['tree-item-layout'] ?? '';
+
+const indentSize = 10; // in px
+
+type TreeItemLayoutProps = TreeItemProps & {
+  className?: string;
+};
+
+export const TreeItemLayout = (props: TreeItemLayoutProps): JSX.Element => {
+  const {
+    className,
+    itemClassName,
+    item,
+    targetPathOrId,
+    isEnableActions,
+    isReadOnlyUser,
+    isWipPageShown = true,
+    showAlternativeContent,
+    onRenamed,
+    onClick,
+    onClickDuplicateMenuItem,
+    onClickDeleteMenuItem,
+    onWheelClick,
+    onToggle,
+  } = props;
+
+  const page = item.getItemData();
+  const itemLevel = item.getItemMeta().level;
+
+  const toggleHandler = useCallback(() => {
+    if (item.isExpanded()) {
+      item.collapse();
+    } else {
+      item.expand();
+    }
+
+    onToggle?.();
+  }, [item, onToggle]);
+
+  const itemClickHandler = useCallback(
+    (e: MouseEvent) => {
+      // DO NOT handle the event when e.currentTarget and e.target is different
+      if (e.target !== e.currentTarget) {
+        return;
+      }
+
+      onClick?.(page);
+    },
+    [onClick, page],
+  );
+
+  const itemMouseupHandler = useCallback(
+    (e: MouseEvent) => {
+      // DO NOT handle the event when e.currentTarget and e.target is different
+      if (e.target !== e.currentTarget) {
+        return;
+      }
+
+      if (e.button === 1) {
+        e.preventDefault();
+        onWheelClick?.(page);
+      }
+    },
+    [onWheelClick, page],
+  );
+
+  // Use item.isFolder() which is evaluated by headless-tree's isItemFolder config
+  // This will be re-evaluated after rebuildTree()
+  const hasDescendants = item.isFolder();
+
+  const isSelected = useMemo(() => {
+    return page._id === targetPathOrId || page.path === targetPathOrId;
+  }, [page, targetPathOrId]);
+
+  // Check if this item is a drag target (being dragged over)
+  const isDragTarget = item.isDragTarget?.() ?? false;
+
+  const toolProps: TreeItemToolProps = {
+    item,
+    isEnableActions,
+    isReadOnlyUser,
+    onRenamed,
+    onClickDuplicateMenuItem,
+    onClickDeleteMenuItem,
+  };
+
+  const EndComponents = props.customEndComponents;
+  const HoveredEndComponents = props.customHoveredEndComponents;
+  const AlternativeComponents = props.customAlternativeComponents;
+
+  if (!isWipPageShown && page.wip) {
+    // biome-ignore lint/complexity/noUselessFragments: ignore
+    return <></>;
+  }
+
+  return (
+    <div
+      id={`tree-item-layout-${page._id}`}
+      data-testid="grw-pagetree-item-container"
+      className={`${moduleClass} ${className}`}
+      style={{ paddingLeft: `${itemLevel > 0 ? indentSize * itemLevel : 0}px` }}
+    >
+      {/* biome-ignore lint/a11y/useKeyWithClickEvents: tree item interaction */}
+      <li
+        className={`list-group-item list-group-item-action
+          ${isSelected ? 'active' : ''}
+          ${isDragTarget ? 'drag-target' : ''}
+          ${itemClassName ?? ''}
+          border-0 py-0 ps-0 d-flex align-items-center rounded-1`}
+        id={`grw-pagetree-list-${page._id}`}
+        onClick={itemClickHandler}
+        onMouseUp={itemMouseupHandler}
+      >
+        <div className="btn-triangle-container d-flex justify-content-center">
+          {hasDescendants && (
+            <button
+              type="button"
+              className={`btn btn-triangle p-0 ${item.isExpanded() ? 'open' : ''}`}
+              onClick={toggleHandler}
+            >
+              <div className="d-flex justify-content-center">
+                <span className="material-symbols-outlined fs-5">
+                  arrow_right
+                </span>
+              </div>
+            </button>
+          )}
+        </div>
+
+        {showAlternativeContent && AlternativeComponents != null ? (
+          AlternativeComponents.map((AlternativeContent, index) => (
+            // biome-ignore lint/suspicious/noArrayIndexKey: static component list
+            <AlternativeContent key={index} {...toolProps} />
+          ))
+        ) : (
+          <>
+            <SimpleItemContent page={page} />
+            <div className="d-hover-none">
+              {EndComponents?.map((EndComponent, index) => (
+                // biome-ignore lint/suspicious/noArrayIndexKey: static component list
+                <EndComponent key={index} {...toolProps} />
+              ))}
+            </div>
+            <div className="d-none d-hover-flex">
+              {HoveredEndComponents?.map((HoveredEndContent, index) => (
+                // biome-ignore lint/suspicious/noArrayIndexKey: static component list
+                <HoveredEndContent key={index} {...toolProps} />
+              ))}
+            </div>
+          </>
+        )}
+      </li>
+    </div>
+  );
+};

+ 105 - 0
apps/app/src/features/page-tree/components/TreeNameInput.tsx

@@ -0,0 +1,105 @@
+import type { FC, InputHTMLAttributes } from 'react';
+import { useState } from 'react';
+import { useTranslation } from 'next-i18next';
+import { debounce } from 'throttle-debounce';
+
+import type { InputValidationResult } from '~/client/util/use-input-validator';
+import {
+  useInputValidator,
+  ValidationTarget,
+} from '~/client/util/use-input-validator';
+
+import { CREATING_PAGE_VIRTUAL_ID } from '../constants/_inner';
+import type { TreeItemToolProps } from '../interfaces';
+
+type TreeNameInputProps = {
+  /**
+   * Props from headless-tree's getRenameInputProps()
+   * Includes value, onChange, onBlur, onKeyDown, ref
+   */
+  inputProps: InputHTMLAttributes<HTMLInputElement> & {
+    ref?: (r: HTMLInputElement | null) => void;
+  };
+  /**
+   * Validation function for the input value
+   */
+  validateName?: (name: string) => InputValidationResult | null;
+  /**
+   * Placeholder text
+   */
+  placeholder?: string;
+  /**
+   * Additional CSS class
+   */
+  className?: string;
+};
+
+/**
+ * Unified input component for tree item name editing (rename/create)
+ * Uses headless-tree's renamingFeature for keyboard handling (Enter/Escape)
+ */
+const TreeNameInputSubstance: FC<TreeNameInputProps> = ({
+  inputProps,
+  validateName,
+  placeholder,
+  className,
+}) => {
+  const [validationResult, setValidationResult] =
+    useState<InputValidationResult | null>(null);
+
+  const validate = debounce(300, (value: string) => {
+    setValidationResult(validateName?.(value) ?? null);
+  });
+
+  const isInvalid = validationResult != null;
+
+  return (
+    <div className={`${className ?? ''} flex-fill`}>
+      <input
+        {...inputProps}
+        onChange={(e) => {
+          inputProps.onChange?.(e);
+          validate(e.target.value);
+        }}
+        onBlur={(e) => {
+          setValidationResult(null);
+          inputProps.onBlur?.(e);
+        }}
+        type="text"
+        placeholder={placeholder}
+        className={`form-control form-control-sm ${isInvalid ? 'is-invalid' : ''}`}
+      />
+      {isInvalid && (
+        <div className="invalid-feedback d-block my-1">
+          {validationResult.message}
+        </div>
+      )}
+    </div>
+  );
+};
+
+/**
+ * Tree item name input component for rename/create mode
+ * Wraps TreeNameInputSubstance with headless-tree's item props
+ */
+export const TreeNameInput: FC<TreeItemToolProps> = ({ item }) => {
+  const { t } = useTranslation();
+  const inputValidator = useInputValidator(ValidationTarget.PAGE);
+
+  const validateName = (name: string): InputValidationResult | null => {
+    return inputValidator(name) ?? null;
+  };
+
+  // Show placeholder only for create mode
+  const isCreating = item.getId() === CREATING_PAGE_VIRTUAL_ID;
+  const placeholder = isCreating ? t('Input page name') : undefined;
+
+  return (
+    <TreeNameInputSubstance
+      inputProps={item.getRenameInputProps()}
+      validateName={validateName}
+      placeholder={placeholder}
+      className="flex-grow-1"
+    />
+  );
+};

+ 1 - 0
apps/app/src/client/components/TreeItem/_tree-item-variables.scss → apps/app/src/features/page-tree/components/_tree-item-variables.scss

@@ -1 +1,2 @@
 $btn-triangle-min-width: 35px;
+$indent-size: 10px;

+ 4 - 0
apps/app/src/features/page-tree/components/index.ts

@@ -0,0 +1,4 @@
+export * from './ItemsTree';
+export * from './SimpleItemContent';
+export * from './TreeItemLayout';
+export * from './TreeNameInput';

+ 2 - 0
apps/app/src/features/page-tree/constants/_inner.ts

@@ -0,0 +1,2 @@
+export const ROOT_PAGE_VIRTUAL_ID = '__virtual_root__';
+export const CREATING_PAGE_VIRTUAL_ID = '__creating_page_placeholder__';

+ 8 - 0
apps/app/src/features/page-tree/hooks/_inner/index.ts

@@ -0,0 +1,8 @@
+export * from './use-auto-expand-ancestors';
+export * from './use-checkbox';
+export * from './use-data-loader';
+export * from './use-expand-parent-on-create';
+export * from './use-scroll-to-selected-item';
+export * from './use-tree-features';
+export * from './use-tree-item-handlers';
+export * from './use-tree-revalidation';

+ 294 - 0
apps/app/src/features/page-tree/hooks/_inner/use-auto-expand-ancestors.spec.tsx

@@ -0,0 +1,294 @@
+import type { ItemInstance } from '@headless-tree/core';
+import { renderHook } from '@testing-library/react';
+
+import type { IPageForTreeItem } from '~/interfaces/page';
+
+import {
+  getAncestorPaths,
+  isAncestorOf,
+  useAutoExpandAncestors,
+} from './use-auto-expand-ancestors';
+
+/**
+ * Create a mock item instance for testing
+ */
+const createMockItem = (
+  path: string,
+  options: {
+    isFolder?: boolean;
+    isExpanded?: boolean;
+  } = {},
+): ItemInstance<IPageForTreeItem> => {
+  const { isFolder = true, isExpanded = false } = options;
+  let expanded = isExpanded;
+
+  return {
+    getItemData: () => ({ path }) as IPageForTreeItem,
+    isFolder: () => isFolder,
+    isExpanded: () => expanded,
+    expand: vi.fn(() => {
+      expanded = true;
+    }),
+  } as unknown as ItemInstance<IPageForTreeItem>;
+};
+
+describe('use-auto-expand-ancestors', () => {
+  describe('getAncestorPaths', () => {
+    describe.each`
+      targetPath                      | expected
+      ${'/'}                          | ${[]}
+      ${'/Sandbox'}                   | ${[]}
+      ${'/Sandbox/Diagrams'}          | ${['/Sandbox']}
+      ${'/Sandbox/Diagrams/figure-1'} | ${['/Sandbox', '/Sandbox/Diagrams']}
+      ${'/a/b/c/d'}                   | ${['/a', '/a/b', '/a/b/c']}
+    `('should return $expected', ({ targetPath, expected }) => {
+      test(`when targetPath is '${targetPath}'`, () => {
+        const result = getAncestorPaths(targetPath);
+        expect(result).toEqual(expected);
+      });
+    });
+  });
+
+  describe('isAncestorOf', () => {
+    describe('when itemPath is root "/"', () => {
+      describe.each`
+        targetPath                      | expected
+        ${'/'}                          | ${false}
+        ${'/Sandbox'}                   | ${true}
+        ${'/Sandbox/Diagrams'}          | ${true}
+        ${'/Sandbox/Diagrams/figure-1'} | ${true}
+      `('should return $expected', ({ targetPath, expected }) => {
+        test(`when targetPath is '${targetPath}'`, () => {
+          const result = isAncestorOf('/', targetPath);
+          expect(result).toBe(expected);
+        });
+      });
+    });
+
+    describe('when itemPath is "/Sandbox"', () => {
+      describe.each`
+        targetPath                      | expected
+        ${'/'}                          | ${false}
+        ${'/Sandbox'}                   | ${false}
+        ${'/SandboxOther'}              | ${false}
+        ${'/Sandbox/Diagrams'}          | ${true}
+        ${'/Sandbox/Diagrams/figure-1'} | ${true}
+      `('should return $expected', ({ targetPath, expected }) => {
+        test(`when targetPath is '${targetPath}'`, () => {
+          const result = isAncestorOf('/Sandbox', targetPath);
+          expect(result).toBe(expected);
+        });
+      });
+    });
+
+    describe('when itemPath is "/Sandbox/Diagrams"', () => {
+      describe.each`
+        targetPath                      | expected
+        ${'/'}                          | ${false}
+        ${'/Sandbox'}                   | ${false}
+        ${'/Sandbox/Diagrams'}          | ${false}
+        ${'/Sandbox/DiagramsOther'}     | ${false}
+        ${'/Sandbox/Diagrams/figure-1'} | ${true}
+        ${'/Sandbox/Diagrams/a/b/c'}    | ${true}
+      `('should return $expected', ({ targetPath, expected }) => {
+        test(`when targetPath is '${targetPath}'`, () => {
+          const result = isAncestorOf('/Sandbox/Diagrams', targetPath);
+          expect(result).toBe(expected);
+        });
+      });
+    });
+  });
+
+  describe('useAutoExpandAncestors', () => {
+    describe('when items is empty', () => {
+      test('should not call onExpanded', () => {
+        const onExpanded = vi.fn();
+
+        renderHook(() =>
+          useAutoExpandAncestors({
+            items: [],
+            targetPath: '/Sandbox/Diagrams/figure-1',
+            onExpanded,
+          }),
+        );
+
+        expect(onExpanded).not.toHaveBeenCalled();
+      });
+
+      test('should call onExpanded when items become available on rerender', () => {
+        const onExpanded = vi.fn();
+        const rootItem = createMockItem('/');
+
+        // First render with empty items
+        const { rerender } = renderHook(
+          ({ items }) =>
+            useAutoExpandAncestors({
+              items,
+              targetPath: '/Sandbox/Diagrams/figure-1',
+              onExpanded,
+            }),
+          { initialProps: { items: [] as ItemInstance<IPageForTreeItem>[] } },
+        );
+
+        expect(onExpanded).not.toHaveBeenCalled();
+
+        // Rerender with items
+        rerender({ items: [rootItem] });
+
+        expect(rootItem.expand).toHaveBeenCalled();
+        expect(onExpanded).toHaveBeenCalledTimes(1);
+      });
+    });
+
+    describe('when items contains ancestors that need to be expanded', () => {
+      test('should expand ancestor items and call onExpanded', () => {
+        const onExpanded = vi.fn();
+        const rootItem = createMockItem('/', { isExpanded: false });
+        const sandboxItem = createMockItem('/Sandbox', { isExpanded: false });
+
+        renderHook(() =>
+          useAutoExpandAncestors({
+            items: [rootItem, sandboxItem],
+            targetPath: '/Sandbox/Diagrams/figure-1',
+            onExpanded,
+          }),
+        );
+
+        expect(rootItem.expand).toHaveBeenCalled();
+        expect(sandboxItem.expand).toHaveBeenCalled();
+        expect(onExpanded).toHaveBeenCalledTimes(1);
+      });
+
+      test('should not expand already expanded items', () => {
+        const onExpanded = vi.fn();
+        const rootItem = createMockItem('/', { isExpanded: true });
+        const sandboxItem = createMockItem('/Sandbox', { isExpanded: false });
+
+        renderHook(() =>
+          useAutoExpandAncestors({
+            items: [rootItem, sandboxItem],
+            targetPath: '/Sandbox/Diagrams/figure-1',
+            onExpanded,
+          }),
+        );
+
+        expect(rootItem.expand).not.toHaveBeenCalled();
+        expect(sandboxItem.expand).toHaveBeenCalled();
+        expect(onExpanded).toHaveBeenCalledTimes(1);
+      });
+    });
+
+    describe('when not all ancestors are loaded yet', () => {
+      test('should continue expanding as ancestors become available', () => {
+        const onExpanded = vi.fn();
+        const rootItem = createMockItem('/', { isExpanded: false });
+
+        // First render - only root is available
+        const { rerender } = renderHook(
+          ({ items }) =>
+            useAutoExpandAncestors({
+              items,
+              targetPath: '/Sandbox/Diagrams/figure-1',
+              onExpanded,
+            }),
+          { initialProps: { items: [rootItem] } },
+        );
+
+        expect(rootItem.expand).toHaveBeenCalled();
+        expect(onExpanded).toHaveBeenCalledTimes(1);
+
+        // Simulate async load - /Sandbox becomes available
+        const sandboxItem = createMockItem('/Sandbox', { isExpanded: false });
+        onExpanded.mockClear();
+
+        rerender({ items: [rootItem, sandboxItem] });
+
+        expect(sandboxItem.expand).toHaveBeenCalled();
+        expect(onExpanded).toHaveBeenCalledTimes(1);
+
+        // Simulate async load - /Sandbox/Diagrams becomes available
+        const diagramsItem = createMockItem('/Sandbox/Diagrams', {
+          isExpanded: false,
+        });
+        onExpanded.mockClear();
+
+        rerender({ items: [rootItem, sandboxItem, diagramsItem] });
+
+        expect(diagramsItem.expand).toHaveBeenCalled();
+        expect(onExpanded).toHaveBeenCalledTimes(1);
+      });
+    });
+
+    describe('when all ancestors are already expanded', () => {
+      test('should not call onExpanded', () => {
+        const onExpanded = vi.fn();
+        const rootItem = createMockItem('/', { isExpanded: true });
+        const sandboxItem = createMockItem('/Sandbox', { isExpanded: true });
+        const diagramsItem = createMockItem('/Sandbox/Diagrams', {
+          isExpanded: true,
+        });
+
+        renderHook(() =>
+          useAutoExpandAncestors({
+            items: [rootItem, sandboxItem, diagramsItem],
+            targetPath: '/Sandbox/Diagrams/figure-1',
+            onExpanded,
+          }),
+        );
+
+        expect(rootItem.expand).not.toHaveBeenCalled();
+        expect(sandboxItem.expand).not.toHaveBeenCalled();
+        expect(diagramsItem.expand).not.toHaveBeenCalled();
+        expect(onExpanded).not.toHaveBeenCalled();
+      });
+
+      test('should not process again on rerender with same targetPath', () => {
+        const onExpanded = vi.fn();
+        const rootItem = createMockItem('/', { isExpanded: true });
+        const sandboxItem = createMockItem('/Sandbox', { isExpanded: true });
+        const diagramsItem = createMockItem('/Sandbox/Diagrams', {
+          isExpanded: true,
+        });
+
+        const { rerender } = renderHook(
+          ({ items }) =>
+            useAutoExpandAncestors({
+              items,
+              targetPath: '/Sandbox/Diagrams/figure-1',
+              onExpanded,
+            }),
+          { initialProps: { items: [rootItem, sandboxItem, diagramsItem] } },
+        );
+
+        expect(onExpanded).not.toHaveBeenCalled();
+
+        // Rerender with same props - should not process again
+        rerender({ items: [rootItem, sandboxItem, diagramsItem] });
+
+        expect(onExpanded).not.toHaveBeenCalled();
+      });
+    });
+
+    describe('when item is not a folder', () => {
+      test('should not expand non-folder items', () => {
+        const onExpanded = vi.fn();
+        const rootItem = createMockItem('/', { isExpanded: false });
+        const sandboxItem = createMockItem('/Sandbox', {
+          isFolder: false,
+          isExpanded: false,
+        });
+
+        renderHook(() =>
+          useAutoExpandAncestors({
+            items: [rootItem, sandboxItem],
+            targetPath: '/Sandbox/Diagrams/figure-1',
+            onExpanded,
+          }),
+        );
+
+        expect(rootItem.expand).toHaveBeenCalled();
+        expect(sandboxItem.expand).not.toHaveBeenCalled();
+      });
+    });
+  });
+});

+ 107 - 0
apps/app/src/features/page-tree/hooks/_inner/use-auto-expand-ancestors.ts

@@ -0,0 +1,107 @@
+import { useEffect, useRef } from 'react';
+import { addTrailingSlash } from '@growi/core/dist/utils/path-utils';
+import type { ItemInstance } from '@headless-tree/core';
+
+import type { IPageForTreeItem } from '~/interfaces/page';
+
+type UseAutoExpandAncestorsProps = {
+  items: ItemInstance<IPageForTreeItem>[];
+  targetPath: string;
+  onExpanded?: () => void;
+};
+
+/**
+ * Get all ancestor paths for a given target path
+ * e.g., "/Sandbox/Diagrams/figure-1" => ["/Sandbox", "/Sandbox/Diagrams"]
+ */
+export const getAncestorPaths = (targetPath: string): string[] => {
+  const segments = targetPath.split('/').filter(Boolean);
+  const ancestors: string[] = [];
+
+  // Build ancestor paths (excluding the target itself)
+  for (let i = 0; i < segments.length - 1; i++) {
+    ancestors.push(`/${segments.slice(0, i + 1).join('/')}`);
+  }
+
+  return ancestors;
+};
+
+/**
+ * Check if itemPath is an ancestor of targetPath
+ */
+export const isAncestorOf = (itemPath: string, targetPath: string): boolean => {
+  if (itemPath === '/') {
+    return targetPath !== '/';
+  }
+  return (
+    targetPath.startsWith(addTrailingSlash(itemPath)) && targetPath !== itemPath
+  );
+};
+
+/**
+ * Hook to auto-expand tree items that are ancestors of the target path.
+ * This is useful for revealing a deeply nested page in a tree view.
+ *
+ * The hook handles async data loading by:
+ * 1. Expanding ancestors as they become available in items
+ * 2. Waiting for all ancestor paths to be loaded before marking as complete
+ * 3. Re-running when items change (e.g., after children are loaded)
+ */
+export const useAutoExpandAncestors = ({
+  items,
+  targetPath,
+  onExpanded,
+}: UseAutoExpandAncestorsProps): void => {
+  const processedTargetPathRef = useRef<string | null>(null);
+
+  useEffect(() => {
+    // Skip if no items loaded yet
+    if (items.length === 0) {
+      return;
+    }
+
+    // Skip if already fully processed for this targetPath
+    if (processedTargetPathRef.current === targetPath) {
+      return;
+    }
+
+    let didExpand = false;
+
+    for (const item of items) {
+      const itemData = item.getItemData();
+      const itemPath = itemData.path;
+
+      if (itemPath == null) continue;
+
+      // Check if this item is an ancestor of targetPath (including root "/")
+      const isAncestorOfTarget =
+        itemPath === '/' || isAncestorOf(itemPath, targetPath);
+
+      if (!isAncestorOfTarget) continue;
+
+      const isFolder = item.isFolder();
+      const isExpanded = item.isExpanded();
+
+      if (isFolder && !isExpanded) {
+        item.expand();
+        didExpand = true;
+      }
+    }
+
+    // If we expanded any items, trigger callback to re-render and load children
+    if (didExpand) {
+      onExpanded?.();
+    } else {
+      // Only mark as fully processed when all ancestors are expanded
+      // Check if we have all the ancestors we need
+      const ancestorPaths = getAncestorPaths(targetPath);
+      const hasAllAncestors = ancestorPaths.every((ancestorPath) =>
+        items.some((item) => item.getItemData().path === ancestorPath),
+      );
+
+      if (hasAllAncestors) {
+        processedTargetPathRef.current = targetPath;
+      }
+    }
+  }, [items, targetPath, onExpanded]);
+};

+ 81 - 0
apps/app/src/features/page-tree/hooks/_inner/use-checkbox.ts

@@ -0,0 +1,81 @@
+import { useCallback, useMemo, useState } from 'react';
+
+import type { IPageForTreeItem } from '~/interfaces/page';
+
+type TreeInstance = {
+  getItemInstance: (
+    id: string,
+  ) => { getItemData: () => IPageForTreeItem } | undefined;
+};
+
+export type UseCheckboxOptions = {
+  enabled: boolean;
+  initialCheckedItems: string[];
+};
+
+export type UseCheckboxProperties = {
+  checkedItemIds: string[];
+  setCheckedItems: ((itemIds: string[]) => void) | undefined;
+  /**
+   * Helper to create a useEffect callback for notifying parent of checked items changes.
+   * Usage in component:
+   * ```
+   * useEffect(
+   *   checkboxProperties.createNotifyEffect(tree, onCheckedItemsChange),
+   *   [checkboxProperties.checkedItemIds, tree, onCheckedItemsChange]
+   * );
+   * ```
+   */
+  createNotifyEffect: (
+    tree: TreeInstance,
+    onCheckedItemsChange?: (checkedItems: IPageForTreeItem[]) => void,
+  ) => () => void;
+};
+
+/**
+ * Hook to manage checkbox state for headless-tree.
+ * Provides state tracking and setter callback for checked items.
+ */
+export const useCheckbox = (
+  options: UseCheckboxOptions,
+): UseCheckboxProperties => {
+  const { enabled, initialCheckedItems } = options;
+
+  // State to track checked items for re-rendering
+  const [checkedItemIds, setCheckedItemIds] =
+    useState<string[]>(initialCheckedItems);
+
+  // Callback to update checked items state (triggers re-render)
+  const handleSetCheckedItems = useCallback((itemIds: string[]) => {
+    setCheckedItemIds(itemIds);
+  }, []);
+
+  // Helper to create useEffect callback for notifying parent
+  const createNotifyEffect = useCallback(
+    (
+      tree: TreeInstance,
+      onCheckedItemsChange?: (checkedItems: IPageForTreeItem[]) => void,
+    ) => {
+      return () => {
+        if (!enabled || onCheckedItemsChange == null) {
+          return;
+        }
+
+        const checkedPages = checkedItemIds
+          .map((id) => tree.getItemInstance(id)?.getItemData())
+          .filter((page): page is IPageForTreeItem => page != null);
+        onCheckedItemsChange(checkedPages);
+      };
+    },
+    [enabled, checkedItemIds],
+  );
+
+  return useMemo(
+    () => ({
+      checkedItemIds,
+      setCheckedItems: enabled ? handleSetCheckedItems : undefined,
+      createNotifyEffect,
+    }),
+    [checkedItemIds, enabled, handleSetCheckedItems, createNotifyEffect],
+  );
+};

+ 346 - 0
apps/app/src/features/page-tree/hooks/_inner/use-data-loader.integration.spec.tsx

@@ -0,0 +1,346 @@
+/**
+ * Integration tests for use-data-loader with real Jotai atoms
+ *
+ * These tests verify that the dataLoader correctly reads creating state
+ * from Jotai atoms. This is critical because:
+ *
+ * 1. The dataLoader callbacks (getChildrenWithData) need to read the latest
+ *    creating state when they are invoked
+ * 2. The dataLoader reference must remain stable to prevent headless-tree
+ *    from refetching all data
+ * 3. Changes to the creating state must be reflected in subsequent
+ *    getChildrenWithData calls WITHOUT recreating the dataLoader
+ *
+ * These tests use real Jotai atoms instead of mocks to ensure the integration
+ * works correctly. This catches bugs like using getDefaultStore() incorrectly.
+ */
+import type { FC, PropsWithChildren } from 'react';
+import { act, renderHook } from '@testing-library/react';
+import { createStore, Provider } from 'jotai';
+
+import type { IPageForTreeItem } from '~/interfaces/page';
+
+import { CREATING_PAGE_VIRTUAL_ID } from '../../constants/_inner';
+import { invalidatePageTreeChildren } from '../../services';
+// Re-import the actions hook to use real implementation
+import {
+  resetCreatingFlagForTesting,
+  usePageTreeCreateActions,
+} from '../../states/_inner';
+import { useDataLoader } from './use-data-loader';
+
+/**
+ * Type helper to extract getChildrenWithData from TreeDataLoader
+ */
+type DataLoaderWithChildrenData = ReturnType<typeof useDataLoader> & {
+  getChildrenWithData: (
+    itemId: string,
+  ) => Promise<{ id: string; data: IPageForTreeItem }[]>;
+};
+
+/**
+ * Combined hook result type for testing both hooks together
+ */
+type CombinedHookResult = {
+  dataLoader: ReturnType<typeof useDataLoader>;
+  actions: ReturnType<typeof usePageTreeCreateActions>;
+};
+
+// Mock the apiv3Get function
+const mockApiv3Get = vi.fn();
+vi.mock('~/client/util/apiv3-client', () => ({
+  apiv3Get: (...args: unknown[]) => mockApiv3Get(...args),
+}));
+
+/**
+ * Create mock page data for testing
+ */
+const createMockPage = (
+  id: string,
+  path: string,
+  options: Partial<IPageForTreeItem> = {},
+): IPageForTreeItem => ({
+  _id: id,
+  path,
+  parent: null,
+  descendantCount: 0,
+  grant: 1,
+  isEmpty: false,
+  wip: false,
+  ...options,
+});
+
+/**
+ * Helper to get typed dataLoader with getChildrenWithData
+ */
+const getDataLoader = (result: {
+  current: CombinedHookResult;
+}): DataLoaderWithChildrenData => {
+  return result.current.dataLoader as DataLoaderWithChildrenData;
+};
+
+describe('use-data-loader integration with Jotai atoms', () => {
+  const ROOT_PAGE_ID = 'root-page-id';
+  const ALL_PAGES_COUNT = 100;
+
+  // Create a fresh Jotai store for each test
+  let store: ReturnType<typeof createStore>;
+
+  // Wrapper component that provides the Jotai store
+  const createWrapper = (): FC<PropsWithChildren> => {
+    const Wrapper: FC<PropsWithChildren> = ({ children }) => (
+      <Provider store={store}>{children}</Provider>
+    );
+    return Wrapper;
+  };
+
+  beforeEach(() => {
+    // Create fresh store for each test
+    store = createStore();
+    // Clear pending requests before each test
+    invalidatePageTreeChildren();
+    // Reset the creating flag for testing
+    resetCreatingFlagForTesting();
+    // Reset mock
+    mockApiv3Get.mockReset();
+  });
+
+  describe('placeholder node with real Jotai atoms', () => {
+    test('should prepend placeholder when creating state is set via actions hook', async () => {
+      const mockChildren = [
+        createMockPage('existing-child', '/parent/existing'),
+      ];
+      mockApiv3Get.mockResolvedValue({ data: { children: mockChildren } });
+
+      const wrapper = createWrapper();
+
+      // Render both hooks together in the same component to share the store
+      // and ensure refs are updated when atom state changes
+      const { result, rerender } = renderHook(
+        (): CombinedHookResult => ({
+          dataLoader: useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+          actions: usePageTreeCreateActions(),
+        }),
+        { wrapper },
+      );
+
+      // First call - no placeholder (creating state is null)
+      const childrenBefore =
+        await getDataLoader(result).getChildrenWithData('parent-id');
+      expect(childrenBefore).toHaveLength(1);
+      expect(childrenBefore[0].id).toBe('existing-child');
+
+      // Set creating state using the actions hook
+      act(() => {
+        result.current.actions.startCreating('parent-id', '/parent');
+      });
+
+      // Rerender to update refs with the new atom state
+      rerender();
+
+      // Clear pending requests to force re-fetch
+      invalidatePageTreeChildren(['parent-id']);
+
+      // Second call - should have placeholder because atom state changed
+      const childrenAfter =
+        await getDataLoader(result).getChildrenWithData('parent-id');
+      expect(childrenAfter).toHaveLength(2);
+      expect(childrenAfter[0].id).toBe(CREATING_PAGE_VIRTUAL_ID);
+      expect(childrenAfter[0].data.parent).toBe('parent-id');
+      expect(childrenAfter[0].data.path).toBe('/parent/');
+      expect(childrenAfter[1].id).toBe('existing-child');
+    });
+
+    test('should remove placeholder when creating state is cancelled', async () => {
+      const mockChildren = [
+        createMockPage('existing-child', '/parent/existing'),
+      ];
+      mockApiv3Get.mockResolvedValue({ data: { children: mockChildren } });
+
+      const wrapper = createWrapper();
+
+      // Render both hooks together in the same component
+      const { result, rerender } = renderHook(
+        (): CombinedHookResult => ({
+          dataLoader: useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+          actions: usePageTreeCreateActions(),
+        }),
+        { wrapper },
+      );
+
+      // Set creating state
+      act(() => {
+        result.current.actions.startCreating('parent-id', '/parent');
+      });
+
+      // Rerender to update refs
+      rerender();
+
+      // Clear pending requests and fetch - should have placeholder
+      invalidatePageTreeChildren(['parent-id']);
+      const childrenWithPlaceholder =
+        await getDataLoader(result).getChildrenWithData('parent-id');
+      expect(childrenWithPlaceholder).toHaveLength(2);
+      expect(childrenWithPlaceholder[0].id).toBe(CREATING_PAGE_VIRTUAL_ID);
+
+      // Cancel creating
+      act(() => {
+        result.current.actions.cancelCreating();
+      });
+
+      // Rerender to update refs
+      rerender();
+
+      // Clear pending requests and fetch - should NOT have placeholder
+      invalidatePageTreeChildren(['parent-id']);
+      const childrenAfterCancel =
+        await getDataLoader(result).getChildrenWithData('parent-id');
+      expect(childrenAfterCancel).toHaveLength(1);
+      expect(childrenAfterCancel[0].id).toBe('existing-child');
+    });
+
+    test('dataLoader reference should remain stable when creating state changes via atom', async () => {
+      const wrapper = createWrapper();
+
+      // Render both hooks together in the same component
+      const { result, rerender } = renderHook(
+        (): CombinedHookResult => ({
+          dataLoader: useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+          actions: usePageTreeCreateActions(),
+        }),
+        { wrapper },
+      );
+
+      const firstDataLoader = result.current.dataLoader;
+
+      // Change creating state via atom
+      act(() => {
+        result.current.actions.startCreating('some-parent', '/some-parent');
+      });
+
+      // Rerender to update refs
+      rerender();
+
+      const secondDataLoader = result.current.dataLoader;
+
+      // DataLoader reference should be STABLE (same reference)
+      // This is critical to prevent headless-tree from refetching all data
+      expect(firstDataLoader).toBe(secondDataLoader);
+    });
+
+    test('should correctly read state changes after rerender', async () => {
+      // This test verifies that the dataLoader callbacks can read the updated
+      // atom state after a React rerender. The refs in useDataLoader are updated
+      // during the render cycle, so a rerender is needed to see state changes.
+
+      const mockChildren = [
+        createMockPage('existing-child', '/parent/existing'),
+      ];
+      mockApiv3Get.mockResolvedValue({ data: { children: mockChildren } });
+
+      const wrapper = createWrapper();
+
+      // Render both hooks together in the same component
+      const { result, rerender } = renderHook(
+        (): CombinedHookResult => ({
+          dataLoader: useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+          actions: usePageTreeCreateActions(),
+        }),
+        { wrapper },
+      );
+
+      // Get the dataLoader reference BEFORE state change
+      const dataLoader = getDataLoader(result);
+
+      // Set creating state
+      act(() => {
+        result.current.actions.startCreating('parent-id', '/parent');
+      });
+
+      // Rerender to update refs
+      rerender();
+
+      // Clear pending requests
+      invalidatePageTreeChildren(['parent-id']);
+
+      // Call getChildrenWithData using the SAME dataLoader reference
+      // This should still see the updated atom state
+      const children = await dataLoader.getChildrenWithData('parent-id');
+
+      expect(children).toHaveLength(2);
+      expect(children[0].id).toBe(CREATING_PAGE_VIRTUAL_ID);
+      expect(children[1].id).toBe('existing-child');
+    });
+
+    test('should work with multiple sequential state changes', async () => {
+      const mockChildren = [
+        createMockPage('existing-child', '/parent/existing'),
+      ];
+      mockApiv3Get.mockResolvedValue({ data: { children: mockChildren } });
+
+      const wrapper = createWrapper();
+
+      // Render both hooks together in the same component
+      const { result, rerender } = renderHook(
+        (): CombinedHookResult => ({
+          dataLoader: useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+          actions: usePageTreeCreateActions(),
+        }),
+        { wrapper },
+      );
+
+      const dataLoader = getDataLoader(result);
+
+      // Sequence: start -> cancel -> start again -> cancel
+      // Each time, the dataLoader should correctly reflect the state
+
+      // 1. Start creating
+      act(() => {
+        result.current.actions.startCreating('parent-id', '/parent');
+      });
+      rerender();
+      invalidatePageTreeChildren(['parent-id']);
+      let children = await dataLoader.getChildrenWithData('parent-id');
+      expect(children).toHaveLength(2);
+      expect(children[0].id).toBe(CREATING_PAGE_VIRTUAL_ID);
+
+      // 2. Cancel
+      act(() => {
+        result.current.actions.cancelCreating();
+      });
+      rerender();
+      invalidatePageTreeChildren(['parent-id']);
+      children = await dataLoader.getChildrenWithData('parent-id');
+      expect(children).toHaveLength(1);
+      expect(children[0].id).toBe('existing-child');
+
+      // 3. Start again with different parent
+      act(() => {
+        result.current.actions.startCreating('other-parent', '/other');
+      });
+      rerender();
+      invalidatePageTreeChildren(['parent-id', 'other-parent']);
+
+      // Original parent should NOT have placeholder
+      children = await dataLoader.getChildrenWithData('parent-id');
+      expect(children).toHaveLength(1);
+
+      // New parent should have placeholder
+      mockApiv3Get.mockResolvedValueOnce({ data: { children: [] } });
+      children = await dataLoader.getChildrenWithData('other-parent');
+      expect(children).toHaveLength(1);
+      expect(children[0].id).toBe(CREATING_PAGE_VIRTUAL_ID);
+      expect(children[0].data.path).toBe('/other/');
+
+      // 4. Cancel again
+      act(() => {
+        result.current.actions.cancelCreating();
+      });
+      rerender();
+      invalidatePageTreeChildren(['other-parent']);
+      mockApiv3Get.mockResolvedValueOnce({ data: { children: [] } });
+      children = await dataLoader.getChildrenWithData('other-parent');
+      expect(children).toHaveLength(0);
+    });
+  });
+});

+ 490 - 0
apps/app/src/features/page-tree/hooks/_inner/use-data-loader.spec.tsx

@@ -0,0 +1,490 @@
+import { renderHook } from '@testing-library/react';
+
+import type { IPageForTreeItem } from '~/interfaces/page';
+
+import {
+  CREATING_PAGE_VIRTUAL_ID,
+  ROOT_PAGE_VIRTUAL_ID,
+} from '../../constants/_inner';
+import { invalidatePageTreeChildren } from '../../services';
+import { useDataLoader } from './use-data-loader';
+
+/**
+ * Type helper to extract getChildrenWithData from TreeDataLoader
+ * TreeDataLoader is a union type, and we're using the variant with getChildrenWithData
+ */
+type DataLoaderWithChildrenData = ReturnType<typeof useDataLoader> & {
+  getChildrenWithData: (
+    itemId: string,
+  ) => Promise<{ id: string; data: IPageForTreeItem }[]>;
+};
+
+// Mock the apiv3Get function
+const mockApiv3Get = vi.fn();
+vi.mock('~/client/util/apiv3-client', () => ({
+  apiv3Get: (...args: unknown[]) => mockApiv3Get(...args),
+}));
+
+// Mutable state for creating parent info
+let mockCreatingParentId: string | null = null;
+let mockCreatingParentPath: string | null = null;
+
+// Mock the page-tree-create state hooks
+// Note: use-data-loader.ts imports from '../../states/_inner' which re-exports from page-tree-create.ts
+vi.mock('../../states/_inner/page-tree-create', async () => {
+  const actual = await vi.importActual('../../states/_inner/page-tree-create');
+  return {
+    ...actual,
+    useCreatingParentId: () => mockCreatingParentId,
+    useCreatingParentPath: () => mockCreatingParentPath,
+  };
+});
+
+/**
+ * Create mock page data for testing
+ */
+const createMockPage = (
+  id: string,
+  path: string,
+  options: Partial<IPageForTreeItem> = {},
+): IPageForTreeItem => ({
+  _id: id,
+  path,
+  parent: null,
+  descendantCount: 0,
+  grant: 1,
+  isEmpty: false,
+  wip: false,
+  ...options,
+});
+
+/**
+ * Helper to get typed dataLoader with getChildrenWithData
+ */
+const getDataLoader = (result: {
+  current: ReturnType<typeof useDataLoader>;
+}): DataLoaderWithChildrenData => {
+  return result.current as DataLoaderWithChildrenData;
+};
+
+describe('use-data-loader', () => {
+  const ROOT_PAGE_ID = 'root-page-id';
+  const ALL_PAGES_COUNT = 100;
+
+  beforeEach(() => {
+    // Clear pending requests before each test
+    invalidatePageTreeChildren();
+    // Reset mock
+    mockApiv3Get.mockReset();
+    // Reset creating state
+    mockCreatingParentId = null;
+    mockCreatingParentPath = null;
+  });
+
+  describe('useDataLoader', () => {
+    describe('getItem', () => {
+      test('should return virtual root data without API call', async () => {
+        const { result } = renderHook(() =>
+          useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+        );
+
+        const item = await result.current.getItem(ROOT_PAGE_VIRTUAL_ID);
+
+        expect(item._id).toBe(ROOT_PAGE_ID);
+        expect(item.path).toBe('/');
+        expect(item.descendantCount).toBe(ALL_PAGES_COUNT);
+        expect(mockApiv3Get).not.toHaveBeenCalled();
+      });
+
+      test('should return placeholder data without API call for creating placeholder', async () => {
+        const { result } = renderHook(() =>
+          useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+        );
+
+        const item = await result.current.getItem(CREATING_PAGE_VIRTUAL_ID);
+
+        expect(item._id).toBe(CREATING_PAGE_VIRTUAL_ID);
+        expect(mockApiv3Get).not.toHaveBeenCalled();
+      });
+
+      test('should call API for regular item', async () => {
+        const mockPage = createMockPage('page-1', '/test');
+        mockApiv3Get.mockResolvedValue({ data: { item: mockPage } });
+
+        const { result } = renderHook(() =>
+          useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+        );
+
+        const item = await result.current.getItem('page-1');
+
+        expect(item).toEqual(mockPage);
+        expect(mockApiv3Get).toHaveBeenCalledTimes(1);
+        expect(mockApiv3Get).toHaveBeenCalledWith('/page-listing/item', {
+          id: 'page-1',
+        });
+      });
+    });
+
+    describe('getChildrenWithData', () => {
+      test('should return root page without API call for virtual root', async () => {
+        const { result } = renderHook(() =>
+          useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+        );
+
+        const children =
+          await getDataLoader(result).getChildrenWithData(ROOT_PAGE_VIRTUAL_ID);
+
+        expect(children).toHaveLength(1);
+        expect(children[0].id).toBe(ROOT_PAGE_ID);
+        expect(children[0].data.path).toBe('/');
+        expect(mockApiv3Get).not.toHaveBeenCalled();
+      });
+
+      test('should return empty array without API call for placeholder node', async () => {
+        const { result } = renderHook(() =>
+          useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+        );
+
+        const children = await getDataLoader(result).getChildrenWithData(
+          CREATING_PAGE_VIRTUAL_ID,
+        );
+
+        expect(children).toHaveLength(0);
+        expect(mockApiv3Get).not.toHaveBeenCalled();
+      });
+
+      test('should call API for regular item', async () => {
+        const mockChildren = [
+          createMockPage('child-1', '/parent/child-1'),
+          createMockPage('child-2', '/parent/child-2'),
+        ];
+        mockApiv3Get.mockResolvedValue({ data: { children: mockChildren } });
+
+        const { result } = renderHook(() =>
+          useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+        );
+
+        const children =
+          await getDataLoader(result).getChildrenWithData('parent-id');
+
+        expect(children).toHaveLength(2);
+        expect(children[0].id).toBe('child-1');
+        expect(children[1].id).toBe('child-2');
+        expect(mockApiv3Get).toHaveBeenCalledTimes(1);
+        expect(mockApiv3Get).toHaveBeenCalledWith('/page-listing/children', {
+          id: 'parent-id',
+        });
+      });
+    });
+
+    describe('concurrent request deduplication', () => {
+      test('should deduplicate concurrent requests for the same itemId', async () => {
+        const mockChildren = [createMockPage('child-1', '/parent/child-1')];
+
+        // Create a promise that we can resolve manually
+        let resolvePromise: ((value: unknown) => void) | undefined;
+        const delayedPromise = new Promise((resolve) => {
+          resolvePromise = resolve;
+        });
+
+        mockApiv3Get.mockReturnValue(delayedPromise);
+
+        const { result } = renderHook(() =>
+          useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+        );
+
+        // Start multiple concurrent requests
+        const promise1 = getDataLoader(result).getChildrenWithData('parent-id');
+        const promise2 = getDataLoader(result).getChildrenWithData('parent-id');
+        const promise3 = getDataLoader(result).getChildrenWithData('parent-id');
+
+        // Resolve the API call
+        resolvePromise?.({ data: { children: mockChildren } });
+
+        // Wait for all promises
+        const [result1, result2, result3] = await Promise.all([
+          promise1,
+          promise2,
+          promise3,
+        ]);
+
+        // All should return the same data
+        expect(result1).toEqual(result2);
+        expect(result2).toEqual(result3);
+
+        // API should only be called once
+        expect(mockApiv3Get).toHaveBeenCalledTimes(1);
+      });
+
+      test('should call API once per unique itemId for concurrent requests', async () => {
+        const mockChildren1 = [createMockPage('child-1', '/parent1/child-1')];
+        const mockChildren2 = [createMockPage('child-2', '/parent2/child-2')];
+
+        let resolvePromise1: ((value: unknown) => void) | undefined;
+        let resolvePromise2: ((value: unknown) => void) | undefined;
+
+        mockApiv3Get
+          .mockReturnValueOnce(
+            new Promise((resolve) => {
+              resolvePromise1 = resolve;
+            }),
+          )
+          .mockReturnValueOnce(
+            new Promise((resolve) => {
+              resolvePromise2 = resolve;
+            }),
+          );
+
+        const { result } = renderHook(() =>
+          useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+        );
+
+        // Start concurrent requests for different IDs
+        const promise1 = getDataLoader(result).getChildrenWithData('parent-1');
+        const promise2 = getDataLoader(result).getChildrenWithData('parent-2');
+
+        // Resolve both
+        resolvePromise1?.({ data: { children: mockChildren1 } });
+        resolvePromise2?.({ data: { children: mockChildren2 } });
+
+        await Promise.all([promise1, promise2]);
+
+        // API should be called once per unique ID
+        expect(mockApiv3Get).toHaveBeenCalledTimes(2);
+      });
+    });
+
+    describe('dataLoader reference stability', () => {
+      test('should return stable dataLoader reference when props do not change', () => {
+        const { result, rerender } = renderHook(() =>
+          useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+        );
+
+        const firstDataLoader = result.current;
+
+        // Rerender with same props
+        rerender();
+
+        const secondDataLoader = result.current;
+
+        // Should be the same reference
+        expect(firstDataLoader).toBe(secondDataLoader);
+      });
+
+      test('should return new dataLoader reference when rootPageId changes', () => {
+        const { result, rerender } = renderHook(
+          ({ rootPageId }) => useDataLoader(rootPageId, ALL_PAGES_COUNT),
+          { initialProps: { rootPageId: ROOT_PAGE_ID } },
+        );
+
+        const firstDataLoader = result.current;
+
+        // Rerender with different rootPageId
+        rerender({ rootPageId: 'new-root-page-id' });
+
+        const secondDataLoader = result.current;
+
+        // Should be a new reference
+        expect(firstDataLoader).not.toBe(secondDataLoader);
+      });
+
+      test('should return new dataLoader reference when allPagesCount changes', () => {
+        const { result, rerender } = renderHook(
+          ({ allPagesCount }) => useDataLoader(ROOT_PAGE_ID, allPagesCount),
+          { initialProps: { allPagesCount: ALL_PAGES_COUNT } },
+        );
+
+        const firstDataLoader = result.current;
+
+        // Rerender with different allPagesCount
+        rerender({ allPagesCount: 200 });
+
+        const secondDataLoader = result.current;
+
+        // Should be a new reference
+        expect(firstDataLoader).not.toBe(secondDataLoader);
+      });
+    });
+
+    describe('error handling', () => {
+      test('should allow retry after API error', async () => {
+        const error = new Error('API Error');
+        mockApiv3Get.mockRejectedValueOnce(error).mockResolvedValueOnce({
+          data: { children: [createMockPage('child-1', '/parent/child-1')] },
+        });
+
+        const { result } = renderHook(() =>
+          useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+        );
+
+        // First call - should fail and remove pending entry
+        try {
+          await getDataLoader(result).getChildrenWithData('parent-id');
+        } catch {
+          // Expected to fail
+        }
+
+        // Second call - should retry since pending entry was removed
+        const children =
+          await getDataLoader(result).getChildrenWithData('parent-id');
+
+        expect(children).toHaveLength(1);
+        expect(mockApiv3Get).toHaveBeenCalledTimes(2);
+      });
+    });
+  });
+
+  describe('placeholder node for page creation', () => {
+    test('should prepend placeholder node when parent is in creating mode', async () => {
+      const mockChildren = [
+        createMockPage('existing-child', '/parent/existing'),
+      ];
+      mockApiv3Get.mockResolvedValue({ data: { children: mockChildren } });
+
+      // Set creating state BEFORE rendering the hook
+      mockCreatingParentId = 'parent-id';
+      mockCreatingParentPath = '/parent';
+
+      const { result } = renderHook(() =>
+        useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+      );
+
+      const children =
+        await getDataLoader(result).getChildrenWithData('parent-id');
+
+      // Should have placeholder + existing children
+      expect(children).toHaveLength(2);
+      // Placeholder should be first
+      expect(children[0].id).toBe(CREATING_PAGE_VIRTUAL_ID);
+      expect(children[0].data._id).toBe(CREATING_PAGE_VIRTUAL_ID);
+      expect(children[0].data.parent).toBe('parent-id');
+      expect(children[0].data.path).toBe('/parent/');
+      // Existing child should be second
+      expect(children[1].id).toBe('existing-child');
+    });
+
+    test('should not add placeholder when parent is not in creating mode', async () => {
+      const mockChildren = [
+        createMockPage('existing-child', '/parent/existing'),
+      ];
+      mockApiv3Get.mockResolvedValue({ data: { children: mockChildren } });
+
+      // Creating state is null (not creating)
+      mockCreatingParentId = null;
+      mockCreatingParentPath = null;
+
+      const { result } = renderHook(() =>
+        useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+      );
+
+      const children =
+        await getDataLoader(result).getChildrenWithData('parent-id');
+
+      // Should only have existing children, no placeholder
+      expect(children).toHaveLength(1);
+      expect(children[0].id).toBe('existing-child');
+    });
+
+    test('should not add placeholder to different parent', async () => {
+      const mockChildren = [
+        createMockPage('existing-child', '/other/existing'),
+      ];
+      mockApiv3Get.mockResolvedValue({ data: { children: mockChildren } });
+
+      // Creating under 'parent-id', but fetching children of 'other-id'
+      mockCreatingParentId = 'parent-id';
+      mockCreatingParentPath = '/parent';
+
+      const { result } = renderHook(() =>
+        useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+      );
+
+      const children =
+        await getDataLoader(result).getChildrenWithData('other-id');
+
+      // Should only have existing children, no placeholder
+      expect(children).toHaveLength(1);
+      expect(children[0].id).toBe('existing-child');
+    });
+
+    test('should add placeholder to empty parent (no existing children)', async () => {
+      // Parent has no existing children
+      mockApiv3Get.mockResolvedValue({ data: { children: [] } });
+
+      // Set creating state
+      mockCreatingParentId = 'empty-parent-id';
+      mockCreatingParentPath = '/empty-parent';
+
+      const { result } = renderHook(() =>
+        useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+      );
+
+      const children =
+        await getDataLoader(result).getChildrenWithData('empty-parent-id');
+
+      // Should have only the placeholder
+      expect(children).toHaveLength(1);
+      expect(children[0].id).toBe(CREATING_PAGE_VIRTUAL_ID);
+      expect(children[0].data.parent).toBe('empty-parent-id');
+      expect(children[0].data.path).toBe('/empty-parent/');
+    });
+
+    test('should read creating state via refs when called after state change', async () => {
+      const mockChildren = [
+        createMockPage('existing-child', '/parent/existing'),
+      ];
+      mockApiv3Get.mockResolvedValue({ data: { children: mockChildren } });
+
+      // Render hook WITHOUT creating state
+      const { result, rerender } = renderHook(() =>
+        useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+      );
+
+      // First call - no placeholder
+      const childrenBefore =
+        await getDataLoader(result).getChildrenWithData('parent-id');
+      expect(childrenBefore).toHaveLength(1);
+      expect(childrenBefore[0].id).toBe('existing-child');
+
+      // Now set creating state
+      mockCreatingParentId = 'parent-id';
+      mockCreatingParentPath = '/parent';
+
+      // Rerender to update refs
+      rerender();
+
+      // Second call - should have placeholder because refs are updated
+      // Note: Since sequential caching is now handled by headless-tree,
+      // we need to clear pending requests to get fresh data
+      invalidatePageTreeChildren();
+
+      const childrenAfter =
+        await getDataLoader(result).getChildrenWithData('parent-id');
+      expect(childrenAfter).toHaveLength(2);
+      expect(childrenAfter[0].id).toBe(CREATING_PAGE_VIRTUAL_ID);
+      expect(childrenAfter[1].id).toBe('existing-child');
+    });
+
+    test('dataLoader reference should remain stable when creating state changes', () => {
+      // Render hook WITHOUT creating state
+      const { result, rerender } = renderHook(() =>
+        useDataLoader(ROOT_PAGE_ID, ALL_PAGES_COUNT),
+      );
+
+      const firstDataLoader = result.current;
+
+      // Change creating state
+      mockCreatingParentId = 'some-parent';
+      mockCreatingParentPath = '/some-parent';
+
+      // Rerender
+      rerender();
+
+      const secondDataLoader = result.current;
+
+      // DataLoader reference should be STABLE (same reference)
+      // This is critical to prevent headless-tree from refetching all data
+      expect(firstDataLoader).toBe(secondDataLoader);
+    });
+  });
+});

+ 120 - 0
apps/app/src/features/page-tree/hooks/_inner/use-data-loader.ts

@@ -0,0 +1,120 @@
+import { useMemo, useRef } from 'react';
+import type { TreeDataLoader } from '@headless-tree/core';
+
+import { apiv3Get } from '~/client/util/apiv3-client';
+import type { IPageForTreeItem } from '~/interfaces/page';
+
+import {
+  CREATING_PAGE_VIRTUAL_ID,
+  ROOT_PAGE_VIRTUAL_ID,
+} from '../../constants/_inner';
+import { type ChildrenData, fetchAndCacheChildren } from '../../services';
+import {
+  createPlaceholderPageData,
+  useCreatingParentId,
+  useCreatingParentPath,
+} from '../../states/_inner';
+
+function constructRootPageForVirtualRoot(
+  rootPageId: string,
+  allPagesCount: number,
+): IPageForTreeItem {
+  return {
+    _id: rootPageId,
+    path: '/',
+    parent: null,
+    descendantCount: allPagesCount,
+    grant: 1,
+    isEmpty: false,
+    wip: false,
+  };
+}
+
+export const useDataLoader = (
+  rootPageId: string,
+  allPagesCount: number,
+): TreeDataLoader<IPageForTreeItem> => {
+  const creatingParentId = useCreatingParentId();
+  const creatingParentPath = useCreatingParentPath();
+
+  // Use refs to avoid recreating dataLoader callbacks when creating state changes
+  // The creating state is accessed via refs so that:
+  // 1. The dataLoader reference stays stable (prevents headless-tree from refetching all data)
+  // 2. The actual creating state is still read at execution time (when invalidateChildrenIds is called)
+  const creatingParentIdRef = useRef(creatingParentId);
+  const creatingParentPathRef = useRef(creatingParentPath);
+  creatingParentIdRef.current = creatingParentId;
+  creatingParentPathRef.current = creatingParentPath;
+
+  // Memoize the entire dataLoader object to ensure reference stability
+  // Only recreate when rootPageId or allPagesCount changes (which are truly needed for the API calls)
+  // Note: Creating state is read from refs inside callbacks to avoid triggering dataLoader recreation
+  const dataLoader = useMemo<TreeDataLoader<IPageForTreeItem>>(() => {
+    const getItem = async (itemId: string): Promise<IPageForTreeItem> => {
+      // Virtual root (should rarely be called since it's provided by getChildrenWithData)
+      if (itemId === ROOT_PAGE_VIRTUAL_ID) {
+        return constructRootPageForVirtualRoot(rootPageId, allPagesCount);
+      }
+
+      // Creating placeholder node - return placeholder data
+      if (itemId === CREATING_PAGE_VIRTUAL_ID) {
+        // This shouldn't normally be called, but return empty placeholder if it is
+        return createPlaceholderPageData('', '/');
+      }
+
+      // For all pages (including root), use /page-listing/item endpoint
+      // Note: This should rarely be called thanks to getChildrenWithData caching
+      const response = await apiv3Get<{ item: IPageForTreeItem }>(
+        '/page-listing/item',
+        { id: itemId },
+      );
+      return response.data.item;
+    };
+
+    const getChildrenWithData = async (
+      itemId: string,
+    ): Promise<ChildrenData> => {
+      // Virtual root returns root page as its only child
+      // Use actual MongoDB _id as tree item ID to avoid duplicate API calls
+      if (itemId === ROOT_PAGE_VIRTUAL_ID) {
+        return [
+          {
+            id: rootPageId,
+            data: constructRootPageForVirtualRoot(rootPageId, allPagesCount),
+          },
+        ];
+      }
+
+      // Placeholder node has no children
+      if (itemId === CREATING_PAGE_VIRTUAL_ID) {
+        return [];
+      }
+
+      const children = await fetchAndCacheChildren(itemId);
+
+      // If this parent is in "creating" mode, prepend placeholder node
+      // Read from refs to get current value without triggering dataLoader recreation
+      const currentCreatingParentId = creatingParentIdRef.current;
+      const currentCreatingParentPath = creatingParentPathRef.current;
+      if (
+        currentCreatingParentId === itemId &&
+        currentCreatingParentPath != null
+      ) {
+        const placeholderData = createPlaceholderPageData(
+          itemId,
+          currentCreatingParentPath,
+        );
+        return [
+          { id: CREATING_PAGE_VIRTUAL_ID, data: placeholderData },
+          ...children,
+        ];
+      }
+
+      return children;
+    };
+
+    return { getItem, getChildrenWithData };
+  }, [allPagesCount, rootPageId]);
+
+  return dataLoader;
+};

+ 59 - 0
apps/app/src/features/page-tree/hooks/_inner/use-expand-parent-on-create.ts

@@ -0,0 +1,59 @@
+import { useEffect, useRef } from 'react';
+import type { TreeInstance } from '@headless-tree/core';
+
+import type { IPageForTreeItem } from '~/interfaces/page';
+
+import { invalidatePageTreeChildren } from '../../services';
+
+type UseExpandParentOnCreateParams = {
+  tree: TreeInstance<IPageForTreeItem>;
+  creatingParentId: string | null;
+  onTreeUpdated?: () => void;
+};
+
+/**
+ * Hook that expands the parent item when page creation is initiated.
+ *
+ * When a new page creation is initiated (creatingParentId is set),
+ * this hook:
+ * 1. Rebuilds the tree to re-evaluate isItemFolder
+ * 2. Expands the parent item if not already expanded
+ * 3. Invalidates children cache to load placeholder
+ * 4. Triggers a re-render via onTreeUpdated callback
+ *
+ * IMPORTANT: This hook uses a ref-based approach to track changes
+ * instead of a dependency array. This prevents infinite loops that
+ * would occur because the tree object changes on every render.
+ */
+export const useExpandParentOnCreate = ({
+  tree,
+  creatingParentId,
+  onTreeUpdated,
+}: UseExpandParentOnCreateParams): void => {
+  // Track previous creatingParentId to detect changes
+  const prevCreatingParentIdRef = useRef<string | null>(null);
+
+  useEffect(() => {
+    // Only run when creatingParentId actually changes (not on every render)
+    if (creatingParentId === prevCreatingParentIdRef.current) return;
+    prevCreatingParentIdRef.current = creatingParentId;
+
+    if (creatingParentId == null) return;
+
+    // Rebuild tree first to re-evaluate isItemFolder
+    tree.rebuildTree();
+
+    // Then expand the parent item
+    const parentItem = tree.getItemInstance(creatingParentId);
+    if (parentItem != null && !parentItem.isExpanded()) {
+      parentItem.expand();
+    }
+
+    // Clear cache for this parent and invalidate children to load placeholder
+    invalidatePageTreeChildren([creatingParentId]);
+    parentItem?.invalidateChildrenIds(true);
+
+    // Trigger re-render
+    onTreeUpdated?.();
+  });
+};

+ 37 - 0
apps/app/src/features/page-tree/hooks/_inner/use-scroll-to-selected-item.ts

@@ -0,0 +1,37 @@
+import { useEffect } from 'react';
+import type { Virtualizer } from '@tanstack/react-virtual';
+
+import type { IPageForTreeItem } from '~/interfaces/page';
+
+type UseScrollToSelectedItemParams = {
+  targetPathOrId?: string;
+  items: Array<{ getItemData: () => IPageForTreeItem }>;
+  virtualizer: Virtualizer<HTMLElement, Element>;
+};
+
+export const useScrollToSelectedItem = ({
+  targetPathOrId,
+  items,
+  virtualizer,
+}: UseScrollToSelectedItemParams): void => {
+  useEffect(() => {
+    if (targetPathOrId == null) return;
+
+    const selectedIndex = items.findIndex((item) => {
+      const itemData = item.getItemData();
+      return (
+        itemData._id === targetPathOrId || itemData.path === targetPathOrId
+      );
+    });
+
+    if (selectedIndex !== -1) {
+      // Use a small delay to ensure the virtualizer is ready
+      setTimeout(() => {
+        virtualizer.scrollToIndex(selectedIndex, {
+          align: 'center',
+          behavior: 'smooth',
+        });
+      }, 100);
+    }
+  }, [targetPathOrId, items, virtualizer]);
+};

+ 84 - 0
apps/app/src/features/page-tree/hooks/_inner/use-tree-features.ts

@@ -0,0 +1,84 @@
+import { useMemo } from 'react';
+import type { FeatureImplementation } from '@headless-tree/core';
+import {
+  asyncDataLoaderFeature,
+  checkboxesFeature,
+  dragAndDropFeature,
+  hotkeysCoreFeature,
+  renamingFeature,
+  selectionFeature,
+} from '@headless-tree/core';
+
+import type { UsePageDndProperties } from '../use-page-dnd';
+import { usePageDnd } from '../use-page-dnd';
+import type { UseCheckboxProperties } from './use-checkbox';
+import { useCheckbox } from './use-checkbox';
+
+export type UseTreeFeaturesOptions = {
+  enableRenaming?: boolean;
+  enableCheckboxes?: boolean;
+  enableDragAndDrop?: boolean;
+  initialCheckedItems?: string[];
+};
+
+export type UseTreeFeaturesResult = {
+  features: FeatureImplementation<unknown>[];
+  checkboxProperties: UseCheckboxProperties;
+  dndProperties: UsePageDndProperties;
+};
+
+/**
+ * Hook to configure tree features based on options.
+ * Returns a stable array of features for use with headless-tree,
+ * along with checkbox state and page D&D handlers.
+ */
+export const useTreeFeatures = (
+  options: UseTreeFeaturesOptions = {},
+): UseTreeFeaturesResult => {
+  const {
+    enableRenaming = true,
+    enableCheckboxes = false,
+    enableDragAndDrop = false,
+    initialCheckedItems = [],
+  } = options;
+
+  // Get checkbox properties
+  const checkboxProperties = useCheckbox({
+    enabled: enableCheckboxes,
+    initialCheckedItems,
+  });
+
+  // Get page D&D handlers
+  const dndProperties = usePageDnd(enableDragAndDrop);
+
+  const features = useMemo(() => {
+    const featureList: FeatureImplementation<unknown>[] = [
+      asyncDataLoaderFeature,
+      selectionFeature,
+      hotkeysCoreFeature,
+    ];
+
+    if (enableRenaming) {
+      featureList.push(renamingFeature);
+    }
+
+    if (enableCheckboxes) {
+      featureList.push(checkboxesFeature);
+    }
+
+    if (enableDragAndDrop) {
+      featureList.push(dragAndDropFeature);
+    }
+
+    return featureList;
+  }, [enableRenaming, enableCheckboxes, enableDragAndDrop]);
+
+  return useMemo(
+    () => ({
+      features,
+      checkboxProperties,
+      dndProperties,
+    }),
+    [features, checkboxProperties, dndProperties],
+  );
+};

+ 125 - 0
apps/app/src/features/page-tree/hooks/_inner/use-tree-item-handlers.tsx

@@ -0,0 +1,125 @@
+import { useCallback, useRef } from 'react';
+import type { ItemInstance, TreeConfig } from '@headless-tree/core';
+
+import type { IPageForTreeItem } from '~/interfaces/page';
+
+import { useCreatingParentId } from '../../states/_inner';
+import { usePageCreate } from '../use-page-create';
+import { usePageRename } from '../use-page-rename';
+
+type UseTreeItemHandlersReturn = {
+  /**
+   * Stable callback for headless-tree getItemName config
+   */
+  getItemName: TreeConfig<IPageForTreeItem>['getItemName'];
+
+  /**
+   * Stable callback for headless-tree isItemFolder config
+   */
+  isItemFolder: TreeConfig<IPageForTreeItem>['isItemFolder'];
+
+  /**
+   * Stable callback for headless-tree onRename config
+   * Handles both rename and create (for placeholder nodes)
+   */
+  handleRename: TreeConfig<IPageForTreeItem>['onRename'];
+
+  /**
+   * Current creating parent ID (for tree expansion logic)
+   */
+  creatingParentId: string | null;
+};
+
+/**
+ * Hook that provides stable callbacks for headless-tree configuration.
+ *
+ * This hook consolidates the integration between page create/rename logic
+ * and headless-tree's useTree configuration. It uses refs to access the latest
+ * state values inside callbacks while keeping the callback references stable.
+ *
+ * @param onAfterRename - Optional callback to trigger after rename/create operation
+ */
+export const useTreeItemHandlers = (
+  onAfterRename?: () => void,
+): UseTreeItemHandlersReturn => {
+  // Page rename hook
+  const { rename, getPageName } = usePageRename();
+
+  // Page create hook
+  const { createFromPlaceholder, isCreatingPlaceholder, cancelCreating } =
+    usePageCreate();
+
+  // Get creating parent id for React re-renders (used in return value and ref)
+  const creatingParentId = useCreatingParentId();
+
+  // Use refs to stabilize callbacks passed to headless-tree
+  // This prevents headless-tree from detecting config changes and refetching data
+  const creatingParentIdRef = useRef(creatingParentId);
+  creatingParentIdRef.current = creatingParentId;
+
+  const getPageNameRef = useRef(getPageName);
+  getPageNameRef.current = getPageName;
+
+  const renameRef = useRef(rename);
+  renameRef.current = rename;
+
+  const createFromPlaceholderRef = useRef(createFromPlaceholder);
+  createFromPlaceholderRef.current = createFromPlaceholder;
+
+  const isCreatingPlaceholderRef = useRef(isCreatingPlaceholder);
+  isCreatingPlaceholderRef.current = isCreatingPlaceholder;
+
+  const cancelCreatingRef = useRef(cancelCreating);
+  cancelCreatingRef.current = cancelCreating;
+
+  const onAfterRenameRef = useRef(onAfterRename);
+  onAfterRenameRef.current = onAfterRename;
+
+  // Stable getItemName callback - receives ItemInstance from headless-tree
+  const getItemName = useCallback((item: ItemInstance<IPageForTreeItem>) => {
+    return getPageNameRef.current(item);
+  }, []);
+
+  // Stable isItemFolder callback
+  // IMPORTANT: Do NOT call item.getChildren() here as it triggers API calls for ALL visible items
+  const isItemFolder = useCallback((item: ItemInstance<IPageForTreeItem>) => {
+    const itemData = item.getItemData();
+    // Read from ref to get current value without triggering callback recreation
+    const currentCreatingParentId = creatingParentIdRef.current;
+    const isCreatingUnderThis = currentCreatingParentId === itemData._id;
+    if (isCreatingUnderThis) return true;
+
+    // Use descendantCount from the item data to determine if it's a folder
+    // This avoids triggering getChildrenWithData API calls
+    return itemData.descendantCount > 0;
+  }, []);
+
+  // Stable onRename handler for headless-tree
+  // Handles both rename and create (for placeholder nodes)
+  const handleRename = useCallback(
+    async (item: ItemInstance<IPageForTreeItem>, newValue: string) => {
+      if (isCreatingPlaceholderRef.current(item)) {
+        // Placeholder node: create new page or cancel if empty
+        if (newValue.trim() === '') {
+          // Empty value means cancel (Esc key or blur)
+          cancelCreatingRef.current();
+        } else {
+          await createFromPlaceholderRef.current(item, newValue);
+        }
+      } else {
+        // Normal node: rename page
+        await renameRef.current(item, newValue);
+      }
+      // Trigger callback after operation
+      onAfterRenameRef.current?.();
+    },
+    [],
+  );
+
+  return {
+    getItemName,
+    isItemFolder,
+    handleRename,
+    creatingParentId,
+  };
+};

+ 51 - 0
apps/app/src/features/page-tree/hooks/_inner/use-tree-revalidation.ts

@@ -0,0 +1,51 @@
+import { useEffect, useRef, useState } from 'react';
+
+import {
+  usePageTreeInformationGeneration,
+  usePageTreeRevalidationEffect,
+} from '../../states/page-tree-update';
+
+type TreeInstance = {
+  getItems: () => unknown[];
+};
+
+type UseTreeRevalidationOptions = {
+  tree: TreeInstance;
+  triggerTreeRebuild: () => void;
+};
+
+/**
+ * Hook to handle tree revalidation when global generation changes
+ * and track items count changes for async data loading
+ */
+export const useTreeRevalidation = (options: UseTreeRevalidationOptions) => {
+  const { tree, triggerTreeRebuild } = options;
+
+  // Track local generation number with state to trigger re-renders
+  const [localGeneration, setLocalGeneration] = useState(1);
+  const globalGeneration = usePageTreeInformationGeneration();
+
+  // Refetch data when global generation is updated
+  usePageTreeRevalidationEffect(
+    tree as Parameters<typeof usePageTreeRevalidationEffect>[0],
+    localGeneration,
+    {
+      // Update local generation number after revalidation
+      onRevalidated: () => {
+        setLocalGeneration(globalGeneration);
+      },
+    },
+  );
+
+  const items = tree.getItems();
+
+  // Track items count to detect when async data loading completes
+  const prevItemsCountRef = useRef(items.length);
+  useEffect(() => {
+    if (items.length !== prevItemsCountRef.current) {
+      prevItemsCountRef.current = items.length;
+      // Trigger re-render when items count changes (e.g., after async load completes)
+      triggerTreeRebuild();
+    }
+  }, [items.length, triggerTreeRebuild]);
+};

+ 5 - 0
apps/app/src/features/page-tree/hooks/index.ts

@@ -0,0 +1,5 @@
+export * from './use-page-create';
+export * from './use-page-dnd';
+export * from './use-page-rename';
+export * from './use-placeholder-rename-effect';
+export * from './use-socket-update-desc-count';

+ 219 - 0
apps/app/src/features/page-tree/hooks/use-page-create.spec.tsx

@@ -0,0 +1,219 @@
+import type { ItemInstance } from '@headless-tree/core';
+import { fireEvent, render, screen } from '@testing-library/react';
+
+import type { IPageForItem } from '~/interfaces/page';
+
+import { resetCreatingFlagForTesting } from '../states/_inner/page-tree-create';
+import { CreateButtonInner } from './use-page-create';
+
+// Mock the dependencies
+vi.mock('~/client/components/NotAvailableForGuest', () => ({
+  NotAvailableForGuest: ({ children }: { children: React.ReactNode }) => (
+    <>{children}</>
+  ),
+}));
+
+vi.mock('~/client/components/NotAvailableForReadOnlyUser', () => ({
+  NotAvailableForReadOnlyUser: ({
+    children,
+  }: {
+    children: React.ReactNode;
+  }) => <>{children}</>,
+}));
+
+// Mock useCreatingParentId to control isCreating state
+const mockUseCreatingParentId = vi.fn<() => string | null>(() => null);
+vi.mock('../states/_inner', () => ({
+  useCreatingParentId: () => mockUseCreatingParentId(),
+  usePageTreeCreateActions: vi.fn(() => ({
+    startCreating: vi.fn(),
+    cancelCreating: vi.fn(),
+  })),
+}));
+
+/**
+ * Create a mock item instance for testing
+ */
+const createMockItem = (
+  id: string,
+  path: string = '/test/path',
+): ItemInstance<IPageForItem> => {
+  return {
+    getId: () => id,
+    getItemData: () => ({ _id: id, path }) as IPageForItem,
+  } as unknown as ItemInstance<IPageForItem>;
+};
+
+describe('CreateButtonInner', () => {
+  beforeEach(() => {
+    resetCreatingFlagForTesting();
+    mockUseCreatingParentId.mockReturnValue(null);
+  });
+
+  describe('rendering', () => {
+    test('should render button for regular page', () => {
+      const mockItem = createMockItem('page-id', '/regular/path');
+      const onStartCreating = vi.fn();
+
+      render(
+        <CreateButtonInner item={mockItem} onStartCreating={onStartCreating} />,
+      );
+
+      expect(screen.getByRole('button')).toBeInTheDocument();
+    });
+
+    test('should NOT render button for users top page', () => {
+      const mockItem = createMockItem('users-top', '/user');
+      const onStartCreating = vi.fn();
+
+      render(
+        <CreateButtonInner item={mockItem} onStartCreating={onStartCreating} />,
+      );
+
+      expect(screen.queryByRole('button')).not.toBeInTheDocument();
+    });
+  });
+
+  describe('onMouseDown behavior', () => {
+    test('should call preventDefault on mousedown to prevent focus change', () => {
+      const mockItem = createMockItem('page-id');
+      const onStartCreating = vi.fn();
+
+      render(
+        <CreateButtonInner item={mockItem} onStartCreating={onStartCreating} />,
+      );
+
+      const button = screen.getByRole('button');
+
+      // Use fireEvent which triggers React's synthetic event handler
+      // and check if the event was prevented by examining defaultPrevented
+      const mouseDownEvent = fireEvent.mouseDown(button);
+
+      // fireEvent returns false if preventDefault was called
+      // (the event's default action was prevented)
+      expect(mouseDownEvent).toBe(false);
+    });
+
+    test('should preserve focus on input when clicking CreateButton', () => {
+      const mockItem = createMockItem('page-id');
+      const onStartCreating = vi.fn();
+
+      render(
+        <div>
+          <input data-testid="placeholder-input" />
+          <CreateButtonInner
+            item={mockItem}
+            onStartCreating={onStartCreating}
+          />
+        </div>,
+      );
+
+      const input = screen.getByTestId('placeholder-input');
+      const button = screen.getByRole('button');
+
+      // Focus the input
+      input.focus();
+      expect(document.activeElement).toBe(input);
+
+      // mousedown on button - React's onMouseDown with preventDefault should fire
+      const mouseDownEvent = fireEvent.mouseDown(button);
+
+      // Verify preventDefault was called
+      expect(mouseDownEvent).toBe(false);
+
+      // Note: jsdom doesn't fully simulate focus behavior with preventDefault,
+      // but we've verified preventDefault is called
+    });
+  });
+
+  describe('onClick behavior', () => {
+    test('should call onStartCreating with item when not already creating', () => {
+      const mockItem = createMockItem('page-id');
+      const onStartCreating = vi.fn();
+
+      // isCreating = false (creatingParentId is null)
+      mockUseCreatingParentId.mockReturnValue(null);
+
+      render(
+        <CreateButtonInner item={mockItem} onStartCreating={onStartCreating} />,
+      );
+
+      const button = screen.getByRole('button');
+      fireEvent.click(button);
+
+      expect(onStartCreating).toHaveBeenCalledTimes(1);
+      expect(onStartCreating).toHaveBeenCalledWith(mockItem);
+    });
+
+    test('should NOT call onStartCreating when already creating', () => {
+      const mockItem = createMockItem('page-id');
+      const onStartCreating = vi.fn();
+
+      // isCreating = true (creatingParentId is not null)
+      mockUseCreatingParentId.mockReturnValue('some-parent-id');
+
+      render(
+        <CreateButtonInner item={mockItem} onStartCreating={onStartCreating} />,
+      );
+
+      const button = screen.getByRole('button');
+      fireEvent.click(button);
+
+      expect(onStartCreating).not.toHaveBeenCalled();
+    });
+
+    test('should call stopPropagation on click to prevent parent handlers', () => {
+      const mockItem = createMockItem('page-id');
+      const onStartCreating = vi.fn();
+      const parentClickHandler = vi.fn();
+
+      render(
+        // biome-ignore lint/a11y/noStaticElementInteractions: ignore
+        // biome-ignore lint/a11y/useKeyWithClickEvents: ignore
+        <div onClick={parentClickHandler}>
+          <CreateButtonInner
+            item={mockItem}
+            onStartCreating={onStartCreating}
+          />
+        </div>,
+      );
+
+      const button = screen.getByRole('button');
+      fireEvent.click(button);
+
+      // Parent should not receive the click event
+      expect(parentClickHandler).not.toHaveBeenCalled();
+    });
+  });
+
+  describe('rapid click prevention', () => {
+    test('should ignore clicks when already in creating mode', () => {
+      const mockItem = createMockItem('page-id');
+      const onStartCreating = vi.fn();
+
+      // Start with not creating
+      mockUseCreatingParentId.mockReturnValue(null);
+
+      const { rerender } = render(
+        <CreateButtonInner item={mockItem} onStartCreating={onStartCreating} />,
+      );
+
+      const button = screen.getByRole('button');
+
+      // First click should work
+      fireEvent.click(button);
+      expect(onStartCreating).toHaveBeenCalledTimes(1);
+
+      // Simulate that creating mode is now active
+      mockUseCreatingParentId.mockReturnValue('page-id');
+
+      rerender(
+        <CreateButtonInner item={mockItem} onStartCreating={onStartCreating} />,
+      );
+
+      // Second click should be ignored
+      fireEvent.click(button);
+      expect(onStartCreating).toHaveBeenCalledTimes(1);
+    });
+  });
+});

+ 281 - 0
apps/app/src/features/page-tree/hooks/use-page-create.tsx

@@ -0,0 +1,281 @@
+import type { FC } from 'react';
+import { useCallback, useId } from 'react';
+import { Origin } from '@growi/core';
+import { pagePathUtils, pathUtils } from '@growi/core/dist/utils';
+import type { ItemInstance } from '@headless-tree/core';
+import { useTranslation } from 'next-i18next';
+import { join } from 'pathe';
+
+import { NotAvailableForGuest } from '~/client/components/NotAvailableForGuest';
+import { NotAvailableForReadOnlyUser } from '~/client/components/NotAvailableForReadOnlyUser';
+import { useCreatePage } from '~/client/services/create-page';
+import { toastError, toastSuccess, toastWarning } from '~/client/util/toastr';
+import type { IPageForItem } from '~/interfaces/page';
+import { mutatePageTree, mutateRecentlyUpdated } from '~/stores/page-listing';
+import { shouldCreateWipPage } from '~/utils/should-create-wip-page';
+
+import { CREATING_PAGE_VIRTUAL_ID } from '../constants/_inner';
+import type { TreeItemToolProps } from '../interfaces';
+import {
+  useCreatingParentId,
+  usePageTreeCreateActions,
+} from '../states/_inner';
+import { usePageTreeInformationUpdate } from '../states/page-tree-update';
+
+// Inner component for CreateButton to properly use hooks
+type CreateButtonInnerProps = {
+  item: ItemInstance<IPageForItem>;
+  onStartCreating: (item: ItemInstance<IPageForItem>) => void;
+};
+
+/**
+ * @internal Exported for testing purposes
+ */
+export const CreateButtonInner: FC<CreateButtonInnerProps> = ({
+  item,
+  onStartCreating,
+}) => {
+  const buttonId = useId();
+  const creatingParentId = useCreatingParentId();
+  const isCreating = creatingParentId != null;
+
+  const page = item.getItemData();
+  const isUsersTopPage = pagePathUtils.isUsersTopPage(page.path ?? '');
+  if (isUsersTopPage) {
+    return null;
+  }
+
+  const handleMouseDown = (e: React.MouseEvent) => {
+    // Prevent focus change which would trigger blur on the input field
+    // and cause cancelCreating to be called
+    e.preventDefault();
+  };
+
+  const handleClick = (e: React.MouseEvent) => {
+    // Always stop propagation to prevent parent item click handlers
+    e.stopPropagation();
+
+    if (isCreating) {
+      return;
+    }
+    onStartCreating(item);
+  };
+
+  return (
+    <NotAvailableForGuest>
+      <NotAvailableForReadOnlyUser>
+        <button
+          id={`page-create-button-in-page-tree-${buttonId}`}
+          type="button"
+          className="border-0 rounded btn btn-page-item-control p-0"
+          onMouseDown={handleMouseDown}
+          onClick={handleClick}
+        >
+          <span className="material-symbols-outlined p-0">add_circle</span>
+        </button>
+      </NotAvailableForReadOnlyUser>
+    </NotAvailableForGuest>
+  );
+};
+
+type CreateResult = {
+  success: boolean;
+  path?: string;
+  error?: Error;
+};
+
+type UsePageCreateReturn = {
+  /**
+   * Create a new page under the specified parent
+   */
+  create: (
+    parentItem: ItemInstance<IPageForItem>,
+    pageName: string,
+  ) => Promise<CreateResult>;
+
+  /**
+   * Create a new page from the placeholder node (called by onRename handler)
+   * The placeholder node's parent is used as the parent of the new page
+   */
+  createFromPlaceholder: (
+    placeholderItem: ItemInstance<IPageForItem>,
+    pageName: string,
+  ) => Promise<CreateResult>;
+
+  /**
+   * Check if an item is the creating placeholder node
+   */
+  isCreatingPlaceholder: (item: ItemInstance<IPageForItem>) => boolean;
+
+  /**
+   * Start creating a new page under the specified parent
+   */
+  startCreating: (parentItem: ItemInstance<IPageForItem>) => void;
+
+  /**
+   * Cancel page creation
+   */
+  cancelCreating: () => void;
+
+  /**
+   * Check if a child is being created under this item
+   */
+  isCreatingChild: (item: ItemInstance<IPageForItem>) => boolean;
+
+  /**
+   * Button component to trigger page creation
+   */
+  CreateButton: FC<TreeItemToolProps>;
+};
+
+/**
+ * Hook for page creation logic in tree
+ * Uses Jotai atom to manage creating state
+ */
+export const usePageCreate = (): UsePageCreateReturn => {
+  const { t } = useTranslation();
+  const { create: createPage } = useCreatePage();
+  const { notifyUpdateItems } = usePageTreeInformationUpdate();
+  const creatingParentId = useCreatingParentId();
+  const {
+    startCreating: startCreatingAction,
+    cancelCreating: cancelCreatingAction,
+  } = usePageTreeCreateActions();
+
+  // Wrapped cancelCreating that also notifies tree to remove placeholder
+  const cancelCreating = useCallback(() => {
+    const parentIdToUpdate = creatingParentId;
+    cancelCreatingAction();
+
+    // Notify tree to reload children (which will remove the placeholder)
+    if (parentIdToUpdate != null) {
+      notifyUpdateItems([parentIdToUpdate]);
+    }
+  }, [cancelCreatingAction, creatingParentId, notifyUpdateItems]);
+
+  const startCreating = useCallback(
+    (parentItem: ItemInstance<IPageForItem>) => {
+      const parentId = parentItem.getId();
+      const parentPath = parentItem.getItemData().path ?? '/';
+
+      // Set creating state - expansion will be handled by ItemsTree
+      startCreatingAction(parentId, parentPath);
+    },
+    [startCreatingAction],
+  );
+
+  const isCreatingChild = useCallback(
+    (item: ItemInstance<IPageForItem>): boolean => {
+      return creatingParentId === item.getId();
+    },
+    [creatingParentId],
+  );
+
+  const isCreatingPlaceholder = useCallback(
+    (item: ItemInstance<IPageForItem>): boolean => {
+      return item.getId() === CREATING_PAGE_VIRTUAL_ID;
+    },
+    [],
+  );
+
+  const create = useCallback(
+    async (
+      parentItem: ItemInstance<IPageForItem>,
+      pageName: string,
+    ): Promise<CreateResult> => {
+      const parentPage = parentItem.getItemData();
+      const parentPath = parentPage.path ?? '/';
+
+      // Trim and validate - empty input means cancel
+      const trimmedName = pageName.trim();
+      if (trimmedName === '') {
+        cancelCreating();
+        return { success: false };
+      }
+
+      // Build new page path
+      const newPagePath = join(
+        pathUtils.addTrailingSlash(parentPath),
+        trimmedName,
+      );
+
+      // Check if page path is creatable
+      const isCreatable = pagePathUtils.isCreatablePage(newPagePath);
+      if (!isCreatable) {
+        toastWarning(t('you_can_not_create_page_with_this_name_or_hierarchy'));
+        return { success: false };
+      }
+
+      // Cancel creating mode first (removes placeholder)
+      cancelCreating();
+
+      try {
+        await createPage(
+          {
+            path: newPagePath,
+            parentPath,
+            body: undefined,
+            // keep grant info undefined to inherit from parent
+            grant: undefined,
+            grantUserGroupIds: undefined,
+            origin: Origin.View,
+            wip: shouldCreateWipPage(newPagePath),
+          },
+          {
+            skipTransition: true,
+            onCreated: () => {
+              mutatePageTree();
+              mutateRecentlyUpdated();
+
+              // Notify headless-tree to update parent's children
+              const parentId = parentItem.getId();
+              notifyUpdateItems([parentId]);
+
+              toastSuccess(t('successfully_saved_the_page'));
+            },
+          },
+        );
+
+        return { success: true, path: newPagePath };
+      } catch (err) {
+        toastError(err);
+        return { success: false, error: err as Error };
+      }
+    },
+    [t, createPage, notifyUpdateItems, cancelCreating],
+  );
+
+  // Create from placeholder node (used by onRename handler in ItemsTree)
+  const createFromPlaceholder = useCallback(
+    async (
+      placeholderItem: ItemInstance<IPageForItem>,
+      pageName: string,
+    ): Promise<CreateResult> => {
+      const parentItem = placeholderItem.getParent();
+      if (parentItem == null) {
+        cancelCreating();
+        return { success: false };
+      }
+      return create(parentItem, pageName);
+    },
+    [create, cancelCreating],
+  );
+
+  // CreateButton component for tree item
+  const CreateButton: FC<TreeItemToolProps> = useCallback(
+    ({ item }) => {
+      return <CreateButtonInner item={item} onStartCreating={startCreating} />;
+    },
+    [startCreating],
+  ) as FC<TreeItemToolProps>;
+
+  return {
+    create,
+    createFromPlaceholder,
+    isCreatingPlaceholder,
+    startCreating,
+    cancelCreating,
+    isCreatingChild,
+    CreateButton,
+  };
+};

+ 7 - 0
apps/app/src/features/page-tree/hooks/use-page-dnd.module.scss

@@ -0,0 +1,7 @@
+// Drag line indicator for drag and drop
+.tree-drag-line {
+  position: relative;
+  height: 3px;
+  pointer-events: none;
+  background-color: var(--bs-list-group-active-border-color);
+}

+ 97 - 0
apps/app/src/features/page-tree/hooks/use-page-dnd.spec.ts

@@ -0,0 +1,97 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+  getNewPathAfterMoved,
+  hasAncestorDescendantRelation,
+} from './use-page-dnd';
+
+describe('getNewPathAfterMoved', () => {
+  it('should return correct path when moving to root', () => {
+    expect(getNewPathAfterMoved('/A/B', '/')).toBe('/B');
+  });
+
+  it('should return correct path when moving to nested parent', () => {
+    expect(getNewPathAfterMoved('/A/B', '/C/D')).toBe('/C/D/B');
+  });
+
+  it('should handle page with special characters in name', () => {
+    expect(getNewPathAfterMoved('/A/Page Name', '/B')).toBe('/B/Page Name');
+  });
+
+  it('should handle deeply nested paths', () => {
+    expect(getNewPathAfterMoved('/A/B/C/D', '/X/Y')).toBe('/X/Y/D');
+  });
+
+  it('should handle moving from root child to another location', () => {
+    expect(getNewPathAfterMoved('/PageA', '/Folder')).toBe('/Folder/PageA');
+  });
+
+  it('should handle Japanese characters in page name', () => {
+    expect(getNewPathAfterMoved('/A/ページ名', '/B')).toBe('/B/ページ名');
+  });
+});
+
+describe('hasAncestorDescendantRelation', () => {
+  // Helper to create mock item instances
+  const createMockItem = (path: string | null) => ({
+    getItemData: () => ({ path }),
+  });
+
+  it('should return true when parent and child are selected', () => {
+    const items = [createMockItem('/A'), createMockItem('/A/B')];
+    expect(hasAncestorDescendantRelation(items as never[])).toBe(true);
+  });
+
+  it('should return true when grandparent and grandchild are selected', () => {
+    const items = [createMockItem('/A'), createMockItem('/A/B/C')];
+    expect(hasAncestorDescendantRelation(items as never[])).toBe(true);
+  });
+
+  it('should return true when child and parent are in reverse order', () => {
+    const items = [createMockItem('/A/B'), createMockItem('/A')];
+    expect(hasAncestorDescendantRelation(items as never[])).toBe(true);
+  });
+
+  it('should return false when siblings are selected', () => {
+    const items = [createMockItem('/A'), createMockItem('/B')];
+    expect(hasAncestorDescendantRelation(items as never[])).toBe(false);
+  });
+
+  it('should return false for single item', () => {
+    const items = [createMockItem('/A')];
+    expect(hasAncestorDescendantRelation(items as never[])).toBe(false);
+  });
+
+  it('should return false for empty array', () => {
+    expect(hasAncestorDescendantRelation([])).toBe(false);
+  });
+
+  it('should return false when paths are similar but not ancestor-descendant', () => {
+    // /A and /AB are not ancestor-descendant
+    const items = [createMockItem('/A'), createMockItem('/AB')];
+    expect(hasAncestorDescendantRelation(items as never[])).toBe(false);
+  });
+
+  it('should handle items with null paths', () => {
+    const items = [createMockItem('/A'), createMockItem(null)];
+    expect(hasAncestorDescendantRelation(items as never[])).toBe(false);
+  });
+
+  it('should return false when multiple siblings are selected', () => {
+    const items = [
+      createMockItem('/A/B'),
+      createMockItem('/A/C'),
+      createMockItem('/A/D'),
+    ];
+    expect(hasAncestorDescendantRelation(items as never[])).toBe(false);
+  });
+
+  it('should return true when one item is ancestor of another in multiple selection', () => {
+    const items = [
+      createMockItem('/X'),
+      createMockItem('/A/B'),
+      createMockItem('/A/B/C'),
+    ];
+    expect(hasAncestorDescendantRelation(items as never[])).toBe(true);
+  });
+});

+ 270 - 0
apps/app/src/features/page-tree/hooks/use-page-dnd.tsx

@@ -0,0 +1,270 @@
+import type { CSSProperties, FC, ReactNode } from 'react';
+import { useCallback, useMemo } from 'react';
+import { pagePathUtils } from '@growi/core/dist/utils';
+import type {
+  DragTarget,
+  ItemInstance,
+  TreeInstance,
+} from '@headless-tree/core';
+import { basename, join } from 'pathe';
+
+import { apiv3Put } from '~/client/util/apiv3-client';
+import type { IPageForTreeItem } from '~/interfaces/page';
+import { mutatePageTree } from '~/stores/page-listing';
+
+import { usePageTreeInformationUpdate } from '../states/page-tree-update';
+
+import styles from './use-page-dnd.module.scss';
+
+/**
+ * Calculate new path after moving a page to a new parent
+ * @param fromPath - The original path of the page being moved
+ * @param newParentPath - The path of the new parent page
+ * @returns The new path after the move
+ */
+export const getNewPathAfterMoved = (
+  fromPath: string,
+  newParentPath: string,
+): string => {
+  const pageTitle = basename(fromPath);
+  return join(newParentPath, pageTitle);
+};
+
+/**
+ * Check if selected items have ancestor-descendant relationship
+ * (e.g., if both /A and /A/B are selected, they have an ancestor-descendant relationship)
+ * @param items - Array of tree item instances
+ * @returns true if any pair has ancestor-descendant relationship
+ */
+export const hasAncestorDescendantRelation = (
+  items: ItemInstance<IPageForTreeItem>[],
+): boolean => {
+  const paths = items
+    .map((item) => item.getItemData().path)
+    .filter((path): path is string => path != null);
+
+  for (let i = 0; i < paths.length; i++) {
+    for (let j = 0; j < paths.length; j++) {
+      if (i === j) continue;
+      // Check if paths[i] is an ancestor of paths[j]
+      if (paths[j].startsWith(`${paths[i]}/`)) {
+        return true;
+      }
+    }
+  }
+  return false;
+};
+
+/**
+ * Error types for page move operations
+ */
+export type PageMoveErrorType = 'operation_blocked' | 'unknown';
+
+/**
+ * Result of a page move operation
+ */
+export type PageMoveResult = {
+  success: boolean;
+  errorType?: PageMoveErrorType;
+};
+
+/**
+ * Props for DragLine component
+ */
+type DragLineProps = {
+  style: CSSProperties;
+  className?: string;
+};
+
+/**
+ * Drag line indicator component
+ */
+const DragLine: FC<DragLineProps> = ({ style, className }) => (
+  <div
+    style={style}
+    className={`${styles['tree-drag-line']} ${className ?? ''}`}
+  />
+);
+
+export type UsePageDndProperties = {
+  canDrag: (items: ItemInstance<IPageForTreeItem>[]) => boolean;
+  canDrop: (
+    items: ItemInstance<IPageForTreeItem>[],
+    target: DragTarget<IPageForTreeItem>,
+  ) => boolean;
+  onDrop: (
+    items: ItemInstance<IPageForTreeItem>[],
+    target: DragTarget<IPageForTreeItem>,
+  ) => Promise<PageMoveResult>;
+  /**
+   * Render the drag line indicator
+   * @param tree - The tree instance from headless-tree
+   * @returns A DragLine component with proper positioning, or null if D&D is disabled
+   */
+  renderDragLine: (tree: TreeInstance<IPageForTreeItem>) => ReactNode;
+};
+
+/**
+ * Hook to handle page drag and drop operations
+ *
+ * Responsibilities:
+ * - Determine if items can be dragged (canDrag)
+ * - Determine if items can be dropped on a target (canDrop)
+ * - Execute page move API call and tree refresh (onDrop)
+ * - Provide drag line rendering (renderDragLine)
+ *
+ * Note: Toast notifications should be handled by the caller based on PageMoveResult
+ *
+ * @returns Object with canDrag, canDrop, onDrop handlers and renderDragLine
+ */
+export const usePageDnd = (
+  isEnabled: boolean = false,
+): UsePageDndProperties => {
+  const { notifyUpdateItems } = usePageTreeInformationUpdate();
+
+  /**
+   * Determine if items can be dragged
+   */
+  const canDrag = useCallback(
+    (items: ItemInstance<IPageForTreeItem>[]): boolean => {
+      // Prevent drag if ancestor-descendant relationship exists
+      if (hasAncestorDescendantRelation(items)) {
+        return false;
+      }
+
+      // Check if all items can be dragged
+      return items.every((item) => {
+        const page = item.getItemData();
+        if (page.path == null) return false;
+        // Protected user pages cannot be dragged
+        return !pagePathUtils.isUsersProtectedPages(page.path);
+      });
+    },
+    [],
+  );
+
+  /**
+   * Determine if items can be dropped on target
+   */
+  const canDrop = useCallback(
+    (
+      items: ItemInstance<IPageForTreeItem>[],
+      target: DragTarget<IPageForTreeItem>,
+    ): boolean => {
+      const targetItem = target.item;
+      if (targetItem == null) return false;
+
+      const targetPage = targetItem.getItemData();
+      if (targetPage.path == null) return false;
+
+      // Prevent drop on users top page
+      if (pagePathUtils.isUsersTopPage(targetPage.path)) {
+        return false;
+      }
+
+      // Check if all items can be moved to the target
+      return items.every((item) => {
+        const fromPage = item.getItemData();
+        if (fromPage.path == null) return false;
+
+        const newPathAfterMoved = getNewPathAfterMoved(
+          fromPage.path,
+          targetPage.path,
+        );
+        return pagePathUtils.canMoveByPath(fromPage.path, newPathAfterMoved);
+      });
+    },
+    [],
+  );
+
+  /**
+   * Handle drop event - move pages to new parent
+   * Returns result with success/failure info for caller to handle UI feedback
+   */
+  const onDrop = useCallback(
+    async (
+      items: ItemInstance<IPageForTreeItem>[],
+      target: DragTarget<IPageForTreeItem>,
+    ): Promise<PageMoveResult> => {
+      const targetItem = target.item;
+      if (targetItem == null) return { success: false, errorType: 'unknown' };
+
+      const targetPage = targetItem.getItemData();
+      if (targetPage.path == null)
+        return { success: false, errorType: 'unknown' };
+
+      // Collect parent IDs for tree invalidation
+      const parentIdsToInvalidate = new Set<string>();
+
+      for (const item of items) {
+        const fromPage = item.getItemData();
+        if (fromPage.path == null) continue;
+
+        // Track original parent for invalidation
+        if (fromPage.parent) {
+          parentIdsToInvalidate.add(String(fromPage.parent));
+        }
+
+        const newPagePath = getNewPathAfterMoved(
+          fromPage.path,
+          targetPage.path,
+        );
+
+        try {
+          await apiv3Put('/pages/rename', {
+            pageId: fromPage._id,
+            revisionId: fromPage.revision,
+            newPagePath,
+            isRenameRedirect: false,
+            updateMetadata: true,
+          });
+        } catch (err) {
+          const errorType: PageMoveErrorType =
+            (err as { code?: string }).code === 'operation__blocked'
+              ? 'operation_blocked'
+              : 'unknown';
+          return { success: false, errorType };
+        }
+      }
+
+      // Add target (new parent) to invalidation list
+      parentIdsToInvalidate.add(targetPage._id);
+
+      // Refresh SWR cache
+      await mutatePageTree();
+
+      // Invalidate headless-tree items (source parents and target)
+      notifyUpdateItems(Array.from(parentIdsToInvalidate));
+
+      // Invalidate target item's data (descendantCount changed) and expand it
+      // Use await to ensure data is refreshed before expanding
+      await targetItem.invalidateItemData();
+      targetItem.expand();
+
+      return { success: true };
+    },
+    [notifyUpdateItems],
+  );
+
+  /**
+   * Render the drag line indicator
+   * Returns null if D&D is disabled
+   */
+  const renderDragLine = useCallback(
+    (tree: TreeInstance<IPageForTreeItem>): ReactNode => {
+      if (!isEnabled) return null;
+      return <DragLine style={tree.getDragLineStyle()} />;
+    },
+    [isEnabled],
+  );
+
+  return useMemo(
+    () => ({
+      canDrag,
+      canDrop,
+      onDrop,
+      renderDragLine,
+    }),
+    [canDrag, canDrop, onDrop, renderDragLine],
+  );
+};

+ 124 - 0
apps/app/src/features/page-tree/hooks/use-page-rename.tsx

@@ -0,0 +1,124 @@
+import { useCallback } from 'react';
+import { pathUtils } from '@growi/core/dist/utils';
+import type { ItemInstance } from '@headless-tree/core';
+import { useTranslation } from 'next-i18next';
+import { basename, dirname, resolve } from 'pathe';
+
+import { apiv3Put } from '~/client/util/apiv3-client';
+import { toastError, toastSuccess } from '~/client/util/toastr';
+import type { IPageForItem } from '~/interfaces/page';
+import { mutatePageTree } from '~/stores/page-listing';
+
+import { CREATING_PAGE_VIRTUAL_ID } from '../constants/_inner';
+import { usePageTreeInformationUpdate } from '../states/page-tree-update';
+
+type RenameResult = {
+  success: boolean;
+  oldPath?: string;
+  newPath?: string;
+  error?: Error;
+};
+
+type UsePageRenameReturn = {
+  /**
+   * Rename a page
+   */
+  rename: (
+    item: ItemInstance<IPageForItem>,
+    newName: string,
+  ) => Promise<RenameResult>;
+
+  /**
+   * Get the current page name (basename) from item
+   */
+  getPageName: (item: ItemInstance<IPageForItem>) => string;
+
+  /**
+   * Check if item is in renaming mode
+   */
+  isRenaming: (item: ItemInstance<IPageForItem>) => boolean;
+};
+
+/**
+ * Hook for page rename logic
+ * Separates business logic from UI for renamingFeature integration
+ */
+export const usePageRename = (): UsePageRenameReturn => {
+  const { t } = useTranslation();
+  const { notifyUpdateItems } = usePageTreeInformationUpdate();
+
+  const getPageName = useCallback(
+    (item: ItemInstance<IPageForItem>): string => {
+      const page = item.getItemData();
+      // Return empty string for placeholder node (new page creation)
+      if (page._id === CREATING_PAGE_VIRTUAL_ID) {
+        return '';
+      }
+      return basename(page.path ?? '');
+    },
+    [],
+  );
+
+  const isRenaming = useCallback(
+    (item: ItemInstance<IPageForItem>): boolean => {
+      return item.isRenaming?.() ?? false;
+    },
+    [],
+  );
+
+  const rename = useCallback(
+    async (
+      item: ItemInstance<IPageForItem>,
+      newName: string,
+    ): Promise<RenameResult> => {
+      const page = item.getItemData();
+      const oldPath = page.path;
+
+      // Trim and validate
+      const trimmedName = newName.trim();
+      if (trimmedName === '') {
+        return { success: false };
+      }
+
+      // Build new path
+      const parentPath = pathUtils.addTrailingSlash(dirname(oldPath ?? ''));
+      const newPagePath = resolve(parentPath, trimmedName);
+
+      // No change needed
+      if (newPagePath === oldPath) {
+        return { success: true, oldPath, newPath: newPagePath };
+      }
+
+      try {
+        await apiv3Put('/pages/rename', {
+          pageId: page._id,
+          revisionId: page.revision,
+          newPagePath,
+        });
+
+        // Mutate page tree
+        mutatePageTree();
+
+        // Notify headless-tree to update
+        const parentId = item.getParent()?.getId();
+        if (parentId) {
+          notifyUpdateItems([parentId]);
+        }
+
+        toastSuccess(t('renamed_pages', { path: oldPath }));
+
+        return { success: true, oldPath, newPath: newPagePath };
+      } catch (err) {
+        toastError(err);
+        return { success: false, oldPath, error: err as Error };
+      }
+    },
+    [t, notifyUpdateItems],
+  );
+
+  return {
+    rename,
+    getPageName,
+    isRenaming,
+  };
+};

+ 265 - 0
apps/app/src/features/page-tree/hooks/use-placeholder-rename-effect.spec.tsx

@@ -0,0 +1,265 @@
+import type { ItemInstance } from '@headless-tree/core';
+import { renderHook } from '@testing-library/react';
+
+import type { IPageForItem } from '~/interfaces/page';
+
+import { CREATING_PAGE_VIRTUAL_ID } from '../constants/_inner';
+import { usePlaceholderRenameEffect } from './use-placeholder-rename-effect';
+
+/**
+ * Create a mock item instance for testing
+ */
+const createMockItem = (
+  id: string,
+  options: {
+    isRenaming?: boolean;
+  } = {},
+): ItemInstance<IPageForItem> & { setIsRenaming: (value: boolean) => void } => {
+  let isRenaming = options.isRenaming ?? false;
+
+  const item = {
+    getId: () => id,
+    getItemData: () => ({ _id: id }) as IPageForItem,
+    isRenaming: vi.fn(() => isRenaming),
+    startRenaming: vi.fn(() => {
+      isRenaming = true;
+    }),
+    // Helper to simulate external renaming state changes (e.g., ESC key press)
+    setIsRenaming: (value: boolean) => {
+      isRenaming = value;
+    },
+  } as unknown as ItemInstance<IPageForItem> & {
+    setIsRenaming: (value: boolean) => void;
+  };
+
+  return item;
+};
+
+describe('usePlaceholderRenameEffect', () => {
+  describe('when item is a placeholder', () => {
+    test('should call startRenaming when placeholder is not in renaming mode', () => {
+      const mockItem = createMockItem(CREATING_PAGE_VIRTUAL_ID, {
+        isRenaming: false,
+      });
+      const onCancelCreate = vi.fn();
+
+      renderHook(() =>
+        usePlaceholderRenameEffect({
+          item: mockItem,
+          onCancelCreate,
+        }),
+      );
+
+      expect(mockItem.startRenaming).toHaveBeenCalledTimes(1);
+      expect(onCancelCreate).not.toHaveBeenCalled();
+    });
+
+    test('should not call startRenaming when placeholder is already in renaming mode', () => {
+      const mockItem = createMockItem(CREATING_PAGE_VIRTUAL_ID, {
+        isRenaming: true,
+      });
+      const onCancelCreate = vi.fn();
+
+      renderHook(() =>
+        usePlaceholderRenameEffect({
+          item: mockItem,
+          onCancelCreate,
+        }),
+      );
+
+      expect(mockItem.startRenaming).not.toHaveBeenCalled();
+      expect(onCancelCreate).not.toHaveBeenCalled();
+    });
+
+    test('should call onCancelCreate when renaming mode ends (ESC key simulation)', () => {
+      const mockItem = createMockItem(CREATING_PAGE_VIRTUAL_ID, {
+        isRenaming: false,
+      });
+      const onCancelCreate = vi.fn();
+
+      const { rerender } = renderHook(() =>
+        usePlaceholderRenameEffect({
+          item: mockItem,
+          onCancelCreate,
+        }),
+      );
+
+      // Initial render: startRenaming should be called
+      expect(mockItem.startRenaming).toHaveBeenCalledTimes(1);
+
+      // Simulate renaming becoming active (startRenaming succeeded)
+      mockItem.setIsRenaming(true);
+      rerender();
+
+      // onCancelCreate should not be called yet
+      expect(onCancelCreate).not.toHaveBeenCalled();
+
+      // Simulate ESC key press - renaming mode ends
+      mockItem.setIsRenaming(false);
+      rerender();
+
+      // onCancelCreate should be called
+      expect(onCancelCreate).toHaveBeenCalledTimes(1);
+    });
+
+    test('should not call onCancelCreate if renaming was never active', () => {
+      const mockItem = createMockItem(CREATING_PAGE_VIRTUAL_ID, {
+        isRenaming: false,
+      });
+      const onCancelCreate = vi.fn();
+
+      // Mock startRenaming to NOT actually change isRenaming state
+      // This simulates a scenario where startRenaming fails
+      mockItem.startRenaming = vi.fn();
+
+      const { rerender } = renderHook(() =>
+        usePlaceholderRenameEffect({
+          item: mockItem,
+          onCancelCreate,
+        }),
+      );
+
+      // startRenaming was called but didn't change state
+      expect(mockItem.startRenaming).toHaveBeenCalled();
+
+      // Rerender with still false
+      rerender();
+
+      // onCancelCreate should NOT be called because wasRenamingRef was never true
+      expect(onCancelCreate).not.toHaveBeenCalled();
+    });
+
+    test('should re-call startRenaming when isRenaming becomes false after rapid clicks', () => {
+      const mockItem = createMockItem(CREATING_PAGE_VIRTUAL_ID, {
+        isRenaming: false,
+      });
+      const onCancelCreate = vi.fn();
+
+      const { rerender } = renderHook(() =>
+        usePlaceholderRenameEffect({
+          item: mockItem,
+          onCancelCreate,
+        }),
+      );
+
+      // First render: startRenaming called
+      expect(mockItem.startRenaming).toHaveBeenCalledTimes(1);
+
+      // Simulate renaming becoming active
+      mockItem.setIsRenaming(true);
+      rerender();
+
+      // Simulate rapid click resetting the renaming state (this was the bug)
+      mockItem.setIsRenaming(false);
+      // Reset the mock to track new calls
+      mockItem.startRenaming = vi.fn(() => {
+        mockItem.setIsRenaming(true);
+      });
+      rerender();
+
+      // startRenaming should be called again because isRenaming is now false
+      // This is the key fix - the effect re-runs when isRenaming changes
+      expect(mockItem.startRenaming).toHaveBeenCalledTimes(1);
+    });
+  });
+
+  describe('when item is NOT a placeholder', () => {
+    test('should not call startRenaming for regular items', () => {
+      const mockItem = createMockItem('regular-page-id', { isRenaming: false });
+      const onCancelCreate = vi.fn();
+
+      renderHook(() =>
+        usePlaceholderRenameEffect({
+          item: mockItem,
+          onCancelCreate,
+        }),
+      );
+
+      expect(mockItem.startRenaming).not.toHaveBeenCalled();
+      expect(onCancelCreate).not.toHaveBeenCalled();
+    });
+
+    test('should not call onCancelCreate for regular items even when renaming state changes', () => {
+      const mockItem = createMockItem('regular-page-id', { isRenaming: true });
+      const onCancelCreate = vi.fn();
+
+      const { rerender } = renderHook(() =>
+        usePlaceholderRenameEffect({
+          item: mockItem,
+          onCancelCreate,
+        }),
+      );
+
+      // Simulate renaming mode ending
+      mockItem.setIsRenaming(false);
+      rerender();
+
+      // onCancelCreate should NOT be called for regular items
+      expect(onCancelCreate).not.toHaveBeenCalled();
+    });
+  });
+
+  describe('edge cases', () => {
+    test('should handle multiple rerender cycles correctly', () => {
+      const mockItem = createMockItem(CREATING_PAGE_VIRTUAL_ID, {
+        isRenaming: false,
+      });
+      const onCancelCreate = vi.fn();
+
+      const { rerender } = renderHook(() =>
+        usePlaceholderRenameEffect({
+          item: mockItem,
+          onCancelCreate,
+        }),
+      );
+
+      // Cycle 1: Start renaming
+      expect(mockItem.startRenaming).toHaveBeenCalledTimes(1);
+      mockItem.setIsRenaming(true);
+      rerender();
+
+      // Cycle 1: Cancel (ESC)
+      mockItem.setIsRenaming(false);
+      rerender();
+      expect(onCancelCreate).toHaveBeenCalledTimes(1);
+
+      // After cancel, isRenaming is false, so startRenaming will be called again on next rerender
+      // This simulates the placeholder being re-rendered after cancel
+      // (In real app, the placeholder would be removed, but this tests the hook's behavior)
+      rerender();
+
+      // The hook will try to start renaming again because isRenaming is false
+      // This is expected behavior - the hook always tries to ensure placeholder is in renaming mode
+      expect(mockItem.startRenaming).toHaveBeenCalledTimes(2);
+    });
+
+    test('should not call onCancelCreate multiple times for the same cancel event', () => {
+      const mockItem = createMockItem(CREATING_PAGE_VIRTUAL_ID, {
+        isRenaming: false,
+      });
+      const onCancelCreate = vi.fn();
+
+      const { rerender } = renderHook(() =>
+        usePlaceholderRenameEffect({
+          item: mockItem,
+          onCancelCreate,
+        }),
+      );
+
+      // Start renaming
+      mockItem.setIsRenaming(true);
+      rerender();
+
+      // Cancel
+      mockItem.setIsRenaming(false);
+      rerender();
+      expect(onCancelCreate).toHaveBeenCalledTimes(1);
+
+      // Multiple rerenders with same state should not trigger additional calls
+      rerender();
+      rerender();
+      rerender();
+      expect(onCancelCreate).toHaveBeenCalledTimes(1);
+    });
+  });
+});

+ 59 - 0
apps/app/src/features/page-tree/hooks/use-placeholder-rename-effect.ts

@@ -0,0 +1,59 @@
+import { useEffect, useRef } from 'react';
+import type { ItemInstance } from '@headless-tree/core';
+
+import type { IPageForItem } from '~/interfaces/page';
+
+import { CREATING_PAGE_VIRTUAL_ID } from '../constants/_inner';
+
+type UsePlaceholderRenameEffectParams = {
+  item: ItemInstance<IPageForItem>;
+  onCancelCreate: () => void;
+};
+
+/**
+ * Hook that manages the renaming mode for placeholder nodes.
+ *
+ * When a placeholder node is rendered:
+ * 1. Automatically starts renaming mode to enable the input field
+ * 2. Tracks when renaming mode becomes active
+ * 3. Detects when renaming mode ends (Esc key) and calls onCancelCreate
+ *
+ * This hook separates the placeholder renaming behavior from the component,
+ * keeping the component focused on rendering.
+ */
+export const usePlaceholderRenameEffect = ({
+  item,
+  onCancelCreate,
+}: UsePlaceholderRenameEffectParams): void => {
+  // Check if this is the creating placeholder node
+  const isPlaceholder = item.getItemData()._id === CREATING_PAGE_VIRTUAL_ID;
+
+  // Track if renaming mode was ever activated for this placeholder
+  const wasRenamingRef = useRef(false);
+  const isRenaming = item.isRenaming();
+
+  // Start renaming mode on placeholder node to enable getRenameInputProps()
+  // Note: isRenaming is included in deps to ensure this effect re-runs
+  // when renaming state changes (e.g., after rapid clicks reset the state)
+  useEffect(() => {
+    if (isPlaceholder && !isRenaming) {
+      item.startRenaming();
+    }
+  }, [isPlaceholder, item, isRenaming]);
+
+  // Track when renaming becomes active
+  useEffect(() => {
+    if (isPlaceholder && isRenaming) {
+      wasRenamingRef.current = true;
+    }
+  }, [isPlaceholder, isRenaming]);
+
+  // Cancel creating when renaming mode ends on placeholder node (Esc key pressed)
+  useEffect(() => {
+    // Only cancel if renaming was previously active and is now inactive
+    if (isPlaceholder && wasRenamingRef.current && !isRenaming) {
+      onCancelCreate();
+      wasRenamingRef.current = false;
+    }
+  }, [isPlaceholder, isRenaming, onCancelCreate]);
+};

+ 39 - 0
apps/app/src/features/page-tree/hooks/use-socket-update-desc-count.ts

@@ -0,0 +1,39 @@
+import { useEffect } from 'react';
+
+import type {
+  UpdateDescCountData,
+  UpdateDescCountRawData,
+} from '~/interfaces/websocket';
+import { SocketEventName } from '~/interfaces/websocket';
+import { useGlobalSocket } from '~/states/socket-io';
+
+import { usePageTreeDescCountMapAction } from '../states/page-tree-desc-count-map';
+
+/**
+ * Hook to listen for Socket.io UpdateDescCount events and update descendant count badges
+ *
+ * This hook subscribes to the UpdateDescCount socket event, which is emitted by the server
+ * when descendant counts change (e.g., when pages are created, deleted, or moved).
+ */
+export const useSocketUpdateDescCount = (): void => {
+  const socket = useGlobalSocket();
+  const { update: updatePtDescCountMap } = usePageTreeDescCountMapAction();
+
+  useEffect(() => {
+    if (socket == null) {
+      return;
+    }
+
+    const handler = (data: UpdateDescCountRawData) => {
+      // Convert from Record to Map format for Jotai state
+      const newData: UpdateDescCountData = new Map(Object.entries(data));
+      updatePtDescCountMap(newData);
+    };
+
+    socket.on(SocketEventName.UpdateDescCount, handler);
+
+    return () => {
+      socket.off(SocketEventName.UpdateDescCount, handler);
+    };
+  }, [socket, updatePtDescCountMap]);
+};

+ 3 - 0
apps/app/src/features/page-tree/index.ts

@@ -0,0 +1,3 @@
+export * from './hooks';
+export * from './interfaces';
+export * from './states';

+ 45 - 0
apps/app/src/features/page-tree/interfaces/index.ts

@@ -0,0 +1,45 @@
+import type { IPageToDeleteWithMeta } from '@growi/core';
+import type { CheckboxesFeatureDef, ItemInstance } from '@headless-tree/core';
+
+import type { IPageForTreeItem } from '~/interfaces/page';
+import type { IPageForPageDuplicateModal } from '~/states/ui/modal/page-duplicate';
+
+type TreeItemBaseProps<
+  TI = IPageForTreeItem,
+  I extends ItemInstance<TI> = ItemInstance<TI>,
+> = {
+  item: I;
+  isEnableActions: boolean;
+  isReadOnlyUser: boolean;
+  onClickDuplicateMenuItem?(pageToDuplicate: IPageForPageDuplicateModal): void;
+  onClickDeleteMenuItem?(pageToDelete: IPageToDeleteWithMeta): void;
+  onRenamed?(fromPath: string | undefined, toPath: string): void;
+};
+
+export type TreeItemToolProps = TreeItemBaseProps;
+
+export type TreeItemWithCheckboxToolProps = TreeItemBaseProps<
+  IPageForTreeItem,
+  ItemInstance<IPageForTreeItem & CheckboxesFeatureDef<unknown>['itemInstance']>
+>;
+
+export type TreeItemProps = TreeItemBaseProps & {
+  targetPath: string;
+  targetPathOrId?: string | null;
+  isWipPageShown?: boolean;
+  itemClassName?: string;
+  customEndComponents?: Array<React.FunctionComponent<TreeItemToolProps>>;
+  customHoveredEndComponents?: Array<
+    React.FunctionComponent<TreeItemToolProps>
+  >;
+  customHeadOfChildrenComponents?: Array<
+    React.FunctionComponent<TreeItemToolProps>
+  >;
+  showAlternativeContent?: boolean;
+  customAlternativeComponents?: Array<
+    React.FunctionComponent<TreeItemToolProps>
+  >;
+  onToggle?: () => void;
+  onClick?(page: IPageForTreeItem): void;
+  onWheelClick?(page: IPageForTreeItem): void;
+};

+ 1 - 0
apps/app/src/features/page-tree/services/index.ts

@@ -0,0 +1 @@
+export * from './page-tree-children';

+ 53 - 0
apps/app/src/features/page-tree/services/page-tree-children.ts

@@ -0,0 +1,53 @@
+import { apiv3Get } from '~/client/util/apiv3-client';
+import type { IPageForTreeItem } from '~/interfaces/page';
+
+export type ChildrenData = { id: string; data: IPageForTreeItem }[];
+
+/**
+ * Pending requests for concurrent deduplication
+ * Note: Sequential cache is handled by headless-tree's internal cache
+ */
+const pending = new Map<string, Promise<ChildrenData>>();
+
+/**
+ * Clear pending requests (for cache invalidation scenarios)
+ */
+export const invalidatePageTreeChildren = (itemIds?: string[]): void => {
+  if (itemIds == null) {
+    pending.clear();
+  } else {
+    itemIds.forEach((id) => {
+      pending.delete(id);
+    });
+  }
+};
+
+/**
+ * Fetch children data with concurrent request deduplication
+ * Sequential caching is delegated to headless-tree's internal cache
+ */
+export const fetchAndCacheChildren = async (
+  itemId: string,
+): Promise<ChildrenData> => {
+  const existing = pending.get(itemId);
+  if (existing) return existing;
+
+  const promise = (async () => {
+    try {
+      const response = await apiv3Get<{ children: IPageForTreeItem[] }>(
+        '/page-listing/children',
+        { id: itemId },
+      );
+      return response.data.children.map((child) => ({
+        id: child._id,
+        data: child,
+      }));
+    } finally {
+      pending.delete(itemId);
+    }
+  })();
+
+  pending.set(itemId, promise);
+
+  return promise;
+};

+ 2 - 0
apps/app/src/features/page-tree/states/_inner/index.ts

@@ -0,0 +1,2 @@
+export * from './page-tree-create';
+export * from './tree-rebuild';

+ 170 - 0
apps/app/src/features/page-tree/states/_inner/page-tree-create.spec.tsx

@@ -0,0 +1,170 @@
+import { act, renderHook } from '@testing-library/react';
+
+import {
+  resetCreatingFlagForTesting,
+  useCreatingParentId,
+  usePageTreeCreateActions,
+} from './page-tree-create';
+
+describe('page-tree-create', () => {
+  beforeEach(() => {
+    // Reset the module-level flag before each test
+    resetCreatingFlagForTesting();
+  });
+
+  describe('usePageTreeCreateActions', () => {
+    describe('startCreating', () => {
+      test('should set creatingParentInfo on first call', () => {
+        const { result: actionsResult } = renderHook(() =>
+          usePageTreeCreateActions(),
+        );
+        const { result: parentIdResult } = renderHook(() =>
+          useCreatingParentId(),
+        );
+
+        act(() => {
+          actionsResult.current.startCreating('parent-id-1', '/parent/path');
+        });
+
+        expect(parentIdResult.current).toBe('parent-id-1');
+      });
+
+      test('should ignore rapid clicks (multiple startCreating calls)', () => {
+        const { result: actionsResult } = renderHook(() =>
+          usePageTreeCreateActions(),
+        );
+        const { result: parentIdResult } = renderHook(() =>
+          useCreatingParentId(),
+        );
+
+        // First call should succeed
+        act(() => {
+          actionsResult.current.startCreating('parent-id-1', '/parent/path1');
+        });
+
+        expect(parentIdResult.current).toBe('parent-id-1');
+
+        // Second call should be ignored (rapid click)
+        act(() => {
+          actionsResult.current.startCreating('parent-id-2', '/parent/path2');
+        });
+
+        // Should still be the first parent
+        expect(parentIdResult.current).toBe('parent-id-1');
+      });
+
+      test('should ignore rapid clicks even from different hook instances', () => {
+        // Simulate different components calling the hook
+        const { result: actionsResult1 } = renderHook(() =>
+          usePageTreeCreateActions(),
+        );
+        const { result: actionsResult2 } = renderHook(() =>
+          usePageTreeCreateActions(),
+        );
+        const { result: parentIdResult } = renderHook(() =>
+          useCreatingParentId(),
+        );
+
+        // First call from instance 1
+        act(() => {
+          actionsResult1.current.startCreating('parent-id-1', '/parent/path1');
+        });
+
+        expect(parentIdResult.current).toBe('parent-id-1');
+
+        // Second call from instance 2 should be ignored
+        act(() => {
+          actionsResult2.current.startCreating('parent-id-2', '/parent/path2');
+        });
+
+        // Should still be the first parent
+        expect(parentIdResult.current).toBe('parent-id-1');
+      });
+    });
+
+    describe('cancelCreating', () => {
+      test('should reset creatingParentInfo to null', () => {
+        const { result: actionsResult } = renderHook(() =>
+          usePageTreeCreateActions(),
+        );
+        const { result: parentIdResult } = renderHook(() =>
+          useCreatingParentId(),
+        );
+
+        // Start creating
+        act(() => {
+          actionsResult.current.startCreating('parent-id-1', '/parent/path');
+        });
+
+        expect(parentIdResult.current).toBe('parent-id-1');
+
+        // Cancel
+        act(() => {
+          actionsResult.current.cancelCreating();
+        });
+
+        expect(parentIdResult.current).toBeNull();
+      });
+
+      test('should allow startCreating to work again after cancelCreating', () => {
+        const { result: actionsResult } = renderHook(() =>
+          usePageTreeCreateActions(),
+        );
+        const { result: parentIdResult } = renderHook(() =>
+          useCreatingParentId(),
+        );
+
+        // First cycle: start and cancel
+        act(() => {
+          actionsResult.current.startCreating('parent-id-1', '/parent/path1');
+        });
+
+        expect(parentIdResult.current).toBe('parent-id-1');
+
+        act(() => {
+          actionsResult.current.cancelCreating();
+        });
+
+        expect(parentIdResult.current).toBeNull();
+
+        // Second cycle: should be able to start again
+        act(() => {
+          actionsResult.current.startCreating('parent-id-2', '/parent/path2');
+        });
+
+        expect(parentIdResult.current).toBe('parent-id-2');
+      });
+
+      test('should reset flag correctly so different hook instance can start', () => {
+        const { result: actionsResult1 } = renderHook(() =>
+          usePageTreeCreateActions(),
+        );
+        const { result: actionsResult2 } = renderHook(() =>
+          usePageTreeCreateActions(),
+        );
+        const { result: parentIdResult } = renderHook(() =>
+          useCreatingParentId(),
+        );
+
+        // Instance 1 starts
+        act(() => {
+          actionsResult1.current.startCreating('parent-id-1', '/parent/path1');
+        });
+
+        // Instance 2 cancels (this can happen from a different component)
+        act(() => {
+          actionsResult2.current.cancelCreating();
+        });
+
+        expect(parentIdResult.current).toBeNull();
+
+        // Instance 2 should now be able to start
+        act(() => {
+          actionsResult2.current.startCreating('parent-id-2', '/parent/path2');
+        });
+
+        expect(parentIdResult.current).toBe('parent-id-2');
+      });
+    });
+  });
+});

+ 107 - 0
apps/app/src/features/page-tree/states/_inner/page-tree-create.ts

@@ -0,0 +1,107 @@
+import { useCallback } from 'react';
+import { atom, useAtomValue, useSetAtom } from 'jotai';
+
+import type { IPageForTreeItem } from '~/interfaces/page';
+
+import { CREATING_PAGE_VIRTUAL_ID } from '../../constants/_inner';
+
+/**
+ * Create a placeholder page data for the creating node
+ */
+export const createPlaceholderPageData = (
+  parentId: string,
+  parentPath: string,
+): IPageForTreeItem => ({
+  _id: CREATING_PAGE_VIRTUAL_ID,
+  path: `${parentPath === '/' ? '' : parentPath}/`,
+  parent: parentId,
+  descendantCount: 0,
+  grant: 1,
+  isEmpty: true,
+  wip: false,
+});
+
+/**
+ * State for managing page creation in the tree
+ * Stores the parent page info where a new page is being created
+ */
+type CreatingParentInfo = {
+  id: string;
+  path: string;
+} | null;
+
+const creatingParentInfoAtom = atom<CreatingParentInfo>(null);
+
+// Module-level flag for synchronous guard against rapid clicks
+// This is shared across all hook instances
+let isCreatingFlag = false;
+
+/**
+ * Reset the creating flag (for testing purposes only)
+ * @internal
+ */
+export const resetCreatingFlagForTesting = (): void => {
+  isCreatingFlag = false;
+};
+
+/**
+ * Hook to get the current creating parent ID
+ */
+export const useCreatingParentId = (): string | null => {
+  const info = useAtomValue(creatingParentInfoAtom);
+  return info?.id ?? null;
+};
+
+/**
+ * Hook to get the current creating parent path
+ */
+export const useCreatingParentPath = (): string | null => {
+  const info = useAtomValue(creatingParentInfoAtom);
+  return info?.path ?? null;
+};
+
+/**
+ * Hook to check if a specific item is in "creating child" mode
+ */
+export const useIsCreatingChild = (parentId: string | undefined): boolean => {
+  const creatingParentId = useCreatingParentId();
+  return parentId != null && creatingParentId === parentId;
+};
+
+type PageTreeCreateActions = {
+  /**
+   * Start creating a new page under the specified parent
+   */
+  startCreating: (parentId: string, parentPath: string) => void;
+  /**
+   * Cancel the current page creation
+   */
+  cancelCreating: () => void;
+};
+
+/**
+ * Hook to get page tree create actions
+ */
+export const usePageTreeCreateActions = (): PageTreeCreateActions => {
+  const setCreatingParentInfo = useSetAtom(creatingParentInfoAtom);
+
+  const startCreating = useCallback(
+    (parentId: string, parentPath: string) => {
+      // Synchronous check to prevent rapid clicks
+      // Uses module-level flag shared across all hook instances
+      if (isCreatingFlag) {
+        return;
+      }
+      isCreatingFlag = true;
+      setCreatingParentInfo({ id: parentId, path: parentPath });
+    },
+    [setCreatingParentInfo],
+  );
+
+  const cancelCreating = useCallback(() => {
+    isCreatingFlag = false;
+    setCreatingParentInfo(null);
+  }, [setCreatingParentInfo]);
+
+  return { startCreating, cancelCreating };
+};

+ 31 - 0
apps/app/src/features/page-tree/states/_inner/tree-rebuild.ts

@@ -0,0 +1,31 @@
+import { atom, useAtomValue, useSetAtom } from 'jotai';
+
+/**
+ * Atom to track when the tree needs to be rebuilt.
+ * Incrementing this value will trigger a re-render in components that subscribe to it.
+ *
+ * This is useful for triggering tree rebuilds after operations that change the tree structure,
+ * such as:
+ * - Creating a new page (placeholder node added)
+ * - Renaming a page
+ * - Expanding/collapsing items with async data loading
+ */
+const treeRebuildTriggerAtom = atom(0);
+
+/**
+ * Hook to get the current rebuild trigger value.
+ * Components using this hook will re-render when the trigger changes.
+ */
+export const useTreeRebuildTrigger = (): number => {
+  return useAtomValue(treeRebuildTriggerAtom);
+};
+
+/**
+ * Hook to get a function that triggers a tree rebuild.
+ * The returned function is stable and can be passed to callbacks without causing re-renders.
+ */
+export const useTriggerTreeRebuild = (): (() => void) => {
+  const setTrigger = useSetAtom(treeRebuildTriggerAtom);
+  // Note: useSetAtom returns a stable function reference
+  return () => setTrigger((prev) => prev + 1);
+};

+ 2 - 0
apps/app/src/features/page-tree/states/index.ts

@@ -0,0 +1,2 @@
+export * from './page-tree-desc-count-map';
+export * from './page-tree-update';

+ 0 - 0
apps/app/src/states/ui/page-tree-desc-count-map.ts → apps/app/src/features/page-tree/states/page-tree-desc-count-map.ts


+ 98 - 0
apps/app/src/features/page-tree/states/page-tree-update.ts

@@ -0,0 +1,98 @@
+import { useCallback, useEffect, useRef } from 'react';
+import type { TreeInstance } from '@headless-tree/core';
+import { atom, useAtomValue, useSetAtom } from 'jotai';
+
+import { ROOT_PAGE_VIRTUAL_ID } from '../constants/_inner';
+import { invalidatePageTreeChildren } from '../services';
+
+// Update generation number
+const generationAtom = atom<number>(1);
+
+// Array of IDs for last updated items
+// null is a special value meaning full tree update
+const lastUpdatedItemIdsAtom = atom<string[] | null>(null);
+
+// Read-only hooks
+export const usePageTreeInformationGeneration = () =>
+  useAtomValue(generationAtom);
+
+export const usePageTreeInformationLastUpdatedItemIds = () =>
+  useAtomValue(lastUpdatedItemIdsAtom);
+
+// Hook for notifying tree updates
+export const usePageTreeInformationUpdate = () => {
+  const setGeneration = useSetAtom(generationAtom);
+  const setLastUpdatedIds = useSetAtom(lastUpdatedItemIdsAtom);
+
+  // Notify update for specific items
+  const notifyUpdateItems = useCallback(
+    (itemIds?: string[]) => {
+      setLastUpdatedIds(itemIds ?? [ROOT_PAGE_VIRTUAL_ID]);
+      setGeneration((prev) => prev + 1);
+    },
+    [setGeneration, setLastUpdatedIds],
+  );
+
+  // Notify update for all trees
+  const notifyUpdateAllTrees = useCallback(() => {
+    setLastUpdatedIds(null);
+    setGeneration((prev) => prev + 1);
+  }, [setGeneration, setLastUpdatedIds]);
+
+  return {
+    notifyUpdateItems,
+    notifyUpdateAllTrees,
+  };
+};
+
+export const usePageTreeRevalidationEffect = (
+  tree: TreeInstance<unknown>,
+  generation: number,
+  opts?: { onRevalidated?: () => void },
+) => {
+  const globalGeneration = useAtomValue(generationAtom);
+  const globalLastUpdatedItemIds = useAtomValue(lastUpdatedItemIdsAtom);
+
+  const { getItemInstance } = tree;
+
+  // Use ref to avoid opts causing infinite loop in dependency array
+  const onRevalidatedRef = useRef(opts?.onRevalidated);
+  onRevalidatedRef.current = opts?.onRevalidated;
+
+  useEffect(() => {
+    if (globalGeneration <= generation) {
+      return;
+    }
+
+    const shouldUpdateAll = globalLastUpdatedItemIds == null;
+
+    if (shouldUpdateAll) {
+      // Full tree update: clear all pending requests
+      invalidatePageTreeChildren();
+
+      // Only invalidate expanded items (they are the ones with visible children)
+      // Using optimistic=true to avoid multiple rebuildTree calls and loading states
+      const expandedItems = tree.getItems().filter((item) => item.isExpanded());
+      expandedItems.forEach((item) => {
+        item.invalidateChildrenIds(true);
+      });
+
+      // Also invalidate root to refresh top-level
+      getItemInstance(ROOT_PAGE_VIRTUAL_ID)?.invalidateChildrenIds(false);
+    } else {
+      // Partial update: only invalidate specified items
+      invalidatePageTreeChildren(globalLastUpdatedItemIds);
+      globalLastUpdatedItemIds.forEach((itemId) => {
+        getItemInstance(itemId)?.invalidateChildrenIds(false);
+      });
+    }
+
+    onRevalidatedRef.current?.();
+  }, [
+    globalGeneration,
+    generation,
+    getItemInstance,
+    globalLastUpdatedItemIds,
+    tree,
+  ]);
+};

+ 1 - 1
apps/app/src/interfaces/page.ts

@@ -26,8 +26,8 @@ export type IPageForTreeItem = Pick<
   | '_id'
   | 'path'
   | 'parent'
-  | 'descendantCount'
   | 'revision'
+  | 'descendantCount'
   | 'grant'
   | 'isEmpty'
   | 'wip'

+ 39 - 116
apps/app/src/server/routes/apiv3/page-listing.ts

@@ -177,152 +177,75 @@ const routerFactory = (crowi: Crowi): Router => {
   /**
    * @swagger
    *
-   * /page-listing/info:
+   * /page-listing/item:
    *   get:
    *     tags: [PageListing]
    *     security:
    *       - bearer: []
    *       - accessTokenInQuery: []
-   *     summary: /page-listing/info
-   *     description: Get summary information of pages
+   *     summary: /page-listing/item
+   *     description: Get a single page item for tree display
    *     parameters:
-   *       - name: pageIds
-   *         in: query
-   *         description: Array of page IDs to retrieve information for (One of pageIds or path is required)
-   *         schema:
-   *           type: array
-   *           items:
-   *             type: string
-   *       - name: path
+   *       - name: id
    *         in: query
-   *         description: Path of the page to retrieve information for (One of pageIds or path is required)
+   *         required: true
    *         schema:
    *           type: string
-   *       - name: attachBookmarkCount
-   *         in: query
-   *         schema:
-   *           type: boolean
-   *       - name: attachShortBody
-   *         in: query
-   *         schema:
-   *           type: boolean
    *     responses:
    *       200:
-   *         description: Get the information of a page
+   *         description: Page item data
    *         content:
    *           application/json:
    *             schema:
    *               type: object
-   *               additionalProperties:
-   *                 $ref: '#/components/schemas/PageInfoAll'
+   *               properties:
+   *                 item:
+   *                   $ref: '#/components/schemas/PageForTreeItem'
    */
   router.get(
-    '/info',
+    '/item',
     accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }),
-    validator.pageIdsOrPathRequired,
-    validator.infoParams,
+    loginRequired,
+    validator.pageIdOrPathRequired,
     apiV3FormValidator,
     async (req: AuthorizedRequest, res: ApiV3Response) => {
-      const {
-        pageIds,
-        path,
-        attachBookmarkCount: attachBookmarkCountParam,
-        attachShortBody: attachShortBodyParam,
-      } = req.query;
+      const { id } = req.query;
 
-      const attachBookmarkCount: boolean = attachBookmarkCountParam === 'true';
-      const attachShortBody: boolean = attachShortBodyParam === 'true';
-
-      const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>(
-        'Page',
-      );
-      // eslint-disable-next-line @typescript-eslint/no-explicit-any
-      const Bookmark = mongoose.model<any, any>('Bookmark');
-      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-      const pageService = crowi.pageService;
-      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-      const pageGrantService: IPageGrantService = crowi.pageGrantService!;
+      if (id == null) {
+        return res.apiv3Err(new ErrorV3('id parameter is required'));
+      }
 
       try {
-        const pages =
-          pageIds != null
-            ? await Page.findByIdsAndViewer(
-                pageIds as string[],
-                req.user,
-                null,
-                true,
-              )
-            : await Page.findByPathAndViewer(
-                path as string,
-                req.user,
-                null,
-                false,
-                true,
-              );
-
-        const foundIds = pages.map((page) => page._id);
-
-        let shortBodiesMap: Record<string, string | null> | undefined;
-        if (attachShortBody) {
-          shortBodiesMap = await pageService.shortBodiesMapByPageIds(
-            foundIds,
-            req.user,
-          );
-        }
-
-        let bookmarkCountMap: Record<string, number> | undefined;
-        if (attachBookmarkCount) {
-          bookmarkCountMap = (await Bookmark.getPageIdToCountMap(
-            foundIds,
-          )) as Record<string, number>;
-        }
-
-        const idToPageInfoMap: Record<string, IPageInfo | IPageInfoForListing> =
-          {};
-
-        const isGuestUser = req.user == null;
-
-        const userRelatedGroups = await pageGrantService.getUserRelatedGroups(
+        const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>(
+          'Page',
+        );
+        const page = await Page.findByIdAndViewer(
+          id as string,
           req.user,
+          null,
+          true,
         );
 
-        for (const page of pages) {
-          const basicPageInfo = {
-            ...pageService.constructBasicPageInfo(page, isGuestUser),
-            bookmarkCount:
-              bookmarkCountMap != null
-                ? (bookmarkCountMap[page._id.toString()] ?? 0)
-                : 0,
-          };
-
-          // TODO: use pageService.getCreatorIdForCanDelete to get creatorId (https://redmine.weseek.co.jp/issues/140574)
-          const canDeleteCompletely = pageService.canDeleteCompletely(
-            page,
-            page.creator == null ? null : getIdForRef(page.creator),
-            req.user,
-            false,
-            userRelatedGroups,
-          ); // use normal delete config
-
-          const pageInfo = !isIPageInfoForEntity(basicPageInfo)
-            ? basicPageInfo
-            : ({
-                ...basicPageInfo,
-                isAbleToDeleteCompletely: canDeleteCompletely,
-                revisionShortBody:
-                  shortBodiesMap != null
-                    ? (shortBodiesMap[page._id.toString()] ?? undefined)
-                    : undefined,
-              } satisfies IPageInfoForListing);
-
-          idToPageInfoMap[page._id.toString()] = pageInfo;
+        if (page == null) {
+          return res.apiv3Err(new ErrorV3('Page not found'), 404);
         }
 
-        return res.apiv3(idToPageInfoMap);
+        const item: IPageForTreeItem = {
+          _id: page._id.toString(),
+          path: page.path,
+          parent: page.parent,
+          revision: page.revision, // required to create an IPageToDeleteWithMeta instance
+          descendantCount: page.descendantCount,
+          grant: page.grant,
+          isEmpty: page.isEmpty,
+          wip: page.wip ?? false,
+        };
+
+        return res.apiv3({ item });
       } catch (err) {
-        logger.error('Error occurred while fetching page informations.', err);
+        logger.error('Error occurred while fetching page item.', err);
         return res.apiv3Err(
-          new ErrorV3('Error occurred while fetching page informations.'),
+          new ErrorV3('Error occurred while fetching page item.'),
         );
       }
     },

+ 0 - 11
apps/app/src/server/service/page-listing/page-listing.integ.ts

@@ -132,7 +132,6 @@ describe('page-listing store integration tests', () => {
       expect(typeof rootPageResult._id).toBe('object'); // ObjectId
       expect(rootPageResult.path).toBe('/');
       expect([null, 1, 2, 3, 4, 5]).toContain(rootPageResult.grant); // Valid grant values
-      expect(rootPageResult.parent).toBeNull(); // Root page has no parent
     });
 
     test('should work without user (guest access) and return type-safe result', async () => {
@@ -197,7 +196,6 @@ describe('page-listing store integration tests', () => {
       expect(children).toHaveLength(2);
       children.forEach((child) => {
         validatePageForTreeItem(child);
-        expect(child.parent?.toString()).toBe(rootPage._id.toString());
         expect(['/child1', '/child2']).toContain(child.path);
       });
     });
@@ -212,7 +210,6 @@ describe('page-listing store integration tests', () => {
       expect(children).toHaveLength(2);
       children.forEach((child) => {
         validatePageForTreeItem(child);
-        expect(child.parent?.toString()).toBe(rootPage._id.toString());
       });
     });
 
@@ -227,7 +224,6 @@ describe('page-listing store integration tests', () => {
       const grandChild = nestedChildren[0];
       validatePageForTreeItem(grandChild);
       expect(grandChild.path).toBe('/child1/grandchild');
-      expect(grandChild.parent?.toString()).toBe(childPage1._id.toString());
     });
 
     test('should return empty array when no children exist', async () => {
@@ -432,13 +428,6 @@ describe('page-listing store integration tests', () => {
         expect(result._id.toString).toBeDefined();
         expect(typeof result._id.toString()).toBe('string');
         expect(result._id.toString().length).toBe(24);
-
-        // Validate parent _id behavior
-        if (result.parent) {
-          expect(result.parent.toString).toBeDefined();
-          expect(typeof result.parent.toString()).toBe('string');
-          expect(result.parent.toString().length).toBe(24);
-        }
       });
     });
   });

+ 34 - 2
apps/app/src/states/ui/modal/page-select.ts

@@ -1,6 +1,8 @@
 import { useCallback } from 'react';
 import { atom, useAtomValue, useSetAtom } from 'jotai';
 
+import type { IPageForItem } from '~/interfaces/page';
+
 import type { OnSelectedFunction } from '../../../interfaces/ui';
 
 type IPageSelectModalOption = {
@@ -23,6 +25,9 @@ const pageSelectModalAtom = atom<PageSelectModalStatus>({
   isOpened: false,
 });
 
+// Atom for selected page in modal
+const selectedPageAtom = atom<IPageForItem | null>(null);
+
 /**
  * Hook for managing page select modal state
  * Returns read-only modal status for optimal performance
@@ -37,17 +42,44 @@ export const usePageSelectModalStatus = (): PageSelectModalStatus => {
  */
 export const usePageSelectModalActions = (): PageSelectModalActions => {
   const setStatus = useSetAtom(pageSelectModalAtom);
+  const setSelectedPage = useSetAtom(selectedPageAtom);
 
   const open = useCallback(
     (opts?: IPageSelectModalOption) => {
       setStatus({ isOpened: true, opts });
+      setSelectedPage(null); // Reset selected page when modal opens
     },
-    [setStatus],
+    [setStatus, setSelectedPage],
   );
 
   const close = useCallback(() => {
     setStatus({ isOpened: false, opts: undefined });
-  }, [setStatus]);
+    setSelectedPage(null); // Reset selected page when modal closes
+  }, [setStatus, setSelectedPage]);
 
   return { open, close };
 };
+
+/**
+ * Hook for getting selected page in modal
+ */
+export const useSelectedPageInModal = (): IPageForItem | null => {
+  return useAtomValue(selectedPageAtom);
+};
+
+/**
+ * Hook for selecting a page in modal
+ */
+export const useSelectPageInModal = (): ((page: IPageForItem) => void) => {
+  const setSelectedPage = useSetAtom(selectedPageAtom);
+
+  return useCallback(
+    (page: IPageForItem) => {
+      if (page.path == null) {
+        return;
+      }
+      setSelectedPage(page);
+    },
+    [setSelectedPage],
+  );
+};

+ 10 - 10
apps/app/src/stores/page-listing.tsx

@@ -190,12 +190,20 @@ export const useSWRxPageInfoForList = (
   };
 };
 
+const MUTATION_ID_FOR_PAGETREE = 'pageTree';
+const keyMatcherForPageTree = (key: Arguments): boolean => {
+  return Array.isArray(key) && key[0] === MUTATION_ID_FOR_PAGETREE;
+};
+export const mutatePageTree = async (): Promise<undefined[]> => {
+  return mutate(keyMatcherForPageTree);
+};
+
 export const useSWRxRootPage = (
   config?: SWRConfiguration,
 ): SWRResponse<RootPageResult, Error> => {
   return useSWR(
-    '/page-listing/root',
-    (endpoint) =>
+    [MUTATION_ID_FOR_PAGETREE, '/page-listing/root'],
+    ([, endpoint]) =>
       apiv3Get(endpoint).then((response) => {
         return {
           rootPage: response.data.rootPage,
@@ -210,14 +218,6 @@ export const useSWRxRootPage = (
   );
 };
 
-const MUTATION_ID_FOR_PAGETREE = 'pageTree';
-const keyMatcherForPageTree = (key: Arguments): boolean => {
-  return Array.isArray(key) && key[0] === MUTATION_ID_FOR_PAGETREE;
-};
-export const mutatePageTree = async (): Promise<undefined[]> => {
-  return mutate(keyMatcherForPageTree);
-};
-
 export const useSWRxPageChildren = (
   id?: string | null,
 ): SWRResponse<ChildrenResult, Error> => {

+ 1 - 2
package.json

@@ -58,7 +58,6 @@
     "@types/estree": "^1.0.1",
     "@types/glob": "^8.1.0",
     "@types/node": "^20.18.3",
-    "@types/path-browserify": "^1.0.0",
     "@typescript-eslint/eslint-plugin": "^5.59.7",
     "@typescript-eslint/parser": "^5.59.7",
     "@vitejs/plugin-react": "^4.3.1",
@@ -81,7 +80,7 @@
     "mock-require": "^3.0.3",
     "nodemon": "^3.1.3",
     "npm-run-all": "^4.1.5",
-    "path-browserify": "^1.0.1",
+    "pathe": "^2.0.3",
     "reg-keygen-git-hash-plugin": "^0.11.1",
     "reg-notify-github-plugin": "^0.11.1",
     "reg-notify-slack-plugin": "^0.11.0",

+ 47 - 11
pnpm-lock.yaml

@@ -61,9 +61,6 @@ importers:
       '@types/node':
         specifier: ^20.18.3
         version: 20.19.17
-      '@types/path-browserify':
-        specifier: ^1.0.0
-        version: 1.0.0
       '@typescript-eslint/eslint-plugin':
         specifier: ^5.59.7
         version: 5.59.7(@typescript-eslint/parser@5.59.7(eslint@8.41.0)(typescript@5.0.4))(eslint@8.41.0)(typescript@5.0.4)
@@ -130,9 +127,9 @@ importers:
       npm-run-all:
         specifier: ^4.1.5
         version: 4.1.5
-      path-browserify:
-        specifier: ^1.0.1
-        version: 1.0.1
+      pathe:
+        specifier: ^2.0.3
+        version: 2.0.3
       reg-keygen-git-hash-plugin:
         specifier: ^0.11.1
         version: 0.11.1
@@ -812,6 +809,12 @@ importers:
       '@handsontable/react':
         specifier: '=2.1.0'
         version: 2.1.0(handsontable@6.2.2)
+      '@headless-tree/core':
+        specifier: ^1.5.1
+        version: 1.5.1
+      '@headless-tree/react':
+        specifier: ^1.5.1
+        version: 1.5.1(@headless-tree/core@1.5.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
       '@next/bundle-analyzer':
         specifier: ^14.1.3
         version: 14.2.4
@@ -824,6 +827,9 @@ importers:
       '@swc/jest':
         specifier: ^0.2.36
         version: 0.2.36(@swc/core@1.10.7(@swc/helpers@0.5.15))
+      '@tanstack/react-virtual':
+        specifier: ^3.13.12
+        version: 3.13.12(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
       '@testing-library/jest-dom':
         specifier: ^6.5.0
         version: 6.5.0
@@ -3226,6 +3232,16 @@ packages:
     peerDependencies:
       handsontable: '>=6.0.0'
 
+  '@headless-tree/core@1.5.1':
+    resolution: {integrity: sha512-uPoFcjPYdnXwuEDJd2oCMY8a4nnsMKyx6P0G1+is6dIGFpUsoV0qjtJN6ykJtOgTHVhBRR11zRmletER6Qgj/Q==}
+
+  '@headless-tree/react@1.5.1':
+    resolution: {integrity: sha512-8r34ug5g25peTDgyGoCZf5Ohy4O0FMhdhUNyiaXzGn/1nwUDpmmwsrqh64DVNPereSA9uhY0s39uRibfvdmTqw==}
+    peerDependencies:
+      '@headless-tree/core': '*'
+      react: '*'
+      react-dom: '*'
+
   '@humanwhocodes/config-array@0.11.8':
     resolution: {integrity: sha512-UybHIJzJnR5Qc/MsD9Kr+RpO2h+/P1GhOwdiLPXK5TWk5sgTdu88bTD9UP+CKbPPh5Rni1u0GjAdYQLemG8g+g==}
     engines: {node: '>=10.10.0'}
@@ -5234,6 +5250,15 @@ packages:
   '@swc/types@0.1.17':
     resolution: {integrity: sha512-V5gRru+aD8YVyCOMAjMpWR1Ui577DD5KSJsHP8RAxopAH22jFz6GZd/qxqjO6MJHQhcsjvjOFXyDhyLQUnMveQ==}
 
+  '@tanstack/react-virtual@3.13.12':
+    resolution: {integrity: sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==}
+    peerDependencies:
+      react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+      react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
+  '@tanstack/virtual-core@3.13.12':
+    resolution: {integrity: sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==}
+
   '@testing-library/dom@10.4.0':
     resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==}
     engines: {node: '>=18'}
@@ -5839,9 +5864,6 @@ packages:
   '@types/oracledb@6.5.2':
     resolution: {integrity: sha512-kK1eBS/Adeyis+3OlBDMeQQuasIDLUYXsi2T15ccNJ0iyUpQ4xDF7svFu3+bGVrI0CMBUclPciz+lsQR3JX3TQ==}
 
-  '@types/path-browserify@1.0.0':
-    resolution: {integrity: sha512-XMCcyhSvxcch8b7rZAtFAaierBYdeHXVvg2iYnxOV0MCQHmPuRRmGZPFDRzPayxcGiiSL1Te9UIO+f3cuj0tfw==}
-
   '@types/pg-pool@2.0.6':
     resolution: {integrity: sha512-TaAUE5rq2VQYxab5Ts7WZhKNmuN78Q6PiFonTDdpbx8a1H0M1vhy3rhiMjl+e2iHmogyMw7jZF4FrE6eJUy5HQ==}
 
@@ -17740,6 +17762,14 @@ snapshots:
     dependencies:
       handsontable: 6.2.2
 
+  '@headless-tree/core@1.5.1': {}
+
+  '@headless-tree/react@1.5.1(@headless-tree/core@1.5.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
+    dependencies:
+      '@headless-tree/core': 1.5.1
+      react: 18.2.0
+      react-dom: 18.2.0(react@18.2.0)
+
   '@humanwhocodes/config-array@0.11.8':
     dependencies:
       '@humanwhocodes/object-schema': 1.2.1
@@ -20595,6 +20625,14 @@ snapshots:
     dependencies:
       '@swc/counter': 0.1.3
 
+  '@tanstack/react-virtual@3.13.12(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
+    dependencies:
+      '@tanstack/virtual-core': 3.13.12
+      react: 18.2.0
+      react-dom: 18.2.0(react@18.2.0)
+
+  '@tanstack/virtual-core@3.13.12': {}
+
   '@testing-library/dom@10.4.0':
     dependencies:
       '@babel/code-frame': 7.27.1
@@ -21487,8 +21525,6 @@ snapshots:
     dependencies:
       '@types/node': 20.19.17
 
-  '@types/path-browserify@1.0.0': {}
-
   '@types/pg-pool@2.0.6':
     dependencies:
       '@types/pg': 8.15.4