Преглед изворни кода

Merge pull request #10604 from growilabs/fix/dropdown-menu-for-empty-page-on-pagetree

imprv: Empty page operation
Yuki Takei пре 3 месеци
родитељ
комит
e1cedd10d2
24 измењених фајлова са 635 додато и 318 уклоњено
  1. 2 1
      .serena/memories/page-state-hooks-useLatestRevision-degradation.md
  2. 1 1
      .serena/memories/page-transition-and-rendering-flow.md
  3. 46 21
      apps/app/src/client/components/Bookmarks/BookmarkItem.tsx
  4. 54 2
      apps/app/src/client/components/Common/Dropdown/PageItemControl.spec.tsx
  5. 19 14
      apps/app/src/client/components/Common/Dropdown/PageItemControl.tsx
  6. 1 2
      apps/app/src/client/components/Navbar/GrowiContextualSubNavigation.tsx
  7. 14 20
      apps/app/src/client/components/PageControls/PageControls.tsx
  8. 1 1
      apps/app/src/client/components/Sidebar/PageTreeItem/use-page-item-control.tsx
  9. 8 2
      apps/app/src/interfaces/bookmark-info.ts
  10. 0 122
      apps/app/src/server/models/bookmark.js
  11. 151 0
      apps/app/src/server/models/bookmark.ts
  12. 52 21
      apps/app/src/server/routes/apiv3/bookmarks.ts
  13. 24 15
      apps/app/src/server/routes/apiv3/page-listing.ts
  14. 26 19
      apps/app/src/server/service/page/index.ts
  15. 5 3
      apps/app/src/server/service/page/page-service.ts
  16. 8 2
      apps/app/src/states/page/hooks.ts
  17. 8 2
      apps/app/src/states/page/hydrate.ts
  18. 6 4
      apps/app/src/states/page/internal-atoms.ts
  19. 157 24
      apps/app/src/states/page/use-fetch-current-page.spec.tsx
  20. 15 6
      apps/app/src/states/page/use-fetch-current-page.ts
  21. 1 1
      apps/app/src/states/socket-io/global-socket.ts
  22. 9 29
      apps/app/src/states/ui/page-abilities.ts
  23. 3 2
      apps/app/src/stores/bookmark.ts
  24. 24 4
      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` にエラーオブジェクトを設定します。

+ 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: true,
+        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

+ 1 - 2
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;

+ 14 - 20
apps/app/src/client/components/PageControls/PageControls.tsx

@@ -3,9 +3,12 @@ import React, {
 } from 'react';
 
 import type {
+  IPageInfoForEmpty,
   IPageInfoForOperation, IPageToDeleteWithMeta, IPageToRenameWithMeta,
 } from '@growi/core';
 import {
+  isIPageInfoForEmpty,
+
   isIPageInfoForEntity, isIPageInfoForOperation,
 } from '@growi/core';
 import { pagePathUtils } from '@growi/core/dist/utils';
@@ -121,7 +124,7 @@ type CommonProps = {
 }
 
 type PageControlsSubstanceProps = CommonProps & {
-  pageInfo: IPageInfoForOperation,
+  pageInfo: IPageInfoForOperation | IPageInfoForEmpty,
   onClickEditTagsButton: () => void,
 }
 
@@ -287,21 +290,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 +307,7 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
         </>
       )}
 
-      {revisionId != null && !isViewMode && _isIPageInfoForOperation && (
+      {revisionId != null && !isViewMode && (
         <Tags
           onClickEditTagsButton={onClickEditTagsButton}
         />
@@ -321,38 +315,38 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
 
       {!hideSubControls && (
         <div className={`hstack gap-1 ${!isViewMode && 'd-none d-lg-flex'}`}>
-          {revisionId != null && _isIPageInfoForOperation && (
+          {isIPageInfoForEntity(pageInfo) && revisionId != null && (
             <SubscribeButton
               status={pageInfo.subscriptionStatus}
               onClick={subscribeClickhandler}
             />
           )}
-          {revisionId != null && _isIPageInfoForOperation && (
+          {isIPageInfoForEntity(pageInfo) && revisionId != null && (
             <LikeButtons
               onLikeClicked={likeClickhandler}
-              sumOfLikers={sumOfLikers}
-              isLiked={isLiked}
+              sumOfLikers={pageInfo.sumOfLikers}
+              isLiked={pageInfo.isLiked}
               likers={likers}
             />
           )}
-          {revisionId != null && _isIPageInfoForOperation && (
+          {revisionId != null && (
             <BookmarkButtons
               pageId={pageId}
               isBookmarked={pageInfo.isBookmarked}
               bookmarkCount={pageInfo.bookmarkCount}
             />
           )}
-          {revisionId != null && !isSearchPage && (
+          {isIPageInfoForEntity(pageInfo) && revisionId != null && !isSearchPage && (
             <SeenUserInfo
               seenUsers={seenUsers}
-              sumOfSeenUsers={sumOfSeenUsers}
+              sumOfSeenUsers={pageInfo.sumOfSeenUsers}
               disabled={disableSeenUserInfoPopover}
             />
           )}
         </div>
       )}
 
-      {showPageControlDropdown && _isIPageInfoForOperation && (
+      {showPageControlDropdown && (
         <PageItemControl
           pageId={pageId}
           pageInfo={pageInfo}
@@ -393,7 +387,7 @@ export const PageControls = memo((props: PageControlsProps): JSX.Element => {
     return <></>;
   }
 
-  if (!isIPageInfoForEntity(pageInfo)) {
+  if (!isIPageInfoForOperation(pageInfo) && !isIPageInfoForEmpty(pageInfo)) {
     return <></>;
   }
 

+ 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 = {

+ 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)

+ 26 - 19
apps/app/src/server/service/page/index.ts

@@ -8,11 +8,11 @@ import {
   PageStatus, YDocStatus, getIdForRef,
   getIdStringForRef,
 } from '@growi/core';
-import { PageGrant, isIPageInfoForEntity } from '@growi/core/dist/interfaces';
+import { PageGrant, isIPageInfoForEmpty, isIPageInfoForEntity } from '@growi/core/dist/interfaces';
 import type {
   Ref, HasObjectId, IUserHasId, IUser,
   IPage, IGrantedGroup, IRevisionHasId,
-  IPageNotFoundInfo, IPageInfoExt, IPageInfo, IPageInfoForEntity, IPageInfoForOperation,
+  IPageNotFoundInfo, IPageInfoExt, IPageInfo, IPageInfoForEmpty, IPageInfoForEntity, IPageInfoForOperation,
   IDataWithRequiredMeta,
 } from '@growi/core/dist/interfaces';
 import {
@@ -450,7 +450,7 @@ class PageService implements IPageService {
           isAbleToDeleteCompletely: false,
           isRevertible: false,
           bookmarkCount: 0,
-        } satisfies IPageInfo | IPageInfoForEntity,
+        } satisfies IPageInfo,
       };
     }
 
@@ -460,12 +460,16 @@ class PageService implements IPageService {
     const pageInfo = {
       ...basicPageInfo,
       bookmarkCount,
-    } satisfies IPageInfo | IPageInfoForEntity;
+    };
 
     if (isGuestUser) {
       return {
         data: page,
-        meta: pageInfo,
+        meta: {
+          ...pageInfo,
+          isDeletable: false,
+          isAbleToDeleteCompletely: false,
+        } satisfies IPageInfo,
       };
     }
 
@@ -475,21 +479,25 @@ class PageService implements IPageService {
 
     const isDeletable = this.canDelete(page, creatorId, user, false);
     const isAbleToDeleteCompletely = this.canDeleteCompletely(page, creatorId, user, false, userRelatedGroups); // use normal delete config
+    const isBookmarked: boolean = isGuestUser
+      ? false
+      : (await Bookmark.findByPageIdAndUserId(pageId, user._id)) != null;
 
-    if (!isIPageInfoForEntity(pageInfo)) {
+    if (pageInfo.isEmpty) {
       return {
         data: page,
         meta: {
           ...pageInfo,
           isDeletable,
           isAbleToDeleteCompletely,
-        } satisfies IPageInfo,
+          isBookmarked,
+        } satisfies IPageInfoForEmpty,
       };
     }
 
-    const isBookmarked: boolean = isGuestUser
-      ? false
-      : (await Bookmark.findByPageIdAndUserId(pageId, user._id)) != null;
+    // IPageInfoForEmpty and IPageInfoForEntity are mutually exclusive
+    // so hereafter we can safely
+    assert(isIPageInfoForEntity(pageInfo));
 
     const isLiked: boolean = page.isLiked(user);
     const subscription = await Subscription.findByUserIdAndTargetId(user._id, page._id);
@@ -2568,26 +2576,27 @@ class PageService implements IPageService {
     });
   }
 
-  constructBasicPageInfo(page: PageDocument, isGuestUser?: boolean): | Omit<IPageInfo | IPageInfoForEntity, 'bookmarkCount'> {
+  constructBasicPageInfo(page: HydratedDocument<PageDocument>, isGuestUser?: boolean): |
+      Omit<IPageInfoForEmpty, 'bookmarkCount' | 'isDeletable' | 'isAbleToDeleteCompletely'> |
+      Omit<IPageInfoForEntity, 'bookmarkCount' | 'isDeletable' | 'isAbleToDeleteCompletely'> {
     const isMovable = isGuestUser ? false : isMovablePage(page.path);
-    const isDeletable = !(isGuestUser || isTopPage(page.path) || isUsersTopPage(page.path));
+    const pageId = page._id.toString();
 
     if (page.isEmpty) {
       return {
+        emptyPageId: pageId,
         isNotFound: true,
         isV5Compatible: true,
         isEmpty: true,
         isMovable,
-        isDeletable: false,
-        isAbleToDeleteCompletely: false,
         isRevertible: false,
-      };
+      } satisfies Omit<IPageInfoForEmpty, 'bookmarkCount' | 'isDeletable' | 'isAbleToDeleteCompletely'>;
     }
 
     const likers = page.liker.slice(0, 15) as Ref<IUserHasId>[];
     const seenUsers = page.seenUsers.slice(0, 15) as Ref<IUserHasId>[];
 
-    const infoForEntity: Omit<IPageInfoForEntity, 'bookmarkCount'> = {
+    const infoForEntity = {
       isNotFound: false,
       isV5Compatible: isTopPage(page.path) || page.parent != null,
       isEmpty: false,
@@ -2596,8 +2605,6 @@ class PageService implements IPageService {
       seenUserIds: this.extractStringIds(seenUsers),
       sumOfSeenUsers: page.seenUsers.length,
       isMovable,
-      isDeletable,
-      isAbleToDeleteCompletely: false,
       isRevertible: isTrashPage(page.path),
       contentAge: page.getContentAge(),
       descendantCount: page.descendantCount,
@@ -2605,7 +2612,7 @@ class PageService implements IPageService {
       // the page must have a revision if it is not empty
       // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
       latestRevisionId: getIdStringForRef(page.revision!),
-    };
+    } satisfies Omit<IPageInfoForEntity, 'bookmarkCount' | 'isDeletable' | 'isAbleToDeleteCompletely'>;
 
     return infoForEntity;
   }

+ 5 - 3
apps/app/src/server/service/page/page-service.ts

@@ -4,8 +4,8 @@ import type {
   HasObjectId,
   IDataWithRequiredMeta,
   IGrantedGroup,
-  IPageInfo, IPageInfoForEntity, IPageNotFoundInfo, IUser, IPageInfoExt, IPage, PageGrant, IUserHasId,
-} from '@growi/core';
+  IPageInfoForEntity, IPageNotFoundInfo, IUser, IPageInfoExt, IPage, PageGrant, IUserHasId, IPageInfoForEmpty,
+} from '@growi/core/dist/interfaces';
 import type { HydratedDocument, Types } from 'mongoose';
 
 import type { ExternalUserGroupDocument } from '~/features/external-user-group/server/models/external-user-group';
@@ -45,7 +45,9 @@ export interface IPageService {
     user: IUser,
 ): Promise<void>
   shortBodiesMapByPageIds(pageIds?: Types.ObjectId[], user?): Promise<Record<string, string | null>>,
-  constructBasicPageInfo(page: PageDocument, isGuestUser?: boolean): Omit<IPageInfo | IPageInfoForEntity, 'bookmarkCount'>,
+  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(

+ 8 - 2
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,7 +34,12 @@ 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);
 

+ 8 - 2
apps/app/src/states/page/hydrate.ts

@@ -3,13 +3,15 @@ import {
   type IPageNotFoundInfo,
   type IPagePopulatedToShowRevision,
   isIPageInfo,
+  isIPageInfoForEmpty,
   isIPageNotFoundInfo,
 } from '@growi/core';
 import { useHydrateAtoms } from 'jotai/utils';
 
 import {
   currentPageDataAtom,
-  currentPageIdAtom,
+  currentPageEmptyIdAtom,
+  currentPageEntityIdAtom,
   isForbiddenAtom,
   isIdenticalPathAtom,
   pageNotFoundAtom,
@@ -56,7 +58,7 @@ export const useHydratePageAtoms = (
 ): void => {
   useHydrateAtoms([
     // Core page state - automatically extract from page object
-    [currentPageIdAtom, page?._id],
+    [currentPageEntityIdAtom, page?._id],
     [currentPageDataAtom, page ?? undefined],
     [
       pageNotFoundAtom,
@@ -68,6 +70,10 @@ export const useHydratePageAtoms = (
       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;
 

+ 157 - 24
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,
@@ -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,7 @@ 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(remoteRevisionBodyAtom)).toBeUndefined();
     });
   });
@@ -843,4 +848,132 @@ describe('useFetchCurrentPage - Integration Test', () => {
       expect(store.get(isForbiddenAtom)).toBe(false);
     });
   });
+
+  it('should set emptyPageId when page not found with IPageInfoForEmpty', async () => {
+    // Arrange: Mock API rejection with ErrorV3 and IPageInfoForEmpty args
+    const emptyPageId = 'empty123';
+    const notFoundErrorWithEmptyPage = {
+      code: 'not_found',
+      message: 'Page not found',
+      args: {
+        isNotFound: true,
+        isForbidden: false,
+        isEmpty: true, // Required for isIPageInfoForEmpty check
+        emptyPageId,
+      },
+    } as const;
+    mockedApiv3Get.mockRejectedValueOnce([notFoundErrorWithEmptyPage]);
+
+    const { result } = renderHookWithProvider();
+    await result.current.fetchCurrentPage({ path: '/empty/page' });
+
+    // Assert: emptyPageId should be set
+    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 rejection with ErrorV3 but without emptyPageId
+    const notFoundErrorWithoutEmptyPage = {
+      code: 'not_found',
+      message: 'Page not found',
+      args: {
+        isNotFound: true,
+        isForbidden: false,
+        // No emptyPageId property
+      },
+    } as const;
+    mockedApiv3Get.mockRejectedValueOnce([notFoundErrorWithoutEmptyPage]);
+
+    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');
+    });
+  });
 });

+ 15 - 6
apps/app/src/states/page/use-fetch-current-page.ts

@@ -1,8 +1,9 @@
 import { useCallback } from 'react';
 import {
   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 +17,8 @@ import loggerFactory from '~/utils/logger';
 
 import {
   currentPageDataAtom,
-  currentPageIdAtom,
+  currentPageEmptyIdAtom,
+  currentPageEntityIdAtom,
   isForbiddenAtom,
   pageErrorAtom,
   pageLoadingAtom,
@@ -176,7 +178,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 +195,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);
 
@@ -237,7 +239,8 @@ export const useFetchCurrentPage = (): {
           const { page: newData } = data;
 
           set(currentPageDataAtom, newData);
-          set(currentPageIdAtom, newData._id);
+          set(currentPageEntityIdAtom, newData._id);
+          set(currentPageEmptyIdAtom, undefined);
           set(pageNotFoundAtom, false);
           set(isForbiddenAtom, false);
 
@@ -260,7 +263,13 @@ export const useFetchCurrentPage = (): {
               set(pageNotFoundAtom, true);
               set(isForbiddenAtom, error.args.isForbidden ?? false);
               set(currentPageDataAtom, undefined);
-              set(currentPageIdAtom, undefined);
+              set(currentPageEntityIdAtom, undefined);
+              set(
+                currentPageEmptyIdAtom,
+                isIPageInfoForEmpty(error.args)
+                  ? error.args.emptyPageId
+                  : 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) {

+ 9 - 29
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;
 });
 
 /**

+ 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) =>

+ 24 - 4
packages/core/src/interfaces/page.ts

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