Yuki Takei 7 месяцев назад
Родитель
Сommit
734ec6e7dc

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

@@ -1,54 +1,50 @@
-### ページ遷移とレンダリングフローの分析
-
-このドキュメントは、ページ遷移から `PageView` コンポーネントのレンダリングまでのデータフローを、Jotai atom の役割に焦点を当てて概説します。
-
-#### 1. ユーザーアクションとナビゲーションの開始
-
-- ユーザーが `<Link>` コンポーネントをクリックするか、ブラウザの履歴を使用すると、Next.js の `useRouter` が URL の変更を検出します。
-- これにより、`[[...path]].page.tsx` コンポーネントが再評価されます。
-
-#### 2. `useSameRouteNavigation` フックによる遷移の処理
-
-- `[[...path]].page.tsx` 内で呼び出される `useSameRouteNavigation` フックは、新しい `router.asPath` を `targetPathname` として検出します。
-- `targetPathname` の変更によって `useEffect` がトリガーされます。
-- `shouldFetchPage` ユーティリティは、現在のページ情報(`currentPageId`, `currentPagePath`)と遷移先の情報(`targetPageId`, `targetPathname`)を比較し、新しいページデータを取得する必要があるかどうかを判断します。
-
-#### 3. `usePageStateManager` による状態の更新とデータ取得
-
-- `useSameRouteNavigation` 内の `usePageStateManager` は `updatePageState` 関数を実行します。
-- **`currentPageIdAtom` の更新**: `setCurrentPageId(targetPageId)` が呼び出され、最初にページIDが更新されます。**この時点では `targetPageId` は `null` です。**
-- **データ取得の実行**: `fetchCurrentPage(targetPathname)` が呼び出されます。この関数は `useFetchCurrentPage` フックによって提供されます。
-
-#### 4. `PageView` の中間レンダリング
-
-- `setCurrentPageId(null)` が実行されると、`currentPageIdAtom` に依存するコンポーネントが再レンダリングされます。
-- ログから、`PageView` が `currentPageId: undefined` の状態で2回再レンダリングされていることがわかります。これは、atomの更新がReactのレンダリングサイクルをトリガーするためです。この時点では、表示されているページの内容は古いままです。
-
-#### 5. `useFetchCurrentPage` フックによるAPI通信とAtomの更新
-
-- `fetchCurrentPage` は `useAtomCallback` で定義されており、Jotai atomを直接更新する権限を持っています。
-- **API呼び出し**: `apiv3Get('/page', ...)` を実行して、サーバーから新しいページデータを取得します。
-- **Atomの一括更新**: APIレスポンスを受け取ると、次のatomを更新します。
-    - `pageLoadingAtom`: `false` に設定して、読み込み状態を終了します。
-    - `pageErrorAtom`: エラーが発生した場合に設定されます。
-    - `pageNotFoundAtom`: ページが見つからない場合に `true` に設定されます。
-    - `currentPageDataAtom`: **これが最も重要な部分です。** 新しいページオブジェクト(`newData`)がこのatomに設定されます。
-    - `currentPageIdAtom`: 取得したデータの `_id` で再度更新され、一貫性を確保します。
-
-#### 6. `PageView` コンポーネントの最終レンダリング
-
-- `useFetchCurrentPage` によって `currentPageDataAtom` と `currentPageIdAtom` の値が更新されると、`PageView` コンポーネントは新しい `page` オブジェクトと `currentPageId` で再度レンダリングされます。
-- 再レンダリングされた `PageView` は、新しいページコンテンツ(タイトル、本文など)を表示します。
-
-#### 7. ナビゲーションの完了と副作用
-
-- データ取得後、`useSameRouteNavigation` 内で `mutateEditingMarkdown` が呼び出され、エディタの状態が更新されます。
-- 最後に、Next.jsのルーターが `router.asPath` を `/6847d935c9748fb9fc99f435` のようなIDベースのパスに更新することがあり、これにより `useSameRouteNavigation` の `useEffect` が再度トリガーされますが、`isSamePageId` のチェックによって重複したデータ取得はスキップされます。
-
-### Jotai Atomの役割の概要
-
-- `currentPageIdAtom`: 現在表示されているページのIDを保持します。遷移の過程で一度 `null` または `undefined` になり、データ取得後に正しいIDで更新されます。
-- `currentPageDataAtom`: 現在のページの完全なデータオブジェクト(`IPagePopulatedToShowRevision`)を保持します。このatomへの変更が、`PageView` の最終的な再レンダリングの直接のトリガーとなります。
-- `pageLoadingAtom`: データ取得中の読み込み状態を管理します。
-- `pageNotFoundAtom`: 存在しないページの状態を管理し、`NotFoundPage` の表示を制御します。
-- `pageErrorAtom`: データ取得中に発生したエラーを保持します。
+### ページ遷移とレンダリングフローの分析(リファクタリング後)
+
+このドキュメントは、リファクタリング後のページ遷移から `PageView` コンポーネントのレンダリングまでのデータフローを、Jotai atom の役割に焦点を当てて概説します。
+
+#### 登場人物
+
+1.  **`[[...path]].page.tsx`**: Next.js の動的ルーティングを担うメインコンポーネント。
+2.  **`useSameRouteNavigation.ts`**: クライアントサイドでのパス変更を検知し、データ取得をトリガーするフック。
+3.  **`useFetchCurrentPage.ts`**: データ取得と関連する Jotai atom の更新を一元管理する、本リファクタリングの心臓部。
+4.  **`useShallowRouting.ts`**: サーバーサイドで正規化されたパスとブラウザのURLを同期させるフック。
+5.  **`server-side-props.ts`**: サーバーサイドレンダリング(SSR)時にページデータを取得し、`props` としてページコンポーネントに渡す。
+
+---
+
+#### フロー1: サーバーサイドレンダリング(初回アクセス時)
+
+1.  **リクエスト受信**: ユーザーがURL(例: `/user/sotarok/memo`)に直接アクセスします。
+2.  **`getServerSideProps` の実行**:
+    - `server-side-props.ts` の `getServerSidePropsForInitial` が実行されます。
+    - `retrievePageData` が呼び出され、パスの正規化(例: `/user/sotarok` → `/user/sotarok/`)が行われ、APIからページデータを取得します。
+    - 取得したデータと、正規化後のパス (`currentPathname`) を `props` として `[[...path]].page.tsx` に渡します。
+3.  **コンポーネントのレンダリング**:
+    - `[[...path]].page.tsx` は `props` を受け取り、`PageView` などのコンポーネントをレンダリングします。
+    - 同時に **`useShallowRouting`** が実行されます。
+4.  **URLの正規化**:
+    - `useShallowRouting` は、ブラウザのURL (`/user/sotarok/memo`) と `props.currentPathname` (`/user/sotarok/memo/`) を比較します。
+    - 差異がある場合、`router.replace` を `shallow: true` で実行し、ブラウザのURLをサーバーが認識している正規化後のパスに静かに更新します。
+
+---
+
+#### フロー2: クライアントサイドナビゲーション(`<Link>` クリック時)
+
+1.  **ナビゲーション開始**:
+    - ユーザーが `<Link href="/new/page">` をクリックします。
+    - Next.js の `useRouter` がURLの変更を検出し、`[[...path]].page.tsx` が再評価されます。
+2.  **`useSameRouteNavigation` によるトリガー**:
+    - このフックの `useEffect` が `router.asPath` の変更 (`/new/page`) を検知します。
+    - **`fetchCurrentPage({ path: '/new/page' })`** を呼び出します。このフックの役割は、データ取得の「トリガー」に専念します。
+3.  **`useFetchCurrentPage` によるデータ取得と状態更新**:
+    - `fetchCurrentPage` 関数が実行されます。
+    - **3a. 読み込み状態開始**: `pageLoadingAtom` を `true` に設定。
+    - **3b. パス解析**: 引数の `path` をデコードし、パーマリンクかどうかを判定します。
+    - **3c. APIパラメータ構築**: パスがパーマリンクなら `pageId`、通常パスなら `path` を使ってAPIリクエストのパラメータを組み立てます。
+    - **3d. API通信**: `apiv3Get('/page', ...)` を実行します。
+4.  **アトミックな状態更新**:
+    - APIからデータが返ってきた後、関連する **すべてのatomを一度に更新** します。
+        - `currentPageDataAtom`, `currentPageIdAtom`, `pageNotFoundAtom`, `pageLoadingAtom` など。
+    - これにより、中間的な状態(`pageId`が`undefined`になるなど)が発生することなく、データが完全に揃った状態で一度だけ状態が更新されます。
+5.  **`PageView` の最終レンダリング**:
+    - `currentPageDataAtom` の更新がトリガーとなり、`PageView` コンポーネントが新しいデータで再レンダリングされます。

+ 1 - 1
apps/app/src/pages/[[...path]].page.tsx

@@ -153,7 +153,7 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
   useSetupGlobalSocketForPage(pageId);
 
   // Use custom hooks for navigation and routing
-  useSameRouteNavigation(props);
+  useSameRouteNavigation();
   useShallowRouting(props);
 
   // Optimized effects with minimal dependencies

+ 0 - 1
apps/app/src/pages/[[...path]]/hooks/index.ts

@@ -8,4 +8,3 @@
 
 export { useSameRouteNavigation } from '../use-same-route-navigation';
 export { useShallowRouting } from '../use-shallow-routing';
-export { extractPageIdFromPathname } from '../navigation-utils';

+ 0 - 45
apps/app/src/pages/[[...path]]/navigation-utils.ts

@@ -1,45 +0,0 @@
-import { isPermalink } from '@growi/core/dist/utils/page-path-utils';
-import { removeHeadingSlash } from '@growi/core/dist/utils/path-utils';
-
-import type { Props, InitialProps, SameRouteEachProps } from './types';
-
-/**
- * Extract pageId from pathname efficiently
- * Returns null for non-permalink paths to optimize conditional checks
- */
-export const extractPageIdFromPathname = (pathname: string): string | null => {
-  return isPermalink(pathname) ? removeHeadingSlash(pathname) : null;
-};
-/**
- * Type guard to check if props are initial props
- * Returns true if props contain initial data from SSR
- */
-export const isInitialProps = (props: Props): props is (InitialProps & SameRouteEachProps) => {
-  return 'isNextjsRoutingTypeInitial' in props && props.isNextjsRoutingTypeInitial;
-};
-/**
- * Determines if page data should be fetched based on current state
- * Pure function with no side effects for better testability
- */
-export interface ShouldFetchPageParams {
-  targetPageId: string | null;
-  targetPathname: string;
-  currentPageId?: string | null;
-  currentPagePath?: string | null;
-}
-
-export const shouldFetchPage = (params: ShouldFetchPageParams): boolean => {
-  const {
-    currentPagePath, targetPageId, currentPageId, targetPathname,
-  } = params;
-
-  // Always fetch if:
-  // 1. No current page data
-  // 2. Different page ID (only if both are defined)
-  // 3. Different path
-  return (
-    !currentPagePath // No current page
-    || (targetPageId != null && currentPageId != null && currentPageId !== targetPageId) // Different page ID (strict comparison)
-    || (currentPagePath !== targetPathname) // Different path
-  );
-};

+ 11 - 35
apps/app/src/pages/[[...path]]/use-same-route-navigation.spec.tsx

@@ -11,7 +11,6 @@ import { mockDeep } from 'vitest-mock-extended';
 
 // eslint-disable-next-line no-restricted-imports
 import * as apiv3Client from '~/client/util/apiv3-client';
-import type { Props } from '~/pages/[[...path]]/types';
 import { useSameRouteNavigation } from '~/pages/[[...path]]/use-same-route-navigation';
 import { currentPageDataAtom, currentPageIdAtom } from '~/states/page/internal-atoms';
 
@@ -96,17 +95,12 @@ describe('useSameRouteNavigation - Integration Test', () => {
   let store: ReturnType<typeof createStore>;
 
   // Helper to render the hook with Jotai provider
-  const renderHookWithProvider = (props: Props) => {
-    return renderHook(() => useSameRouteNavigation(props), {
+  const renderHookWithProvider = () => {
+    return renderHook(() => useSameRouteNavigation(), {
       wrapper: ({ children }) => <Provider store={store}>{children}</Provider>,
     });
   };
 
-  const createProps = (currentPathname: string): Props => {
-    // Create a minimal props object that satisfies the type checker
-    return { currentPathname, ...{ isNextjsRoutingTypeInitial: false } } as unknown as Props;
-  };
-
   const mockApiResponse = (page: IPagePopulatedToShowRevision): AxiosResponse<{ page: IPagePopulatedToShowRevision }> => {
     return {
       data: { page },
@@ -143,8 +137,8 @@ describe('useSameRouteNavigation - Integration Test', () => {
     mockedApiv3Get.mockResolvedValue(mockApiResponse(newPageData));
 
     // Act
-    const { rerender } = renderHookWithProvider(createProps('/initial/path'));
-    rerender(createProps('/new/page')); // Rerender with new props to simulate navigation
+    const { rerender } = renderHookWithProvider();
+    rerender(); // Rerender to simulate navigation
 
     // Assert: Wait for state updates
     await waitFor(() => {
@@ -160,26 +154,7 @@ describe('useSameRouteNavigation - Integration Test', () => {
     });
   });
 
-  it('should use router.asPath as the source of truth, not stale props.currentPathname', async() => {
-    // Arrange: props.currentPathname is stale
-    const props = createProps('/stale/props/path');
-    // Arrange: router.asPath is the current, correct path
-    mockRouter.asPath = '/actual/browser/path';
-    const actualPageData = createPageDataMock('actualPageId', '/actual/browser/path', 'actual content');
-    mockedApiv3Get.mockResolvedValue(mockApiResponse(actualPageData));
-
-    // Act
-    renderHookWithProvider(props);
-
-    // Assert
-    await waitFor(() => {
-      expect(mockedApiv3Get).toHaveBeenCalledWith('/page', expect.objectContaining({ path: '/actual/browser/path' }));
-      expect(mockedApiv3Get).not.toHaveBeenCalledWith('/page', expect.objectContaining({ path: '/stale/props/path' }));
-      expect(store.get(currentPageIdAtom)).toBe(actualPageData._id);
-    });
-  });
-
-  it('should not re-fetch if target path and page id are the same as current', async() => {
+  it('should not re-fetch if target path is the same as current', async() => {
     // Arrange: Current state is set
     const currentPageData = createPageDataMock('page1', '/same/path', 'current content');
     store.set(currentPageIdAtom, currentPageData._id);
@@ -187,8 +162,8 @@ describe('useSameRouteNavigation - Integration Test', () => {
     mockRouter.asPath = '/same/path';
 
     // Act
-    const { rerender } = renderHookWithProvider(createProps('/same/path'));
-    rerender(createProps('/same/path')); // Rerender with same props
+    const { rerender } = renderHookWithProvider();
+    rerender(); // Rerender with same path
 
     // Assert
     // Use a short timeout to ensure no fetch is initiated
@@ -201,9 +176,10 @@ describe('useSameRouteNavigation - Integration Test', () => {
     // Arrange: Start on a regular page
     const regularPageData = createPageDataMock('regularPageId', '/some/page', 'Regular page content');
     mockedApiv3Get.mockResolvedValue(mockApiResponse(regularPageData));
-    mockRouter.asPath = '/some/page';
 
-    const { rerender } = renderHookWithProvider(createProps('/some/page'));
+    // Set initial router path and render
+    mockRouter.asPath = '/some/page';
+    const { rerender } = renderHookWithProvider();
 
     await waitFor(() => {
       expect(store.get(currentPageIdAtom)).toBe('regularPageId');
@@ -217,7 +193,7 @@ describe('useSameRouteNavigation - Integration Test', () => {
     mockRouter.asPath = '/';
 
     // Act
-    rerender(createProps('/'));
+    rerender();
 
     // Assert: Navigation to root works
     await waitFor(() => {

+ 16 - 213
apps/app/src/pages/[[...path]]/use-same-route-navigation.ts

@@ -1,229 +1,32 @@
-import {
-  useEffect, useRef, useMemo, useCallback,
-} from 'react';
+import { useEffect } from 'react';
 
 import { useRouter } from 'next/router';
 
-
-import {
-  useCurrentPageData, useFetchCurrentPage, useCurrentPageId,
-} from '~/states/page';
+import { useCurrentPageData, useFetchCurrentPage } from '~/states/page';
 import { useEditingMarkdown } from '~/stores/editor';
-import loggerFactory from '~/utils/logger';
-
-import { extractPageIdFromPathname, isInitialProps, shouldFetchPage } from './navigation-utils';
-import type { Props } from './types';
-
-const logger = loggerFactory('growi:hooks:useSameRouteNavigation');
-
-/**
- * Custom hook to calculate the target pathname for navigation
- * Uses router.asPath as the primary source of truth for current location
- */
-const useNavigationTarget = (router: ReturnType<typeof useRouter>, props: Props): string => {
-  return useMemo(() => {
-    // Always prefer router.asPath for accurate browser state
-    return router.asPath || props.currentPathname;
-  }, [router.asPath, props.currentPathname]);
-};
-
-/**
- * Handles page state updates during navigation
- * Centralizes all page-related state management logic
- */
-const usePageStateManager = () => {
-  const [pageId, setCurrentPageId] = useCurrentPageId();
-  const { fetchCurrentPage } = useFetchCurrentPage();
-  const { mutate: mutateEditingMarkdown } = useEditingMarkdown();
-
-  const updatePageState = useCallback(async(targetPathname: string, targetPageId: string | null) => {
-    console.log('[NAV-DEBUG] updatePageState called:', {
-      targetPathname,
-      targetPageId,
-      isRootPage: targetPathname === '/',
-      timestamp: new Date().toISOString(),
-    });
 
-    try {
-      // BACK TO ORIGINAL: Only update pageId if it actually changes
-      const currentPageId = pageId;
-      if (currentPageId !== targetPageId) {
-        console.log('[NAV-DEBUG] Updating pageId:', { from: currentPageId, to: targetPageId });
-        setCurrentPageId(targetPageId || undefined);
-      }
-      else {
-        console.log('[NAV-DEBUG] PageId unchanged, skipping update');
-      }
-
-      // Fetch page data
-      const fetchStartTime = performance.now();
-      console.log('[NAV-DEBUG] Calling fetchCurrentPage with:', targetPathname, 'at', fetchStartTime, 'ms');
-      const pageData = await fetchCurrentPage(targetPathname);
-      const fetchEndTime = performance.now();
-      console.log('[NAV-DEBUG] fetchCurrentPage completed in:', fetchEndTime - fetchStartTime, 'ms');
-
-      // Update editing markdown if we have body content
-      if (pageData?.revision?.body !== undefined) {
-        const markdownUpdateStartTime = performance.now();
-        console.log('[NAV-DEBUG] Updating editing markdown, body length:', pageData.revision.body.length);
-        mutateEditingMarkdown(pageData.revision.body);
-        const markdownUpdateEndTime = performance.now();
-        console.log('[NAV-DEBUG] Markdown update completed in:', markdownUpdateEndTime - markdownUpdateStartTime, 'ms');
-      }
-
-      console.log('[NAV-DEBUG] Navigation successful for:', targetPathname);
-      return true; // Success
-    }
-    catch (error) {
-      logger.error('Navigation failed for pathname:', targetPathname, error);
-      console.log('[NAV-DEBUG] Navigation failed:', { targetPathname, error });
-      return false; // Failure
-    }
-  }, [pageId, setCurrentPageId, fetchCurrentPage, mutateEditingMarkdown]);
-
-  return { pageId, updatePageState };
-};
-
-/**
- * Custom hook to check if initial data should be used
- */
-const useInitialDataCheck = (props: Props): boolean => {
-  return useMemo(() => {
-    const skipSSR = isInitialProps(props) ? props.skipSSR : false;
-    return isInitialProps(props) && !skipSSR;
-  }, [props]);
-};
-
-/**
- * Custom hook for handling same-route navigation and fetching page data when needed
- * Optimized for minimal re-renders and efficient state updates using centralized navigation state
- */
-export const useSameRouteNavigation = (props: Props): void => {
+export const useSameRouteNavigation = (): void => {
   const router = useRouter();
   const [currentPage] = useCurrentPageData();
+  const { fetchCurrentPage } = useFetchCurrentPage();
+  const { mutate: mutateEditingMarkdown } = useEditingMarkdown();
 
-  // Use custom hooks for better separation of concerns
-  const targetPathname = useNavigationTarget(router, props);
-  const hasInitialData = useInitialDataCheck(props);
-  const { pageId, updatePageState } = usePageStateManager();
-
-  // Track the last processed pathname to prevent unnecessary operations
-  const lastProcessedPathnameRef = useRef<string | null>(null);
-  const lastProcessedPageIdRef = useRef<string | null>(null);
-  const isFetchingRef = useRef<boolean>(false);
-
-  // Process pathname changes - monitor both props.currentPathname and router.asPath
+  // useEffect to trigger data fetching when the path changes
   useEffect(() => {
-    const startTime = performance.now();
-    console.log('[NAV-DEBUG] useEffect triggered:', {
-      targetPathname,
-      lastProcessedPathname: lastProcessedPathnameRef.current,
-      hasInitialData,
-      currentPageId: pageId,
-      currentPagePath: currentPage?.path,
-      isFetching: isFetchingRef.current,
-      timestamp: new Date().toISOString(),
-      performanceStart: startTime,
-    });
-
-    // Skip if we already processed this pathname
-    if (lastProcessedPathnameRef.current === targetPathname) {
-      console.log('[NAV-DEBUG] Skipping - already processed:', targetPathname);
-      return;
-    }
-
-    // Skip if we have initial data and don't need to refetch
-    if (hasInitialData) {
-      console.log('[NAV-DEBUG] Skipping - has initial data');
-      lastProcessedPathnameRef.current = targetPathname;
-      return;
-    }
-
-    // Check if we need to fetch data
-    const targetPageId = extractPageIdFromPathname(targetPathname);
+    const targetPath = router.asPath;
     const currentPagePath = currentPage?.path;
 
-    console.log('[NAV-DEBUG] Checking if should fetch:', {
-      targetPageId,
-      targetPathname,
-      currentPageId: pageId,
-      currentPagePath,
-    });
-
-    // Enhanced duplicate check: prevent navigation to same page by both pathname and pageId
-    const isSamePathname = lastProcessedPathnameRef.current === targetPathname;
-    const isSamePageId = targetPageId && lastProcessedPageIdRef.current === targetPageId;
-    const isCurrentPageSame = pageId && pageId === targetPageId;
-
-    if (isSamePathname || isSamePageId || isCurrentPageSame) {
-      console.log('[NAV-DEBUG] Skipping - same page detected:', {
-        isSamePathname,
-        isSamePageId,
-        isCurrentPageSame,
-        lastProcessedPathname: lastProcessedPathnameRef.current,
-        lastProcessedPageId: lastProcessedPageIdRef.current,
-        currentPageId: pageId,
-      });
-      // Update tracking refs even when skipping
-      lastProcessedPathnameRef.current = targetPathname;
-      if (targetPageId) lastProcessedPageIdRef.current = targetPageId;
-      return;
-    }
-
-    // Use extracted shouldFetchPage function
-    const shouldFetch = shouldFetchPage({
-      targetPageId,
-      targetPathname,
-      currentPageId: pageId,
-      currentPagePath,
-    });
-
-    console.log('[NAV-DEBUG] shouldFetch result:', shouldFetch);
-
-    if (!shouldFetch) {
-      console.log('[NAV-DEBUG] Skipping - shouldFetch is false');
-      lastProcessedPathnameRef.current = targetPathname;
-      return;
-    }
-
-    // Prevent concurrent fetches
-    if (isFetchingRef.current) {
-      console.log('[NAV-DEBUG] Skipping - already fetching');
+    // Do nothing if the target path is the same as the currently loaded page's path
+    if (targetPath === currentPagePath) {
       return;
     }
 
-    isFetchingRef.current = true;
-    console.log('[NAV-DEBUG] Starting navigation for:', targetPathname, 'at', performance.now() - startTime, 'ms');
-
-    const performNavigation = async(): Promise<void> => {
-      const navigationStartTime = performance.now();
-      await updatePageState(targetPathname, targetPageId);
-      const navigationEndTime = performance.now();
-
-      // Mark as processed regardless of success to prevent retry loops
-      lastProcessedPathnameRef.current = targetPathname;
-      if (targetPageId) lastProcessedPageIdRef.current = targetPageId;
-      isFetchingRef.current = false;
-      console.log('[NAV-DEBUG] Navigation completed for:', targetPathname, {
-        navigationDuration: navigationEndTime - navigationStartTime,
-        totalDuration: navigationEndTime - startTime,
-        timestamp: new Date().toISOString(),
-      });
-    };
-
-    performNavigation();
-
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-  }, [
-    targetPathname, // Memoized value that includes both router.asPath and props.currentPathname
-    hasInitialData, // Memoized value for initial data check
-    // pageId removed to prevent infinite loops when fetchCurrentPage updates the pageId atom
-  ]);
-
-  // Cleanup on unmount
-  useEffect(() => {
-    return () => {
-      isFetchingRef.current = false;
+    const fetch = async() => {
+      const pageData = await fetchCurrentPage({ path: targetPath });
+      if (pageData?.revision?.body != null) {
+        mutateEditingMarkdown(pageData.revision.body);
+      }
     };
-  }, []);
+    fetch();
+  }, [router.asPath, currentPage?.path, fetchCurrentPage, mutateEditingMarkdown]);
 };

+ 63 - 72
apps/app/src/states/page/use-fetch-current-page.ts

@@ -2,7 +2,8 @@ import { useCallback } from 'react';
 
 import type { IPagePopulatedToShowRevision } from '@growi/core';
 import { isClient } from '@growi/core/dist/utils';
-import { isCreatablePage } from '@growi/core/dist/utils/page-path-utils';
+import { isCreatablePage, isPermalink } from '@growi/core/dist/utils/page-path-utils';
+import { removeHeadingSlash } from '@growi/core/dist/utils/path-utils';
 import { useAtomValue } from 'jotai';
 import { useAtomCallback } from 'jotai/utils';
 
@@ -10,25 +11,21 @@ import { apiv3Get } from '~/client/util/apiv3-client';
 import { useShareLinkId } from '~/stores-universal/context';
 
 import {
-  currentPageIdAtom, currentPageDataAtom, pageNotFoundAtom, pageNotCreatableAtom,
-  pageLoadingAtom, pageErrorAtom,
+  currentPageDataAtom, currentPageIdAtom, pageErrorAtom, pageLoadingAtom, pageNotCreatableAtom, pageNotFoundAtom,
 } from './internal-atoms';
 
-// API parameter types for better type safety
-interface PageApiParams {
-  pageId?: string;
-  path?: string;
-  shareLinkId?: string;
-  revisionId?: string;
+type FetchPageArgs = {
+  path?: string,
+  pageId?: string,
+  revisionId?: string,
 }
 
-
 /**
  * Simplified page fetching hook using Jotai state management
  * All state is managed through atoms for consistent global state
  */
 export const useFetchCurrentPage = (): {
-  fetchCurrentPage: (currentPathname?: string) => Promise<IPagePopulatedToShowRevision | null>,
+  fetchCurrentPage: (args?: FetchPageArgs) => Promise<IPagePopulatedToShowRevision | null>,
   isLoading: boolean,
   error: Error | null,
 } => {
@@ -39,70 +36,68 @@ export const useFetchCurrentPage = (): {
   const error = useAtomValue(pageErrorAtom);
 
   const fetchCurrentPage = useAtomCallback(
-    useCallback(async(get, set, currentPathname?: string) => {
-      const fetchStartTime = performance.now();
-      const currentPath = currentPathname || (isClient() ? decodeURIComponent(window.location.pathname) : '');
+    useCallback(async(get, set, args?: FetchPageArgs) => {
+      set(pageLoadingAtom, true);
+      set(pageErrorAtom, null);
 
       const currentPageId = get(currentPageIdAtom);
 
-      // DEBUG: Log detailed navigation state
-      console.log('[FETCH-DEBUG] useFetchCurrentPage called:', {
-        currentPathname,
-        currentPath,
-        currentPageId,
-        timestamp: new Date().toISOString(),
-        performanceStart: fetchStartTime,
-        isRootPage: currentPath === '/',
-      });
-
-      // Get URL parameter for specific revisionId - only when needed
-      const revisionId = isClient() && window.location.search
-        ? new URLSearchParams(window.location.search).get('revisionId') || undefined
-        : undefined;
+      // determine parameters
+      const path = args?.path;
+      const pageId = args?.pageId;
+      const revisionId = args?.revisionId ?? (isClient() ? new URLSearchParams(window.location.search).get('revisionId') : undefined);
 
-      set(pageLoadingAtom, true);
-      set(pageErrorAtom, null);
+      // params for API
+      const params: { path?: string, pageId?: string, revisionId?: string, shareLinkId?: string } = {};
+      if (shareLinkId != null) {
+        params.shareLinkId = shareLinkId;
+      }
+      if (revisionId != null) {
+        params.revisionId = revisionId;
+      }
 
-      try {
-        // Build API parameters with type safety
-        const apiParams: PageApiParams = {
-          ...(shareLinkId && { shareLinkId }),
-          ...(revisionId && { revisionId }),
-        };
-
-        // Use pageId when available, fallback to path
-        if (currentPageId) {
-          apiParams.pageId = currentPageId;
-          console.log('[FETCH-DEBUG] Using pageId:', currentPageId);
+      // Process path first to handle permalinks
+      let decodedPath: string | undefined;
+      if (path != null) {
+        try {
+          decodedPath = decodeURIComponent(path);
         }
-        else if (currentPath) {
-          apiParams.path = currentPath;
-          console.log('[FETCH-DEBUG] Using path:', currentPath);
+        catch (e) {
+          decodedPath = path;
         }
-        else {
-          console.log('[FETCH-DEBUG] No valid identifier, returning null');
-          return null; // No valid identifier
+      }
+
+      // priority: pageId > permalink > path
+      if (pageId != null) {
+        params.pageId = pageId;
+      }
+      else if (decodedPath != null && isPermalink(decodedPath)) {
+        params.pageId = removeHeadingSlash(decodedPath);
+      }
+      else if (decodedPath != null) {
+        params.path = decodedPath;
+      }
+      // if args is empty, get from global state
+      else if (currentPageId != null) {
+        params.pageId = currentPageId;
+      }
+      else if (isClient()) {
+        try {
+          params.path = decodeURIComponent(window.location.pathname);
+        }
+        catch (e) {
+          params.path = window.location.pathname;
         }
+      }
+      else {
+        // TODO: https://github.com/weseek/growi/pull/9118
+        // throw new Error('Either path or pageId must be provided when not in a browser environment');
+        return null;
+      }
 
-        const apiStartTime = performance.now();
-        const response = await apiv3Get<{ page: IPagePopulatedToShowRevision }>(
-          '/page',
-          apiParams,
-        );
-        const apiEndTime = performance.now();
-
-        const newData = response.data.page;
-
-        // DEBUG: Log successful fetch result
-        console.log('[FETCH-DEBUG] Page fetched successfully:', {
-          pageId: newData?._id,
-          path: newData?.path,
-          isRootPage: newData?.path === '/',
-          previousPageId: currentPageId,
-          pageIdChanged: newData?._id !== currentPageId,
-          apiDuration: apiEndTime - apiStartTime,
-          totalDuration: apiEndTime - fetchStartTime,
-        });
+      try {
+        const { data } = await apiv3Get<{ page: IPagePopulatedToShowRevision }>('/page', params);
+        const { page: newData } = data;
 
         // Batch atom updates to minimize re-renders
         set(currentPageDataAtom, newData);
@@ -110,10 +105,6 @@ export const useFetchCurrentPage = (): {
 
         // Update pageId atom if data differs from current
         if (newData?._id !== currentPageId) {
-          console.log('[FETCH-DEBUG] Updating pageId atom:', {
-            from: currentPageId,
-            to: newData?._id,
-          });
           set(currentPageIdAtom, newData?._id);
         }
 
@@ -128,8 +119,8 @@ export const useFetchCurrentPage = (): {
           set(pageNotFoundAtom, true);
           set(currentPageDataAtom, undefined);
 
-          if (currentPath) {
-            set(pageNotCreatableAtom, !isCreatablePage(currentPath));
+          if (path != null) {
+            set(pageNotCreatableAtom, !isCreatablePage(path));
           }
           return null;
         }