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

Merge branch 'dev/7.4.x' of https://github.com/growilabs/growi into support/156162-175867-app-client-utils-biome

Futa Arai 3 месяцев назад
Родитель
Сommit
475fa1adf4
35 измененных файлов с 1475 добавлено и 701 удалено
  1. 2 1
      .serena/memories/page-state-hooks-useLatestRevision-degradation.md
  2. 1 1
      .serena/memories/page-transition-and-rendering-flow.md
  3. 1 0
      apps/app/.eslintrc.js
  4. 46 21
      apps/app/src/client/components/Bookmarks/BookmarkItem.tsx
  5. 54 2
      apps/app/src/client/components/Common/Dropdown/PageItemControl.spec.tsx
  6. 19 14
      apps/app/src/client/components/Common/Dropdown/PageItemControl.tsx
  7. 0 40
      apps/app/src/client/components/Hotkeys/Subscribers/EditPage.jsx
  8. 74 0
      apps/app/src/client/components/Hotkeys/Subscribers/EditPage.tsx
  9. 16 19
      apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx
  10. 5 23
      apps/app/src/client/components/Navbar/PageEditorModeManager.tsx
  11. 36 40
      apps/app/src/client/components/PageControls/PageControls.tsx
  12. 1 1
      apps/app/src/client/components/Sidebar/PageTreeItem/use-page-item-control.tsx
  13. 38 0
      apps/app/src/client/services/use-start-editing.tsx
  14. 1 1
      apps/app/src/features/collaborative-editor/side-effects/index.ts
  15. 8 2
      apps/app/src/interfaces/bookmark-info.ts
  16. 0 122
      apps/app/src/server/models/bookmark.js
  17. 151 0
      apps/app/src/server/models/bookmark.ts
  18. 52 21
      apps/app/src/server/routes/apiv3/bookmarks.ts
  19. 24 15
      apps/app/src/server/routes/apiv3/page-listing.ts
  20. 11 18
      apps/app/src/server/routes/apiv3/page/index.ts
  21. 42 21
      apps/app/src/server/service/page/delete-completely-user-home-by-system.integ.ts
  22. 36 25
      apps/app/src/server/service/page/delete-completely-user-home-by-system.ts
  23. 412 174
      apps/app/src/server/service/page/index.ts
  24. 180 42
      apps/app/src/server/service/page/page-service.ts
  25. 3 1
      apps/app/src/server/service/page/should-use-v4-process.ts
  26. 14 3
      apps/app/src/states/page/hooks.ts
  27. 9 9
      apps/app/src/states/page/hydrate.ts
  28. 6 4
      apps/app/src/states/page/internal-atoms.ts
  29. 170 26
      apps/app/src/states/page/use-fetch-current-page.spec.tsx
  30. 29 14
      apps/app/src/states/page/use-fetch-current-page.ts
  31. 1 1
      apps/app/src/states/socket-io/global-socket.ts
  32. 11 34
      apps/app/src/states/ui/page-abilities.ts
  33. 3 2
      apps/app/src/stores/bookmark.ts
  34. 1 2
      biome.json
  35. 18 2
      packages/core/src/interfaces/page.ts

+ 2 - 1
.serena/memories/page-state-hooks-useLatestRevision-degradation.md

@@ -305,7 +305,8 @@ export const useFetchCurrentPage = () => {
       const { page: newData } = data;
 
       set(currentPageDataAtom, newData);
-      set(currentPageIdAtom, newData._id);
+      set(currentPageEntityIdAtom, newData._id);
+      set(currentPageEmptyIdAtom, undefined);
 
       // ✅ 追加: PageInfo を再フェッチ
       mutatePageInfo();  // 引数なし = revalidate (再フェッチ)

+ 1 - 1
.serena/memories/page-transition-and-rendering-flow.md

@@ -53,7 +53,7 @@
     - **3d. API通信**: `apiv3Get('/page', ...)` を実行してサーバーから新しいページデータを取得します。パラメータには、パス、ページID、リビジョンIDなどが含まれます。
 4.  **アトミックな状態更新**:
     - **API成功時**:
-        - 関連する **すべてのatomを一度に更新** します (`currentPageDataAtom`, `currentPageIdAtom`, `pageNotFoundAtom`, `pageLoadingAtom` など)。
+        - 関連する **すべてのatomを一度に更新** します (`currentPageDataAtom`, `currentPageEntityIdAtom`, `currentPageEmptyIdAtom`, `pageNotFoundAtom`, `pageLoadingAtom` など)。
         - これにより、中間的な状態(`pageId`が`undefined`になるなど)が発生することなく、データが完全に揃った状態で一度だけ状態が更新されます。
     - **APIエラー時 (例: 404 Not Found)**:
         - `pageErrorAtom` にエラーオブジェクトを設定します。

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

@@ -85,6 +85,7 @@ module.exports = {
     'src/server/service/in-app-notification/**',
     'src/server/service/interfaces/**',
     'src/server/service/normalize-data/**',
+    'src/server/service/page/**',
     'src/client/interfaces/**',
     'src/client/models/**',
     'src/client/util/**',

+ 46 - 21
apps/app/src/client/components/Bookmarks/BookmarkItem.tsx

@@ -1,8 +1,11 @@
-import React, { useCallback, useState, type JSX } from 'react';
+import React, {
+  useCallback, useMemo, useState, type JSX,
+} from 'react';
 
 import nodePath from 'path';
 
 import type { IPageHasId, IPageInfoExt, IPageToDeleteWithMeta } from '@growi/core';
+import { getIdStringForRef } from '@growi/core';
 import { DevidedPagePath } from '@growi/core/dist/models';
 import { pathUtils } from '@growi/core/dist/utils';
 import { useRouter } from 'next/router';
@@ -59,17 +62,21 @@ export const BookmarkItem = (props: Props): JSX.Element => {
     ...bookmarkedPage, parentFolder,
   };
 
+  const bookmarkedPageId = bookmarkedPage?._id;
+  const bookmarkedPagePath = bookmarkedPage?.path;
+  const bookmarkedPageRevision = bookmarkedPage?.revision;
+
   const onClickMoveToRootHandler = useCallback(async() => {
-    if (bookmarkedPage == null) return;
+    if (bookmarkedPageId == null) return;
 
     try {
-      await addBookmarkToFolder(bookmarkedPage._id, null);
+      await addBookmarkToFolder(bookmarkedPageId, null);
       bookmarkFolderTreeMutation();
     }
     catch (err) {
       toastError(err);
     }
-  }, [bookmarkFolderTreeMutation, bookmarkedPage]);
+  }, [bookmarkFolderTreeMutation, bookmarkedPageId]);
 
   const bookmarkMenuItemClickHandler = useCallback(async(pageId: string, shouldBookmark: boolean) => {
     if (shouldBookmark) {
@@ -91,23 +98,23 @@ export const BookmarkItem = (props: Props): JSX.Element => {
   }, []);
 
   const rename = useCallback(async(inputText: string) => {
-    if (bookmarkedPage == null) return;
+    if (bookmarkedPageId == null) return;
 
 
     if (inputText.trim() === '') {
       return cancel();
     }
 
-    const parentPath = pathUtils.addTrailingSlash(nodePath.dirname(bookmarkedPage.path ?? ''));
+    const parentPath = pathUtils.addTrailingSlash(nodePath.dirname(bookmarkedPagePath ?? ''));
     const newPagePath = nodePath.resolve(parentPath, inputText.trim());
-    if (newPagePath === bookmarkedPage.path) {
+    if (newPagePath === bookmarkedPagePath) {
       setRenameInputShown(false);
       return;
     }
 
     try {
       setRenameInputShown(false);
-      await renamePage(bookmarkedPage._id, bookmarkedPage.revision, newPagePath);
+      await renamePage(bookmarkedPageId, bookmarkedPageRevision, newPagePath);
       bookmarkFolderTreeMutation();
       mutatePageInfo();
     }
@@ -115,26 +122,26 @@ export const BookmarkItem = (props: Props): JSX.Element => {
       setRenameInputShown(true);
       toastError(err);
     }
-  }, [bookmarkedPage, cancel, bookmarkFolderTreeMutation, mutatePageInfo]);
+  }, [bookmarkedPageId, bookmarkedPagePath, bookmarkedPageRevision, cancel, bookmarkFolderTreeMutation, mutatePageInfo]);
 
   const deleteMenuItemClickHandler = useCallback(async(_pageId: string, pageInfo: IPageInfoExt | undefined): Promise<void> => {
-    if (bookmarkedPage == null) return;
+    if (bookmarkedPageId == null) return;
 
-    if (bookmarkedPage._id == null || bookmarkedPage.path == null) {
+    if (bookmarkedPageId == null || bookmarkedPagePath == null) {
       throw Error('_id and path must not be null.');
     }
 
     const pageToDelete: IPageToDeleteWithMeta = {
       data: {
-        _id: bookmarkedPage._id,
-        revision: bookmarkedPage.revision as string,
-        path: bookmarkedPage.path,
+        _id: bookmarkedPageId,
+        revision: bookmarkedPageRevision == null ? null : getIdStringForRef(bookmarkedPageRevision),
+        path: bookmarkedPagePath,
       },
       meta: pageInfo,
     };
 
     onClickDeleteMenuItemHandler(pageToDelete);
-  }, [bookmarkedPage, onClickDeleteMenuItemHandler]);
+  }, [bookmarkedPageId, bookmarkedPagePath, bookmarkedPageRevision, onClickDeleteMenuItemHandler]);
 
   const putBackClickHandler = useCallback(() => {
     if (bookmarkedPage == null) return;
@@ -156,15 +163,33 @@ export const BookmarkItem = (props: Props): JSX.Element => {
     openPutBackPageModal({ pageId, path }, { onPutBacked: putBackedHandler });
   }, [bookmarkedPage, openPutBackPageModal, bookmarkFolderTreeMutation, router, fetchCurrentPage, t]);
 
+  const {
+    pageTitle, formerPagePath, isFormerRoot, bookmarkItemId,
+  } = useMemo(() => {
+    const bookmarkItemId = `bookmark-item-${bookmarkedPageId}`;
+
+    if (bookmarkedPagePath == null) {
+      return {
+        pageTitle: '',
+        formerPagePath: '',
+        isFormerRoot: false,
+        bookmarkItemId,
+      };
+    }
+
+    const dPagePath = new DevidedPagePath(bookmarkedPagePath, false, true);
+    return {
+      pageTitle: dPagePath.latter,
+      formerPagePath: dPagePath.former,
+      isFormerRoot: dPagePath.isFormerRoot,
+      bookmarkItemId,
+    };
+  }, [bookmarkedPagePath, bookmarkedPageId]);
+
   if (bookmarkedPage == null) {
     return <></>;
   }
 
-  const dPagePath = new DevidedPagePath(bookmarkedPage.path, false, true);
-  const { latter: pageTitle, former: formerPagePath } = dPagePath;
-
-  const bookmarkItemId = `bookmark-item-${bookmarkedPage._id}`;
-
   return (
     <DragAndDropWrapper
       item={dragItem}
@@ -215,7 +240,7 @@ export const BookmarkItem = (props: Props): JSX.Element => {
           target={bookmarkItemId}
           fade={false}
         >
-          {dPagePath.isFormerRoot ? '/' : `${formerPagePath}/`}
+          {isFormerRoot ? '/' : `${formerPagePath}/`}
         </UncontrolledTooltip>
       </li>
     </DragAndDropWrapper>

+ 54 - 2
apps/app/src/client/components/Common/Dropdown/PageItemControl.spec.tsx

@@ -1,4 +1,4 @@
-import { type IPageInfoForOperation } from '@growi/core/dist/interfaces';
+import { type IPageInfoForOperation, type IPageInfoForEmpty } from '@growi/core/dist/interfaces';
 import {
   fireEvent, screen, within,
 } from '@testing-library/dom';
@@ -8,14 +8,16 @@ import { mock } from 'vitest-mock-extended';
 import { PageItemControl } from './PageItemControl';
 
 
-// mock for isIPageInfoForOperation
+// mock for isIPageInfoForOperation and isIPageInfoForEmpty
 
 const mocks = vi.hoisted(() => ({
   isIPageInfoForOperationMock: vi.fn(),
+  isIPageInfoForEmptyMock: vi.fn(),
 }));
 
 vi.mock('@growi/core/dist/interfaces', () => ({
   isIPageInfoForOperation: mocks.isIPageInfoForOperationMock,
+  isIPageInfoForEmpty: mocks.isIPageInfoForEmptyMock,
 }));
 
 
@@ -32,6 +34,8 @@ describe('PageItemControl.tsx', () => {
           return true;
         }
       });
+      // return false for isIPageInfoForEmpty since we're using IPageInfoForOperation
+      mocks.isIPageInfoForEmptyMock.mockReturnValue(false);
 
       const props = {
         pageId: 'dummy-page-id',
@@ -51,5 +55,53 @@ describe('PageItemControl.tsx', () => {
       // then
       expect(onClickRenameMenuItemMock).toHaveBeenCalled();
     });
+
+    it('with empty page (IPageInfoForEmpty)', async() => {
+      // setup - Create an empty page mock with required properties
+      const pageInfo: IPageInfoForEmpty = {
+        emptyPageId: 'empty-page-id',
+        isNotFound: false,
+        isEmpty: true,
+        isV5Compatible: true,
+        isMovable: true, // Allow rename operation
+        isDeletable: true,
+        isAbleToDeleteCompletely: false,
+        isRevertible: false,
+        bookmarkCount: 0,
+        isBookmarked: false,
+      };
+
+      const onClickRenameMenuItemMock = vi.fn();
+
+      // return false for isIPageInfoForOperation since this is an empty page
+      mocks.isIPageInfoForOperationMock.mockReturnValue(false);
+
+      // return true when the argument is pageInfo (empty page)
+      mocks.isIPageInfoForEmptyMock.mockImplementation((arg) => {
+        if (arg === pageInfo) {
+          return true;
+        }
+        return false;
+      });
+
+      const props = {
+        pageId: 'dummy-page-id',
+        isEnableActions: true,
+        pageInfo,
+        onClickRenameMenuItem: onClickRenameMenuItemMock,
+      };
+
+      render(<PageItemControl {...props} />);
+
+      // when
+      const button = within(screen.getByTestId('open-page-item-control-btn')).getByText(/more_vert/);
+      fireEvent.click(button);
+      const renameMenuItem = await screen.findByTestId('rename-page-btn');
+      fireEvent.click(renameMenuItem);
+
+      // then
+      expect(onClickRenameMenuItemMock).toHaveBeenCalled();
+      expect(onClickRenameMenuItemMock).toHaveBeenCalledWith('dummy-page-id', pageInfo);
+    });
   });
 });

+ 19 - 14
apps/app/src/client/components/Common/Dropdown/PageItemControl.tsx

@@ -3,7 +3,7 @@ import React, {
 } from 'react';
 
 import {
-  type IPageInfoExt, isIPageInfoForOperation,
+  type IPageInfoExt, isIPageInfoForOperation, isIPageInfoForEmpty,
 } from '@growi/core/dist/interfaces';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
@@ -76,21 +76,24 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
 
   // eslint-disable-next-line react-hooks/rules-of-hooks
   const bookmarkItemClickedHandler = useCallback(async() => {
-    if (!isIPageInfoForOperation(pageInfo) || onClickBookmarkMenuItem == null) {
+    if (onClickBookmarkMenuItem == null) return;
+
+    if (!isIPageInfoForEmpty(pageInfo) && !isIPageInfoForOperation(pageInfo)) {
       return;
     }
+
     await onClickBookmarkMenuItem(pageId, !pageInfo.isBookmarked);
   }, [onClickBookmarkMenuItem, pageId, pageInfo]);
 
   // eslint-disable-next-line react-hooks/rules-of-hooks
   const renameItemClickedHandler = useCallback(async() => {
-    if (onClickRenameMenuItem == null) {
-      return;
-    }
-    if (!isIPageInfoForOperation(pageInfo) || !pageInfo?.isMovable) {
+    if (onClickRenameMenuItem == null) return;
+
+    if (!(isIPageInfoForEmpty(pageInfo) || isIPageInfoForOperation(pageInfo)) || !pageInfo?.isMovable) {
       logger.warn('This page could not be renamed.');
       return;
     }
+
     await onClickRenameMenuItem(pageId, pageInfo);
   }, [onClickRenameMenuItem, pageId, pageInfo]);
 
@@ -111,10 +114,9 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
 
   // eslint-disable-next-line react-hooks/rules-of-hooks
   const deleteItemClickedHandler = useCallback(async() => {
-    if (pageInfo == null || onClickDeleteMenuItem == null) {
-      return;
-    }
-    if (!isIPageInfoForOperation(pageInfo) || !pageInfo?.isDeletable) {
+    if (onClickDeleteMenuItem == null) return;
+
+    if (!(isIPageInfoForEmpty(pageInfo) || isIPageInfoForOperation(pageInfo)) || !pageInfo?.isDeletable) {
       logger.warn('This page could not be deleted.');
       return;
     }
@@ -173,7 +175,7 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
         ) }
 
         {/* Bookmark */}
-        { !forceHideMenuItems?.includes(MenuItemType.BOOKMARK) && isEnableActions && isIPageInfoForOperation(pageInfo) && (
+        { !forceHideMenuItems?.includes(MenuItemType.BOOKMARK) && isEnableActions && (isIPageInfoForEmpty(pageInfo) || isIPageInfoForOperation(pageInfo)) && (
           <DropdownItem
             onClick={bookmarkItemClickedHandler}
             className="grw-page-control-dropdown-item"
@@ -186,7 +188,8 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
 
         {/* Move/Rename */}
         { !forceHideMenuItems?.includes(MenuItemType.RENAME) && isEnableActions && !isReadOnlyUser
-          && isIPageInfoForOperation(pageInfo) && pageInfo.isMovable && (
+          && (isIPageInfoForEmpty(pageInfo) || isIPageInfoForOperation(pageInfo))
+          && pageInfo.isMovable && (
           <DropdownItem
             onClick={renameItemClickedHandler}
             data-testid="rename-page-btn"
@@ -211,7 +214,8 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
 
         {/* Revert */}
         { !forceHideMenuItems?.includes(MenuItemType.REVERT) && isEnableActions && !isReadOnlyUser
-          && isIPageInfoForOperation(pageInfo) && pageInfo.isRevertible && (
+          && (isIPageInfoForEmpty(pageInfo) || isIPageInfoForOperation(pageInfo))
+          && pageInfo.isRevertible && (
           <DropdownItem
             onClick={revertItemClickedHandler}
             className="grw-page-control-dropdown-item"
@@ -242,7 +246,8 @@ const PageItemControlDropdownMenu = React.memo((props: DropdownMenuProps): JSX.E
         {/* divider */}
         {/* Delete */}
         { !forceHideMenuItems?.includes(MenuItemType.DELETE) && isEnableActions && !isReadOnlyUser
-          && isIPageInfoForOperation(pageInfo) && pageInfo.isDeletable && (
+          && (isIPageInfoForEmpty(pageInfo) || isIPageInfoForOperation(pageInfo))
+          && pageInfo.isDeletable && (
           <>
             { showDeviderBeforeDelete && <DropdownItem divider /> }
             <DropdownItem

+ 0 - 40
apps/app/src/client/components/Hotkeys/Subscribers/EditPage.jsx

@@ -1,40 +0,0 @@
-import { useEffect } from 'react';
-
-import PropTypes from 'prop-types';
-
-import { useIsEditable } from '~/states/page';
-import { EditorMode, useEditorMode } from '~/states/ui/editor';
-
-const EditPage = (props) => {
-  const isEditable = useIsEditable();
-  const { setEditorMode } = useEditorMode();
-
-  // setup effect
-  useEffect(() => {
-    if (!isEditable) {
-      return;
-    }
-
-    // ignore when dom that has 'modal in' classes exists
-    if (document.getElementsByClassName('modal in').length > 0) {
-      return;
-    }
-
-    setEditorMode(EditorMode.Editor);
-
-    // remove this
-    props.onDeleteRender(this);
-  }, [isEditable, props, setEditorMode]);
-
-  return null;
-};
-
-EditPage.propTypes = {
-  onDeleteRender: PropTypes.func.isRequired,
-};
-
-EditPage.getHotkeyStrokes = () => {
-  return [['e']];
-};
-
-export default EditPage;

+ 74 - 0
apps/app/src/client/components/Hotkeys/Subscribers/EditPage.tsx

@@ -0,0 +1,74 @@
+import { useCallback, useEffect, useRef } from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+import { useStartEditing } from '~/client/services/use-start-editing';
+import { toastError } from '~/client/util/toastr';
+import { useCurrentPathname } from '~/states/global';
+import { useIsEditable, useCurrentPagePath } from '~/states/page';
+
+type Props = {
+  onDeleteRender: () => void,
+}
+
+/**
+ * Custom hook for edit page logic
+ */
+const useEditPage = (
+    onCompleted: () => void,
+    onError?: (path: string) => void,
+): void => {
+  const isEditable = useIsEditable();
+  const startEditing = useStartEditing();
+  const currentPagePath = useCurrentPagePath();
+  const currentPathname = useCurrentPathname();
+  const path = currentPagePath ?? currentPathname;
+  const isExecutedRef = useRef(false);
+
+  useEffect(() => {
+    (async() => {
+      // Prevent multiple executions
+      if (isExecutedRef.current) return;
+      isExecutedRef.current = true;
+
+      if (!isEditable) {
+        return;
+      }
+
+      // ignore when dom that has 'modal in' classes exists
+      if (document.getElementsByClassName('modal in').length > 0) {
+        return;
+      }
+
+      try {
+        await startEditing(path);
+      }
+      catch (err) {
+        onError?.(path);
+      }
+
+      onCompleted();
+    })();
+  }, [startEditing, isEditable, path, onCompleted, onError]);
+};
+
+/**
+ * EditPage component - handles hotkey 'e' for editing
+ */
+const EditPage = (props: Props): null => {
+  const { t } = useTranslation('commons');
+
+  const handleError = useCallback((path: string) => {
+    toastError(t('toaster.create_failed', { target: path }));
+  }, [t]);
+
+  useEditPage(props.onDeleteRender, handleError);
+
+  return null;
+};
+
+EditPage.getHotkeyStrokes = () => {
+  return [['e']];
+};
+
+export default EditPage;

+ 16 - 19
apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -270,7 +270,7 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   const revisionId = (revision != null && isPopulated(revision)) ? revision._id : undefined;
 
   const { editorMode } = useEditorMode();
-  const pageId = useCurrentPageId();
+  const pageId = useCurrentPageId(true);
   const currentUser = useCurrentUser();
   const isGuestUser = useIsGuestUser();
   const isReadOnlyUser = useIsReadOnlyUser();
@@ -291,7 +291,6 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
 
   const [isStickyActive, setStickyActive] = useState(false);
 
-
   const path = currentPage?.path ?? currentPathname;
   // const grant = currentPage?.grant ?? grantData?.grant;
   // const grantUserGroupId = currentPage?.grantedGroup?._id ?? grantData?.grantedGroup?.id;
@@ -405,23 +404,21 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
             id="grw-contextual-sub-nav"
           >
 
-            {pageId != null && (
-              <PageControls
-                pageId={pageId}
-                revisionId={revisionId}
-                shareLinkId={shareLinkId}
-                path={path ?? currentPathname} // If the page is empty, "path" is undefined
-                expandContentWidth={shouldExpandContent}
-                disableSeenUserInfoPopover={isSharedUser}
-                hideSubControls={hideSubControls}
-                showPageControlDropdown={isAbleToShowPageManagement}
-                additionalMenuItemRenderer={additionalMenuItemsRenderer}
-                onClickDuplicateMenuItem={duplicateItemClickedHandler}
-                onClickRenameMenuItem={renameItemClickedHandler}
-                onClickDeleteMenuItem={deleteItemClickedHandler}
-                onClickSwitchContentWidth={switchContentWidthHandler}
-              />
-            )}
+            <PageControls
+              pageId={pageId}
+              revisionId={revisionId}
+              shareLinkId={shareLinkId}
+              path={path ?? currentPathname} // If the page is empty, "path" is undefined
+              expandContentWidth={shouldExpandContent}
+              disableSeenUserInfoPopover={isSharedUser}
+              hideSubControls={hideSubControls}
+              showPageControlDropdown={isAbleToShowPageManagement}
+              additionalMenuItemRenderer={additionalMenuItemsRenderer}
+              onClickDuplicateMenuItem={duplicateItemClickedHandler}
+              onClickRenameMenuItem={renameItemClickedHandler}
+              onClickDeleteMenuItem={deleteItemClickedHandler}
+              onClickSwitchContentWidth={switchContentWidthHandler}
+            />
 
             {isAbleToChangeEditorMode && (
               <PageEditorModeManager

+ 5 - 23
apps/app/src/client/components/Navbar/PageEditorModeManager.tsx

@@ -2,20 +2,15 @@ import React, {
   type ReactNode, useCallback, useMemo, type JSX,
 } from 'react';
 
-import { Origin } from '@growi/core';
-import { getParentPath } from '@growi/core/dist/utils/path-utils';
 import { useTranslation } from 'next-i18next';
 
 import { useCreatePage } from '~/client/services/create-page';
+import { useStartEditing } from '~/client/services/use-start-editing';
 import { toastError } from '~/client/util/toastr';
 import { useCurrentPageYjsData } from '~/features/collaborative-editor/states';
-import { usePageNotFound } from '~/states/page';
 import { useDeviceLargerThanMd } from '~/states/ui/device';
 import { useEditorMode, EditorMode } from '~/states/ui/editor';
 
-import { shouldCreateWipPage } from '../../../utils/should-create-wip-page';
-
-
 import styles from './PageEditorModeManager.module.scss';
 
 
@@ -66,34 +61,21 @@ export const PageEditorModeManager = (props: Props): JSX.Element => {
 
   const { t } = useTranslation('commons');
 
-  const isNotFound = usePageNotFound();
   const { setEditorMode } = useEditorMode();
   const [isDeviceLargerThanMd] = useDeviceLargerThanMd();
   const currentPageYjsData = useCurrentPageYjsData();
+  const startEditing = useStartEditing();
 
-  const { isCreating, create } = useCreatePage();
+  const { isCreating } = useCreatePage();
 
   const editButtonClickedHandler = useCallback(async () => {
-    if (!isNotFound) {
-      setEditorMode(EditorMode.Editor);
-      return;
-    }
-
-    // Create a new page if it does not exist and transit to the editor mode
     try {
-      const parentPath = path != null ? getParentPath(path) : undefined; // does not have to exist
-      await create(
-        {
-          path, parentPath, wip: shouldCreateWipPage(path), origin: Origin.View,
-        },
-      );
-
-      setEditorMode(EditorMode.Editor);
+      await startEditing(path);
     }
     catch (err) {
       toastError(t('toaster.create_failed', { target: path }));
     }
-  }, [create, isNotFound, setEditorMode, path, t]);
+  }, [startEditing, path, t]);
 
   const _isBtnDisabled = isCreating || isBtnDisabled;
 

+ 36 - 40
apps/app/src/client/components/PageControls/PageControls.tsx

@@ -3,9 +3,11 @@ import React, {
 } from 'react';
 
 import type {
-  IPageInfoForOperation, IPageToDeleteWithMeta, IPageToRenameWithMeta,
+  IPageInfo, IPageToDeleteWithMeta, IPageToRenameWithMeta,
 } from '@growi/core';
 import {
+  isIPageInfoForEmpty,
+
   isIPageInfoForEntity, isIPageInfoForOperation,
 } from '@growi/core';
 import { pagePathUtils } from '@growi/core/dist/utils';
@@ -104,7 +106,7 @@ const WideViewMenuItem = (props: WideViewMenuItemProps): JSX.Element => {
 
 
 type CommonProps = {
-  pageId: string,
+  pageId?: string,
   shareLinkId?: string | null,
   revisionId?: string | null,
   path?: string | null,
@@ -121,7 +123,7 @@ type CommonProps = {
 }
 
 type PageControlsSubstanceProps = CommonProps & {
-  pageInfo: IPageInfoForOperation,
+  pageInfo: IPageInfo | undefined,
   onClickEditTagsButton: () => void,
 }
 
@@ -167,10 +169,12 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
   const seenUsers = usersList != null ? usersList.filter(({ _id }) => seenUserIds.includes(_id)).slice(0, 15) : [];
 
   const subscribeClickhandler = useCallback(async () => {
-    if (isGuestUser ?? true) {
+    if (isGuestUser) {
+      logger.warn('Guest users cannot subscribe to pages');
       return;
     }
-    if (!isIPageInfoForOperation(pageInfo)) {
+    if (!isIPageInfoForOperation(pageInfo) || pageId == null) {
+      logger.warn('PageInfo is not for operation or pageId is null');
       return;
     }
 
@@ -179,10 +183,12 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
   }, [isGuestUser, mutatePageInfo, pageId, pageInfo]);
 
   const likeClickhandler = useCallback(async () => {
-    if (isGuestUser ?? true) {
+    if (isGuestUser) {
+      logger.warn('Guest users cannot like pages');
       return;
     }
-    if (!isIPageInfoForOperation(pageInfo)) {
+    if (!isIPageInfoForOperation(pageInfo) || pageId == null) {
+      logger.warn('PageInfo is not for operation or pageId is null');
       return;
     }
 
@@ -191,7 +197,8 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
   }, [isGuestUser, mutatePageInfo, pageId, pageInfo]);
 
   const duplicateMenuItemClickHandler = useCallback(async (): Promise<void> => {
-    if (onClickDuplicateMenuItem == null || path == null) {
+    if (onClickDuplicateMenuItem == null || pageId == null || path == null) {
+      logger.warn('Cannot duplicate the page because onClickDuplicateMenuItem, pageId or path is null');
       return;
     }
     const page: IPageForPageDuplicateModal = { pageId, path };
@@ -200,7 +207,8 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
   }, [onClickDuplicateMenuItem, pageId, path]);
 
   const renameMenuItemClickHandler = useCallback(async (): Promise<void> => {
-    if (onClickRenameMenuItem == null || path == null) {
+    if (onClickRenameMenuItem == null || pageId == null || path == null) {
+      logger.warn('Cannot rename the page because onClickRenameMenuItem, pageId or path is null');
       return;
     }
 
@@ -217,7 +225,8 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
   }, [onClickRenameMenuItem, pageId, pageInfo, path, revisionId]);
 
   const deleteMenuItemClickHandler = useCallback(async (): Promise<void> => {
-    if (onClickDeleteMenuItem == null || path == null) {
+    if (onClickDeleteMenuItem == null || pageId == null || path == null) {
+      logger.warn('Cannot delete the page because onClickDeleteMenuItem, pageId or path is null');
       return;
     }
 
@@ -234,22 +243,22 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
   }, [onClickDeleteMenuItem, pageId, pageInfo, path, revisionId]);
 
   const switchContentWidthClickHandler = useCallback(() => {
-    if (onClickSwitchContentWidth == null) {
+    if (isGuestUser || isReadOnlyUser) {
+      logger.warn('Guest or read-only users cannot switch content width');
       return;
     }
 
-    const newValue = !expandContentWidth;
-    if ((isGuestUser ?? true) || (isReadOnlyUser ?? true)) {
-      logger.warn('Could not switch content width', {
-        isGuestUser,
-        isReadOnlyUser,
-      });
+    if (onClickSwitchContentWidth == null || pageId == null) {
+      logger.warn('Cannot switch content width because onClickSwitchContentWidth or pageId is null');
       return;
     }
     if (!isIPageInfoForEntity(pageInfo)) {
+      logger.warn('PageInfo is not for entity');
       return;
     }
+
     try {
+      const newValue = !expandContentWidth;
       onClickSwitchContentWidth(pageId, newValue);
     }
     catch (err) {
@@ -287,21 +296,12 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
     return wideviewMenuItemRenderer;
   }, [pageInfo, expandContentWidth, onClickSwitchContentWidth, switchContentWidthClickHandler]);
 
-  if (!isIPageInfoForEntity(pageInfo)) {
-    return <></>;
-  }
-
-  const {
-    sumOfLikers, sumOfSeenUsers, isLiked,
-  } = pageInfo;
-
   const forceHideMenuItemsWithAdditions = [
     ...(forceHideMenuItems ?? []),
     MenuItemType.BOOKMARK,
     MenuItemType.REVERT,
   ];
 
-  const _isIPageInfoForOperation = isIPageInfoForOperation(pageInfo);
   const isViewMode = editorMode === EditorMode.View;
 
   return (
@@ -313,7 +313,7 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
         </>
       )}
 
-      {revisionId != null && !isViewMode && _isIPageInfoForOperation && (
+      {revisionId != null && !isViewMode && (
         <Tags
           onClickEditTagsButton={onClickEditTagsButton}
         />
@@ -321,38 +321,38 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
 
       {!hideSubControls && (
         <div className={`hstack gap-1 ${!isViewMode && 'd-none d-lg-flex'}`}>
-          {revisionId != null && _isIPageInfoForOperation && (
+          {isIPageInfoForOperation(pageInfo) && (
             <SubscribeButton
               status={pageInfo.subscriptionStatus}
               onClick={subscribeClickhandler}
             />
           )}
-          {revisionId != null && _isIPageInfoForOperation && (
+          {isIPageInfoForOperation(pageInfo) && (
             <LikeButtons
               onLikeClicked={likeClickhandler}
-              sumOfLikers={sumOfLikers}
-              isLiked={isLiked}
+              sumOfLikers={pageInfo.sumOfLikers}
+              isLiked={pageInfo.isLiked}
               likers={likers}
             />
           )}
-          {revisionId != null && _isIPageInfoForOperation && (
+          {(isIPageInfoForOperation(pageInfo) || isIPageInfoForEmpty(pageInfo)) && pageId != null && (
             <BookmarkButtons
               pageId={pageId}
               isBookmarked={pageInfo.isBookmarked}
               bookmarkCount={pageInfo.bookmarkCount}
             />
           )}
-          {revisionId != null && !isSearchPage && (
+          {isIPageInfoForEntity(pageInfo) && !isSearchPage && (
             <SeenUserInfo
               seenUsers={seenUsers}
-              sumOfSeenUsers={sumOfSeenUsers}
+              sumOfSeenUsers={pageInfo.sumOfSeenUsers}
               disabled={disableSeenUserInfoPopover}
             />
           )}
         </div>
       )}
 
-      {showPageControlDropdown && _isIPageInfoForOperation && (
+      {showPageControlDropdown && (
         <PageItemControl
           pageId={pageId}
           pageInfo={pageInfo}
@@ -383,7 +383,7 @@ export const PageControls = memo((props: PageControlsProps): JSX.Element => {
   const { open: openTagEditModal } = useTagEditModalActions();
 
   const onClickEditTagsButton = useCallback(() => {
-    if (tagsInfoData == null || revisionId == null) {
+    if (tagsInfoData == null || pageId == null || revisionId == null) {
       return;
     }
     openTagEditModal(tagsInfoData.tags, pageId, revisionId);
@@ -393,10 +393,6 @@ export const PageControls = memo((props: PageControlsProps): JSX.Element => {
     return <></>;
   }
 
-  if (!isIPageInfoForEntity(pageInfo)) {
-    return <></>;
-  }
-
   return (
     <PageControlsSubstance
       pageInfo={pageInfo}

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

@@ -9,11 +9,11 @@ import { DropdownToggle } from 'reactstrap';
 import { NotAvailableForGuest } from '~/client/components/NotAvailableForGuest';
 import { bookmark, unbookmark, resumeRenameOperation } from '~/client/services/page-operation';
 import { toastError, toastSuccess } from '~/client/util/toastr';
+import type { TreeItemToolProps } from '~/features/page-tree/interfaces';
 import { useSWRMUTxCurrentUserBookmarks } from '~/stores/bookmark';
 import { useSWRMUTxPageInfo } from '~/stores/page';
 
 import { PageItemControl } from '../../Common/Dropdown/PageItemControl';
-import type { TreeItemToolProps } from '~/features/page-tree/interfaces';
 
 
 type UsePageItemControl = {

+ 38 - 0
apps/app/src/client/services/use-start-editing.tsx

@@ -0,0 +1,38 @@
+import { useCallback } from 'react';
+
+import { Origin } from '@growi/core';
+import { getParentPath } from '@growi/core/dist/utils/path-utils';
+
+import { useCreatePage } from '~/client/services/create-page';
+import { usePageNotFound } from '~/states/page';
+import { useEditorMode, EditorMode } from '~/states/ui/editor';
+
+import { shouldCreateWipPage } from '../../utils/should-create-wip-page';
+
+export const useStartEditing = (): ((path?: string) => Promise<void>) => {
+  const isNotFound = usePageNotFound();
+  const { setEditorMode } = useEditorMode();
+  const { create } = useCreatePage();
+
+  return useCallback(async (path?: string) => {
+    if (!isNotFound) {
+      setEditorMode(EditorMode.Editor);
+      return;
+    }
+    // Create a new page if it does not exist and transit to the editor mode
+    try {
+      const parentPath = path != null ? getParentPath(path) : undefined; // does not have to exist
+      await create(
+        {
+          path, parentPath, wip: shouldCreateWipPage(path), origin: Origin.View,
+        },
+      );
+
+      setEditorMode(EditorMode.Editor);
+    }
+    catch (err) {
+      throw new Error(err);
+    }
+  }, [create, isNotFound, setEditorMode]);
+
+};

+ 1 - 1
apps/app/src/features/collaborative-editor/side-effects/index.ts

@@ -15,7 +15,7 @@ export const useCurrentPageYjsDataAutoLoadEffect = (): void => {
   const pageId = useCurrentPageId();
   const currentPage = useCurrentPageData();
   const isGuestUser = useIsGuestUser();
-  const isNotFound = usePageNotFound();
+  const isNotFound = usePageNotFound(false);
 
   // Optimized effects with minimal dependencies
   useEffect(() => {

+ 8 - 2
apps/app/src/interfaces/bookmark-info.ts

@@ -1,9 +1,15 @@
-import type { IPageHasId, IUser, Ref } from '@growi/core';
+import type { IPageHasId, IUser, Ref } from '@growi/core/dist/interfaces';
+import type { IUserSerializedSecurely } from '@growi/core/dist/models/serializers';
+
+export interface IBookmark {
+  page: Ref<IPageHasId>;
+  user: Ref<IUser>;
+}
 
 export interface IBookmarkInfo {
   sumOfBookmarks: number;
   isBookmarked: boolean;
-  bookmarkedUsers: IUser[];
+  bookmarkedUsers: IUserSerializedSecurely<IUser>[];
   pageId: string;
 }
 

+ 0 - 122
apps/app/src/server/models/bookmark.js

@@ -1,122 +0,0 @@
-/* eslint-disable no-return-await */
-
-import mongoose from 'mongoose';
-import mongoosePaginate from 'mongoose-paginate-v2';
-import uniqueValidator from 'mongoose-unique-validator';
-
-import loggerFactory from '~/utils/logger';
-
-const logger = loggerFactory('growi:models:bookmark');
-
-const ObjectId = mongoose.Schema.Types.ObjectId;
-
-/** @param {import('~/server/crowi').default} crowi Crowi instance */
-const factory = (crowi) => {
-  const bookmarkEvent = crowi.event('bookmark');
-
-  let bookmarkSchema = null;
-
-  bookmarkSchema = new mongoose.Schema(
-    {
-      page: { type: ObjectId, ref: 'Page', index: true },
-      user: { type: ObjectId, ref: 'User', index: true },
-    },
-    {
-      timestamps: { createdAt: true, updatedAt: false },
-    },
-  );
-  bookmarkSchema.index({ page: 1, user: 1 }, { unique: true });
-  bookmarkSchema.plugin(mongoosePaginate);
-  bookmarkSchema.plugin(uniqueValidator);
-
-  bookmarkSchema.statics.countByPageId = async function (pageId) {
-    return await this.count({ page: pageId });
-  };
-
-  /**
-   * @return {object} key: page._id, value: bookmark count
-   */
-  bookmarkSchema.statics.getPageIdToCountMap = async function (pageIds) {
-    const results = await this.aggregate()
-      .match({ page: { $in: pageIds } })
-      .group({ _id: '$page', count: { $sum: 1 } });
-
-    // convert to map
-    const idToCountMap = {};
-    results.forEach((result) => {
-      idToCountMap[result._id] = result.count;
-    });
-
-    return idToCountMap;
-  };
-
-  // bookmark チェック用
-  bookmarkSchema.statics.findByPageIdAndUserId = function (pageId, userId) {
-    return new Promise((resolve, reject) => {
-      return this.findOne({ page: pageId, user: userId }, (err, doc) => {
-        if (err) {
-          return reject(err);
-        }
-
-        return resolve(doc);
-      });
-    });
-  };
-
-  bookmarkSchema.statics.add = async function (page, user) {
-    // biome-ignore lint/complexity/noUselessThisAlias: ignore
-    const Bookmark = this;
-
-    const newBookmark = new Bookmark({ page, user });
-
-    try {
-      const bookmark = await newBookmark.save();
-      bookmarkEvent.emit('create', page._id);
-      return bookmark;
-    } catch (err) {
-      if (err.code === 11000) {
-        // duplicate key (dummy response of new object)
-        return newBookmark;
-      }
-      logger.debug('Bookmark.save failed', err);
-      throw err;
-    }
-  };
-
-  /**
-   * Remove bookmark
-   * used only when removing the page
-   * @param {string} pageId
-   */
-  bookmarkSchema.statics.removeBookmarksByPageId = async function (pageId) {
-    // biome-ignore lint/complexity/noUselessThisAlias: ignore
-    const Bookmark = this;
-
-    try {
-      const data = await Bookmark.remove({ page: pageId });
-      bookmarkEvent.emit('delete', pageId);
-      return data;
-    } catch (err) {
-      logger.debug('Bookmark.remove failed (removeBookmarkByPage)', err);
-      throw err;
-    }
-  };
-
-  bookmarkSchema.statics.removeBookmark = async function (pageId, user) {
-    // biome-ignore lint/complexity/noUselessThisAlias: ignore
-    const Bookmark = this;
-
-    try {
-      const data = await Bookmark.findOneAndRemove({ page: pageId, user });
-      bookmarkEvent.emit('delete', pageId);
-      return data;
-    } catch (err) {
-      logger.debug('Bookmark.findOneAndRemove failed', err);
-      throw err;
-    }
-  };
-
-  return mongoose.model('Bookmark', bookmarkSchema);
-};
-
-export default factory;

+ 151 - 0
apps/app/src/server/models/bookmark.ts

@@ -0,0 +1,151 @@
+import type { Document, Model, Types } from 'mongoose';
+import { Schema } from 'mongoose';
+import mongoosePaginate from 'mongoose-paginate-v2';
+import uniqueValidator from 'mongoose-unique-validator';
+
+import type { IBookmark } from '~/interfaces/bookmark-info';
+import loggerFactory from '~/utils/logger';
+
+import type Crowi from '../crowi';
+import { getOrCreateModel } from '../util/mongoose-utils';
+
+const logger = loggerFactory('growi:models:bookmark');
+
+export interface BookmarkDocument extends IBookmark, Document {
+  _id: Types.ObjectId;
+  page: Types.ObjectId;
+  user: Types.ObjectId;
+  createdAt: Date;
+}
+
+export interface BookmarkModel extends Model<BookmarkDocument> {
+  countByPageId(pageId: Types.ObjectId | string): Promise<number>;
+  getPageIdToCountMap(
+    pageIds: Types.ObjectId[],
+  ): Promise<{ [key: string]: number }>;
+  findByPageIdAndUserId(
+    pageId: Types.ObjectId | string,
+    userId: Types.ObjectId | string,
+  ): Promise<BookmarkDocument | null>;
+  add(
+    page: Types.ObjectId | string,
+    user: Types.ObjectId | string,
+  ): Promise<BookmarkDocument>;
+  removeBookmarksByPageId(
+    pageId: Types.ObjectId | string,
+  ): Promise<{ deletedCount: number }>;
+  removeBookmark(
+    pageId: Types.ObjectId | string,
+    user: Types.ObjectId | string,
+  ): Promise<BookmarkDocument | null>;
+}
+
+const factory = (crowi: Crowi) => {
+  const bookmarkEvent = crowi.event('bookmark');
+
+  const bookmarkSchema = new Schema<BookmarkDocument, BookmarkModel>(
+    {
+      page: { type: Schema.Types.ObjectId, ref: 'Page', index: true },
+      user: { type: Schema.Types.ObjectId, ref: 'User', index: true },
+    },
+    {
+      timestamps: { createdAt: true, updatedAt: false },
+    },
+  );
+
+  bookmarkSchema.index({ page: 1, user: 1 }, { unique: true });
+  bookmarkSchema.plugin(mongoosePaginate);
+  bookmarkSchema.plugin(uniqueValidator);
+
+  bookmarkSchema.statics.countByPageId = async function (
+    pageId: Types.ObjectId | string,
+  ): Promise<number> {
+    return await this.countDocuments({ page: pageId });
+  };
+
+  /**
+   * @return {object} key: page._id, value: bookmark count
+   */
+  bookmarkSchema.statics.getPageIdToCountMap = async function (
+    pageIds: Types.ObjectId[],
+  ): Promise<{ [key: string]: number }> {
+    const results = await this.aggregate()
+      .match({ page: { $in: pageIds } })
+      .group({ _id: '$page', count: { $sum: 1 } });
+
+    // convert to map
+    const idToCountMap: { [key: string]: number } = {};
+    results.forEach((result) => {
+      idToCountMap[result._id] = result.count;
+    });
+
+    return idToCountMap;
+  };
+
+  // bookmark チェック用
+  bookmarkSchema.statics.findByPageIdAndUserId = async function (
+    pageId: Types.ObjectId | string,
+    userId: Types.ObjectId | string,
+  ): Promise<BookmarkDocument | null> {
+    return await this.findOne({ page: pageId, user: userId });
+  };
+
+  bookmarkSchema.statics.add = async function (
+    page: Types.ObjectId | string,
+    user: Types.ObjectId | string,
+  ): Promise<BookmarkDocument> {
+    const newBookmark = new this({ page, user });
+
+    try {
+      const bookmark = await newBookmark.save();
+      bookmarkEvent.emit('create', page);
+      return bookmark;
+    } catch (err: any) {
+      if (err.code === 11000) {
+        // duplicate key (dummy response of new object)
+        return newBookmark;
+      }
+      logger.debug('Bookmark.save failed', err);
+      throw err;
+    }
+  };
+
+  /**
+   * Remove bookmark
+   * used only when removing the page
+   * @param {string} pageId
+   */
+  bookmarkSchema.statics.removeBookmarksByPageId = async function (
+    pageId: Types.ObjectId | string,
+  ): Promise<{ deletedCount: number }> {
+    try {
+      const result = await this.deleteMany({ page: pageId });
+      bookmarkEvent.emit('delete', pageId);
+      return { deletedCount: result.deletedCount ?? 0 };
+    } catch (err) {
+      logger.debug('Bookmark.remove failed (removeBookmarkByPage)', err);
+      throw err;
+    }
+  };
+
+  bookmarkSchema.statics.removeBookmark = async function (
+    pageId: Types.ObjectId | string,
+    user: Types.ObjectId | string,
+  ): Promise<BookmarkDocument | null> {
+    try {
+      const data = await this.findOneAndDelete({ page: pageId, user });
+      bookmarkEvent.emit('delete', pageId);
+      return data;
+    } catch (err) {
+      logger.debug('Bookmark.findOneAndRemove failed', err);
+      throw err;
+    }
+  };
+
+  return getOrCreateModel<BookmarkDocument, BookmarkModel>(
+    'Bookmark',
+    bookmarkSchema,
+  );
+};
+
+export default factory;

+ 52 - 21
apps/app/src/server/routes/apiv3/bookmarks.js → apps/app/src/server/routes/apiv3/bookmarks.ts

@@ -1,9 +1,14 @@
+import type { IUserHasId } from '@growi/core';
 import { SCOPE } from '@growi/core/dist/interfaces';
 import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
+import mongoose, { type HydratedDocument } from 'mongoose';
 
 import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
+import type { IBookmarkInfo } from '~/interfaces/bookmark-info';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { generateAddActivityMiddleware } from '~/server/middlewares/add-activity';
+import type { BookmarkDocument, BookmarkModel } from '~/server/models/bookmark';
+import type { PageDocument, PageModel } from '~/server/models/page';
 import { serializeBookmarkSecurely } from '~/server/models/serializers/bookmark-serializer';
 import { preNotifyService } from '~/server/service/pre-notify';
 import loggerFactory from '~/utils/logger';
@@ -91,15 +96,16 @@ module.exports = (crowi) => {
     crowi,
     true,
   );
-  const addActivity = generateAddActivityMiddleware(crowi);
+  const addActivity = generateAddActivityMiddleware();
 
   const activityEvent = crowi.event('activity');
 
-  const { Page, Bookmark } = crowi.models;
-
   const validator = {
     bookmarks: [body('pageId').isString(), body('bool').isBoolean()],
     bookmarkInfo: [query('pageId').isMongoId()],
+    userBookmarkList: [
+      param('userId').isMongoId().withMessage('userId is required'),
+    ],
   };
 
   /**
@@ -134,18 +140,32 @@ module.exports = (crowi) => {
       const { user } = req;
       const { pageId } = req.query;
 
-      const responsesParams = {};
+      // Prevent NoSQL injection - ensure pageId is a string
+      if (typeof pageId !== 'string') {
+        return res.status(400).apiv3Err('Invalid pageId parameter', 400);
+      }
+
+      const responsesParams: IBookmarkInfo = {
+        sumOfBookmarks: 0,
+        isBookmarked: false,
+        bookmarkedUsers: [],
+        pageId: '',
+      };
+
+      const Bookmark: BookmarkModel = mongoose.model<
+        HydratedDocument<BookmarkDocument>,
+        BookmarkModel
+      >('Bookmark');
 
       try {
-        const bookmarks = await Bookmark.find({ page: pageId }).populate(
-          'user',
+        const bookmarks = await Bookmark.find({
+          page: { $eq: pageId },
+        }).populate<{
+          user: IUserHasId;
+        }>('user');
+        const users = bookmarks.map((bookmark) =>
+          serializeUserSecurely(bookmark.user),
         );
-        let users = [];
-        if (bookmarks.length > 0) {
-          users = bookmarks.map((bookmark) =>
-            serializeUserSecurely(bookmark.user),
-          );
-        }
         responsesParams.sumOfBookmarks = bookmarks.length;
         responsesParams.bookmarkedUsers = users;
         responsesParams.pageId = pageId;
@@ -194,10 +214,6 @@ module.exports = (crowi) => {
    *                schema:
    *                  $ref: '#/components/schemas/Bookmarks'
    */
-  validator.userBookmarkList = [
-    param('userId').isMongoId().withMessage('userId is required'),
-  ];
-
   router.get(
     '/:userId',
     accessTokenParser([SCOPE.READ.FEATURES.BOOKMARK], { acceptLegacy: true }),
@@ -210,6 +226,12 @@ module.exports = (crowi) => {
       if (userId == null) {
         return res.apiv3Err('User id is not found or forbidden', 400);
       }
+
+      const Bookmark: BookmarkModel = mongoose.model<
+        HydratedDocument<BookmarkDocument>,
+        BookmarkModel
+      >('Bookmark');
+
       try {
         const bookmarkIdsInFolders = await BookmarkFolder.distinct(
           'bookmarks',
@@ -281,10 +303,19 @@ module.exports = (crowi) => {
         return res.apiv3Err('A logged in user is required.');
       }
 
-      let page;
-      let bookmark;
+      const Page: PageModel = mongoose.model<
+        HydratedDocument<PageDocument>,
+        PageModel
+      >('Page');
+      const Bookmark: BookmarkModel = mongoose.model<
+        HydratedDocument<BookmarkDocument>,
+        BookmarkModel
+      >('Bookmark');
+
+      let page: HydratedDocument<PageDocument> | null;
+      let bookmark: HydratedDocument<BookmarkDocument> | null;
       try {
-        page = await Page.findByIdAndViewer(pageId, req.user);
+        page = await Page.findByIdAndViewer(pageId, req.user, undefined, true);
         if (page == null) {
           return res.apiv3Err(`Page '${pageId}' is not found or forbidden`);
         }
@@ -293,7 +324,7 @@ module.exports = (crowi) => {
 
         if (bookmark == null) {
           if (bool) {
-            bookmark = await Bookmark.add(page, req.user);
+            bookmark = await Bookmark.add(page._id, req.user);
           } else {
             logger.warn(
               `Removing the bookmark for ${page._id} by ${req.user._id} failed because the bookmark does not exist.`,
@@ -306,7 +337,7 @@ module.exports = (crowi) => {
               `Adding the bookmark for ${page._id} by ${req.user._id} failed because the bookmark has already exist.`,
             );
           } else {
-            bookmark = await Bookmark.removeBookmark(page, req.user);
+            bookmark = await Bookmark.removeBookmark(page._id, req.user);
           }
         }
       } catch (err) {

+ 24 - 15
apps/app/src/server/routes/apiv3/page-listing.ts

@@ -1,6 +1,6 @@
-import type { IPageInfo, IPageInfoForListing, IUserHasId } from '@growi/core';
+import type { IPageInfoForListing, IUserHasId } from '@growi/core';
 import { getIdForRef, isIPageInfoForEntity } from '@growi/core';
-import { SCOPE } from '@growi/core/dist/interfaces';
+import { type IPageInfoForEmpty, SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, Router } from 'express';
 import express from 'express';
@@ -275,8 +275,10 @@ const routerFactory = (crowi: Crowi): Router => {
           )) as Record<string, number>;
         }
 
-        const idToPageInfoMap: Record<string, IPageInfo | IPageInfoForListing> =
-          {};
+        const idToPageInfoMap: Record<
+          string,
+          IPageInfoForEmpty | IPageInfoForListing
+        > = {};
 
         const isGuestUser = req.user == null;
 
@@ -285,16 +287,14 @@ const routerFactory = (crowi: Crowi): Router => {
         );
 
         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(
+          const isDeletable = pageService.canDelete(
+            page,
+            page.creator == null ? null : getIdForRef(page.creator),
+            req.user,
+            false,
+          );
+          const isAbleToDeleteCompletely = pageService.canDeleteCompletely(
             page,
             page.creator == null ? null : getIdForRef(page.creator),
             req.user,
@@ -302,11 +302,20 @@ const routerFactory = (crowi: Crowi): Router => {
             userRelatedGroups,
           ); // use normal delete config
 
+          const basicPageInfo = {
+            ...pageService.constructBasicPageInfo(page, isGuestUser),
+            isDeletable,
+            isAbleToDeleteCompletely,
+            bookmarkCount:
+              bookmarkCountMap != null
+                ? (bookmarkCountMap[page._id.toString()] ?? 0)
+                : 0,
+          };
+
           const pageInfo = !isIPageInfoForEntity(basicPageInfo)
-            ? basicPageInfo
+            ? (basicPageInfo satisfies IPageInfoForEmpty)
             : ({
                 ...basicPageInfo,
-                isAbleToDeleteCompletely: canDeleteCompletely,
                 revisionShortBody:
                   shortBodiesMap != null
                     ? (shortBodiesMap[page._id.toString()] ?? undefined)

+ 11 - 18
apps/app/src/server/routes/apiv3/page/index.ts

@@ -1,3 +1,5 @@
+import type { Readable } from 'node:stream';
+import { pipeline } from 'node:stream/promises';
 import type {
   IDataWithMeta,
   IPage,
@@ -19,10 +21,8 @@ import { convertToNewAffiliationPath } from '@growi/core/dist/utils/page-path-ut
 import { normalizePath } from '@growi/core/dist/utils/path-utils';
 import type { HydratedDocument } from 'mongoose';
 import mongoose from 'mongoose';
-import path from 'path';
+import path from 'pathe';
 import sanitize from 'sanitize-filename';
-import type { Readable } from 'stream';
-import { pipeline } from 'stream/promises';
 
 import { SupportedAction, SupportedTargetModel } from '~/interfaces/activity';
 import type { IPageGrantData } from '~/interfaces/page';
@@ -592,7 +592,6 @@ module.exports = (crowi: Crowi) => {
 
         if (isIPageNotFoundInfo(meta)) {
           // Return error only when the page is forbidden
-          // Empty pages (isEmpty: true) should return page info for UI operations
           if (meta.isForbidden) {
             return res.apiv3Err(
               new ErrorV3(
@@ -604,9 +603,9 @@ module.exports = (crowi: Crowi) => {
               403,
             );
           }
-          // For not found but not forbidden pages (isEmpty: true), return the meta info
         }
 
+        // Empty pages (isEmpty: true) should return page info for UI operations
         return res.apiv3(meta);
       } catch (err) {
         logger.error('get-page-info', err);
@@ -640,7 +639,7 @@ module.exports = (crowi: Crowi) => {
    *                    isGrantNormalized:
    *                      type: boolean
    *          400:
-   *            description: Bad request. Page is unreachable or empty.
+   *            description: Bad request. Page is unreachable.
    *          500:
    *            description: Internal server error.
    */
@@ -656,15 +655,12 @@ module.exports = (crowi: Crowi) => {
       const Page = mongoose.model<IPage, PageModel>('Page');
       const pageGrantService = crowi.pageGrantService as IPageGrantService;
 
-      const page = await Page.findByIdAndViewer(pageId, req.user, null, false);
+      const page = await Page.findByIdAndViewer(pageId, req.user, null, true);
 
       if (page == null) {
         // Empty page should not be related to grant API
         return res.apiv3Err(
-          new ErrorV3(
-            'Page is unreachable or empty.',
-            'page_unreachable_or_empty',
-          ),
+          new ErrorV3('Page is unreachable', 'page_unreachable'),
           400,
         );
       }
@@ -708,7 +704,7 @@ module.exports = (crowi: Crowi) => {
         getIdForRef(page.parent),
         req.user,
         null,
-        false,
+        true,
       );
 
       // user isn't allowed to see parent's grant
@@ -866,7 +862,7 @@ module.exports = (crowi: Crowi) => {
    *                     items:
    *                       type: string
    *         400:
-   *           description: Bad request. Page is unreachable or empty.
+   *           description: Bad request. Page is unreachable.
    *         500:
    *           description: Internal server error.
    */
@@ -880,15 +876,12 @@ module.exports = (crowi: Crowi) => {
       const { pageId } = req.query;
 
       const Page = mongoose.model<IPage, PageModel>('Page');
-      const page = await Page.findByIdAndViewer(pageId, req.user, null);
+      const page = await Page.findByIdAndViewer(pageId, req.user, null, true);
 
       if (page == null) {
         // Empty page should not be related to grant API
         return res.apiv3Err(
-          new ErrorV3(
-            'Page is unreachable or empty.',
-            'page_unreachable_or_empty',
-          ),
+          new ErrorV3('Page is unreachable', 'page_unreachable'),
           400,
         );
       }

+ 42 - 21
apps/app/src/server/service/page/delete-completely-user-home-by-system.integ.ts

@@ -1,41 +1,45 @@
 import type EventEmitter from 'events';
-
 import mongoose from 'mongoose';
 import { vi } from 'vitest';
 import { mock } from 'vitest-mock-extended';
 
+import type { IPage } from '^/../../packages/core/dist';
+
 import { getPageSchema } from '~/server/models/obsolete-page';
 import { configManager } from '~/server/service/config-manager';
 
+import type { PageModel } from '../../models/page';
 import pageModel from '../../models/page';
-
 import { deleteCompletelyUserHomeBySystem } from './delete-completely-user-home-by-system';
 import type { IPageService } from './page-service';
 
 // TODO: use actual user model after ~/server/models/user.js becomes importable in vitest
 // ref: https://github.com/vitest-dev/vitest/issues/846
-const userSchema = new mongoose.Schema({
-  name: { type: String },
-  username: { type: String, required: true, unique: true },
-  email: { type: String, unique: true, sparse: true },
-}, {
-  timestamps: true,
-});
+const userSchema = new mongoose.Schema(
+  {
+    name: { type: String },
+    username: { type: String, required: true, unique: true },
+    email: { type: String, unique: true, sparse: true },
+  },
+  {
+    timestamps: true,
+  },
+);
 const User = mongoose.model('User', userSchema);
 
 describe('delete-completely-user-home-by-system test', () => {
-  let Page;
+  let Page: PageModel;
 
   const initialEnv = process.env;
 
   const userId1 = new mongoose.Types.ObjectId();
   const user1HomepageId = new mongoose.Types.ObjectId();
 
-  beforeAll(async() => {
+  beforeAll(async () => {
     // setup page model
     getPageSchema(null);
     pageModel(null);
-    Page = mongoose.model('Page');
+    Page = mongoose.model<IPage, PageModel>('Page');
 
     // setup config
     await configManager.loadConfigs();
@@ -45,7 +49,10 @@ describe('delete-completely-user-home-by-system test', () => {
 
     // setup user documents
     const user1 = await User.create({
-      _id: userId1, name: 'user1', username: 'user1', email: 'user1@example.com',
+      _id: userId1,
+      name: 'user1',
+      username: 'user1',
+      email: 'user1@example.com',
     });
 
     // setup page documents
@@ -85,10 +92,16 @@ describe('delete-completely-user-home-by-system test', () => {
 
   describe('deleteCompletelyUserHomeBySystem()', () => {
     // setup
-    const mockUpdateDescendantCountOfAncestors = vi.fn().mockImplementation(() => Promise.resolve());
-    const mockDeleteCompletelyOperation = vi.fn().mockImplementation(() => Promise.resolve());
+    const mockUpdateDescendantCountOfAncestors = vi
+      .fn()
+      .mockImplementation(() => Promise.resolve());
+    const mockDeleteCompletelyOperation = vi
+      .fn()
+      .mockImplementation(() => Promise.resolve());
     const mockPageEvent = mock<EventEmitter>();
-    const mockDeleteMultipleCompletely = vi.fn().mockImplementation(() => Promise.resolve());
+    const mockDeleteMultipleCompletely = vi
+      .fn()
+      .mockImplementation(() => Promise.resolve());
 
     const mockPageService = mock<IPageService>({
       updateDescendantCountOfAncestors: mockUpdateDescendantCountOfAncestors,
@@ -97,10 +110,13 @@ describe('delete-completely-user-home-by-system test', () => {
       deleteMultipleCompletely: mockDeleteMultipleCompletely,
     });
 
-    it('should call used page service functions', async() => {
+    it('should call used page service functions', async () => {
       // when
       const existsUserHomepagePath = '/user/user1';
-      await deleteCompletelyUserHomeBySystem(existsUserHomepagePath, mockPageService);
+      await deleteCompletelyUserHomeBySystem(
+        existsUserHomepagePath,
+        mockPageService,
+      );
 
       // then
       expect(mockUpdateDescendantCountOfAncestors).toHaveBeenCalled();
@@ -109,13 +125,18 @@ describe('delete-completely-user-home-by-system test', () => {
       expect(mockDeleteMultipleCompletely).toHaveBeenCalled();
     });
 
-    it('should throw error if userHomepage is not exists', async() => {
+    it('should throw error if userHomepage is not exists', async () => {
       // when
       const notExistsUserHomepagePath = '/user/not_exists_user';
-      const deleteUserHomepageFunction = deleteCompletelyUserHomeBySystem(notExistsUserHomepagePath, mockPageService);
+      const deleteUserHomepageFunction = deleteCompletelyUserHomeBySystem(
+        notExistsUserHomepagePath,
+        mockPageService,
+      );
 
       // then
-      expect(deleteUserHomepageFunction).rejects.toThrow('user homepage is not found.');
+      expect(deleteUserHomepageFunction).rejects.toThrow(
+        'user homepage is not found.',
+      );
     });
   });
 });

+ 36 - 25
apps/app/src/server/service/page/delete-completely-user-home-by-system.ts

@@ -1,11 +1,10 @@
-import { Writable } from 'stream';
-import { pipeline } from 'stream/promises';
-
-import { getIdForRef } from '@growi/core';
 import type { IPage, Ref } from '@growi/core';
+import { getIdForRef } from '@growi/core';
 import { isUsersHomepage } from '@growi/core/dist/utils/page-path-utils';
 import type { HydratedDocument } from 'mongoose';
 import mongoose from 'mongoose';
+import { Writable } from 'stream';
+import { pipeline } from 'stream/promises';
 
 import type { PageDocument, PageModel } from '~/server/models/page';
 import { createBatchStream } from '~/server/util/batch-stream';
@@ -17,29 +16,33 @@ import { shouldUseV4Process } from './should-use-v4-process';
 
 const logger = loggerFactory('growi:services:page');
 
-
-type IPageUnderV5 = Omit<IPage, 'parent'> & { parent: Ref<IPage> }
+type IPageUnderV5 = Omit<IPage, 'parent'> & { parent: Ref<IPage> };
 
 const _shouldUseV5Process = (page: IPage): page is IPageUnderV5 => {
   return !shouldUseV4Process(page);
 };
 
 /**
-   * @description This function is intended to be used exclusively for forcibly deleting the user homepage by the system.
-   * It should only be called from within the appropriate context and with caution as it performs a system-level operation.
-   *
-   * @param {string} userHomepagePath - The path of the user's homepage.
-   * @returns {Promise<void>} - A Promise that resolves when the deletion is complete.
-   * @throws {Error} - If an error occurs during the deletion process.
-   */
-export const deleteCompletelyUserHomeBySystem = async(userHomepagePath: string, pageService: IPageService): Promise<void> => {
+ * @description This function is intended to be used exclusively for forcibly deleting the user homepage by the system.
+ * It should only be called from within the appropriate context and with caution as it performs a system-level operation.
+ *
+ * @param {string} userHomepagePath - The path of the user's homepage.
+ * @returns {Promise<void>} - A Promise that resolves when the deletion is complete.
+ * @throws {Error} - If an error occurs during the deletion process.
+ */
+export const deleteCompletelyUserHomeBySystem = async (
+  userHomepagePath: string,
+  pageService: IPageService,
+): Promise<void> => {
   if (!isUsersHomepage(userHomepagePath)) {
     const msg = 'input value is not user homepage path.';
     logger.error(msg);
     throw new Error(msg);
   }
 
-  const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
+  const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>(
+    'Page',
+  );
   const userHomepage = await Page.findByPath(userHomepagePath, true);
 
   if (userHomepage == null) {
@@ -56,8 +59,14 @@ export const deleteCompletelyUserHomeBySystem = async(userHomepagePath: string,
   try {
     if (shouldUseV5Process) {
       // Ensure consistency of ancestors
-      const inc = userHomepage.isEmpty ? -userHomepage.descendantCount : -(userHomepage.descendantCount + 1);
-      await pageService.updateDescendantCountOfAncestors(getIdForRef(userHomepage.parent), inc, true);
+      const inc = userHomepage.isEmpty
+        ? -userHomepage.descendantCount
+        : -(userHomepage.descendantCount + 1);
+      await pageService.updateDescendantCountOfAncestors(
+        getIdForRef(userHomepage.parent),
+        inc,
+        true,
+      );
     }
 
     // Delete the user's homepage
@@ -65,7 +74,9 @@ export const deleteCompletelyUserHomeBySystem = async(userHomepagePath: string,
 
     if (shouldUseV5Process) {
       // Remove leaf empty pages
-      await Page.removeLeafEmptyPagesRecursively(getIdForRef(userHomepage.parent));
+      await Page.removeLeafEmptyPagesRecursively(
+        getIdForRef(userHomepage.parent),
+      );
     }
 
     if (!userHomepage.isEmpty) {
@@ -82,8 +93,7 @@ export const deleteCompletelyUserHomeBySystem = async(userHomepagePath: string,
 
     // Stream processing to delete descendant pages
     // ────────┤ start │─────────
-    const readStream = await builder
-      .query
+    const readStream = await builder.query
       .lean()
       .cursor({ batchSize: BULK_REINDEX_SIZE });
 
@@ -98,8 +108,7 @@ export const deleteCompletelyUserHomeBySystem = async(userHomepagePath: string,
           // Delete multiple pages completely
           await pageService.deleteMultipleCompletely(batch, undefined);
           logger.debug(`Adding pages progressing: (count=${count})`);
-        }
-        catch (err) {
+        } catch (err) {
           logger.error('addAllPages error on add anyway: ', err);
         }
         callback();
@@ -112,9 +121,11 @@ export const deleteCompletelyUserHomeBySystem = async(userHomepagePath: string,
 
     await pipeline(readStream, batchStream, writeStream);
     // ────────┤ end │─────────
-  }
-  catch (err) {
-    logger.error('Error occurred while deleting user homepage and subpages.', err);
+  } catch (err) {
+    logger.error(
+      'Error occurred while deleting user homepage and subpages.',
+      err,
+    );
     throw err;
   }
 };

Разница между файлами не показана из-за своего большого размера
+ 412 - 174
apps/app/src/server/service/page/index.ts


+ 180 - 42
apps/app/src/server/service/page/page-service.ts

@@ -1,11 +1,17 @@
-import type EventEmitter from 'events';
-
+import type { EventEmitter } from 'node:events';
 import type {
   HasObjectId,
   IDataWithRequiredMeta,
   IGrantedGroup,
-  IPageInfo, IPageInfoForEntity, IPageNotFoundInfo, IUser, IPageInfoExt, IPage, PageGrant, IUserHasId,
-} from '@growi/core';
+  IPage,
+  IPageInfoExt,
+  IPageInfoForEmpty,
+  IPageInfoForEntity,
+  IPageNotFoundInfo,
+  IUser,
+  IUserHasId,
+  PageGrant,
+} from '@growi/core/dist/interfaces';
 import type { HydratedDocument, Types } from 'mongoose';
 
 import type { ExternalUserGroupDocument } from '~/features/external-user-group/server/models/external-user-group';
@@ -19,59 +25,191 @@ import type { PageOperationDocument } from '~/server/models/page-operation';
 import type { UserGroupDocument } from '~/server/models/user-group';
 
 export interface IPageService {
-  create(path: string, body: string, user: HasObjectId, options: IOptionsForCreate): Promise<HydratedDocument<PageDocument>>,
-  forceCreateBySystem(path: string, body: string, options: IOptionsForCreate): Promise<PageDocument>,
+  create(
+    path: string,
+    body: string,
+    user: HasObjectId,
+    options: IOptionsForCreate,
+  ): Promise<HydratedDocument<PageDocument>>;
+  forceCreateBySystem(
+    path: string,
+    body: string,
+    options: IOptionsForCreate,
+  ): Promise<PageDocument>;
   updatePage(
-    pageData: HydratedDocument<PageDocument>, body: string | null, previousBody: string | null, user: IUser, options: IOptionsForUpdate
-  ): Promise<HydratedDocument<PageDocument>>,
-  updateDescendantCountOfAncestors: (pageId: ObjectIdLike, inc: number, shouldIncludeTarget: boolean) => Promise<void>,
+    pageData: HydratedDocument<PageDocument>,
+    body: string | null,
+    previousBody: string | null,
+    user: IUser,
+    options: IOptionsForUpdate,
+  ): Promise<HydratedDocument<PageDocument>>;
+  updateDescendantCountOfAncestors: (
+    pageId: ObjectIdLike,
+    inc: number,
+    shouldIncludeTarget: boolean,
+  ) => Promise<void>;
   updateGrant(
-    page: HydratedDocument<PageDocument>, user: IUserHasId, grantData: {grant: PageGrant, userRelatedGrantedGroups: IGrantedGroup[]},
-  ): Promise<PageDocument>,
-  deleteCompletelyOperation: (pageIds: ObjectIdLike[], pagePaths: string[]) => Promise<void>,
-  getEventEmitter: () => EventEmitter,
-  deleteMultipleCompletely: (pages: ObjectIdLike[], user: IUser | undefined) => Promise<void>,
+    page: HydratedDocument<PageDocument>,
+    user: IUserHasId,
+    grantData: { grant: PageGrant; userRelatedGrantedGroups: IGrantedGroup[] },
+  ): Promise<PageDocument>;
+  deleteCompletelyOperation: (
+    pageIds: ObjectIdLike[],
+    pagePaths: string[],
+  ) => Promise<void>;
+  getEventEmitter: () => EventEmitter;
+  deleteMultipleCompletely: (
+    pages: ObjectIdLike[],
+    user: IUser | undefined,
+  ) => Promise<void>;
   findPageAndMetaDataByViewer(
-      pageId: string, path: string | null, user?: HydratedDocument<IUser>, isSharedPage?: boolean,
-  ): Promise<IDataWithRequiredMeta<HydratedDocument<PageDocument>, IPageInfoExt> | IDataWithRequiredMeta<null, IPageNotFoundInfo>>
+    pageId: string,
+    path: string | null,
+    user?: HydratedDocument<IUser>,
+    isSharedPage?: boolean,
+  ): Promise<
+    | IDataWithRequiredMeta<HydratedDocument<PageDocument>, IPageInfoExt>
+    | IDataWithRequiredMeta<null, IPageNotFoundInfo>
+  >;
   findPageAndMetaDataByViewer(
-      pageId: string | null, path: string, user?: HydratedDocument<IUser>, isSharedPage?: boolean,
-  ): Promise<IDataWithRequiredMeta<HydratedDocument<PageDocument>, IPageInfoExt> | IDataWithRequiredMeta<null, IPageNotFoundInfo>>
-  resumeRenameSubOperation(renamedPage: PageDocument, pageOp: PageOperationDocument, activity?): Promise<void>
+    pageId: string | null,
+    path: string,
+    user?: HydratedDocument<IUser>,
+    isSharedPage?: boolean,
+  ): Promise<
+    | IDataWithRequiredMeta<HydratedDocument<PageDocument>, IPageInfoExt>
+    | IDataWithRequiredMeta<null, IPageNotFoundInfo>
+  >;
+  resumeRenameSubOperation(
+    renamedPage: PageDocument,
+    pageOp: PageOperationDocument,
+    activity?,
+  ): Promise<void>;
   handlePrivatePagesForGroupsToDelete(
     groupsToDelete: UserGroupDocument[] | ExternalUserGroupDocument[],
     action: PageActionOnGroupDelete,
     transferToUserGroup: IGrantedGroup | undefined,
     user: IUser,
-): Promise<void>
-  shortBodiesMapByPageIds(pageIds?: Types.ObjectId[], user?): Promise<Record<string, string | null>>,
-  constructBasicPageInfo(page: PageDocument, isGuestUser?: boolean): Omit<IPageInfo | IPageInfoForEntity, 'bookmarkCount'>,
-  normalizeAllPublicPages(): Promise<void>,
-  canDelete(page: PageDocument, creatorId: ObjectIdLike | null, operator: any | null, isRecursively: boolean): boolean,
+  ): Promise<void>;
+  shortBodiesMapByPageIds(
+    pageIds?: Types.ObjectId[],
+    user?,
+  ): Promise<Record<string, string | null>>;
+  constructBasicPageInfo(
+    page: HydratedDocument<PageDocument>,
+    isGuestUser?: boolean,
+  ):
+    | Omit<
+        IPageInfoForEmpty,
+        'bookmarkCount' | 'isDeletable' | 'isAbleToDeleteCompletely'
+      >
+    | Omit<
+        IPageInfoForEntity,
+        'bookmarkCount' | 'isDeletable' | 'isAbleToDeleteCompletely'
+      >;
+  normalizeAllPublicPages(): Promise<void>;
+  canDelete(
+    page: PageDocument,
+    creatorId: ObjectIdLike | null,
+    operator: any | null,
+    isRecursively: boolean,
+  ): boolean;
   canDeleteCompletely(
-    page: PageDocument, creatorId: ObjectIdLike | null, operator: any | null, isRecursively: boolean, userRelatedGroups: PopulatedGrantedGroup[]
-  ): boolean,
+    page: PageDocument,
+    creatorId: ObjectIdLike | null,
+    operator: any | null,
+    isRecursively: boolean,
+    userRelatedGroups: PopulatedGrantedGroup[],
+  ): boolean;
   canDeleteCompletelyAsMultiGroupGrantedPage(
-    page: PageDocument, creatorId: ObjectIdLike | null, operator: any | null, userRelatedGroups: PopulatedGrantedGroup[]
-  ): boolean,
-  getYjsData(pageId: string, revisionBody?: string): Promise<CurrentPageYjsData>,
-  updateDescendantCountOfPagesWithPaths(paths: string[]): Promise<void>,
-  revertRecursivelyMainOperation(page, user, options, pageOpId: ObjectIdLike, activity?): Promise<void>,
-  revertDeletedPage(page, user, options, isRecursively: boolean, activityParameters?),
-  deleteCompletelyRecursivelyMainOperation(page, user, options, pageOpId: ObjectIdLike, activity?): Promise<void>,
-  deleteCompletely(page, user, options, isRecursively: boolean, preventEmitting: boolean, activityParameters),
-  deleteRecursivelyMainOperation(page, user, pageOpId: ObjectIdLike, activity?): Promise<void>,
-  deletePage(page, user, options, isRecursively: boolean, activityParameters),
+    page: PageDocument,
+    creatorId: ObjectIdLike | null,
+    operator: any | null,
+    userRelatedGroups: PopulatedGrantedGroup[],
+  ): boolean;
+  getYjsData(
+    pageId: string,
+    revisionBody?: string,
+  ): Promise<CurrentPageYjsData>;
+  updateDescendantCountOfPagesWithPaths(paths: string[]): Promise<void>;
+  revertRecursivelyMainOperation(
+    page,
+    user,
+    options,
+    pageOpId: ObjectIdLike,
+    activity?,
+  ): Promise<void>;
+  revertDeletedPage(
+    page,
+    user,
+    options,
+    isRecursively: boolean,
+    activityParameters?,
+  );
+  deleteCompletelyRecursivelyMainOperation(
+    page,
+    user,
+    options,
+    pageOpId: ObjectIdLike,
+    activity?,
+  ): Promise<void>;
+  deleteCompletely(
+    page,
+    user,
+    options,
+    isRecursively: boolean,
+    preventEmitting: boolean,
+    activityParameters,
+  );
+  deleteRecursivelyMainOperation(
+    page,
+    user,
+    pageOpId: ObjectIdLike,
+    activity?,
+  ): Promise<void>;
+  deletePage(page, user, options, isRecursively: boolean, activityParameters);
   duplicateRecursivelyMainOperation(
     page: PageDocument,
     newPagePath: string,
     user,
     pageOpId: ObjectIdLike,
     onlyDuplicateUserRelatedResources: boolean,
-  ): Promise<void>,
-  duplicate(page: PageDocument, newPagePath: string, user, isRecursively: boolean, onlyDuplicateUserRelatedResources: boolean),
-  renameSubOperation(page, newPagePath: string, user, options, renamedPage, pageOpId: ObjectIdLike, activity?): Promise<void>,
-  renamePage(page: IPage, newPagePath, user, options, activityParameters): Promise<PageDocument | null>,
-  renameMainOperation(page, newPagePath: string, user, options, pageOpId: ObjectIdLike, activity?): Promise<PageDocument | null>,
-  createSubOperation(page, user, options: IOptionsForCreate, pageOpId: ObjectIdLike): Promise<void>,
+  ): Promise<void>;
+  duplicate(
+    page: PageDocument,
+    newPagePath: string,
+    user,
+    isRecursively: boolean,
+    onlyDuplicateUserRelatedResources: boolean,
+  );
+  renameSubOperation(
+    page,
+    newPagePath: string,
+    user,
+    options,
+    renamedPage,
+    pageOpId: ObjectIdLike,
+    activity?,
+  ): Promise<void>;
+  renamePage(
+    page: IPage,
+    newPagePath,
+    user,
+    options,
+    activityParameters,
+  ): Promise<PageDocument | null>;
+  renameMainOperation(
+    page,
+    newPagePath: string,
+    user,
+    options,
+    pageOpId: ObjectIdLike,
+    activity?,
+  ): Promise<PageDocument | null>;
+  createSubOperation(
+    page,
+    user,
+    options: IOptionsForCreate,
+    pageOpId: ObjectIdLike,
+  ): Promise<void>;
 }

+ 3 - 1
apps/app/src/server/service/page/should-use-v4-process.ts

@@ -14,7 +14,9 @@ export const shouldUseV4Process = (page: IPage): boolean => {
   const isRoot = isTopPage(page.path);
   const isPageRestricted = page.grant === Page.GRANT_RESTRICTED;
 
-  const shouldUseV4Process = !isRoot && (!isV5Compatible || !isPageMigrated || isTrashPage || isPageRestricted);
+  const shouldUseV4Process =
+    !isRoot &&
+    (!isV5Compatible || !isPageMigrated || isTrashPage || isPageRestricted);
 
   return shouldUseV4Process;
 };

+ 14 - 3
apps/app/src/states/page/hooks.ts

@@ -10,7 +10,8 @@ import { useIsGuestUser, useIsReadOnlyUser } from '../context';
 import { useCurrentPathname } from '../global';
 import {
   currentPageDataAtom,
-  currentPageIdAtom,
+  currentPageEmptyIdAtom,
+  currentPageEntityIdAtom,
   currentPagePathAtom,
   isForbiddenAtom,
   isIdenticalPathAtom,
@@ -33,11 +34,21 @@ import {
  */
 
 // Read-only hooks for page state
-export const useCurrentPageId = () => useAtomValue(currentPageIdAtom);
+export const useCurrentPageId = (includeEmpty: boolean = false) => {
+  const entityPageId = useAtomValue(currentPageEntityIdAtom);
+  const emptyPageId = useAtomValue(currentPageEmptyIdAtom);
+
+  return includeEmpty ? (entityPageId ?? emptyPageId) : entityPageId;
+};
 
 export const useCurrentPageData = () => useAtomValue(currentPageDataAtom);
 
-export const usePageNotFound = () => useAtomValue(pageNotFoundAtom);
+export const usePageNotFound = (includeEmpty: boolean = true) => {
+  const isPageNotFound = useAtomValue(pageNotFoundAtom);
+  const emptyPageId = useAtomValue(currentPageEmptyIdAtom);
+
+  return includeEmpty ? isPageNotFound || emptyPageId != null : isPageNotFound;
+};
 
 export const useIsIdenticalPath = () => useAtomValue(isIdenticalPathAtom);
 

+ 9 - 9
apps/app/src/states/page/hydrate.ts

@@ -2,14 +2,15 @@ import {
   type IPageInfo,
   type IPageNotFoundInfo,
   type IPagePopulatedToShowRevision,
-  isIPageInfo,
+  isIPageInfoForEmpty,
   isIPageNotFoundInfo,
 } from '@growi/core';
 import { useHydrateAtoms } from 'jotai/utils';
 
 import {
   currentPageDataAtom,
-  currentPageIdAtom,
+  currentPageEmptyIdAtom,
+  currentPageEntityIdAtom,
   isForbiddenAtom,
   isIdenticalPathAtom,
   pageNotFoundAtom,
@@ -56,18 +57,17 @@ export const useHydratePageAtoms = (
 ): void => {
   useHydrateAtoms([
     // Core page state - automatically extract from page object
-    [currentPageIdAtom, page?._id],
+    [currentPageEntityIdAtom, page?._id],
     [currentPageDataAtom, page ?? undefined],
-    [
-      pageNotFoundAtom,
-      isIPageInfo(pageMeta)
-        ? pageMeta.isNotFound
-        : page == null || page.isEmpty,
-    ],
+    [pageNotFoundAtom, isIPageNotFoundInfo(pageMeta)],
     [
       isForbiddenAtom,
       isIPageNotFoundInfo(pageMeta) ? pageMeta.isForbidden : false,
     ],
+    [
+      currentPageEmptyIdAtom,
+      isIPageInfoForEmpty(pageMeta) ? pageMeta.emptyPageId : undefined,
+    ],
 
     // Remote revision data - used by ConflictDiffModal
     [remoteRevisionBodyAtom, page?.revision?.body],

+ 6 - 4
apps/app/src/states/page/internal-atoms.ts

@@ -8,7 +8,8 @@ import { atom } from 'jotai';
  */
 
 // Core page state atoms (internal)
-export const currentPageIdAtom = atom<string>();
+export const currentPageEntityIdAtom = atom<string>();
+export const currentPageEmptyIdAtom = atom<string>();
 export const currentPageDataAtom = atom<IPagePopulatedToShowRevision>();
 export const pageNotFoundAtom = atom(false);
 export const isIdenticalPathAtom = atom<boolean>(false);
@@ -46,7 +47,7 @@ const untitledPageStateAtom = atom<boolean>(false);
 // Derived atom for untitled page state with currentPageId dependency
 export const isUntitledPageAtom = atom(
   (get) => {
-    const currentPageId = get(currentPageIdAtom);
+    const currentPageId = get(currentPageEntityIdAtom);
     // If no current page ID exists, return false (no page loaded)
     if (currentPageId == null) {
       return false;
@@ -55,7 +56,7 @@ export const isUntitledPageAtom = atom(
     return get(untitledPageStateAtom);
   },
   (get, set, newValue: boolean) => {
-    const currentPageId = get(currentPageIdAtom);
+    const currentPageId = get(currentPageEntityIdAtom);
     // Only update state if current page ID exists
     if (currentPageId != null) {
       set(untitledPageStateAtom, newValue);
@@ -124,7 +125,8 @@ export const _atomsForDerivedAbilities = {
   currentPagePathAtom,
   isIdenticalPathAtom,
   shareLinkIdAtom,
-  currentPageIdAtom,
+  currentPageEntityIdAtom,
+  currentPageEmptyIdAtom,
   isTrashPageAtom,
 } as const;
 

+ 170 - 26
apps/app/src/states/page/use-fetch-current-page.spec.tsx

@@ -7,7 +7,7 @@ import type {
   Lang,
   PageGrant,
   PageStatus,
-} from '@growi/core';
+} from '@growi/core/dist/interfaces';
 import { renderHook, waitFor } from '@testing-library/react';
 // biome-ignore lint/style/noRestrictedImports: import only types
 import type { AxiosResponse } from 'axios';
@@ -19,7 +19,8 @@ import * as apiv3Client from '~/client/util/apiv3-client';
 import { useFetchCurrentPage } from '~/states/page';
 import {
   currentPageDataAtom,
-  currentPageIdAtom,
+  currentPageEmptyIdAtom,
+  currentPageEntityIdAtom,
   isForbiddenAtom,
   pageErrorAtom,
   pageLoadingAtom,
@@ -120,9 +121,9 @@ describe('useFetchCurrentPage - Integration Test', () => {
 
   const mockApiResponse = (
     page: IPagePopulatedToShowRevision,
-  ): AxiosResponse<{ page: IPagePopulatedToShowRevision }> => {
+  ): AxiosResponse<{ page: IPagePopulatedToShowRevision; meta: unknown }> => {
     return {
-      data: { page },
+      data: { page, meta: {} },
       status: 200,
       statusText: 'OK',
       headers: {},
@@ -167,7 +168,7 @@ describe('useFetchCurrentPage - Integration Test', () => {
       '/initial/path',
       'initial content',
     );
-    store.set(currentPageIdAtom, initialPageData._id);
+    store.set(currentPageEntityIdAtom, initialPageData._id);
     store.set(currentPageDataAtom, initialPageData);
 
     // Arrange: Navigate to a new page
@@ -191,7 +192,7 @@ describe('useFetchCurrentPage - Integration Test', () => {
       );
 
       // 2. Atoms were updated
-      expect(store.get(currentPageIdAtom)).toBe(newPageData._id);
+      expect(store.get(currentPageEntityIdAtom)).toBe(newPageData._id);
       expect(store.get(currentPageDataAtom)).toEqual(newPageData);
       expect(store.get(pageLoadingAtom)).toBe(false);
       expect(store.get(pageNotFoundAtom)).toBe(false);
@@ -206,7 +207,7 @@ describe('useFetchCurrentPage - Integration Test', () => {
       '/same/path',
       'current content',
     );
-    store.set(currentPageIdAtom, currentPageData._id);
+    store.set(currentPageEntityIdAtom, currentPageData._id);
     store.set(currentPageDataAtom, currentPageData);
 
     // Act
@@ -226,7 +227,7 @@ describe('useFetchCurrentPage - Integration Test', () => {
       '/some/path',
       'current content',
     );
-    store.set(currentPageIdAtom, currentPageData._id);
+    store.set(currentPageEntityIdAtom, currentPageData._id);
     store.set(currentPageDataAtom, currentPageData);
 
     // Act
@@ -245,7 +246,7 @@ describe('useFetchCurrentPage - Integration Test', () => {
       '/same/path',
       'current content',
     );
-    store.set(currentPageIdAtom, currentPageData._id);
+    store.set(currentPageEntityIdAtom, currentPageData._id);
     store.set(currentPageDataAtom, currentPageData);
 
     // Arrange: API returns different revision
@@ -289,7 +290,7 @@ describe('useFetchCurrentPage - Integration Test', () => {
       'current content',
     );
     const currentRevisionId = currentPageData.revision?._id;
-    store.set(currentPageIdAtom, currentPageData._id);
+    store.set(currentPageEntityIdAtom, currentPageData._id);
     store.set(currentPageDataAtom, currentPageData);
 
     // Act
@@ -311,7 +312,7 @@ describe('useFetchCurrentPage - Integration Test', () => {
       '/same/path',
       'old content',
     );
-    store.set(currentPageIdAtom, currentPageData._id);
+    store.set(currentPageEntityIdAtom, currentPageData._id);
     store.set(currentPageDataAtom, currentPageData);
 
     // Arrange: API returns old revision
@@ -354,7 +355,7 @@ describe('useFetchCurrentPage - Integration Test', () => {
       '/same/path',
       'old content',
     );
-    store.set(currentPageIdAtom, currentPageData._id);
+    store.set(currentPageEntityIdAtom, currentPageData._id);
     store.set(currentPageDataAtom, currentPageData);
 
     // Arrange: API returns updated data
@@ -392,7 +393,7 @@ describe('useFetchCurrentPage - Integration Test', () => {
       '/some/path',
       'old content',
     );
-    store.set(currentPageIdAtom, currentPageData._id);
+    store.set(currentPageEntityIdAtom, currentPageData._id);
     store.set(currentPageDataAtom, currentPageData);
 
     // Arrange: API returns updated data
@@ -431,7 +432,7 @@ describe('useFetchCurrentPage - Integration Test', () => {
       '/actual/path',
       'old content',
     );
-    store.set(currentPageIdAtom, permalinkId);
+    store.set(currentPageEntityIdAtom, permalinkId);
     store.set(currentPageDataAtom, currentPageData);
 
     // Arrange: API returns updated data
@@ -478,7 +479,7 @@ describe('useFetchCurrentPage - Integration Test', () => {
     await result.current.fetchCurrentPage({ path: '/some/page' });
 
     await waitFor(() => {
-      expect(store.get(currentPageIdAtom)).toBe('regularPageId');
+      expect(store.get(currentPageEntityIdAtom)).toBe('regularPageId');
     });
 
     // Arrange: Navigate to the root page
@@ -499,7 +500,7 @@ describe('useFetchCurrentPage - Integration Test', () => {
         '/page',
         expect.objectContaining({ path: '/' }),
       );
-      expect(store.get(currentPageIdAtom)).toBe('rootPageId');
+      expect(store.get(currentPageEntityIdAtom)).toBe('rootPageId');
     });
   });
 
@@ -524,7 +525,7 @@ describe('useFetchCurrentPage - Integration Test', () => {
         '/page',
         expect.objectContaining({ path: decodedPath }),
       );
-      expect(store.get(currentPageIdAtom)).toBe('encodedPageId');
+      expect(store.get(currentPageEntityIdAtom)).toBe('encodedPageId');
     });
   });
 
@@ -548,7 +549,9 @@ describe('useFetchCurrentPage - Integration Test', () => {
         '/page',
         expect.objectContaining({ pageId: '65d4e0a0f7b7b2e5a8652e86' }),
       );
-      expect(store.get(currentPageIdAtom)).toBe('65d4e0a0f7b7b2e5a8652e86');
+      expect(store.get(currentPageEntityIdAtom)).toBe(
+        '65d4e0a0f7b7b2e5a8652e86',
+      );
     });
   });
 
@@ -574,14 +577,14 @@ describe('useFetchCurrentPage - Integration Test', () => {
         '/page',
         expect.objectContaining({ pageId: expectedPageId }),
       );
-      // 2. API should NOT be called with path
+      // 2. API should NOT use the permalink from path
       expect(mockedApiv3Get).toHaveBeenCalledWith(
         '/page',
         expect.not.objectContaining({ path: expect.anything() }),
       );
 
       // 3. State should be updated correctly
-      expect(store.get(currentPageIdAtom)).toBe(expectedPageId);
+      expect(store.get(currentPageEntityIdAtom)).toBe(expectedPageId);
       expect(store.get(currentPageDataAtom)).toEqual(pageData);
       expect(store.get(pageLoadingAtom)).toBe(false);
       expect(store.get(pageNotFoundAtom)).toBe(false);
@@ -621,7 +624,7 @@ describe('useFetchCurrentPage - Integration Test', () => {
       );
 
       // 3. State should be updated with explicit pageId
-      expect(store.get(currentPageIdAtom)).toBe(explicitPageId);
+      expect(store.get(currentPageEntityIdAtom)).toBe(explicitPageId);
       expect(store.get(currentPageDataAtom)).toEqual(pageData);
     });
   });
@@ -654,7 +657,7 @@ describe('useFetchCurrentPage - Integration Test', () => {
       );
 
       // 3. State should be updated correctly
-      expect(store.get(currentPageIdAtom)).toBe('regularPageId123');
+      expect(store.get(currentPageEntityIdAtom)).toBe('regularPageId123');
       expect(store.get(currentPageDataAtom)).toEqual(pageData);
     });
   });
@@ -688,7 +691,7 @@ describe('useFetchCurrentPage - Integration Test', () => {
       );
 
       // 3. State should be updated correctly
-      expect(store.get(currentPageIdAtom)).toBe(expectedPageId);
+      expect(store.get(currentPageEntityIdAtom)).toBe(expectedPageId);
       expect(store.get(currentPageDataAtom)).toEqual(pageData);
       expect(store.get(pageLoadingAtom)).toBe(false);
       expect(store.get(pageNotFoundAtom)).toBe(false);
@@ -739,7 +742,9 @@ describe('useFetchCurrentPage - Integration Test', () => {
           '/page',
           expect.objectContaining({ pageId: testCase.expectedPageId }),
         );
-        expect(store.get(currentPageIdAtom)).toBe(testCase.expectedPageId);
+        expect(store.get(currentPageEntityIdAtom)).toBe(
+          testCase.expectedPageId,
+        );
       });
     }
   });
@@ -751,7 +756,7 @@ describe('useFetchCurrentPage - Integration Test', () => {
       '/some/existing',
       'existing body',
     );
-    store.set(currentPageIdAtom, existingPage._id);
+    store.set(currentPageEntityIdAtom, existingPage._id);
     store.set(currentPageDataAtom, existingPage);
     store.set(remoteRevisionBodyAtom, 'remote body');
 
@@ -776,7 +781,8 @@ describe('useFetchCurrentPage - Integration Test', () => {
         message: 'Page not found',
       });
       expect(store.get(currentPageDataAtom)).toBeUndefined();
-      expect(store.get(currentPageIdAtom)).toBeUndefined();
+      expect(store.get(currentPageEntityIdAtom)).toBeUndefined();
+      expect(store.get(currentPageEmptyIdAtom)).toBeUndefined();
       expect(store.get(remoteRevisionBodyAtom)).toBeUndefined();
     });
   });
@@ -843,4 +849,142 @@ describe('useFetchCurrentPage - Integration Test', () => {
       expect(store.get(isForbiddenAtom)).toBe(false);
     });
   });
+
+  it('should set emptyPageId when page not found with IPageInfoForEmpty in meta', async () => {
+    // Arrange: Mock API response with null page and IPageInfoForEmpty meta
+    const emptyPageId = 'empty123';
+    const notFoundResponseWithEmptyPage = {
+      data: {
+        page: null,
+        meta: {
+          isNotFound: true,
+          isForbidden: false,
+          isEmpty: true, // Required for isIPageInfoForEmpty check
+          emptyPageId,
+        },
+      },
+      status: 200,
+      statusText: 'OK',
+      headers: {},
+      config: {} as AxiosResponse['config'],
+    };
+    mockedApiv3Get.mockResolvedValueOnce(notFoundResponseWithEmptyPage);
+
+    const { result } = renderHookWithProvider();
+    await result.current.fetchCurrentPage({ path: '/empty/page' });
+
+    // Assert: emptyPageId should be set from meta
+    await waitFor(() => {
+      expect(store.get(pageLoadingAtom)).toBe(false);
+      expect(store.get(pageNotFoundAtom)).toBe(true);
+      expect(store.get(isForbiddenAtom)).toBe(false);
+      expect(store.get(currentPageEmptyIdAtom)).toBe(emptyPageId);
+      expect(store.get(currentPageDataAtom)).toBeUndefined();
+      expect(store.get(currentPageEntityIdAtom)).toBeUndefined();
+    });
+  });
+
+  it('should not set emptyPageId when page not found without IPageInfoForEmpty', async () => {
+    // Arrange: Mock API response with null page and IPageNotFoundInfo meta without emptyPageId
+    const notFoundResponseWithoutEmptyPage = {
+      data: {
+        page: null,
+        meta: {
+          isNotFound: true,
+          isForbidden: false,
+          // No emptyPageId property - not IPageInfoForEmpty
+        },
+      },
+      status: 200,
+      statusText: 'OK',
+      headers: {},
+      config: {} as AxiosResponse['config'],
+    };
+    mockedApiv3Get.mockResolvedValueOnce(notFoundResponseWithoutEmptyPage);
+
+    const { result } = renderHookWithProvider();
+    await result.current.fetchCurrentPage({ path: '/regular/not/found' });
+
+    // Assert: emptyPageId should be undefined
+    await waitFor(() => {
+      expect(store.get(pageLoadingAtom)).toBe(false);
+      expect(store.get(pageNotFoundAtom)).toBe(true);
+      expect(store.get(isForbiddenAtom)).toBe(false);
+      expect(store.get(currentPageEmptyIdAtom)).toBeUndefined();
+      expect(store.get(currentPageDataAtom)).toBeUndefined();
+      expect(store.get(currentPageEntityIdAtom)).toBeUndefined();
+    });
+  });
+
+  it('should reset emptyPageId to undefined on successful fetch', async () => {
+    // Arrange: Set emptyPageId from a previous failed fetch
+    store.set(currentPageEmptyIdAtom, 'previousEmptyPageId');
+    store.set(pageNotFoundAtom, true);
+
+    // Arrange: API returns successful page data
+    const successPageData = createPageDataMock(
+      'newPageId',
+      '/success/path',
+      'success content',
+    );
+    mockedApiv3Get.mockResolvedValue(mockApiResponse(successPageData));
+
+    // Act
+    const { result } = renderHookWithProvider();
+    await result.current.fetchCurrentPage({ path: '/success/path' });
+
+    // Assert: emptyPageId should be reset to undefined
+    await waitFor(() => {
+      expect(store.get(pageLoadingAtom)).toBe(false);
+      expect(store.get(pageNotFoundAtom)).toBe(false);
+      expect(store.get(currentPageEmptyIdAtom)).toBeUndefined();
+      expect(store.get(currentPageDataAtom)).toEqual(successPageData);
+      expect(store.get(currentPageEntityIdAtom)).toBe('newPageId');
+    });
+  });
+
+  it('should handle path with encoded Japanese characters', async () => {
+    // Arrange: Path with Japanese characters
+    const japanesePath = '/日本語/ページ';
+    const encodedPath = encodeURIComponent(japanesePath);
+    const pageData = createPageDataMock(
+      'japanesePageId',
+      japanesePath,
+      'Japanese content',
+    );
+    mockedApiv3Get.mockResolvedValue(mockApiResponse(pageData));
+
+    // Act
+    const { result } = renderHookWithProvider();
+    await result.current.fetchCurrentPage({ path: japanesePath });
+
+    // Assert: Path should be properly decoded and sent to API
+    await waitFor(() => {
+      expect(mockedApiv3Get).toHaveBeenCalledWith(
+        '/page',
+        expect.objectContaining({ path: japanesePath }),
+      );
+      expect(store.get(currentPageEntityIdAtom)).toBe('japanesePageId');
+    });
+  });
+
+  it('should call mutatePageInfo after successful fetch', async () => {
+    // Arrange
+    const pageData = createPageDataMock(
+      'pageId123',
+      '/test/path',
+      'test content',
+    );
+    mockedApiv3Get.mockResolvedValue(mockApiResponse(pageData));
+
+    // Act
+    const { result } = renderHookWithProvider();
+    await result.current.fetchCurrentPage({ path: '/test/path' });
+
+    // Assert: mutatePageInfo should be called to refetch metadata
+    await waitFor(() => {
+      expect(mockMutatePageInfo).toHaveBeenCalled();
+      expect(store.get(currentPageEntityIdAtom)).toBe('pageId123');
+    });
+  });
 });

+ 29 - 14
apps/app/src/states/page/use-fetch-current-page.ts

@@ -1,8 +1,10 @@
 import { useCallback } from 'react';
 import {
+  type IPageNotFoundInfo,
   type IPagePopulatedToShowRevision,
+  isIPageInfoForEmpty,
   isIPageNotFoundInfo,
-} from '@growi/core';
+} from '@growi/core/dist/interfaces';
 import { isErrorV3 } from '@growi/core/dist/models';
 import { isClient } from '@growi/core/dist/utils';
 import { isPermalink } from '@growi/core/dist/utils/page-path-utils';
@@ -16,7 +18,8 @@ import loggerFactory from '~/utils/logger';
 
 import {
   currentPageDataAtom,
-  currentPageIdAtom,
+  currentPageEmptyIdAtom,
+  currentPageEntityIdAtom,
   isForbiddenAtom,
   pageErrorAtom,
   pageLoadingAtom,
@@ -35,6 +38,10 @@ type FetchPageArgs = {
   force?: true;
 };
 
+type FetchedPageResult =
+  | { page: IPagePopulatedToShowRevision; meta: unknown }
+  | { page: null; meta: IPageNotFoundInfo };
+
 /**
  * Process path to handle URL decoding and hash fragment removal
  *
@@ -176,7 +183,7 @@ export const useFetchCurrentPage = (): {
   error: Error | null;
 } => {
   const shareLinkId = useAtomValue(shareLinkIdAtom);
-  const currentPageId = useAtomValue(currentPageIdAtom);
+  const currentPageId = useAtomValue(currentPageEntityIdAtom);
 
   const isLoading = useAtomValue(pageLoadingAtom);
   const error = useAtomValue(pageErrorAtom);
@@ -193,7 +200,7 @@ export const useFetchCurrentPage = (): {
         set,
         args?: FetchPageArgs,
       ): Promise<IPagePopulatedToShowRevision | null> => {
-        const currentPageId = get(currentPageIdAtom);
+        const currentPageId = get(currentPageEntityIdAtom);
         const currentPageData = get(currentPageDataAtom);
         const revisionIdFromUrl = get(revisionIdFromUrlAtom);
 
@@ -231,15 +238,22 @@ export const useFetchCurrentPage = (): {
         }
 
         try {
-          const { data } = await apiv3Get<{
-            page: IPagePopulatedToShowRevision;
-          }>('/page', params);
-          const { page: newData } = data;
-
-          set(currentPageDataAtom, newData);
-          set(currentPageIdAtom, newData._id);
-          set(pageNotFoundAtom, false);
-          set(isForbiddenAtom, false);
+          const { data } = await apiv3Get<FetchedPageResult>('/page', params);
+          const { page: newData, meta } = data;
+
+          console.log('Fetched page data:', { newData, meta });
+
+          set(currentPageDataAtom, newData ?? undefined);
+          set(currentPageEntityIdAtom, newData?._id);
+          set(
+            currentPageEmptyIdAtom,
+            isIPageInfoForEmpty(meta) ? meta.emptyPageId : undefined,
+          );
+          set(pageNotFoundAtom, isIPageNotFoundInfo(meta));
+          set(
+            isForbiddenAtom,
+            isIPageNotFoundInfo(meta) ? (meta.isForbidden ?? false) : false,
+          );
 
           // Mutate PageInfo to refetch latest metadata including latestRevisionId
           mutatePageInfo();
@@ -260,7 +274,8 @@ export const useFetchCurrentPage = (): {
               set(pageNotFoundAtom, true);
               set(isForbiddenAtom, error.args.isForbidden ?? false);
               set(currentPageDataAtom, undefined);
-              set(currentPageIdAtom, undefined);
+              set(currentPageEntityIdAtom, undefined);
+              set(currentPageEmptyIdAtom, undefined);
               set(remoteRevisionBodyAtom, undefined);
             }
           }

+ 1 - 1
apps/app/src/states/socket-io/global-socket.ts

@@ -68,7 +68,7 @@ export const useSetupGlobalSocket = (): void => {
  */
 export const useSetupGlobalSocketForPage = (): void => {
   const socket = useAtomValue(globalSocketAtom);
-  const pageId = useCurrentPageId();
+  const pageId = useCurrentPageId(true);
 
   useEffect(() => {
     if (socket == null || pageId == null) {

+ 11 - 34
apps/app/src/states/ui/page-abilities.ts

@@ -42,7 +42,9 @@ const isAbleToShowTagLabelAtom = atom((get) => {
 
   // "/trash" page does not exist on page collection and unable to add tags
   return (
+    // biome-ignore lint/style/noNonNullAssertion: currentPagePath should be defined here
     !isUsersTopPage(currentPagePath!) &&
+    // biome-ignore lint/style/noNonNullAssertion: currentPagePath should be defined here
     !isTrashTopPage(currentPagePath!) &&
     shareLinkId == null &&
     !isIdenticalPath &&
@@ -60,22 +62,13 @@ export const useIsAbleToShowTagLabel = (): boolean => {
 // Derived atom for TrashPageManagementButtons display ability
 const isAbleToShowTrashPageManagementButtonsAtom = atom((get) => {
   const currentUser = get(globalAtoms.currentUserAtom);
-  const currentPageId = get(pageAtoms.currentPageIdAtom);
-  const isNotFound = get(pageAtoms.pageNotFoundAtom);
+  const currentPageEntityId = get(pageAtoms.currentPageEntityIdAtom);
+  const currentPageEmptyId = get(pageAtoms.currentPageEmptyIdAtom);
   const isTrashPage = get(pageAtoms.isTrashPageAtom);
   const isReadOnlyUser = get(contextAtoms.isReadOnlyUserAtom);
 
-  // Return false if any dependency is undefined
-  if (
-    [currentUser, currentPageId, isNotFound, isReadOnlyUser, isTrashPage].some(
-      (v) => v === undefined,
-    )
-  ) {
-    return false;
-  }
-
   const isCurrentUserExist = currentUser != null;
-  const isPageExist = currentPageId != null && isNotFound === false;
+  const isPageExist = currentPageEntityId != null || currentPageEmptyId != null;
   const isTrashPageCondition = isPageExist && isTrashPage === true;
   const isReadOnlyUserCondition = isPageExist && isReadOnlyUser === true;
 
@@ -91,29 +84,16 @@ export const useIsAbleToShowTrashPageManagementButtons = (): boolean => {
 
 // Derived atom for PageManagement display ability
 const isAbleToShowPageManagementAtom = atom((get) => {
-  const currentPageId = get(pageAtoms.currentPageIdAtom);
-  const isNotFound = get(pageAtoms.pageNotFoundAtom);
+  const currentPageEntityId = get(pageAtoms.currentPageEntityIdAtom);
+  const currentPageEmptyId = get(pageAtoms.currentPageEmptyIdAtom);
   const isTrashPage = get(pageAtoms.isTrashPageAtom);
   const isSharedUser = get(contextAtoms.isSharedUserAtom);
 
-  const pageId = currentPageId;
-
-  // Return false if any dependency is undefined
-  if (
-    [pageId, isTrashPage, isSharedUser, isNotFound].some((v) => v === undefined)
-  ) {
-    return false;
-  }
-
-  const isPageExist = pageId != null && isNotFound === false;
-  const isEmptyPage = pageId != null && isNotFound === true;
+  const isPageExist = currentPageEntityId != null || currentPageEmptyId != null;
   const isTrashPageCondition = isPageExist && isTrashPage === true;
   const isSharedUserCondition = isPageExist && isSharedUser === true;
 
-  return (
-    (isPageExist && !isTrashPageCondition && !isSharedUserCondition) ||
-    isEmptyPage
-  );
+  return isPageExist && !isTrashPageCondition && !isSharedUserCondition;
 });
 
 /**
@@ -143,15 +123,12 @@ export const useIsAbleToChangeEditorMode = (): boolean => {
  */
 export const useIsAbleToShowPageAuthors = (): boolean => {
   const pageId = useCurrentPageId();
-  const isNotFound = usePageNotFound();
   const pagePath = useCurrentPagePath();
 
-  const includesUndefined = [pageId, pagePath, isNotFound].some(
-    (v) => v === undefined,
-  );
+  const includesUndefined = [pageId, pagePath].some((v) => v === undefined);
   if (includesUndefined) return false;
 
-  const isPageExist = pageId != null && !isNotFound;
+  const isPageExist = pageId != null;
   const isUsersTopPagePath = pagePath != null && isUsersTopPage(pagePath);
 
   return isPageExist && !isUsersTopPagePath;

+ 3 - 2
apps/app/src/stores/bookmark.ts

@@ -1,4 +1,5 @@
-import type { IPageHasId, IUser } from '@growi/core';
+import type { IPageHasId, IUser } from '@growi/core/dist/interfaces';
+import type { IUserSerializedSecurely } from '@growi/core/dist/models/serializers';
 import type { SWRResponse } from 'swr';
 import useSWR from 'swr';
 import useSWRImmutable from 'swr/immutable';
@@ -11,7 +12,7 @@ import type { IBookmarkInfo } from '../interfaces/bookmark-info';
 
 export const useSWRxBookmarkedUsers = (
   pageId: string | null,
-): SWRResponse<IUser[], Error> => {
+): SWRResponse<IUserSerializedSecurely<IUser>[], Error> => {
   return useSWR(
     pageId != null ? `/bookmarks/info?pageId=${pageId}` : null,
     (endpoint) =>

+ 1 - 2
biome.json

@@ -29,8 +29,7 @@
       "!packages/pdf-converter-client/src/index.ts",
       "!packages/pdf-converter-client/specs",
       "!apps/app/src/client/components",
-      "!apps/app/src/client/services",
-      "!apps/app/src/server/service/page"
+      "!apps/app/src/client/services"
     ]
   },
   "formatter": {

+ 18 - 2
packages/core/src/interfaces/page.ts

@@ -83,10 +83,10 @@ export type PageStatus = (typeof PageStatus)[keyof typeof PageStatus];
 
 export type IPageHasId = IPage & HasObjectId;
 
+// Special type to represent page is an empty page or not found or forbidden status
 export type IPageNotFoundInfo = {
   isNotFound: true;
   isForbidden: boolean;
-  isEmpty?: true;
 };
 
 export type IPageInfo = {
@@ -100,6 +100,13 @@ export type IPageInfo = {
   bookmarkCount: number;
 };
 
+export type IPageInfoForEmpty = Omit<IPageInfo, 'isNotFound' | 'isEmpty'> & {
+  emptyPageId: string;
+  isNotFound: false;
+  isEmpty: true;
+  isBookmarked?: boolean;
+};
+
 export type IPageInfoForEntity = Omit<IPageInfo, 'isNotFound' | 'isEmpty'> & {
   isNotFound: false;
   isEmpty: false;
@@ -123,6 +130,7 @@ export type IPageInfoForListing = IPageInfoForEntity & HasRevisionShortbody;
 
 export type IPageInfoExt =
   | IPageInfo
+  | IPageInfoForEmpty
   | IPageInfoForEntity
   | IPageInfoForOperation
   | IPageInfoForListing;
@@ -134,7 +142,8 @@ export const isIPageNotFoundInfo = (
   return (
     pageInfo != null &&
     pageInfo instanceof Object &&
-    pageInfo.isNotFound === true
+    pageInfo.isNotFound === true &&
+    'isForbidden' in pageInfo
   );
 };
 
@@ -147,6 +156,13 @@ export const isIPageInfo = (
   );
 };
 
+export const isIPageInfoForEmpty = (
+  // biome-ignore lint/suspicious/noExplicitAny: ignore
+  pageInfo: any | undefined,
+): pageInfo is IPageInfoForEmpty => {
+  return isIPageInfo(pageInfo) && pageInfo.isEmpty === true;
+};
+
 export const isIPageInfoForEntity = (
   // biome-ignore lint/suspicious/noExplicitAny: ignore
   pageInfo: any | undefined,

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