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

Merge pull request #10749 from growilabs/copilot/fix-search-redirect-issues

fix: Search navigation
mergify[bot] 2 месяцев назад
Родитель
Сommit
b5a52a149f

+ 1 - 1
apps/app/AGENTS.md

@@ -25,7 +25,7 @@ pnpm run dev:migrate            # Run database migrations
 # Quality Checks
 pnpm run lint:typecheck         # TypeScript type check
 pnpm run lint:biome             # Biome linter
-pnpm run test            # Run tests
+pnpm run test                   # Run tests
 
 # Build
 pnpm run build                  # Build for production

+ 67 - 0
apps/app/playwright/30-search/search.spect.ts → apps/app/playwright/30-search/search.spec.ts

@@ -239,3 +239,70 @@ test('Search current tree by word is successfully loaded', async ({ page }) => {
   await expect(page.getByTestId('search-result-list')).toBeVisible();
   await expect(page.getByTestId('search-result-content')).toBeVisible();
 });
+
+test.describe('Search result navigation and repeated search', () => {
+  test('Repeated search works', async ({ page }) => {
+    // Step 1: Start from the home page and reload to clear any state
+    await page.goto('/');
+    await page.reload();
+
+    // Step 2: Open search modal and search for "sandbox"
+    await page.getByTestId('open-search-modal-button').click();
+    await expect(page.getByTestId('search-modal')).toBeVisible();
+    await page.locator('.form-control').fill('sandbox');
+
+    // Step 3: Submit the search by clicking on "search in all" menu item
+    await expect(page.getByTestId('search-all-menu-item')).toBeVisible();
+    await page.getByTestId('search-all-menu-item').click();
+
+    // Step 4: Verify that the search page is displayed with results
+    await expect(page.getByTestId('search-result-base')).toBeVisible();
+    await expect(page.getByTestId('search-result-list')).toBeVisible();
+    await expect(page.getByTestId('search-result-content')).toBeVisible();
+    await expect(page).toHaveURL(/\/_search\?q=sandbox/);
+
+    // Step 5: Click on the first search result to navigate to a page
+    const sandboxPageLink = page
+      .getByTestId('search-result-list')
+      .getByRole('link', { name: 'Sandbox' })
+      .first();
+    await sandboxPageLink.click();
+    await expect(page).toHaveTitle(/Sandbox/);
+
+    // Step 6: Wait for leaving search results and verify page content is displayed
+    await expect(page.getByTestId('search-result-base')).not.toBeVisible();
+    // Verify page body is rendered (not empty due to stale atom data)
+    await expect(page.locator('.wiki')).toBeVisible();
+    await expect(page.locator('.wiki')).not.toBeEmpty();
+
+    // Step 7: From the navigated page, open search modal again
+    await page.getByTestId('open-search-modal-button').click();
+    await expect(page.getByTestId('search-modal')).toBeVisible();
+
+    // Step 8: Search for the same keyword ("sandbox")
+    await page.locator('.form-control').fill('sandbox');
+
+    // Step 9: Submit the search by clicking on "search in all" menu item
+    await expect(page.getByTestId('search-all-menu-item')).toBeVisible();
+    await page.getByTestId('search-all-menu-item').click();
+
+    // Step 10: Verify that the search page is displayed with results
+    await expect(page.getByTestId('search-result-base')).toBeVisible();
+    await expect(page.getByTestId('search-result-list')).toBeVisible();
+    await expect(page.getByTestId('search-result-content')).toBeVisible();
+    await expect(page).toHaveURL(/\/_search\?q=sandbox/);
+
+    // Step 11: Click on the second search result to navigate to a page
+    const mathPageLink = page
+      .getByTestId('search-result-list')
+      .getByRole('link', { name: 'Math' })
+      .first();
+    await mathPageLink.click();
+    // and verify the page that is not Sandbox is loaded
+    await expect(page).not.toHaveTitle(/Sandbox/);
+
+    // Step 12: Verify page body is rendered (not empty due to stale atom data)
+    await expect(page.locator('.wiki')).toBeVisible();
+    await expect(page.locator('.wiki')).not.toBeEmpty();
+  });
+});

+ 2 - 2
apps/app/src/features/search/client/components/SearchModal.tsx

@@ -38,8 +38,8 @@ const SearchModalSubstance = (props: Props): JSX.Element => {
   }, []);
 
   const selectSearchMenuItemHandler = useCallback(
-    (selectedItem: DownshiftItem) => {
-      router.push(selectedItem.url);
+    async (selectedItem: DownshiftItem) => {
+      await router.push(selectedItem.url);
       closeSearchModal();
     },
     [closeSearchModal, router],

+ 9 - 18
apps/app/src/pages/[[...path]]/index.page.tsx

@@ -36,10 +36,7 @@ import { getServerSideCommonEachProps } from '../common-props';
 import { useInitialCSRFetch } from '../general-page';
 import { useHydrateGeneralPageConfigurationAtoms } from '../general-page/hydrate';
 import { registerPageToShowRevisionWithMeta } from '../general-page/superjson';
-import {
-  detectNextjsRoutingType,
-  NextjsRoutingType,
-} from '../utils/nextjs-routing-utils';
+import { NextjsRoutingType } from '../utils/nextjs-routing-utils';
 import { useCustomTitleForPage } from '../utils/page-title-customization';
 import { mergeGetServerSidePropsResults } from '../utils/server-side-props';
 import { NEXT_JS_ROUTING_PAGE } from './consts';
@@ -96,11 +93,8 @@ const EditablePageEffects = dynamic(
 
 type Props = EachProps | InitialProps;
 
-const isInitialProps = (props: Props): props is InitialProps => {
-  return (
-    'isNextjsRoutingTypeInitial' in props && props.isNextjsRoutingTypeInitial
-  );
-};
+const isInitialProps = (props: Props): props is InitialProps =>
+  props.nextjsRoutingType !== NextjsRoutingType.SAME_ROUTE;
 
 const Page: NextPageWithLayout<Props> = (props: Props) => {
   // Initialize Jotai atoms with initial data - must be called unconditionally
@@ -130,8 +124,11 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
   useSameRouteNavigation();
   useShallowRouting(props);
 
-  // If initial props and skipSSR, fetch page data on client-side
-  useInitialCSRFetch(isInitialProps(props) && props.skipSSR);
+  // Fetch page data on client-side when SSR is skipped or navigating from outside
+  useInitialCSRFetch({
+    nextjsRoutingType: props.nextjsRoutingType,
+    skipSSR: isInitialProps(props) ? props.skipSSR : false,
+  });
 
   useEffect(() => {
     // Initialize editing markdown only when page path changes
@@ -269,16 +266,10 @@ export const getServerSideProps: GetServerSideProps<Props> = async (
   // STAGE 2
   //
 
-  // detect Next.js routing type
-  const nextjsRoutingType = detectNextjsRoutingType(
-    context,
-    NEXT_JS_ROUTING_PAGE,
-  );
-
   // Merge all results in a type-safe manner (using sequential merging)
   return mergeGetServerSidePropsResults(
     commonEachPropsResult,
-    nextjsRoutingType === NextjsRoutingType.SAME_ROUTE
+    commonEachProps.nextjsRoutingType === NextjsRoutingType.SAME_ROUTE
       ? await getServerSidePropsForSameRoute(context)
       : await getServerSidePropsForInitial(context),
   );

+ 21 - 13
apps/app/src/pages/[[...path]]/server-side-props.ts

@@ -10,6 +10,7 @@ import type { CrowiRequest } from '~/interfaces/crowi-request';
 
 import { getServerSideBasicLayoutProps } from '../basic-layout-page';
 import {
+  getServerSideCommonEachProps,
   getServerSideCommonInitialProps,
   getServerSideI18nProps,
 } from '../common-props';
@@ -86,6 +87,7 @@ export async function getServerSidePropsForInitial(
   context: GetServerSidePropsContext,
 ): Promise<GetServerSidePropsResult<Stage2InitialProps>> {
   const [
+    commonEachResult,
     commonInitialResult,
     basicLayoutResult,
     generalPageResult,
@@ -93,6 +95,7 @@ export async function getServerSidePropsForInitial(
     i18nPropsResult,
     pageDataResult,
   ] = await Promise.all([
+    getServerSideCommonEachProps(context),
     getServerSideCommonInitialProps(context),
     getServerSideBasicLayoutProps(context),
     getServerSideGeneralPageProps(context),
@@ -102,22 +105,29 @@ export async function getServerSidePropsForInitial(
   ]);
 
   // Merge all results in a type-safe manner (using sequential merging)
-  const mergedResult = mergeGetServerSidePropsResults(
-    commonInitialResult,
+  const mergedResult: GetServerSidePropsResult<Stage2InitialProps> =
     mergeGetServerSidePropsResults(
-      basicLayoutResult,
+      commonEachResult,
       mergeGetServerSidePropsResults(
-        generalPageResult,
+        commonInitialResult,
         mergeGetServerSidePropsResults(
-          rendererConfigResult,
+          basicLayoutResult,
           mergeGetServerSidePropsResults(
-            i18nPropsResult,
-            mergeGetServerSidePropsResults(pageDataResult, nextjsRoutingProps),
+            generalPageResult,
+            mergeGetServerSidePropsResults(
+              rendererConfigResult,
+              mergeGetServerSidePropsResults(
+                i18nPropsResult,
+                mergeGetServerSidePropsResults(
+                  pageDataResult,
+                  nextjsRoutingProps,
+                ),
+              ),
+            ),
           ),
         ),
       ),
-    ),
-  );
+    );
 
   // Check for early return (redirect/notFound)
   if ('redirect' in mergedResult || 'notFound' in mergedResult) {
@@ -187,10 +197,8 @@ export async function getServerSidePropsForSameRoute(
   })();
   addActivity(context, activityAction);
 
-  const mergedResult = mergeGetServerSidePropsResults(
-    { props: pageDataProps },
-    i18nPropsResult,
-  );
+  const mergedResult: GetServerSidePropsResult<Stage2EachProps> =
+    mergeGetServerSidePropsResults({ props: pageDataProps }, i18nPropsResult);
 
   return mergedResult;
 }

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

@@ -57,11 +57,15 @@ describe('useSameRouteNavigation', () => {
   });
 
   it('should call fetchCurrentPage and mutateEditingMarkdown on path change', async () => {
-    // Arrange
+    // Arrange: Initial render (SSR case - no fetch on initial render)
     mockRouter.asPath = '/initial-path';
     const { rerender } = renderHook(() => useSameRouteNavigation());
 
-    // Act: Simulate navigation
+    // Assert: No fetch on initial render (useRef previousPath is null)
+    expect(mockFetchCurrentPage).not.toHaveBeenCalled();
+    expect(mockSetEditingMarkdown).not.toHaveBeenCalled();
+
+    // Act: Simulate CSR navigation to a new path
     mockRouter.asPath = '/new-path';
     rerender();
 
@@ -78,49 +82,46 @@ describe('useSameRouteNavigation', () => {
   });
 
   it('should not trigger effects if the path does not change', async () => {
-    // Arrange
+    // Arrange: Initial render
     mockRouter.asPath = '/same-path';
     const { rerender } = renderHook(() => useSameRouteNavigation());
-    // call on initial render
-    await waitFor(() => {
-      expect(mockFetchCurrentPage).toHaveBeenCalledTimes(1);
-      expect(mockSetEditingMarkdown).toHaveBeenCalledTimes(1);
-    });
 
-    // Act: Rerender with the same path
+    // Initial render should not trigger fetch (previousPath is null initially)
+    expect(mockFetchCurrentPage).not.toHaveBeenCalled();
+    expect(mockSetEditingMarkdown).not.toHaveBeenCalled();
+
+    // Act: Rerender with the same path (simulates a non-navigation re-render)
     rerender();
 
-    // Assert
-    // A short delay to ensure no async operations are triggered
+    // Assert: Still no fetch because path hasn't changed
     await new Promise((resolve) => setTimeout(resolve, 100));
-    expect(mockFetchCurrentPage).toHaveBeenCalledTimes(1); // Should not be called again
-    expect(mockSetEditingMarkdown).toHaveBeenCalledTimes(1);
+    expect(mockFetchCurrentPage).not.toHaveBeenCalled();
+    expect(mockSetEditingMarkdown).not.toHaveBeenCalled();
   });
 
   it('should not call mutateEditingMarkdown if pageData or revision is null', async () => {
-    // Arrange: first, fetch successfully
+    // Arrange: Initial render
     mockRouter.asPath = '/initial-path';
     const { rerender } = renderHook(() => useSameRouteNavigation());
-    await waitFor(() => {
-      expect(mockFetchCurrentPage).toHaveBeenCalledTimes(1);
-      expect(mockSetEditingMarkdown).toHaveBeenCalledTimes(1);
-    });
 
-    // Arrange: next, fetch fails (returns null)
+    // Initial render should not trigger fetch
+    expect(mockFetchCurrentPage).not.toHaveBeenCalled();
+
+    // Arrange: Mock fetch to return null for the next navigation
     mockFetchCurrentPage.mockResolvedValue(null);
 
-    // Act
+    // Act: Navigate to a new path
     mockRouter.asPath = '/path-with-no-data';
     rerender();
 
     // Assert
     await waitFor(() => {
-      // fetch should be called again
+      // fetch should be called
       expect(mockFetchCurrentPage).toHaveBeenCalledWith({
         path: '/path-with-no-data',
       });
-      // but mutate should not be called again
-      expect(mockSetEditingMarkdown).toHaveBeenCalledTimes(1);
+      // but mutate should not be called because pageData is null
+      expect(mockSetEditingMarkdown).not.toHaveBeenCalled();
     });
   });
 });

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

@@ -1,33 +1,55 @@
-import { useEffect } from 'react';
+import { useEffect, useRef } from 'react';
 import { useRouter } from 'next/router';
 
 import { useFetchCurrentPage, useIsIdenticalPath } from '~/states/page';
 import { useSetEditingMarkdown } from '~/states/ui/editor';
 
 /**
- * This hook is a trigger to fetch page data on client-side navigation.
- * It detects changes in `router.asPath` and calls `fetchCurrentPage`.
- * The responsibility for determining whether to actually fetch data
- * is delegated to `useFetchCurrentPage`.
+ * Hook for handling SAME_ROUTE client-side navigation within [[...path]] route.
+ *
+ * Responsibilities:
+ * - Detects path changes during SAME_ROUTE CSR navigation
+ * - Fetches page data when navigating to a different page within the same route
+ * - Updates editing markdown state with fetched content
+ *
+ * Note: FROM_OUTSIDE initial navigation is handled by useInitialCSRFetch.
+ *
+ * This hook uses useRef to track the previous path, enabling detection of
+ * client-side navigations. On initial render (including FROM_OUTSIDE),
+ * previousPathRef is null, so no fetch is triggered.
  */
 export const useSameRouteNavigation = (): void => {
   const router = useRouter();
+  const previousPathRef = useRef<string | null>(null);
 
   const isIdenticalPath = useIsIdenticalPath();
   const { fetchCurrentPage } = useFetchCurrentPage();
   const setEditingMarkdown = useSetEditingMarkdown();
 
-  // useEffect to trigger data fetching when the path changes
   useEffect(() => {
-    // If the path is identical, do not fetch
+    const currentPath = router.asPath;
+    const previousPath = previousPathRef.current;
+
+    // Update ref for next render
+    previousPathRef.current = currentPath;
+
+    // Skip on initial render (SSR data is already available)
+    if (previousPath === null) return;
+
+    // Skip if path hasn't changed
+    if (previousPath === currentPath) return;
+
+    // Skip if this is an identical path page
     if (isIdenticalPath) return;
 
+    // CSR navigation detected - fetch page data
     const fetch = async () => {
-      const pageData = await fetchCurrentPage({ path: router.asPath });
+      const pageData = await fetchCurrentPage({ path: currentPath });
       if (pageData?.revision?.body != null) {
         setEditingMarkdown(pageData.revision.body);
       }
     };
+
     fetch();
   }, [router.asPath, isIdenticalPath, fetchCurrentPage, setEditingMarkdown]);
 };

+ 0 - 5
apps/app/src/pages/_private-legacy-pages/index.page.tsx

@@ -36,11 +36,6 @@ type Props = CommonInitialProps &
 const PrivateLegacyPage: NextPage<Props> = (props: Props) => {
   const { t } = useTranslation();
 
-  // clear the cache for the current page
-  //  in order to fix https://redmine.weseek.co.jp/issues/135811
-  // useHydratePageAtoms(undefined);
-  // useCurrentPathname('/_private-legacy-pages');
-
   // Hydrate server-side data
   useHydrateBasicLayoutConfigurationAtoms(
     props.searchConfig,

+ 0 - 5
apps/app/src/pages/_search/index.page.tsx

@@ -38,11 +38,6 @@ type Props = CommonInitialProps &
 const SearchResultPage: NextPageWithLayout<Props> = (props: Props) => {
   const { t } = useTranslation();
 
-  // clear the cache for the current page
-  //  in order to fix https://redmine.weseek.co.jp/issues/135811
-  // useHydratePageAtoms(undefined);
-  // useCurrentPathname('/_search');
-
   // Hydrate server-side data
   useHydrateBasicLayoutConfigurationAtoms(
     props.searchConfig,

+ 11 - 8
apps/app/src/pages/common-props/commons.ts

@@ -6,10 +6,14 @@ import type { CrowiRequest } from '~/interfaces/crowi-request';
 import { getGrowiVersion } from '~/utils/growi-version';
 import loggerFactory from '~/utils/logger';
 
+import {
+  detectNextjsRoutingType,
+  type NextjsRoutingType,
+} from '../utils/nextjs-routing-utils';
+
 const logger = loggerFactory('growi:pages:common-props:commons');
 
 export type CommonInitialProps = {
-  isNextjsRoutingTypeInitial: true;
   appTitle: string;
   siteUrl: string | undefined;
   siteUrlWithEmptyValueWarn: string;
@@ -43,7 +47,6 @@ export const getServerSideCommonInitialProps: GetServerSideProps<
 
   return {
     props: {
-      isNextjsRoutingTypeInitial: true,
       appTitle: appService.getAppTitle(),
       siteUrl: configManager.getConfig('app:siteUrl'),
       siteUrlWithEmptyValueWarn: growiInfoService.getSiteUrl(),
@@ -56,7 +59,7 @@ export const getServerSideCommonInitialProps: GetServerSideProps<
         'app:growiAppIdForCloud',
       ),
       forcedColorScheme,
-    },
+    } satisfies CommonInitialProps,
   };
 };
 
@@ -70,11 +73,9 @@ export const isCommonInitialProps = (
 
   const p = props as Record<string, unknown>;
 
-  // Essential properties validation
-  if (p.isNextjsRoutingTypeInitial !== true) {
+  if (!('growiVersion' in p && 'appTitle' in p && 'siteUrl' in p)) {
     logger.warn(
-      'isCommonInitialProps: isNextjsRoutingTypeInitial is not true',
-      { isNextjsRoutingTypeInitial: p.isNextjsRoutingTypeInitial },
+      'isCommonInitialProps: props does not have growiVersion property',
     );
     return false;
   }
@@ -83,6 +84,7 @@ export const isCommonInitialProps = (
 };
 
 export type CommonEachProps = {
+  nextjsRoutingType: NextjsRoutingType;
   currentPathname: string;
   nextjsRoutingPage?: string; // must be set by each page
   currentUser?: IUserHasId;
@@ -179,12 +181,13 @@ export const getServerSideCommonEachProps = async (
   }
 
   const props = {
+    nextjsRoutingType: detectNextjsRoutingType(context, nextjsRoutingPage),
     currentPathname,
     nextjsRoutingPage,
     currentUser,
     isMaintenanceMode,
     redirectDestination,
-  };
+  } satisfies CommonEachProps;
 
   const shouldContainNextjsRoutingPage = nextjsRoutingPage != null;
   if (!isValidCommonEachRouteProps(props, shouldContainNextjsRoutingPage)) {

+ 1 - 1
apps/app/src/pages/general-page/index.ts

@@ -4,4 +4,4 @@ export {
 } from './configuration-props';
 export { isValidGeneralPageInitialProps } from './type-guards';
 export type * from './types';
-export { useInitialCSRFetch } from './use-initial-skip-ssr-fetch';
+export { useInitialCSRFetch } from './use-initial-csr-fetch';

+ 4 - 3
apps/app/src/pages/general-page/type-guards.ts

@@ -2,6 +2,7 @@ import { isIPageInfo } from '@growi/core/dist/interfaces';
 
 import loggerFactory from '~/utils/logger';
 
+import { NextjsRoutingType } from '../utils/nextjs-routing-utils';
 import type { GeneralPageInitialProps } from './types';
 
 const logger = loggerFactory('growi:pages:general-page:type-guards');
@@ -17,10 +18,10 @@ export function isValidGeneralPageInitialProps(
 
   // Then validate GeneralPageInitialProps-specific properties
   // CommonPageInitialProps
-  if (p.isNextjsRoutingTypeInitial !== true) {
+  if (p.nextjsRoutingType === NextjsRoutingType.SAME_ROUTE) {
     logger.warn(
-      'isValidGeneralPageInitialProps: isNextjsRoutingTypeInitial is not true',
-      { isNextjsRoutingTypeInitial: p.isNextjsRoutingTypeInitial },
+      'isValidGeneralPageInitialProps: nextjsRoutingType must be equal to NextjsRoutingType.INITIAL or NextjsRoutingType.FROM_OUTSIDE',
+      { nextjsRoutingType: p.nextjsRoutingType },
     );
     return false;
   }

+ 130 - 0
apps/app/src/pages/general-page/use-initial-csr-fetch.spec.tsx

@@ -0,0 +1,130 @@
+import type { NextRouter } from 'next/router';
+import { useRouter } from 'next/router';
+import { renderHook } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { useFetchCurrentPage } from '~/states/page';
+
+import { NextjsRoutingType } from '../utils/nextjs-routing-utils';
+import { useInitialCSRFetch } from './use-initial-csr-fetch';
+
+// Mock dependencies
+vi.mock('next/router', () => ({
+  useRouter: vi.fn(),
+}));
+vi.mock('~/states/page');
+
+// Define stable mock functions outside of describe/beforeEach
+const mockFetchCurrentPage = vi.fn();
+
+describe('useInitialCSRFetch', () => {
+  let mockRouter: { asPath: string };
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+
+    mockRouter = {
+      asPath: '/Sandbox',
+    };
+
+    (useRouter as ReturnType<typeof vi.fn>).mockReturnValue(
+      mockRouter as NextRouter,
+    );
+
+    (useFetchCurrentPage as ReturnType<typeof vi.fn>).mockReturnValue({
+      fetchCurrentPage: mockFetchCurrentPage,
+      isLoading: false,
+      error: null,
+    });
+  });
+
+  describe('when nextjsRoutingType is FROM_OUTSIDE', () => {
+    it('should fetch with current path', () => {
+      renderHook(() =>
+        useInitialCSRFetch({
+          nextjsRoutingType: NextjsRoutingType.FROM_OUTSIDE,
+          skipSSR: false,
+        }),
+      );
+
+      expect(mockFetchCurrentPage).toHaveBeenCalledTimes(1);
+      expect(mockFetchCurrentPage).toHaveBeenCalledWith({
+        force: true,
+        path: '/Sandbox',
+      });
+    });
+  });
+
+  describe('when skipSSR is true', () => {
+    it('should fetch with current path for INITIAL routing', () => {
+      renderHook(() =>
+        useInitialCSRFetch({
+          nextjsRoutingType: NextjsRoutingType.INITIAL,
+          skipSSR: true,
+        }),
+      );
+
+      expect(mockFetchCurrentPage).toHaveBeenCalledTimes(1);
+      expect(mockFetchCurrentPage).toHaveBeenCalledWith({
+        force: true,
+        path: '/Sandbox',
+      });
+    });
+  });
+
+  describe('when nextjsRoutingType is INITIAL and skipSSR is false', () => {
+    it('should NOT fetch', () => {
+      renderHook(() =>
+        useInitialCSRFetch({
+          nextjsRoutingType: NextjsRoutingType.INITIAL,
+          skipSSR: false,
+        }),
+      );
+
+      expect(mockFetchCurrentPage).not.toHaveBeenCalled();
+    });
+  });
+
+  describe('when nextjsRoutingType is SAME_ROUTE', () => {
+    it('should NOT fetch', () => {
+      renderHook(() =>
+        useInitialCSRFetch({
+          nextjsRoutingType: NextjsRoutingType.SAME_ROUTE,
+          skipSSR: false,
+        }),
+      );
+
+      expect(mockFetchCurrentPage).not.toHaveBeenCalled();
+    });
+
+    it('should fetch when skipSSR is true', () => {
+      // skipSSR: true triggers fetch regardless of routing type
+      renderHook(() =>
+        useInitialCSRFetch({
+          nextjsRoutingType: NextjsRoutingType.SAME_ROUTE,
+          skipSSR: true,
+        }),
+      );
+
+      expect(mockFetchCurrentPage).toHaveBeenCalledTimes(1);
+    });
+  });
+
+  describe('path handling', () => {
+    it('should use router.asPath as the fetch path', () => {
+      mockRouter.asPath = '/custom/path?query=1';
+
+      renderHook(() =>
+        useInitialCSRFetch({
+          nextjsRoutingType: NextjsRoutingType.FROM_OUTSIDE,
+          skipSSR: false,
+        }),
+      );
+
+      expect(mockFetchCurrentPage).toHaveBeenCalledWith({
+        force: true,
+        path: '/custom/path?query=1',
+      });
+    });
+  });
+});

+ 40 - 0
apps/app/src/pages/general-page/use-initial-csr-fetch.ts

@@ -0,0 +1,40 @@
+import { useEffect } from 'react';
+import { useRouter } from 'next/router';
+
+import { useFetchCurrentPage } from '~/states/page';
+
+import { NextjsRoutingType } from '../utils/nextjs-routing-utils';
+
+/**
+ * Hook for handling initial CSR fetch when SSR data is not available.
+ *
+ * Responsibilities:
+ * - Fetches page data on client-side when skipSSR is true
+ *   (e.g., when page content exceeds ssrMaxRevisionBodyLength)
+ * - Fetches page data on client-side when navigating from outside routes (FROM_OUTSIDE)
+ *   (e.g., when navigating from /_search to /page)
+ *
+ * Note: SAME_ROUTE navigation is handled by useSameRouteNavigation.
+ */
+export const useInitialCSRFetch = (condition: {
+  nextjsRoutingType: NextjsRoutingType;
+  skipSSR?: boolean;
+}): void => {
+  const router = useRouter();
+  const { fetchCurrentPage } = useFetchCurrentPage();
+
+  useEffect(() => {
+    const isFromOutside =
+      condition.nextjsRoutingType === NextjsRoutingType.FROM_OUTSIDE;
+    if (condition.skipSSR || isFromOutside) {
+      // Pass current path to ensure fetching the correct page
+      // (atoms may contain stale data from the previous page)
+      fetchCurrentPage({ force: true, path: router.asPath });
+    }
+  }, [
+    fetchCurrentPage,
+    condition.skipSSR,
+    condition.nextjsRoutingType,
+    router.asPath,
+  ]);
+};

+ 0 - 20
apps/app/src/pages/general-page/use-initial-skip-ssr-fetch.ts

@@ -1,20 +0,0 @@
-import { useEffect } from 'react';
-
-import { useFetchCurrentPage } from '~/states/page';
-
-/**
- * useInitialCSRFetch
- *
- * Fetches current page data on client-side if shouldFetch === true
- *
- * @param shouldFetch - Whether SSR is skipped (from props)
- */
-export const useInitialCSRFetch = (shouldFetch?: boolean): void => {
-  const { fetchCurrentPage } = useFetchCurrentPage();
-
-  useEffect(() => {
-    if (shouldFetch) {
-      fetchCurrentPage({ force: true });
-    }
-  }, [fetchCurrentPage, shouldFetch]);
-};

+ 0 - 5
apps/app/src/pages/me/[[...path]].page.tsx

@@ -92,11 +92,6 @@ const MePage: NextPageWithLayout<Props> = (props: Props) => {
 
   const targetPage = getTargetPageToRender(mePagesMap, pagePathKeys);
 
-  // // clear the cache for the current page
-  // //  in order to fix https://redmine.weseek.co.jp/issues/135811
-  // useHydratePageAtoms(undefined);
-  // useCurrentPathname('/me');
-
   const title = useCustomTitle(targetPage.title);
 
   return (

+ 10 - 17
apps/app/src/pages/share/[[...path]]/index.page.tsx

@@ -9,10 +9,7 @@ import { DrawioViewerScript } from '~/components/Script/DrawioViewerScript';
 import { ShareLinkPageView } from '~/components/ShareLinkPageView';
 import type { CommonEachProps } from '~/pages/common-props';
 import { getServerSideCommonEachProps } from '~/pages/common-props';
-import {
-  detectNextjsRoutingType,
-  NextjsRoutingType,
-} from '~/pages/utils/nextjs-routing-utils';
+import { NextjsRoutingType } from '~/pages/utils/nextjs-routing-utils';
 import { useCustomTitleForPage } from '~/pages/utils/page-title-customization';
 import { mergeGetServerSidePropsResults } from '~/pages/utils/server-side-props';
 import { useCurrentPageData, useCurrentPagePath } from '~/states/page';
@@ -41,11 +38,8 @@ const GrowiContextualSubNavigation = dynamic(
 
 type Props = CommonEachProps | InitialProps;
 
-const isInitialProps = (props: Props): props is InitialProps => {
-  return (
-    'isNextjsRoutingTypeInitial' in props && props.isNextjsRoutingTypeInitial
-  );
-};
+const isInitialProps = (props: Props): props is InitialProps =>
+  props.nextjsRoutingType === NextjsRoutingType.INITIAL;
 
 const SharedPage: NextPageWithLayout<Props> = (props: Props) => {
   // Initialize Jotai atoms with initial data - must be called unconditionally
@@ -66,8 +60,11 @@ const SharedPage: NextPageWithLayout<Props> = (props: Props) => {
   // Use custom hooks for navigation and routing
   // useSameRouteNavigation();
 
-  // If initial props and skipSSR, fetch page data on client-side
-  useInitialCSRFetch(isInitialProps(props) && props.skipSSR);
+  // Fetch page data on client-side when SSR is skipped
+  useInitialCSRFetch({
+    nextjsRoutingType: props.nextjsRoutingType,
+    skipSSR: isInitialProps(props) ? props.skipSSR : false,
+  });
 
   // If the data on the page changes without router.push, pageWithMeta remains old because getServerSideProps() is not executed
   // So preferentially take page data from useSWRxCurrentPage
@@ -162,16 +159,12 @@ export const getServerSideProps: GetServerSideProps<Props> = async (
   // STAGE 2
   //
 
-  // detect Next.js routing type
-  const nextjsRoutingType = detectNextjsRoutingType(
-    context,
-    NEXT_JS_ROUTING_PAGE,
-  );
+  const commonEachProps = await commonEachPropsResult.props;
 
   // Merge all results in a type-safe manner (using sequential merging)
   return mergeGetServerSidePropsResults(
     commonEachPropsResult,
-    nextjsRoutingType === NextjsRoutingType.INITIAL
+    commonEachProps.nextjsRoutingType === NextjsRoutingType.INITIAL
       ? await getServerSidePropsForInitial(context)
       : emptyProps,
   );

+ 11 - 5
apps/app/src/pages/share/[[...path]]/server-side-props.ts

@@ -7,6 +7,7 @@ import {
 import type { IShareLinkHasId } from '~/interfaces/share-link';
 
 import {
+  getServerSideCommonEachProps,
   getServerSideCommonInitialProps,
   getServerSideI18nProps,
 } from '../../common-props';
@@ -47,12 +48,14 @@ export async function getServerSidePropsForInitial(
   context: GetServerSidePropsContext,
 ): Promise<GetServerSidePropsResult<Stage2InitialProps>> {
   const [
+    commonEachResult,
     commonInitialResult,
     generalPageResult,
     rendererConfigResult,
     i18nPropsResult,
     pageDataResult,
   ] = await Promise.all([
+    getServerSideCommonEachProps(context),
     getServerSideCommonInitialProps(context),
     getServerSideGeneralPageProps(context),
     getServerSideRendererConfigProps(context),
@@ -62,14 +65,17 @@ export async function getServerSidePropsForInitial(
 
   // Merge all results in a type-safe manner (using sequential merging)
   const mergedResult = mergeGetServerSidePropsResults(
-    commonInitialResult,
+    commonEachResult,
     mergeGetServerSidePropsResults(
-      generalPageResult,
+      commonInitialResult,
       mergeGetServerSidePropsResults(
-        rendererConfigResult,
+        generalPageResult,
         mergeGetServerSidePropsResults(
-          i18nPropsResult,
-          mergeGetServerSidePropsResults(pageDataResult, basisProps),
+          rendererConfigResult,
+          mergeGetServerSidePropsResults(
+            i18nPropsResult,
+            mergeGetServerSidePropsResults(pageDataResult, basisProps),
+          ),
         ),
       ),
     ),

+ 0 - 5
apps/app/src/pages/tags/index.page.tsx

@@ -48,11 +48,6 @@ type Props = CommonInitialProps &
 const TagPage: NextPageWithLayout<Props> = (props: Props) => {
   const { t } = useTranslation();
 
-  // // clear the cache for the current page
-  // //  in order to fix https://redmine.weseek.co.jp/issues/135811
-  // useHydratePageAtoms(undefined);
-  // useCurrentPathname('/tags');
-
   const [activePage, setActivePage] = useState<number>(1);
   const [offset, setOffset] = useState<number>(0);
 

+ 0 - 5
apps/app/src/pages/trash/index.page.tsx

@@ -47,11 +47,6 @@ type Props = CommonInitialProps &
   RendererConfigProps;
 
 const TrashPage: NextPageWithLayout<Props> = (props: Props) => {
-  // // clear the cache for the current page
-  // //  in order to fix https://redmine.weseek.co.jp/issues/135811
-  // useHydratePageAtoms(undefined);
-  // useCurrentPathname('/trash');
-
   // Hydrate server-side data
   useHydrateServerConfigurationAtoms(props.serverConfig);
 

+ 52 - 0
apps/app/src/pages/utils/nextjs-routing-utils.spec.ts

@@ -275,5 +275,57 @@ describe('nextjs-routing-utils', () => {
 
       expect(result).toBe('initial');
     });
+
+    describe('when previousRoutingPage is undefined', () => {
+      it('should return INITIAL when request is not CSR', () => {
+        const context = createMockContext(false);
+
+        const result = detectNextjsRoutingType(context);
+
+        expect(result).toBe('initial');
+      });
+
+      it('should return FROM_OUTSIDE when CSR and no cookie exists', () => {
+        const context = createMockContext(true); // No cookie value
+
+        const result = detectNextjsRoutingType(context);
+
+        expect(result).toBe('from-outside');
+      });
+
+      it('should return FROM_OUTSIDE when CSR and cookie exists', () => {
+        const context = createMockContext(true, '/some-page');
+
+        const result = detectNextjsRoutingType(context);
+
+        expect(result).toBe('from-outside');
+      });
+
+      it('should return FROM_OUTSIDE when CSR and cookie is empty string', () => {
+        const context = createMockContext(true, '');
+
+        const result = detectNextjsRoutingType(context);
+
+        expect(result).toBe('from-outside');
+      });
+    });
+
+    describe('when both cookie and previousRoutingPage are undefined/null', () => {
+      it('should return INITIAL when request is not CSR and both are missing', () => {
+        const context = createMockContext(false);
+
+        const result = detectNextjsRoutingType(context);
+
+        expect(result).toBe('initial');
+      });
+
+      it('should return FROM_OUTSIDE when CSR and both are missing', () => {
+        const context = createMockContext(true); // No cookie
+
+        const result = detectNextjsRoutingType(context);
+
+        expect(result).toBe('from-outside');
+      });
+    });
   });
 });

+ 6 - 2
apps/app/src/pages/utils/nextjs-routing-utils.ts

@@ -27,7 +27,7 @@ export type NextjsRoutingType =
 
 export const detectNextjsRoutingType = (
   context: GetServerSidePropsContext,
-  previousRoutingPage: string,
+  previousRoutingPage?: string,
 ): NextjsRoutingType => {
   const isCSR = !!context.req.headers['x-nextjs-data'];
 
@@ -38,7 +38,11 @@ export const detectNextjsRoutingType = (
   // Read cookie from server-side context
   const nextjsRoutingPage = context.req.cookies[COOKIE_NAME];
 
-  if (nextjsRoutingPage != null && nextjsRoutingPage === previousRoutingPage) {
+  if (
+    nextjsRoutingPage != null &&
+    previousRoutingPage != null &&
+    nextjsRoutingPage === previousRoutingPage
+  ) {
     return NextjsRoutingType.SAME_ROUTE;
   }
 

+ 3 - 1
apps/app/src/states/search/keyword-manager.ts

@@ -66,7 +66,9 @@ export const useSetSearchKeyword = (
   return useCallback(
     (newKeyword: string) => {
       setKeyword((prevKeyword) => {
-        if (prevKeyword !== newKeyword) {
+        const isOnSearchPage = routerRef.current.pathname === pathname;
+        // Navigate if keyword changed OR if not currently on search page
+        if (prevKeyword !== newKeyword || !isOnSearchPage) {
           const newUrl = new URL(pathname, 'http://example.com');
           newUrl.searchParams.append('q', newKeyword);
           routerRef.current.push(`${newUrl.pathname}${newUrl.search}`, '');