Yuki Takei 8 месяцев назад
Родитель
Сommit
6dc8b8a0e5

+ 83 - 0
apps/app/src/pages/[[...path]]/common-helpers.ts

@@ -0,0 +1,83 @@
+import type { GetServerSidePropsContext, GetServerSidePropsResult } from 'next';
+
+import type { CrowiRequest } from '~/interfaces/crowi-request';
+import { getServerSideCommonInitialProps } from '~/pages/utils/commons';
+import { getServerSidePageTitleCustomizationProps } from '~/pages/utils/page-title-customization';
+import { getServerSideUserUISettingsProps } from '~/pages/utils/user-ui-settings';
+
+import { getServerSideConfigurationProps } from './configuration-props';
+
+export const NEXT_JS_ROUTING_PAGE = '[[...path]]';
+
+// Private helper function to create i18n config
+export async function createNextI18NextConfig(context: GetServerSidePropsContext, namespacesRequired?: string[]) {
+  const { serverSideTranslations } = await import('next-i18next/serverSideTranslations');
+  const lang = 'en_US';
+  const namespaces = ['commons', ...(namespacesRequired ?? ['translation'])];
+  return serverSideTranslations(lang, namespaces);
+}
+
+// Common props collection helper
+export async function collectProps(context: GetServerSidePropsContext) {
+  const propResults = await Promise.all([
+    getServerSideCommonInitialProps(context),
+    getServerSidePageTitleCustomizationProps(context),
+    getServerSideUserUISettingsProps(context),
+    getServerSideConfigurationProps(context),
+  ]);
+
+  // Validate all results have props
+  if (propResults.some(result => !('props' in result))) {
+    throw new Error('invalid getSSP result');
+  }
+
+  return propResults.reduce((acc, result) => ({
+    ...acc,
+    ...('props' in result ? result.props : {}),
+  }), {});
+}
+
+// Common user and redirect handling
+export function handleUserAndRedirects(context: GetServerSidePropsContext, props: Record<string, unknown>) {
+  const req = context.req as CrowiRequest;
+  const { user } = req;
+
+  // Add current user if exists
+  if (user != null) {
+    props.currentUser = user.toObject();
+  }
+
+  // Check for redirect destination
+  const redirectDestination = props.redirectDestination;
+  if (typeof redirectDestination === 'string') {
+    return {
+      redirect: {
+        permanent: false,
+        destination: redirectDestination,
+      },
+    };
+  }
+
+  return null;
+}
+
+// Helper function to handle page data result - returns early return result or merged props
+export function handlePageDataResult<T>(
+    result: GetServerSidePropsResult<T>,
+    currentProps: Record<string, unknown>,
+): { earlyReturn: GetServerSidePropsResult<unknown> } | { mergedProps: Record<string, unknown> } {
+  if ('redirect' in result) {
+    return { earlyReturn: result };
+  }
+  if ('notFound' in result) {
+    return { earlyReturn: result };
+  }
+
+  // Return new merged props without side effects
+  return {
+    mergedProps: {
+      ...currentProps,
+      ...result.props,
+    },
+  };
+}

+ 0 - 185
apps/app/src/pages/[[...path]]/page-data-injectors.ts

@@ -1,185 +0,0 @@
-import { pagePathUtils, pathUtils } from '@growi/core/dist/utils';
-import type { GetServerSidePropsContext } from 'next';
-
-import type { CrowiRequest } from '~/interfaces/crowi-request';
-import type { PageModel, PageDocument } from '~/server/models/page';
-import type { PageRedirectModel } from '~/server/models/page-redirect';
-
-import type { ExtendedInitialProps, SameRouteEachProps } from './types';
-
-const { isPermalink: _isPermalink, isCreatablePage } = pagePathUtils;
-const { removeHeadingSlash } = pathUtils;
-
-// Private helper function to check if SSR should be skipped
-async function skipSSR(page: PageDocument, ssrMaxRevisionBodyLength: number): Promise<boolean> {
-  const latestRevisionBodyLength = await page.getLatestRevisionBodyLength();
-  if (latestRevisionBodyLength == null) return true;
-  return ssrMaxRevisionBodyLength < latestRevisionBodyLength;
-}
-
-function getPageIdFromPathname(currentPathname: string): string | null {
-  return _isPermalink(currentPathname) ? removeHeadingSlash(currentPathname) : null;
-}
-
-// Common URL conversion helper
-function handleUrlConversion(
-    page: PageDocument | null,
-    currentPathname: string,
-    isPermalink: boolean,
-): string {
-  if (page != null && !page.isEmpty) {
-    if (isPermalink) {
-      return page.path;
-    }
-
-    const isToppage = pagePathUtils.isTopPage(currentPathname);
-    if (!isToppage) {
-      return `/${page._id}`;
-    }
-  }
-  return currentPathname;
-}
-
-// Helper function to inject page data for initial access
-export async function injectPageDataForInitial(context: GetServerSidePropsContext, props: ExtendedInitialProps): Promise<void> {
-  const { model: mongooseModel } = await import('mongoose');
-  const req: CrowiRequest = context.req as CrowiRequest;
-  const { crowi, user } = req;
-  const { revisionId } = req.query;
-
-  // Parse path from URL
-  let { path: pathFromQuery } = context.query;
-  pathFromQuery = pathFromQuery != null ? pathFromQuery as string[] : [];
-  let pathFromUrl = `/${pathFromQuery.join('/')}`;
-  pathFromUrl = pathFromUrl === '//' ? '/' : pathFromUrl;
-
-  const Page = crowi.model('Page') as PageModel;
-  const PageRedirect = mongooseModel('PageRedirect') as PageRedirectModel;
-  const { pageService, configManager } = crowi;
-
-  const pageId = getPageIdFromPathname(pathFromUrl);
-  const isPermalink = _isPermalink(pathFromUrl);
-
-  // Simple path handling with correct redirect method
-  let currentPathname = pathFromUrl;
-
-  // Check for redirects using the correct method from the original code
-  if (!isPermalink) {
-    const chains = await PageRedirect.retrievePageRedirectEndpoints(pathFromUrl);
-    if (chains != null) {
-      // overwrite currentPathname
-      currentPathname = chains.end.toPath;
-      props.redirectFrom = chains.start.fromPath;
-    }
-  }
-
-  props.currentPathname = currentPathname;
-
-  // Check multiple pages hits - handled directly for consistency with other page states
-  const multiplePagesCount = await Page.countByPathAndViewer(currentPathname, user, null, true);
-  props.isIdenticalPathPage = multiplePagesCount > 1;
-
-  // Early return for identical path pages - efficiency optimization from original code
-  if (props.isIdenticalPathPage) {
-    props.pageWithMeta = null;
-    props.isNotFound = false;
-    props.isNotCreatable = true; // Cannot create when multiple pages exist with same path
-    props.isForbidden = false;
-    return; // Skip expensive operations
-  }
-
-  // Get full page data for SSR (only when not identical path)
-  const pageWithMeta = await pageService.findPageAndMetaDataByViewer(pageId, currentPathname, user, true);
-  const { data: page, meta } = pageWithMeta ?? {};
-
-  // Add user to seen users
-  if (page != null && user != null) {
-    await page.seen(user);
-  }
-
-  props.pageWithMeta = null;
-
-  if (page != null) {
-    // Handle existing page
-    page.initLatestRevisionField(revisionId);
-    const ssrMaxRevisionBodyLength = configManager.getConfig('app:ssrMaxRevisionBodyLength');
-    props.skipSSR = await skipSSR(page, ssrMaxRevisionBodyLength);
-    const populatedPage = await page.populateDataToShowRevision(props.skipSSR);
-
-    props.pageWithMeta = { data: populatedPage, meta };
-    props.isNotFound = page.isEmpty;
-    props.isNotCreatable = false;
-    props.isForbidden = false;
-
-    // Handle URL conversion
-    props.currentPathname = handleUrlConversion(page, currentPathname, isPermalink);
-  }
-  else {
-    // Handle non-existent page
-    props.pageWithMeta = null;
-    props.isNotFound = true;
-    props.isNotCreatable = !isCreatablePage(currentPathname);
-
-    // Check if forbidden or just doesn't exist
-    const count = isPermalink
-      ? await Page.count({ _id: pageId })
-      : await Page.count({ path: currentPathname });
-    props.isForbidden = count > 0;
-  }
-}
-
-// For same route access: Minimal data injection (client will fetch page data)
-export async function injectSameRoutePageData(context: GetServerSidePropsContext, props: SameRouteEachProps): Promise<void> {
-  const { model: mongooseModel } = await import('mongoose');
-  const req: CrowiRequest = context.req as CrowiRequest;
-  const { crowi, user } = req;
-
-  const Page = crowi.model('Page') as PageModel;
-  const PageRedirect = mongooseModel('PageRedirect') as PageRedirectModel;
-
-  const currentPathname = props.currentPathname;
-  const pageId = getPageIdFromPathname(currentPathname);
-  const isPermalink = _isPermalink(currentPathname);
-
-  // Handle redirects using the correct method from original code
-  let resolvedPathname = currentPathname;
-
-  if (!isPermalink) {
-    const chains = await PageRedirect.retrievePageRedirectEndpoints(currentPathname);
-    if (chains != null) {
-      // overwrite resolvedPathname
-      resolvedPathname = chains.end.toPath;
-      props.redirectFrom = chains.start.fromPath;
-    }
-  }
-
-  props.currentPathname = resolvedPathname;
-
-  // Check multiple pages hits - handled directly for consistency
-  const multiplePagesCount = await Page.countByPathAndViewer(resolvedPathname, user, null, true);
-  props.isIdenticalPathPage = multiplePagesCount > 1;
-
-  // Early return for identical path pages - efficiency optimization
-  if (props.isIdenticalPathPage) {
-    return; // Skip expensive page lookup operations
-  }
-
-  // For same route access, we only do minimal checks
-  // The client will use fetchCurrentPage to get the actual page data
-  const basicPageInfo = await Page.findOne(
-    isPermalink ? { _id: pageId } : { path: resolvedPathname },
-  ).exec();
-
-  if (basicPageInfo != null) {
-    // Handle URL conversion using shared helper
-    props.currentPathname = handleUrlConversion(basicPageInfo, resolvedPathname, isPermalink);
-  }
-
-  // For same route, routing state properties (isNotFound, isForbidden, isNotCreatable)
-  // are handled client-side via fetchCurrentPage in useFetchCurrentPage hook.
-  // The fetchCurrentPage function will set appropriate routing state atoms based on API response:
-  // - pageNotFoundAtom: set to true when page doesn't exist (404)
-  // - pageNotCreatableAtom: determined by path analysis for 404s, or set to true for 403s
-  // This approach provides better performance for same-route navigation while maintaining
-  // the same routing behavior as initial page loads.
-}

+ 200 - 0
apps/app/src/pages/[[...path]]/page-data-props.ts

@@ -0,0 +1,200 @@
+import type { GetServerSidePropsContext, GetServerSidePropsResult } from 'next';
+
+import type { CrowiRequest } from '~/interfaces/crowi-request';
+import type { PageModel } from '~/server/models/page';
+import type { PageRedirectModel } from '~/server/models/page-redirect';
+
+import type { InitialProps, SameRouteEachProps } from './types';
+
+// Page data retrieval for initial load - returns GetServerSidePropsResult
+export async function getPageDataForInitial(
+    context: GetServerSidePropsContext,
+): Promise<GetServerSidePropsResult<Partial<InitialProps & SameRouteEachProps>>> {
+  const { pagePathUtils, pathUtils } = await import('@growi/core/dist/utils');
+  const { model: mongooseModel } = await import('mongoose');
+
+  const { isPermalink: _isPermalink, isCreatablePage } = pagePathUtils;
+  const { removeHeadingSlash } = pathUtils;
+
+  const req: CrowiRequest = context.req as CrowiRequest;
+  const { crowi, user } = req;
+  const { revisionId } = req.query;
+
+  // Parse path from URL
+  let { path: pathFromQuery } = context.query;
+  pathFromQuery = pathFromQuery != null ? pathFromQuery as string[] : [];
+  let pathFromUrl = `/${pathFromQuery.join('/')}`;
+  pathFromUrl = pathFromUrl === '//' ? '/' : pathFromUrl;
+
+  const Page = crowi.model('Page') as PageModel;
+  const PageRedirect = mongooseModel('PageRedirect') as PageRedirectModel;
+  const { pageService, configManager } = crowi;
+
+  const pageId = _isPermalink(pathFromUrl) ? removeHeadingSlash(pathFromUrl) : null;
+  const isPermalink = _isPermalink(pathFromUrl);
+
+  let currentPathname = pathFromUrl;
+
+  // Check for redirects
+  if (!isPermalink) {
+    const chains = await PageRedirect.retrievePageRedirectEndpoints(pathFromUrl);
+    if (chains != null) {
+      currentPathname = chains.end.toPath;
+    }
+  }
+
+  // Check multiple pages hits
+  const multiplePagesCount = await Page.countByPathAndViewer(currentPathname, user, null, true);
+  const isIdenticalPathPage = multiplePagesCount > 1;
+
+  // Early return for identical path pages
+  if (isIdenticalPathPage) {
+    return {
+      props: {
+        currentPathname,
+        isIdenticalPathPage: true,
+        pageWithMeta: null,
+        isNotFound: false,
+        isNotCreatable: true,
+        isForbidden: false,
+      },
+    };
+  }
+
+  // Get full page data
+  const pageWithMeta = await pageService.findPageAndMetaDataByViewer(pageId, currentPathname, user, true);
+  const { data: page, meta } = pageWithMeta ?? {};
+
+  // Add user to seen users
+  if (page != null && user != null) {
+    await page.seen(user);
+  }
+
+  if (page != null) {
+    // Handle existing page
+    page.initLatestRevisionField(revisionId);
+    const ssrMaxRevisionBodyLength = configManager.getConfig('app:ssrMaxRevisionBodyLength');
+
+    // Check if SSR should be skipped
+    const latestRevisionBodyLength = await page.getLatestRevisionBodyLength();
+    const skipSSR = latestRevisionBodyLength != null && ssrMaxRevisionBodyLength < latestRevisionBodyLength;
+
+    const populatedPage = await page.populateDataToShowRevision(skipSSR);
+
+    // Handle URL conversion
+    let finalPathname = currentPathname;
+    if (page != null && !page.isEmpty) {
+      if (isPermalink) {
+        finalPathname = page.path;
+      }
+      else {
+        const isToppage = pagePathUtils.isTopPage(currentPathname);
+        if (!isToppage) {
+          finalPathname = `/${page._id}`;
+        }
+      }
+    }
+
+    return {
+      props: {
+        currentPathname: finalPathname,
+        isIdenticalPathPage: false,
+        pageWithMeta: { data: populatedPage, meta },
+        isNotFound: page.isEmpty,
+        isNotCreatable: false,
+        isForbidden: false,
+        skipSSR,
+      },
+    };
+  }
+
+  // Handle non-existent page
+  const count = isPermalink
+    ? await Page.count({ _id: pageId })
+    : await Page.count({ path: currentPathname });
+
+  return {
+    props: {
+      currentPathname,
+      isIdenticalPathPage: false,
+      pageWithMeta: null,
+      isNotFound: true,
+      isNotCreatable: !isCreatablePage(currentPathname),
+      isForbidden: count > 0,
+    },
+  };
+}
+
+// Page data retrieval for same-route navigation
+export async function getPageDataForSameRoute(
+    context: GetServerSidePropsContext,
+): Promise<GetServerSidePropsResult<Partial<SameRouteEachProps>>> {
+  const { pagePathUtils, pathUtils } = await import('@growi/core/dist/utils');
+  const { model: mongooseModel } = await import('mongoose');
+
+  const { isPermalink: _isPermalink } = pagePathUtils;
+  const { removeHeadingSlash } = pathUtils;
+
+  const req: CrowiRequest = context.req as CrowiRequest;
+  const { crowi, user } = req;
+
+  const Page = crowi.model('Page') as PageModel;
+  const PageRedirect = mongooseModel('PageRedirect') as PageRedirectModel;
+
+  const currentPathname = decodeURIComponent(context.resolvedUrl?.split('?')[0] ?? '/');
+  const pageId = _isPermalink(currentPathname) ? removeHeadingSlash(currentPathname) : null;
+  const isPermalink = _isPermalink(currentPathname);
+
+  // Handle redirects
+  let resolvedPathname = currentPathname;
+  let redirectFrom: string | undefined;
+
+  if (!isPermalink) {
+    const chains = await PageRedirect.retrievePageRedirectEndpoints(currentPathname);
+    if (chains != null) {
+      resolvedPathname = chains.end.toPath;
+      redirectFrom = chains.start.fromPath;
+    }
+  }
+
+  // Check multiple pages hits
+  const multiplePagesCount = await Page.countByPathAndViewer(resolvedPathname, user, null, true);
+  const isIdenticalPathPage = multiplePagesCount > 1;
+
+  // Early return for identical path pages
+  if (isIdenticalPathPage) {
+    return {
+      props: {
+        currentPathname: resolvedPathname,
+        isIdenticalPathPage: true,
+        redirectFrom,
+      },
+    };
+  }
+
+  // For same route access, do minimal page lookup
+  const basicPageInfo = await Page.findOne(
+    isPermalink ? { _id: pageId } : { path: resolvedPathname },
+  ).exec();
+
+  let finalPathname = resolvedPathname;
+  if (basicPageInfo != null && !basicPageInfo.isEmpty) {
+    if (isPermalink) {
+      finalPathname = basicPageInfo.path;
+    }
+    else {
+      const isToppage = pagePathUtils.isTopPage(resolvedPathname);
+      if (!isToppage) {
+        finalPathname = `/${basicPageInfo._id}`;
+      }
+    }
+  }
+
+  return {
+    props: {
+      currentPathname: finalPathname,
+      isIdenticalPathPage: false,
+      redirectFrom,
+    },
+  };
+}

+ 32 - 72
apps/app/src/pages/[[...path]]/server-side-props.ts

@@ -2,70 +2,21 @@ import type { GetServerSidePropsContext, GetServerSidePropsResult } from 'next';
 
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import { addActivity } from '~/pages/utils/activity';
-import { getServerSideCommonInitialProps } from '~/pages/utils/commons';
 import type { PageTitleCustomizationProps } from '~/pages/utils/page-title-customization';
 import { getServerSidePageTitleCustomizationProps } from '~/pages/utils/page-title-customization';
 import { getServerSideSSRProps } from '~/pages/utils/ssr';
-import { getServerSideUserUISettingsProps } from '~/pages/utils/user-ui-settings';
 
-import { getServerSideConfigurationProps } from './configuration-props';
-import { injectPageDataForInitial, injectSameRoutePageData } from './page-data-injectors';
+import {
+  NEXT_JS_ROUTING_PAGE,
+  collectProps,
+  createNextI18NextConfig,
+  handleUserAndRedirects,
+  handlePageDataResult,
+} from './common-helpers';
+import { getPageDataForInitial, getPageDataForSameRoute } from './page-data-props';
 import type { InitialProps, SameRouteEachProps } from './types';
 import { getAction } from './utils';
 
-const NEXT_JS_ROUTING_PAGE = '[[...path]]';
-
-// Private helper function to create i18n config
-async function createNextI18NextConfig(context: GetServerSidePropsContext, namespacesRequired?: string[]) {
-  const { serverSideTranslations } = await import('next-i18next/serverSideTranslations');
-  const lang = 'en_US';
-  const namespaces = ['commons', ...(namespacesRequired ?? ['translation'])];
-  return serverSideTranslations(lang, namespaces);
-}
-
-// Common props collection helper
-async function collectProps(context: GetServerSidePropsContext) {
-  const propResults = await Promise.all([
-    getServerSideCommonInitialProps(context),
-    getServerSidePageTitleCustomizationProps(context),
-    getServerSideUserUISettingsProps(context),
-    getServerSideConfigurationProps(context),
-  ]);
-
-  // Validate all results have props
-  if (propResults.some(result => !('props' in result))) {
-    throw new Error('invalid getSSP result');
-  }
-
-  return propResults.reduce((acc, result) => ({
-    ...acc,
-    ...('props' in result ? result.props : {}),
-  }), {});
-}
-
-// Common user and redirect handling
-function handleUserAndRedirects(context: GetServerSidePropsContext, props: Record<string, unknown>) {
-  const req = context.req as CrowiRequest;
-  const { user } = req;
-
-  // Add current user if exists
-  if (user != null) {
-    props.currentUser = user.toObject();
-  }
-
-  // Check for redirect destination
-  const redirectDestination = props.redirectDestination;
-  if (typeof redirectDestination === 'string') {
-    return {
-      redirect: {
-        permanent: false,
-        destination: redirectDestination,
-      },
-    };
-  }
-
-  return null;
-}
 export async function getServerSidePropsForInitial(context: GetServerSidePropsContext): Promise<GetServerSidePropsResult<InitialProps & SameRouteEachProps>> {
   // Collect all required props
   const collectedProps = await collectProps(context);
@@ -76,32 +27,36 @@ export async function getServerSidePropsForInitial(context: GetServerSidePropsCo
     isNotFound: false,
     isForbidden: false,
     isNotCreatable: false,
-    isIdenticalPathPage: false, // Will be set by injectPageDataForInitial
+    isIdenticalPathPage: false,
   } as InitialProps & SameRouteEachProps;
 
   // Handle user and redirects
   const redirectResult = handleUserAndRedirects(context, props);
   if (redirectResult) return redirectResult;
 
-  // Inject page data - now handles isIdenticalPathPage internally
-  await injectPageDataForInitial(context, props);
+  // Get page data
+  const pageDataResult = await getPageDataForInitial(context);
+  const handleResult = handlePageDataResult(pageDataResult, props);
+  if ('earlyReturn' in handleResult) return handleResult.earlyReturn as GetServerSidePropsResult<InitialProps & SameRouteEachProps>;
+
+  // Use merged props from page data
+  const mergedProps = handleResult.mergedProps as InitialProps & SameRouteEachProps;
 
   // Handle SSR configuration
-  if (props.pageWithMeta?.data != null) {
-    const ssrPropsResult = await getServerSideSSRProps(context, props.pageWithMeta.data, ['translation']);
+  if (mergedProps.pageWithMeta?.data != null) {
+    const ssrPropsResult = await getServerSideSSRProps(context, mergedProps.pageWithMeta.data, ['translation']);
     if ('props' in ssrPropsResult) {
-      Object.assign(props, ssrPropsResult.props);
+      Object.assign(mergedProps, ssrPropsResult.props);
     }
   }
   else {
-    props.skipSSR = true;
+    mergedProps.skipSSR = true;
     const nextI18NextConfig = await createNextI18NextConfig(context, ['translation']);
-    props._nextI18Next = nextI18NextConfig._nextI18Next;
+    mergedProps._nextI18Next = nextI18NextConfig._nextI18Next;
   }
 
-  await addActivity(context, getAction(props));
-
-  return { props };
+  await addActivity(context, getAction(mergedProps));
+  return { props: mergedProps };
 }
 
 export async function getServerSidePropsForSameRoute(context: GetServerSidePropsContext): Promise<GetServerSidePropsResult<SameRouteEachProps>> {
@@ -121,7 +76,7 @@ export async function getServerSidePropsForSameRoute(context: GetServerSideProps
     nextjsRoutingPage: NEXT_JS_ROUTING_PAGE,
     csrfToken: req.csrfToken?.() ?? '',
     isMaintenanceMode: req.crowi.configManager.getConfig('app:isMaintenanceMode'),
-    isIdenticalPathPage: false, // Will be set by injectSameRoutePageData
+    isIdenticalPathPage: false,
   };
 
   // Handle user
@@ -130,8 +85,13 @@ export async function getServerSidePropsForSameRoute(context: GetServerSideProps
     props.currentUser = user.toObject();
   }
 
-  // Page data injection - now handles isIdenticalPathPage internally
-  await injectSameRoutePageData(context, props);
+  // Page data retrieval
+  const sameRouteDataResult = await getPageDataForSameRoute(context);
+  const handleResult = handlePageDataResult(sameRouteDataResult, props);
+  if ('earlyReturn' in handleResult) return handleResult.earlyReturn as GetServerSidePropsResult<SameRouteEachProps>;
+
+  // Use merged props from same route data
+  const mergedProps = handleResult.mergedProps as SameRouteEachProps;
 
-  return { props };
+  return { props: mergedProps };
 }