2
0
Эх сурвалжийг харах

Merge pull request #11276 from growilabs/fix/11272-mobile-grant-selector-initialization

fix(editor): Preserve page grant on mobile & before grant loads (#11272)
mergify[bot] 2 өдөр өмнө
parent
commit
43ddf56d05

+ 77 - 0
apps/app/playwright/23-editor/grant-preload-race.spec.ts

@@ -0,0 +1,77 @@
+import { expect, test } from '@playwright/test';
+
+import { appendTextToEditorUntilContains } from '../utils/AppendTextToEditorUntilContains';
+
+/**
+ * Regression test for the pre-load race in issue #11272.
+ *
+ * When the editor opens, the current page's grant is fetched asynchronously
+ * (GET /_api/v3/page/grant-data) and synced into selectedGrantAtom. Until that
+ * resolves, selectedGrant is null. Saving in that window must NOT change the
+ * page's grant — otherwise a restricted page is silently published.
+ *
+ * This drives the real cross-stack behavior:
+ *   1. create a GRANT_OWNER ("only me") page,
+ *   2. hold the grant-data response so the editor opens with selectedGrant still null,
+ *   3. edit and save immediately (a real save to the DB),
+ *   4. read the page's grant back and assert it is still GRANT_OWNER.
+ *
+ * page.request (APIRequestContext) is not subject to page.route, so the setup and
+ * verification calls bypass the hold that only affects the browser's fetch.
+ */
+
+const GRANT_DATA_ROUTE = '**/_api/v3/page/grant-data**';
+const GRANT_OWNER = 4; // PageGrant.GRANT_OWNER
+
+const readGrant = async (
+  request: import('@playwright/test').APIRequestContext,
+  pageId: string,
+): Promise<number> => {
+  const res = await request.get('/_api/v3/page/grant-data', {
+    params: { pageId },
+  });
+  expect(res.ok()).toBeTruthy();
+  return (await res.json()).grantData.currentPageGrant.grant;
+};
+
+test('keeps an owner-only grant when saving before the grant loads (#11272)', async ({
+  page,
+}) => {
+  const pagePath = `/grant-preload-race-${Date.now()}`;
+
+  // 1. Create an "only me" (GRANT_OWNER) page.
+  const createRes = await page.request.post('/_api/v3/page', {
+    data: { path: pagePath, body: 'initial body', grant: GRANT_OWNER },
+  });
+  expect(createRes.ok()).toBeTruthy();
+  const createdPageId: string = (await createRes.json()).page._id;
+  expect(await readGrant(page.request, createdPageId)).toBe(GRANT_OWNER);
+
+  // 2. Block the browser's grant-data fetch so the editor opens with
+  //    selectedGrant still unresolved (null) — the pre-load window. Aborting is a
+  //    deterministic stand-in for "not loaded yet". page.request (used below for
+  //    verification) is an APIRequestContext and is NOT affected by page.route.
+  await page.route(GRANT_DATA_ROUTE, async (route) => {
+    if (route.request().method() === 'GET') {
+      await route.abort();
+      return;
+    }
+    await route.continue();
+  });
+
+  await page.goto(pagePath);
+  await page.getByTestId('editor-button').click();
+  await expect(page.getByTestId('grw-editor-navbar-bottom')).toBeVisible();
+
+  // 3. Edit and save immediately, while selectedGrant is still null.
+  await appendTextToEditorUntilContains(page, 'edited before grant loaded');
+  const updateResponse = page.waitForResponse(
+    (res) =>
+      res.url().includes('/_api/v3/page') && res.request().method() === 'PUT',
+  );
+  await page.getByTestId('save-page-btn').click();
+  expect((await updateResponse).ok()).toBeTruthy();
+
+  // 4. The stored grant must still be owner-only (not published).
+  expect(await readGrant(page.request, createdPageId)).toBe(GRANT_OWNER);
+});

+ 56 - 0
apps/app/src/client/components/PageEditor/EditorNavbarBottom/GrantSelector.spec.tsx

@@ -0,0 +1,56 @@
+import type { ReactNode } from 'react';
+import { PageGrant } from '@growi/core';
+import { act, render, renderHook } from '@testing-library/react';
+import { createStore, Provider } from 'jotai';
+
+import type { IPageSelectedGrant } from '~/interfaces/page';
+import { useSelectedGrant } from '~/states/ui/editor';
+
+import { GrantSelector } from './GrantSelector';
+
+vi.mock('next-i18next', () => ({
+  useTranslation: () => ({ t: (key: string) => key }),
+}));
+vi.mock('~/states/global', () => ({ useCurrentUser: vi.fn(() => undefined) }));
+vi.mock('~/states/page', () => ({ useCurrentPageId: vi.fn(() => 'page1') }));
+vi.mock('~/stores/page', () => ({
+  useSWRxCurrentGrantData: vi.fn(() => ({ data: undefined })),
+}));
+
+const renderGrantSelector = (
+  seed?: (set: (grant: IPageSelectedGrant | null) => void) => void,
+) => {
+  const store = createStore();
+  const wrapper = ({ children }: { children: ReactNode }) => (
+    <Provider store={store}>{children}</Provider>
+  );
+
+  if (seed != null) {
+    const { result } = renderHook(() => useSelectedGrant(), { wrapper });
+    act(() => seed(result.current[1]));
+  }
+
+  return render(<GrantSelector />, { wrapper });
+};
+
+describe('GrantSelector', () => {
+  // Before the current page's grant is loaded, selectedGrant is null. Showing the
+  // default "Public" option would mislead the user; show a loading state instead.
+  // See: https://github.com/growilabs/growi/issues/11272
+  it('shows a loading state while the grant is not yet resolved (null)', () => {
+    const { queryByTestId } = renderGrantSelector();
+
+    expect(queryByTestId('grw-grant-selector-loading')).not.toBeNull();
+    // ...and the selector dropdown is not shown yet (no misleading "Public").
+    expect(queryByTestId('grw-grant-selector-dropdown-menu')).toBeNull();
+  });
+
+  it('shows the grant selector once the grant is available', () => {
+    const { queryByTestId } = renderGrantSelector((set) =>
+      set({ grant: PageGrant.GRANT_OWNER }),
+    );
+
+    expect(queryByTestId('grw-grant-selector-loading')).toBeNull();
+    expect(queryByTestId('grw-grant-selector-dropdown-menu')).not.toBeNull();
+  });
+});

+ 30 - 27
apps/app/src/client/components/PageEditor/EditorNavbarBottom/GrantSelector.tsx

@@ -1,10 +1,4 @@
-import React, {
-  type JSX,
-  type ReactNode,
-  useCallback,
-  useEffect,
-  useState,
-} from 'react';
+import React, { type JSX, type ReactNode, useCallback, useState } from 'react';
 import { GroupType, getIdForRef, PageGrant } from '@growi/core';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
@@ -22,7 +16,7 @@ import type { UserRelatedGroupsData } from '~/interfaces/page';
 import { UserGroupPageGrantStatus } from '~/interfaces/page';
 import { useCurrentUser } from '~/states/global';
 import { useCurrentPageId } from '~/states/page';
-import { useSelectedGrant } from '~/states/ui/editor';
+import { toSelectedGrant, useSelectedGrant } from '~/states/ui/editor';
 import { useSWRxCurrentGrantData } from '~/stores/page';
 
 const AVAILABLE_GRANTS = [
@@ -79,26 +73,14 @@ export const GrantSelector = (props: Props): JSX.Element => {
   const currentPageGrantData = grantData?.grantData.currentPageGrant;
   const groupGrantData = currentPageGrantData?.groupGrantData;
 
+  // Re-apply the current page grant when the user (re)opens the group selection,
+  // so the modal reflects the groups currently granted to the page.
+  // Initial sync of selectedGrantAtom is owned by useSyncSelectedGrantWithCurrentPage
+  // (called from the always-mounted SavePageControls) — see issue #11272.
   const applyCurrentPageGrantToSelectedGrant = useCallback(() => {
-    const currentPageGrant = grantData?.grantData.currentPageGrant;
-    if (currentPageGrant == null) return;
-
-    const userRelatedGrantedGroups =
-      currentPageGrant.groupGrantData?.userRelatedGroups
-        .filter((group) => group.status === UserGroupPageGrantStatus.isGranted)
-        ?.map((group) => {
-          return { item: group.id, type: group.type };
-        }) ?? [];
-    setSelectedGrant({
-      grant: currentPageGrant.grant,
-      userRelatedGrantedGroups,
-    });
-  }, [grantData?.grantData.currentPageGrant, setSelectedGrant]);
-
-  // sync grant data
-  useEffect(() => {
-    applyCurrentPageGrantToSelectedGrant();
-  }, [applyCurrentPageGrantToSelectedGrant]);
+    if (currentPageGrantData == null) return;
+    setSelectedGrant(toSelectedGrant(currentPageGrantData));
+  }, [currentPageGrantData, setSelectedGrant]);
 
   const showSelectGroupModal = useCallback(() => {
     setIsSelectGroupModalShown(true);
@@ -159,6 +141,27 @@ export const GrantSelector = (props: Props): JSX.Element => {
    * Render grant selector DOM.
    */
   const renderGrantSelector = useCallback(() => {
+    // Until the current page grant is loaded, selectedGrant is null. Show a loading
+    // state instead of defaulting the toggle to "Public", which would mislead the
+    // user about the page's actual visibility. See issue #11272.
+    if (selectedGrant == null) {
+      return (
+        <div
+          className="grw-grant-selector mb-0"
+          data-testid="grw-grant-selector"
+        >
+          <button
+            type="button"
+            className="btn btn-outline-secondary btn-sm w-100 d-flex justify-content-center align-items-center"
+            disabled
+            data-testid="grw-grant-selector-loading"
+          >
+            <LoadingSpinner />
+          </button>
+        </div>
+      );
+    }
+
     let dropdownToggleBtnColor: string | undefined;
     let dropdownToggleLabelElm: ReactNode | undefined;
 

+ 7 - 0
apps/app/src/client/components/PageEditor/EditorNavbarBottom/SavePageControls.tsx

@@ -32,6 +32,7 @@ import {
   useEditorMode,
   useIsSlackEnabled,
   useSelectedGrant,
+  useSyncSelectedGrantWithCurrentPage,
   useWaitingSaveProcessing,
 } from '~/states/ui/editor';
 import { useSWRxSlackChannels } from '~/stores/editor';
@@ -219,6 +220,12 @@ export const SavePageControls = (): JSX.Element | null => {
   const [isSavePageControlsModalShown, setIsSavePageControlsModalShown] =
     useState<boolean>(false);
 
+  // Initialize selectedGrantAtom from the current page's grant here, because
+  // SavePageControls is always mounted while editing. GrantSelector — which used
+  // to own this — is rendered inside a closed Modal on mobile and never mounts.
+  // See: https://github.com/growilabs/growi/issues/11272
+  useSyncSelectedGrantWithCurrentPage();
+
   // DO NOT dependent on slackChannelsData directly: https://github.com/growilabs/growi/pull/7332
   const slackChannelsDataString = slackChannelsData?.toString();
   useEffect(() => {

+ 6 - 7
apps/app/src/client/components/PageEditor/PageEditor.tsx

@@ -47,6 +47,7 @@ import {
 } from '~/states/server-configurations';
 import {
   EditorMode,
+  toPageUpdateGrantParams,
   useCurrentIndentSize,
   useCurrentIndentSizeActions,
   useEditingMarkdown,
@@ -223,11 +224,8 @@ export const PageEditorSubstance = (props: Props): JSX.Element => {
 
   const save: Save = useCallback(
     async (revisionId, markdown, opts, onConflict) => {
-      if (pageId == null || selectedGrant == null) {
-        logger.error(
-          { pageId, selectedGrant },
-          'Some materials to save are invalid',
-        );
+      if (pageId == null) {
+        logger.error({ pageId }, 'Some materials to save are invalid');
         throw new Error('Some materials to save are invalid');
       }
 
@@ -239,9 +237,10 @@ export const PageEditorSubstance = (props: Props): JSX.Element => {
           revisionId,
           wip: opts?.wip,
           body: markdown ?? '',
-          grant: selectedGrant?.grant,
           origin: Origin.Editor,
-          userRelatedGrantUserGroupIds: selectedGrant?.userRelatedGrantedGroups,
+          // Omits grant when none is selected (null) so the server preserves the
+          // page's existing grant instead of overwriting it — see issue https://github.com/growilabs/growi/issues/11272.
+          ...toPageUpdateGrantParams(selectedGrant),
           ...(opts ?? {}),
         });
 

+ 125 - 0
apps/app/src/server/service/page/grant-preserve-on-update.integ.ts

@@ -0,0 +1,125 @@
+import { type IUserHasId, PageGrant } from '@growi/core';
+import type { HydratedDocument, Model } from 'mongoose';
+import mongoose from 'mongoose';
+import { vi } from 'vitest';
+
+import { getInstance } from '^/test/setup/crowi';
+
+import type Crowi from '~/server/crowi';
+import type { PageDocument, PageModel } from '~/server/models/page';
+
+/**
+ * Characterization test for the server premise that the pre-load race fix relies
+ * on (issue #11272): when a page is updated WITHOUT a grant, the update endpoint
+ * must preserve the page's existing grant rather than defaulting it.
+ *
+ * The editor omits the grant from the update request while selectedGrant is
+ * unresolved (toPageUpdateGrantParams), so this preservation is what keeps a
+ * restricted page from being silently published.
+ */
+describe('PageService.updatePage grant preservation', () => {
+  let crowi: Crowi;
+  let Page: PageModel;
+  let User: Model<IUserHasId>;
+  let user: HydratedDocument<IUserHasId>;
+
+  const create = async (
+    path: string,
+    body: string,
+    options = {},
+  ): Promise<HydratedDocument<PageDocument>> => {
+    const mockedCreateSubOperation = vi
+      .spyOn(crowi.pageService, 'createSubOperation')
+      .mockReturnValue(Promise.resolve());
+
+    const createdPage = await crowi.pageService.create(
+      path,
+      body,
+      user,
+      options,
+    );
+
+    const argsForCreateSubOperation = mockedCreateSubOperation.mock.calls[0];
+    mockedCreateSubOperation.mockRestore();
+    await crowi.pageService.createSubOperation(
+      ...(argsForCreateSubOperation as Parameters<
+        typeof crowi.pageService.createSubOperation
+      >),
+    );
+
+    return createdPage;
+  };
+
+  beforeAll(async () => {
+    crowi = await getInstance();
+    await crowi.configManager.updateConfig('app:isV5Compatible', true);
+
+    User = mongoose.model<IUserHasId>('User');
+    Page = mongoose.model<PageDocument, PageModel>('Page');
+
+    // Suppress page events so their async listeners (e.g. obsolete-page onUpdate)
+    // don't run DB work after the in-memory mongo is torn down. Same pattern as
+    // page.integ.ts. The grant is set synchronously in create/updatePage, so this
+    // does not affect what we assert.
+    vi.spyOn(crowi.pageService.pageEvent, 'emit').mockReturnValue(true);
+
+    // updatePage fires updatePageSubOperation without awaiting it (descendant
+    // bookkeeping). Stub it so that fire-and-forget DB work doesn't outlive the
+    // test and hit the closed connection pool. The grant is already applied to the
+    // saved page before this runs, so stubbing it doesn't affect the assertions.
+    vi.spyOn(crowi.pageService, 'updatePageSubOperation').mockResolvedValue();
+
+    // Ensure a root page exists so created pages can be attached to the tree.
+    const existingRoot = await Page.findOne({ path: '/' });
+    if (existingRoot == null) {
+      await Page.create({ path: '/', grant: Page.GRANT_PUBLIC });
+    }
+
+    const username = 'grantPreserveUser';
+    user =
+      (await User.findOne({ username })) ??
+      (await User.create({
+        name: username,
+        username,
+        email: 'grant-preserve@example.com',
+      }));
+  });
+
+  it('keeps GRANT_OWNER when the update omits a grant', async () => {
+    const page = await create('/grant-preserve-owner', 'initial body', {
+      grant: PageGrant.GRANT_OWNER,
+    });
+    expect(page.grant).toBe(PageGrant.GRANT_OWNER);
+
+    const updated = await crowi.pageService.updatePage(
+      page,
+      'updated body',
+      'initial body',
+      user,
+      {}, // no grant
+    );
+
+    expect(updated.grant).toBe(PageGrant.GRANT_OWNER);
+  });
+
+  it('changes the grant when the update explicitly provides one', async () => {
+    const page = await create(
+      '/grant-preserve-owner-to-public',
+      'initial body',
+      {
+        grant: PageGrant.GRANT_OWNER,
+      },
+    );
+    expect(page.grant).toBe(PageGrant.GRANT_OWNER);
+
+    const updated = await crowi.pageService.updatePage(
+      page,
+      'updated body',
+      'initial body',
+      user,
+      { grant: PageGrant.GRANT_PUBLIC },
+    );
+
+    expect(updated.grant).toBe(PageGrant.GRANT_PUBLIC);
+  });
+});

+ 7 - 0
apps/app/src/server/service/page/page-service.ts

@@ -185,6 +185,13 @@ export interface IPageService {
     options: IOptionsForCreate,
     pageOpId: ObjectIdLike,
   ): Promise<void>;
+  updatePageSubOperation(
+    page,
+    user,
+    exPage,
+    options: IOptionsForUpdate,
+    pageOpId: ObjectIdLike,
+  ): Promise<void>;
 
   getCreatorIdForCanDelete(page: PageDocument): Promise<ObjectIdLike | null>;
 

+ 1 - 0
apps/app/src/states/ui/editor/index.ts

@@ -8,6 +8,7 @@ export * from './reserved-next-caret-line';
 export * from './selected-grant';
 export type { EditorMode as EditorModeType } from './types';
 export { EditorMode } from './types';
+export { useSyncSelectedGrantWithCurrentPage } from './use-sync-selected-grant';
 // Export utility functions that might be needed elsewhere
 export { determineEditorModeByHash } from './utils';
 export * from './waiting-save-processing';

+ 82 - 0
apps/app/src/states/ui/editor/selected-grant.spec.ts

@@ -0,0 +1,82 @@
+import { GroupType, PageGrant } from '@growi/core';
+
+import type { IPageGrantData } from '~/interfaces/page';
+import { UserGroupPageGrantStatus } from '~/interfaces/page';
+
+import { toPageUpdateGrantParams, toSelectedGrant } from './selected-grant';
+
+describe('toSelectedGrant', () => {
+  it('maps the grant of the current page', () => {
+    const currentPageGrant: IPageGrantData = { grant: PageGrant.GRANT_OWNER };
+
+    expect(toSelectedGrant(currentPageGrant).grant).toBe(PageGrant.GRANT_OWNER);
+  });
+
+  it('returns an empty userRelatedGrantedGroups when groupGrantData is absent', () => {
+    const currentPageGrant: IPageGrantData = { grant: PageGrant.GRANT_PUBLIC };
+
+    expect(toSelectedGrant(currentPageGrant).userRelatedGrantedGroups).toEqual(
+      [],
+    );
+  });
+
+  it('includes only groups whose status is isGranted, mapped to { item, type }', () => {
+    const currentPageGrant: IPageGrantData = {
+      grant: PageGrant.GRANT_USER_GROUP,
+      groupGrantData: {
+        userRelatedGroups: [
+          {
+            id: 'granted-group',
+            name: 'granted',
+            type: GroupType.userGroup,
+            status: UserGroupPageGrantStatus.isGranted,
+          },
+          {
+            id: 'not-granted-group',
+            name: 'not granted',
+            type: GroupType.userGroup,
+            status: UserGroupPageGrantStatus.notGranted,
+          },
+          {
+            id: 'cannot-grant-group',
+            name: 'cannot grant',
+            type: GroupType.externalUserGroup,
+            status: UserGroupPageGrantStatus.cannotGrant,
+          },
+        ],
+        nonUserRelatedGrantedGroups: [],
+      },
+    };
+
+    expect(toSelectedGrant(currentPageGrant).userRelatedGrantedGroups).toEqual([
+      { item: 'granted-group', type: GroupType.userGroup },
+    ]);
+  });
+});
+
+describe('toPageUpdateGrantParams', () => {
+  // When the grant has not been chosen/loaded (null), the update must omit grant
+  // so the server preserves the page's existing grant — see issue #11272.
+  it('omits grant fields when no grant is selected (null)', () => {
+    expect(toPageUpdateGrantParams(null)).toEqual({
+      grant: undefined,
+      userRelatedGrantUserGroupIds: undefined,
+    });
+  });
+
+  it('passes through the selected grant and granted groups', () => {
+    const userRelatedGrantedGroups = [
+      { item: 'group-1', type: GroupType.userGroup },
+    ];
+
+    expect(
+      toPageUpdateGrantParams({
+        grant: PageGrant.GRANT_USER_GROUP,
+        userRelatedGrantedGroups,
+      }),
+    ).toEqual({
+      grant: PageGrant.GRANT_USER_GROUP,
+      userRelatedGrantUserGroupIds: userRelatedGrantedGroups,
+    });
+  });
+});

+ 50 - 6
apps/app/src/states/ui/editor/selected-grant.ts

@@ -1,18 +1,62 @@
-import { PageGrant } from '@growi/core/dist/interfaces';
 import { atom, useAtom } from 'jotai';
 
-import type { IPageSelectedGrant } from '~/interfaces/page';
+import type {
+  IOptionsForUpdate,
+  IPageGrantData,
+  IPageSelectedGrant,
+} from '~/interfaces/page';
+import { UserGroupPageGrantStatus } from '~/interfaces/page';
 
 /**
  * Atom for selected grant in page editor
- * Stores temporary grant selection before it's applied to the page
+ * Stores temporary grant selection before it's applied to the page.
+ *
+ * Defaults to null ("not yet loaded") — NOT GRANT_PUBLIC. The real grant is the
+ * page's current grant (which, for a newly created page, is inherited from the
+ * closest ancestor), supplied asynchronously by useSyncSelectedGrantWithCurrentPage.
+ * A GRANT_PUBLIC default would let an early save publish a restricted page before
+ * that value arrives — see [[use-sync-selected-grant]] and issue #11272.
  */
-const selectedGrantAtom = atom<IPageSelectedGrant | null>({
-  grant: PageGrant.GRANT_PUBLIC,
-});
+const selectedGrantAtom = atom<IPageSelectedGrant | null>(null);
 
 /**
  * Hook for managing selected grant in page editor
  * Used for temporary grant selection before applying to the page
  */
 export const useSelectedGrant = () => useAtom(selectedGrantAtom);
+
+/**
+ * Convert the page's current grant data (server-side shape) into the
+ * IPageSelectedGrant shape held by the editor's selected-grant state.
+ *
+ * Pure function so it can be reused from both the sync hook
+ * ([[use-sync-selected-grant]]) and GrantSelector's change handler.
+ */
+export const toSelectedGrant = (
+  currentPageGrant: IPageGrantData,
+): IPageSelectedGrant => {
+  const userRelatedGrantedGroups =
+    currentPageGrant.groupGrantData?.userRelatedGroups
+      .filter((group) => group.status === UserGroupPageGrantStatus.isGranted)
+      .map((group) => ({ item: group.id, type: group.type })) ?? [];
+
+  return {
+    grant: currentPageGrant.grant,
+    userRelatedGrantedGroups,
+  };
+};
+
+/**
+ * Build the grant-related params for a page update from the selected grant.
+ *
+ * When nothing is selected (null) — e.g. the grant has not loaded yet, or
+ * GrantSelector never mounted on mobile — both fields are omitted (undefined),
+ * so the update endpoint preserves the page's existing grant rather than
+ * overwriting it with a stale default. See issue #11272.
+ */
+export const toPageUpdateGrantParams = (
+  selectedGrant: IPageSelectedGrant | null,
+): Pick<IOptionsForUpdate, 'grant' | 'userRelatedGrantUserGroupIds'> => ({
+  grant: selectedGrant?.grant,
+  userRelatedGrantUserGroupIds: selectedGrant?.userRelatedGrantedGroups,
+});

+ 90 - 0
apps/app/src/states/ui/editor/use-sync-selected-grant.spec.tsx

@@ -0,0 +1,90 @@
+import { PageGrant } from '@growi/core';
+import { act, renderHook } from '@testing-library/react';
+import { createStore, Provider } from 'jotai';
+
+import { useCurrentPageId } from '~/states/page';
+import { useSWRxCurrentGrantData } from '~/stores/page';
+
+import { useSelectedGrant } from './selected-grant';
+import { useSyncSelectedGrantWithCurrentPage } from './use-sync-selected-grant';
+
+vi.mock('~/states/page', () => ({ useCurrentPageId: vi.fn() }));
+vi.mock('~/stores/page', () => ({ useSWRxCurrentGrantData: vi.fn() }));
+
+const mockedUseCurrentPageId = vi.mocked(useCurrentPageId);
+const mockedUseSWRxCurrentGrantData = vi.mocked(useSWRxCurrentGrantData);
+
+// Build a plain SWR response. vitest-mock-extended's mock<SWRResponse>() cannot be
+// used here: its deep proxy auto-stubs `.then`, so React treats `data` as a thenable
+// and breaks rendering. A plain object with a single localized cast is the repo norm
+// (see states/page/use-fetch-current-page.spec.tsx).
+const grantDataResponse = (currentPageGrant?: {
+  grant: PageGrant;
+}): ReturnType<typeof useSWRxCurrentGrantData> =>
+  ({
+    data:
+      currentPageGrant == null
+        ? undefined
+        : {
+            isGrantNormalized: true,
+            grantData: { isForbidden: false, currentPageGrant },
+          },
+    error: undefined,
+    isLoading: false,
+    isValidating: false,
+    mutate: vi.fn(),
+  }) as ReturnType<typeof useSWRxCurrentGrantData>;
+
+describe('useSyncSelectedGrantWithCurrentPage', () => {
+  let store: ReturnType<typeof createStore>;
+
+  // Render the consumer's view (useSelectedGrant) alongside the sync hook so we
+  // assert on the observable atom value, not on the setter being called.
+  const renderSyncHook = () =>
+    renderHook(
+      () => {
+        const selected = useSelectedGrant();
+        useSyncSelectedGrantWithCurrentPage();
+        return selected;
+      },
+      {
+        wrapper: ({ children }) => (
+          <Provider store={store}>{children}</Provider>
+        ),
+      },
+    );
+
+  beforeEach(() => {
+    store = createStore();
+    mockedUseCurrentPageId.mockReturnValue('page1');
+  });
+
+  it("initializes selectedGrant from the current page's grant", () => {
+    mockedUseSWRxCurrentGrantData.mockReturnValue(
+      grantDataResponse({ grant: PageGrant.GRANT_OWNER }),
+    );
+
+    // renderHook flushes mount effects in its internal act(), so the sync has
+    // already applied by the time it returns.
+    const { result } = renderSyncHook();
+
+    expect(result.current[0]).toEqual({
+      grant: PageGrant.GRANT_OWNER,
+      userRelatedGrantedGroups: [],
+    });
+  });
+
+  it('does not overwrite an existing selection while grant data is unavailable', () => {
+    mockedUseSWRxCurrentGrantData.mockReturnValue(grantDataResponse());
+
+    const { result } = renderSyncHook();
+
+    act(() => {
+      result.current[1]({ grant: PageGrant.GRANT_RESTRICTED });
+    });
+
+    // The sync effect re-runs on the update but must leave the selection intact
+    // because there is no grant data to apply yet.
+    expect(result.current[0]).toEqual({ grant: PageGrant.GRANT_RESTRICTED });
+  });
+});

+ 33 - 0
apps/app/src/states/ui/editor/use-sync-selected-grant.ts

@@ -0,0 +1,33 @@
+import { useEffect } from 'react';
+
+import { useCurrentPageId } from '~/states/page';
+import { useSWRxCurrentGrantData } from '~/stores/page';
+
+import { toSelectedGrant, useSelectedGrant } from './selected-grant';
+
+/**
+ * Sync selectedGrantAtom with the current page's grant.
+ *
+ * The atom starts as null (unresolved); this fills it with the page's actual
+ * grant so the editor reflects the real visibility. It must run from an
+ * always-mounted component: on mobile, GrantSelector is rendered only inside a
+ * closed Modal and therefore never mounts, so it cannot own this sync. (Saving
+ * while the atom is still null omits the grant, so the server preserves it — the
+ * pre-load race is handled separately in PageEditor's save path.)
+ *
+ * Call this once from an always-mounted editor component (e.g. SavePageControls).
+ *
+ * @see https://github.com/growilabs/growi/issues/11272
+ */
+export const useSyncSelectedGrantWithCurrentPage = (): void => {
+  const currentPageId = useCurrentPageId();
+  const { data } = useSWRxCurrentGrantData(currentPageId);
+  const [, setSelectedGrant] = useSelectedGrant();
+
+  const currentPageGrant = data?.grantData.currentPageGrant;
+
+  useEffect(() => {
+    if (currentPageGrant == null) return;
+    setSelectedGrant(toSelectedGrant(currentPageGrant));
+  }, [currentPageGrant, setSelectedGrant]);
+};