Yuki Takei 7 months ago
parent
commit
534e12693e

+ 1 - 1
apps/app/docs/plan/jotai-migration-progress.md

@@ -21,7 +21,7 @@
 - `states/ui/sidebar.ts`: サイドバー状態の完全実装
 - `states/ui/device.ts`: デバイス状態
 - `states/ui/editor.ts`: エディター状態(部分)
-- `states/hydrate/sidebar.ts`: SSRハイドレーション
+- `states/sidebar/hydrate.ts`: SSRハイドレーション
 
 ## 🚧 次の実装ステップ(優先度順)
 

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

@@ -21,7 +21,6 @@ import { BasicLayout } from '~/components/Layout/BasicLayout';
 import { PageView } from '~/components/PageView/PageView';
 import { DrawioViewerScript } from '~/components/Script/DrawioViewerScript';
 import { useEditorModeClassName } from '~/services/layout/use-editor-mode-class-name';
-import { useHydrateSidebarAtoms } from '~/states/hydrate/sidebar';
 import {
   useCurrentPageData, useCurrentPageId, useCurrentPagePath, usePageNotFound,
 } from '~/states/page';
@@ -31,6 +30,7 @@ import {
   useRendererConfig,
 } from '~/states/server-configurations';
 import { useHydrateServerConfigurationAtoms } from '~/states/server-configurations/hydrate';
+import { useHydrateSidebarAtoms } from '~/states/sidebar/hydrate';
 import {
   useIsSharedUser,
   useIsSearchPage,
@@ -48,7 +48,7 @@ import type {
 } from './[[...path]]/types';
 import type { NextPageWithLayout } from './_app.page';
 import { NextjsRoutingType, detectNextjsRoutingType } from './utils/nextjs-routing-utils';
-import { generateCustomTitleForPage } from './utils/page-title-customization';
+import { useCustomTitleForPage } from './utils/page-title-customization';
 
 declare global {
   // eslint-disable-next-line vars-on-top, no-var
@@ -176,7 +176,7 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
   // So preferentially take page data from useSWRxCurrentPage
   const pagePath = currentPagePath ?? props.currentPathname;
 
-  const title = generateCustomTitleForPage(props, pagePath);
+  const title = useCustomTitleForPage(pagePath);
 
   return (
     <>
@@ -217,7 +217,7 @@ const Layout = ({ children, ...props }: LayoutProps): JSX.Element => {
   const sidebarConfig = initialProps?.sidebarConfig;
   const userUISettings = initialProps?.userUISettings;
   useHydrateSidebarAtoms(sidebarConfig, userUISettings);
-  useHydrateServerConfigurationAtoms(initialProps);
+  useHydrateServerConfigurationAtoms(initialProps?.serverConfig, initialProps?.rendererConfig);
 
   return <BasicLayoutWithEditor>{children}</BasicLayoutWithEditor>;
 };

+ 61 - 36
apps/app/src/pages/[[...path]]/configuration-props.ts

@@ -2,52 +2,31 @@ import type { GetServerSideProps, GetServerSidePropsContext } from 'next';
 
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import { RegistrationMode } from '~/interfaces/registration-mode';
-import type { ServerConfigurationInitialProps } from '~/states/server-configurations/hydrate';
 
-export const getServerSideConfigurationProps: GetServerSideProps<ServerConfigurationInitialProps> = async(context: GetServerSidePropsContext) => {
+import type { RendererConfigProps, ServerConfigurationProps, SidebarConfigProps } from './types';
+
+export const getServerSideSidebarConfigProps: GetServerSideProps<SidebarConfigProps> = async(context: GetServerSidePropsContext) => {
   const req: CrowiRequest = context.req as CrowiRequest;
   const { crowi } = req;
-  const {
-    configManager, searchService, aclService, fileUploadService,
-    slackIntegrationService, passportService,
-  } = crowi;
+  const { configManager } = crowi;
 
   return {
     props: {
-      aiEnabled: configManager.getConfig('app:aiEnabled'),
-      limitLearnablePageCountPerAssistant: configManager.getConfig('openai:limitLearnablePageCountPerAssistant'),
-      isUsersHomepageDeletionEnabled: configManager.getConfig('security:user-homepage-deletion:isEnabled'),
-      isSearchServiceConfigured: searchService.isConfigured,
-      isSearchServiceReachable: searchService.isReachable,
-      isSearchScopeChildrenAsDefault: configManager.getConfig('customize:isSearchScopeChildrenAsDefault'),
-      elasticsearchMaxBodyLengthToIndex: configManager.getConfig('app:elasticsearchMaxBodyLengthToIndex'),
-
-      isRomUserAllowedToComment: configManager.getConfig('security:isRomUserAllowedToComment'),
-
-      isSlackConfigured: slackIntegrationService.isSlackConfigured,
-      isAclEnabled: aclService.isAclEnabled(),
-      drawioUri: configManager.getConfig('app:drawioUri'),
-      isAllReplyShown: configManager.getConfig('customize:isAllReplyShown'),
-      showPageSideAuthors: configManager.getConfig('customize:showPageSideAuthors'),
-      isContainerFluid: configManager.getConfig('customize:isContainerFluid'),
-      isEnabledStaleNotification: configManager.getConfig('customize:isEnabledStaleNotification'),
-      disableLinkSharing: configManager.getConfig('security:disableLinkSharing'),
-      isUploadAllFileAllowed: fileUploadService.getFileUploadEnabled(),
-      isUploadEnabled: fileUploadService.getIsUploadable(),
-
-      // TODO: remove growiCloudUri condition when bulk export can be relased for GROWI.cloud (https://redmine.weseek.co.jp/issues/163220)
-      isBulkExportPagesEnabled: configManager.getConfig('app:isBulkExportPagesEnabled') && configManager.getConfig('app:growiCloudUri') == null,
-      isPdfBulkExportEnabled: configManager.getConfig('app:pageBulkExportPdfConverterUri') != null,
-      isLocalAccountRegistrationEnabled: passportService.isLocalStrategySetup
-        && configManager.getConfig('security:registrationMode') !== RegistrationMode.CLOSED,
-
-      adminPreferredIndentSize: configManager.getConfig('markdown:adminPreferredIndentSize'),
-      isIndentSizeForced: configManager.getConfig('markdown:isIndentSizeForced'),
-      isEnabledAttachTitleHeader: configManager.getConfig('customize:isEnabledAttachTitleHeader'),
       sidebarConfig: {
         isSidebarCollapsedMode: configManager.getConfig('customize:isSidebarCollapsedMode'),
         isSidebarClosedAtDockMode: configManager.getConfig('customize:isSidebarClosedAtDockMode'),
       },
+    },
+  };
+};
+
+export const getServerSideRendererConfigProps: GetServerSideProps<RendererConfigProps> = async(context: GetServerSidePropsContext) => {
+  const req: CrowiRequest = context.req as CrowiRequest;
+  const { crowi } = req;
+  const { configManager } = crowi;
+
+  return {
+    props: {
       rendererConfig: {
         isEnabledLinebreaks: configManager.getConfig('markdown:isEnabledLinebreaks'),
         isEnabledLinebreaksInComments: configManager.getConfig('markdown:isEnabledLinebreaksInComments'),
@@ -70,3 +49,49 @@ export const getServerSideConfigurationProps: GetServerSideProps<ServerConfigura
     },
   };
 };
+
+export const getServerSideConfigurationProps: GetServerSideProps<ServerConfigurationProps> = async(context: GetServerSidePropsContext) => {
+  const req: CrowiRequest = context.req as CrowiRequest;
+  const { crowi } = req;
+  const {
+    configManager, searchService, aclService, fileUploadService,
+    slackIntegrationService, passportService,
+  } = crowi;
+
+  return {
+    props: {
+      serverConfig: {
+        aiEnabled: configManager.getConfig('app:aiEnabled'),
+        limitLearnablePageCountPerAssistant: configManager.getConfig('openai:limitLearnablePageCountPerAssistant'),
+        isUsersHomepageDeletionEnabled: configManager.getConfig('security:user-homepage-deletion:isEnabled'),
+        isSearchServiceConfigured: searchService.isConfigured,
+        isSearchServiceReachable: searchService.isReachable,
+        isSearchScopeChildrenAsDefault: configManager.getConfig('customize:isSearchScopeChildrenAsDefault'),
+        elasticsearchMaxBodyLengthToIndex: configManager.getConfig('app:elasticsearchMaxBodyLengthToIndex'),
+
+        isRomUserAllowedToComment: configManager.getConfig('security:isRomUserAllowedToComment'),
+
+        isSlackConfigured: slackIntegrationService.isSlackConfigured,
+        isAclEnabled: aclService.isAclEnabled(),
+        drawioUri: configManager.getConfig('app:drawioUri'),
+        isAllReplyShown: configManager.getConfig('customize:isAllReplyShown'),
+        showPageSideAuthors: configManager.getConfig('customize:showPageSideAuthors'),
+        isContainerFluid: configManager.getConfig('customize:isContainerFluid'),
+        isEnabledStaleNotification: configManager.getConfig('customize:isEnabledStaleNotification'),
+        disableLinkSharing: configManager.getConfig('security:disableLinkSharing'),
+        isUploadAllFileAllowed: fileUploadService.getFileUploadEnabled(),
+        isUploadEnabled: fileUploadService.getIsUploadable(),
+
+        // TODO: remove growiCloudUri condition when bulk export can be relased for GROWI.cloud (https://redmine.weseek.co.jp/issues/163220)
+        isBulkExportPagesEnabled: configManager.getConfig('app:isBulkExportPagesEnabled') && configManager.getConfig('app:growiCloudUri') == null,
+        isPdfBulkExportEnabled: configManager.getConfig('app:pageBulkExportPdfConverterUri') != null,
+        isLocalAccountRegistrationEnabled: passportService.isLocalStrategySetup
+          && configManager.getConfig('security:registrationMode') !== RegistrationMode.CLOSED,
+
+        adminPreferredIndentSize: configManager.getConfig('markdown:adminPreferredIndentSize'),
+        isIndentSizeForced: configManager.getConfig('markdown:isIndentSizeForced'),
+        isEnabledAttachTitleHeader: configManager.getConfig('customize:isEnabledAttachTitleHeader'),
+      },
+    },
+  };
+};

+ 7 - 2
apps/app/src/pages/[[...path]]/page-data-props.ts

@@ -9,7 +9,10 @@ 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>>> {
+): Promise<GetServerSidePropsResult<
+    Pick<InitialProps, 'pageWithMeta' | 'isNotFound' | 'isNotCreatable' | 'isForbidden' | 'skipSSR'> &
+    Pick<SameRouteEachProps, 'currentPathname' | 'isIdenticalPathPage'>
+  >> {
   const { pagePathUtils, pathUtils } = await import('@growi/core/dist/utils');
   const { model: mongooseModel } = await import('mongoose');
 
@@ -57,6 +60,7 @@ export async function getPageDataForInitial(
         isNotFound: false,
         isNotCreatable: true,
         isForbidden: false,
+        skipSSR: false,
       },
     };
   }
@@ -121,6 +125,7 @@ export async function getPageDataForInitial(
       isNotFound: true,
       isNotCreatable: !isCreatablePage(currentPathname),
       isForbidden: count > 0,
+      skipSSR: false,
     },
   };
 }
@@ -128,7 +133,7 @@ export async function getPageDataForInitial(
 // Page data retrieval for same-route navigation
 export async function getPageDataForSameRoute(
     context: GetServerSidePropsContext,
-): Promise<GetServerSidePropsResult<Partial<SameRouteEachProps>>> {
+): Promise<GetServerSidePropsResult<Pick<SameRouteEachProps, 'currentPathname' | 'isIdenticalPathPage' | 'redirectFrom'>>> {
   const { pagePathUtils, pathUtils } = await import('@growi/core/dist/utils');
   const { model: mongooseModel } = await import('mongoose');
 

+ 50 - 89
apps/app/src/pages/[[...path]]/server-side-props.ts

@@ -2,76 +2,44 @@ import type { GetServerSidePropsContext, GetServerSidePropsResult } from 'next';
 
 import { addActivity } from '~/pages/utils/activity';
 import { getServerSideI18nProps } from '~/pages/utils/i18n';
-import type { PageTitleCustomizationProps } from '~/pages/utils/page-title-customization';
-import { getServerSidePageTitleCustomizationProps } from '~/pages/utils/page-title-customization';
-import type { ServerConfigurationInitialProps } from '~/states/server-configurations/hydrate';
 
-import type { CommonInitialProps, CommonEachProps } from '../utils/commons';
 import { getServerSideCommonInitialProps, getServerSideCommonEachProps } from '../utils/commons';
-import type { UserUISettingsProps } from '../utils/user-ui-settings';
 import { getServerSideUserUISettingsProps } from '../utils/user-ui-settings';
 
 import {
   NEXT_JS_ROUTING_PAGE,
   mergeGetServerSidePropsResults,
 } from './common-helpers';
-import { getServerSideConfigurationProps } from './configuration-props';
+import { getServerSideConfigurationProps, getServerSideRendererConfigProps, getServerSideSidebarConfigProps } from './configuration-props';
 import { getPageDataForInitial, getPageDataForSameRoute } from './page-data-props';
 import type { InitialProps, SameRouteEachProps } from './types';
 import { isValidInitialAndSameRouteProps, isValidSameRouteProps } from './types';
 import { getAction } from './utils';
 
-// Common props collection helper with improved type safety
-async function getServerSideBasisProps(context: GetServerSidePropsContext): Promise<
-  GetServerSidePropsResult<CommonEachProps & CommonInitialProps & PageTitleCustomizationProps & UserUISettingsProps & ServerConfigurationInitialProps>
-> {
-  const [
-    commonEachResult,
-    commonInitialResult,
-    pageTitleResult,
-    userUIResult,
-    configResult,
-  ] = await Promise.all([
-    getServerSideCommonEachProps(context),
-    getServerSideCommonInitialProps(context),
-    getServerSidePageTitleCustomizationProps(context),
-    getServerSideUserUISettingsProps(context),
-    getServerSideConfigurationProps(context),
-  ]);
-
-  const nextjsRoutingProps = {
-    props: { nextjsRoutingPage: NEXT_JS_ROUTING_PAGE },
-  };
-
-  // Return the merged result
-  return mergeGetServerSidePropsResults(commonEachResult,
-    mergeGetServerSidePropsResults(commonInitialResult,
-      mergeGetServerSidePropsResults(pageTitleResult,
-        mergeGetServerSidePropsResults(userUIResult,
-          mergeGetServerSidePropsResults(configResult, nextjsRoutingProps)))));
-}
+const nextjsRoutingProps = {
+  props: {
+    nextjsRoutingPage: NEXT_JS_ROUTING_PAGE,
+  },
+};
 
 export async function getServerSidePropsForInitial(context: GetServerSidePropsContext): Promise<GetServerSidePropsResult<InitialProps & SameRouteEachProps>> {
   //
   // STAGE 1
   //
 
-  // Collect all required props with type safety (includes CommonEachProps now)
-  const basisPropsResult = await getServerSideBasisProps(context);
-
+  const commonEachPropsResult = await getServerSideCommonEachProps(context);
   // Handle early return cases (redirect/notFound)
-  if ('redirect' in basisPropsResult || 'notFound' in basisPropsResult) {
-    return basisPropsResult;
+  if ('redirect' in commonEachPropsResult || 'notFound' in commonEachPropsResult) {
+    return commonEachPropsResult;
   }
-
-  const basisProps = await basisPropsResult.props;
+  const commonEachProps = await commonEachPropsResult.props;
 
   // Handle redirect destination from common props
-  if (basisProps.redirectDestination != null) {
+  if (commonEachProps.redirectDestination != null) {
     return {
       redirect: {
         permanent: false,
-        destination: basisProps.redirectDestination,
+        destination: commonEachProps.redirectDestination,
       },
     };
   }
@@ -79,43 +47,49 @@ export async function getServerSidePropsForInitial(context: GetServerSidePropsCo
   //
   // STAGE 2
   //
-  const initialPropsResult = mergeGetServerSidePropsResults(
-    basisPropsResult,
-    {
-      props: {
-        isNotFound: false,
-        isForbidden: false,
-        isNotCreatable: false,
-      },
-    },
-  );
-
-  //
-  // STAGE 3
-  //
 
-  // Get page data and i18n props concurrently
-  const [pageDataResult, i18nPropsResult] = await Promise.all([
-    getPageDataForInitial(context),
+  const [
+    commonInitialResult,
+    userUIResult,
+    serverConfigResult,
+    rendererConfigResult,
+    sidebarConfigResult,
+    i18nPropsResult,
+    pageDataResult,
+  ] = await Promise.all([
+    getServerSideCommonInitialProps(context),
+    getServerSideUserUISettingsProps(context),
+    getServerSideConfigurationProps(context),
+    getServerSideRendererConfigProps(context),
+    getServerSideSidebarConfigProps(context),
     getServerSideI18nProps(context, ['translation']),
+    getPageDataForInitial(context),
   ]);
 
   // Merge all results in a type-safe manner (using sequential merging)
-  const mergedResult = mergeGetServerSidePropsResults(initialPropsResult,
-    mergeGetServerSidePropsResults(pageDataResult, i18nPropsResult));
+  const mergedResult = mergeGetServerSidePropsResults(commonEachPropsResult,
+    mergeGetServerSidePropsResults(commonInitialResult,
+      mergeGetServerSidePropsResults(userUIResult,
+        mergeGetServerSidePropsResults(serverConfigResult,
+          mergeGetServerSidePropsResults(rendererConfigResult,
+            mergeGetServerSidePropsResults(sidebarConfigResult,
+              mergeGetServerSidePropsResults(i18nPropsResult,
+                mergeGetServerSidePropsResults(pageDataResult, nextjsRoutingProps))))))));
 
   // Check for early return (redirect/notFound)
   if ('redirect' in mergedResult || 'notFound' in mergedResult) {
     return mergedResult;
   }
 
+  const mergedProps = await mergedResult.props;
+
   // Type-safe props validation AFTER skipSSR is properly set
-  if (!isValidInitialAndSameRouteProps(mergedResult.props)) {
+  if (!isValidInitialAndSameRouteProps(mergedProps)) {
     throw new Error('Invalid merged props structure');
   }
 
-  await addActivity(context, getAction(mergedResult.props));
-  return { props: mergedResult.props };
+  await addActivity(context, getAction(mergedProps));
+  return mergedResult;
 }
 
 export async function getServerSidePropsForSameRoute(context: GetServerSidePropsContext): Promise<GetServerSidePropsResult<SameRouteEachProps>> {
@@ -123,22 +97,19 @@ export async function getServerSidePropsForSameRoute(context: GetServerSideProps
   // STAGE 1
   //
 
-  // Get combined props but extract only what's needed for SameRoute
-  const basisPropsResult = await getServerSideBasisProps(context);
-
+  const commonEachPropsResult = await getServerSideCommonEachProps(context);
   // Handle early return cases (redirect/notFound)
-  if ('redirect' in basisPropsResult || 'notFound' in basisPropsResult) {
-    return basisPropsResult;
+  if ('redirect' in commonEachPropsResult || 'notFound' in commonEachPropsResult) {
+    return commonEachPropsResult;
   }
-
-  const basisProps = await basisPropsResult.props;
+  const commonEachProps = await commonEachPropsResult.props;
 
   // Handle redirect destination from common props
-  if (basisProps.redirectDestination != null) {
+  if (commonEachProps.redirectDestination != null) {
     return {
       redirect: {
         permanent: false,
-        destination: basisProps.redirectDestination,
+        destination: commonEachProps.redirectDestination,
       },
     };
   }
@@ -146,22 +117,13 @@ export async function getServerSidePropsForSameRoute(context: GetServerSideProps
   //
   // STAGE 2
   //
-  const sameRoutePropsResult = mergeGetServerSidePropsResults(
-    basisPropsResult,
-    {
-      props: {},
-    },
-  );
-
-  //
-  // STAGE 3
-  //
 
   // Get page data
-  const sameRouteDataResult = await getPageDataForSameRoute(context);
+  const sameRoutePageDataResult = await getPageDataForSameRoute(context);
 
   // Merge results in a type-safe manner
-  const mergedResult = mergeGetServerSidePropsResults(sameRoutePropsResult, sameRouteDataResult);
+  const mergedResult = mergeGetServerSidePropsResults(commonEachPropsResult,
+    mergeGetServerSidePropsResults(sameRoutePageDataResult, nextjsRoutingProps));
 
   // Check for early return (redirect/notFound)
   if ('redirect' in mergedResult || 'notFound' in mergedResult) {
@@ -172,7 +134,6 @@ export async function getServerSidePropsForSameRoute(context: GetServerSideProps
   if (!isValidSameRouteProps(mergedResult.props)) {
     throw new Error('Invalid same route props structure');
   }
-  const mergedProps = mergedResult.props;
 
-  return { props: mergedProps };
+  return mergedResult;
 }

+ 16 - 38
apps/app/src/pages/[[...path]]/types.ts

@@ -5,9 +5,9 @@ import type {
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { ISidebarConfig } from '~/interfaces/sidebar-config';
 import type { CommonEachProps, CommonInitialProps } from '~/pages/utils/commons';
-import type { PageTitleCustomizationProps } from '~/pages/utils/page-title-customization';
 import type { UserUISettingsProps } from '~/pages/utils/user-ui-settings';
 import type { PageDocument } from '~/server/models/page';
+import type { ServerConfigurationHyderateArgs } from '~/states/server-configurations/hydrate';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:pages:[[...path]]:types');
@@ -15,43 +15,21 @@ const logger = loggerFactory('growi:pages:[[...path]]:types');
 
 export type IPageToShowRevisionWithMeta = IDataWithMeta<IPagePopulatedToShowRevision & PageDocument, IPageInfo>;
 
-export type InitialProps = CommonInitialProps & UserUISettingsProps & {
-  pageWithMeta: IPageToShowRevisionWithMeta | null,
-  skipSSR?: boolean,
-
+export type SidebarConfigProps = {
   sidebarConfig: ISidebarConfig,
+}
+
+export type RendererConfigProps = {
   rendererConfig: RendererConfig,
+}
+
+export type ServerConfigurationProps = {
+  serverConfig: ServerConfigurationHyderateArgs,
+}
 
-  isSearchServiceConfigured: boolean,
-  isSearchServiceReachable: boolean,
-  isSearchScopeChildrenAsDefault: boolean,
-  elasticsearchMaxBodyLengthToIndex: number,
-  isEnabledMarp: boolean,
-
-  isRomUserAllowedToComment: boolean,
-
-  isSlackConfigured: boolean,
-  isAclEnabled: boolean,
-  drawioUri: string | null,
-  isAllReplyShown: boolean,
-  showPageSideAuthors: boolean,
-
-  isContainerFluid: boolean,
-  isUploadEnabled: boolean,
-  isUploadAllFileAllowed: boolean,
-  isBulkExportPagesEnabled: boolean,
-  isPdfBulkExportEnabled: boolean,
-  isEnabledStaleNotification: boolean,
-  isEnabledAttachTitleHeader: boolean,
-  isUsersHomepageDeletionEnabled: boolean,
-  isLocalAccountRegistrationEnabled: boolean,
-
-  adminPreferredIndentSize: number,
-  isIndentSizeForced: boolean,
-  disableLinkSharing: boolean,
-
-  aiEnabled: boolean,
-  limitLearnablePageCountPerAssistant: number,
+export type InitialProps = CommonInitialProps & UserUISettingsProps & SidebarConfigProps & RendererConfigProps & ServerConfigurationProps & {
+  pageWithMeta: IPageToShowRevisionWithMeta | null,
+  skipSSR?: boolean,
 
   // Page state information determined on server-side
   isNotFound: boolean,
@@ -59,7 +37,7 @@ export type InitialProps = CommonInitialProps & UserUISettingsProps & {
   isNotCreatable: boolean,
 }
 
-export type SameRouteEachProps = CommonEachProps & PageTitleCustomizationProps & {
+export type SameRouteEachProps = CommonEachProps & {
   redirectFrom?: string;
 
   isIdenticalPathPage: boolean,
@@ -86,8 +64,8 @@ export function isValidSameRouteProps(props: unknown): props is SameRouteEachPro
   const p = props as Record<string, unknown>;
 
   // Essential properties validation
-  if (typeof p.appTitle !== 'string') {
-    logger.warn('isValidSameRouteProps: appTitle is not a string', { appTitle: p.appTitle });
+  if (typeof p.nextjsRoutingPage !== 'string' && p.nextjsRoutingPage !== null) {
+    logger.warn('isValidSameRouteProps: nextjsRoutingPage is not a string or null', { nextjsRoutingPage: p.nextjsRoutingPage });
     return false;
   }
   if (typeof p.currentPathname !== 'string') {

+ 5 - 3
apps/app/src/pages/utils/commons.ts

@@ -11,6 +11,7 @@ export type CommonInitialProps = {
   confidential: string,
   growiVersion: string,
   isDefaultLogo: boolean,
+  customTitleTemplate: string,
   growiCloudUri: string | undefined,
   forcedColorScheme?: ColorScheme,
 };
@@ -19,7 +20,7 @@ export const getServerSideCommonInitialProps: GetServerSideProps<CommonInitialPr
   const req = context.req as CrowiRequest;
   const { crowi } = req;
   const {
-    appService, configManager, attachmentService,
+    appService, configManager, attachmentService, customizeService,
   } = crowi;
 
   const isCustomizedLogoUploaded = await attachmentService.isBrandLogoExist();
@@ -34,6 +35,7 @@ export const getServerSideCommonInitialProps: GetServerSideProps<CommonInitialPr
       confidential: appService.getAppConfidential() || '',
       growiVersion: getGrowiVersion(),
       isDefaultLogo,
+      customTitleTemplate: customizeService.customTitleTemplate,
       growiCloudUri: configManager.getConfig('app:growiCloudUri'),
       forcedColorScheme,
     },
@@ -42,15 +44,15 @@ export const getServerSideCommonInitialProps: GetServerSideProps<CommonInitialPr
 
 export type CommonEachProps = {
   currentPathname: string,
+  nextjsRoutingPage: string | null, // must be set by each page
   currentUser?: IUserHasId,
-  nextjsRoutingPage?: string,
   csrfToken: string,
   isMaintenanceMode: boolean,
   redirectDestination?: string | null,
 };
 
 
-export const getServerSideCommonEachProps: GetServerSideProps<CommonEachProps> = async(context: GetServerSidePropsContext) => {
+export const getServerSideCommonEachProps: GetServerSideProps<Omit<CommonEachProps, 'nextjsRoutingPage'>> = async(context: GetServerSidePropsContext) => {
   const req = context.req as CrowiRequest;
   const { crowi, user } = req;
   const { appService } = crowi;

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

@@ -5,7 +5,7 @@ import { type GetServerSidePropsContext } from 'next';
 
 const COOKIE_NAME = 'nextjsRoutingPage';
 
-export const useNextjsRoutingPageRegister = (nextjsRoutingPage: string | undefined): void => {
+export const useNextjsRoutingPageRegister = (nextjsRoutingPage: string | null): void => {
   useEffect(() => {
     if (nextjsRoutingPage == null) {
       Cookies.remove(COOKIE_NAME);

+ 13 - 27
apps/app/src/pages/utils/page-title-customization.ts

@@ -1,36 +1,19 @@
 import { DevidedPagePath } from '@growi/core/dist/models';
-import type { GetServerSideProps, GetServerSidePropsContext } from 'next';
 
-import type { CrowiRequest } from '~/interfaces/crowi-request';
+import { useAppTitle, useCustomTitleTemplate } from '~/states/global';
 
-export type PageTitleCustomizationProps = {
-  appTitle: string,
-  customTitleTemplate: string,
-};
-
-export const getServerSidePageTitleCustomizationProps: GetServerSideProps<PageTitleCustomizationProps> = async(context: GetServerSidePropsContext) => {
-  const req = context.req as CrowiRequest;
-  const { crowi } = req;
-  const {
-    appService, customizeService,
-  } = crowi;
-
-  return {
-    props: {
-      appTitle: appService.getAppTitle(),
-      customTitleTemplate: customizeService.customTitleTemplate,
-    },
-  };
-};
 
 /**
  * Generate whole title string for the specified title
  * @param props
  * @param title
  */
-export const generateCustomTitle = (props: PageTitleCustomizationProps, title: string): string => {
-  return props.customTitleTemplate
-    .replace('{{sitename}}', props.appTitle)
+export const useCustomTitle = (title: string): string => {
+  const [appTitle] = useAppTitle();
+  const [customTitleTemplate] = useCustomTitleTemplate();
+
+  return customTitleTemplate
+    .replace('{{sitename}}', appTitle)
     .replace('{{pagepath}}', title)
     .replace('{{pagename}}', title);
 };
@@ -40,11 +23,14 @@ export const generateCustomTitle = (props: PageTitleCustomizationProps, title: s
  * @param props
  * @param pagePath
  */
-export const generateCustomTitleForPage = (props: PageTitleCustomizationProps, pagePath: string): string => {
+export const useCustomTitleForPage = (pagePath: string): string => {
+  const [appTitle] = useAppTitle();
+  const [customTitleTemplate] = useCustomTitleTemplate();
+
   const dPagePath = new DevidedPagePath(pagePath, true, true);
 
-  return props.customTitleTemplate
-    .replace('{{sitename}}', props.appTitle)
+  return customTitleTemplate
+    .replace('{{sitename}}', appTitle)
     .replace('{{pagepath}}', pagePath)
     .replace('{{pagename}}', dPagePath.latter);
 };

+ 6 - 0
apps/app/src/states/global/global.ts

@@ -27,6 +27,12 @@ export const useAppTitle = (): UseAtom<typeof appTitleAtom> => {
   return useAtom(appTitleAtom);
 };
 
+// Custom Title Template atom (no persistence needed as it's server-provided)
+export const customTitleTemplateAtom = atom<string>('');
+export const useCustomTitleTemplate = (): UseAtom<typeof customTitleTemplateAtom> => {
+  return useAtom(customTitleTemplateAtom);
+};
+
 // Site URL atom (no persistence needed as it's server-provided)
 export const siteUrlAtom = atom<string | undefined>(undefined);
 export const useSiteUrl = (): UseAtom<typeof siteUrlAtom> => {

+ 2 - 0
apps/app/src/states/global/hydrate.ts

@@ -8,6 +8,7 @@ import {
   confidentialAtom,
   growiVersionAtom,
   isDefaultLogoAtom,
+  customTitleTemplateAtom,
   growiCloudUriAtom,
   forcedColorSchemeAtom,
 } from './global';
@@ -26,6 +27,7 @@ export const useHydrateGlobalInitialAtoms = (commonInitialProps: CommonInitialPr
     [confidentialAtom, commonInitialProps.confidential],
     [growiVersionAtom, commonInitialProps.growiVersion],
     [isDefaultLogoAtom, commonInitialProps.isDefaultLogo],
+    [customTitleTemplateAtom, commonInitialProps.customTitleTemplate],
     [growiCloudUriAtom, commonInitialProps.growiCloudUri],
     [forcedColorSchemeAtom, commonInitialProps.forcedColorScheme],
   ]);

+ 35 - 33
apps/app/src/states/server-configurations/hydrate.ts

@@ -33,11 +33,13 @@ import {
 /**
  * Type for server configuration initial props
  */
-export type ServerConfigurationInitialProps = {
+export type ServerConfigurationHyderateArgs = {
   aiEnabled: boolean;
   limitLearnablePageCountPerAssistant: number;
   isUsersHomepageDeletionEnabled: boolean;
   adminPreferredIndentSize: number;
+  isSearchServiceConfigured: boolean;
+  isSearchServiceReachable: boolean;
   isSearchScopeChildrenAsDefault: boolean;
   elasticsearchMaxBodyLengthToIndex: number;
   isRomUserAllowedToComment: boolean;
@@ -49,51 +51,51 @@ export type ServerConfigurationInitialProps = {
   disableLinkSharing: boolean;
   isIndentSizeForced: boolean;
   isEnabledAttachTitleHeader: boolean;
-  isSearchServiceConfigured: boolean;
-  isSearchServiceReachable: boolean;
   isSlackConfigured: boolean;
   isAclEnabled: boolean;
-  isUploadAllFileAllowed: boolean;
   isUploadEnabled: boolean;
+  isUploadAllFileAllowed: boolean;
   isBulkExportPagesEnabled: boolean;
   isPdfBulkExportEnabled: boolean;
   isLocalAccountRegistrationEnabled: boolean;
-  rendererConfig: RendererConfig;
 };
 
 /**
  * Hook for hydrating server configuration atoms with server-side data
  * This should be called early in the app component to ensure atoms are properly initialized before rendering
  *
- * @param serverConfigProps - Server-side server configuration properties
+ * @param serverConfigs - Server-side server configuration properties
  */
-export const useHydrateServerConfigurationAtoms = (serverConfigProps: ServerConfigurationInitialProps | undefined): void => {
+export const useHydrateServerConfigurationAtoms = (
+    serverConfigs: ServerConfigurationHyderateArgs | undefined,
+    rendererConfigs: RendererConfig | undefined,
+): void => {
   // Hydrate server configuration atoms with server-side data
-  useHydrateAtoms(serverConfigProps == null ? [] : [
-    [aiEnabledAtom, serverConfigProps.aiEnabled],
-    [limitLearnablePageCountPerAssistantAtom, serverConfigProps.limitLearnablePageCountPerAssistant],
-    [isUsersHomepageDeletionEnabledAtom, serverConfigProps.isUsersHomepageDeletionEnabled],
-    [defaultIndentSizeAtom, serverConfigProps.adminPreferredIndentSize],
-    [isSearchScopeChildrenAsDefaultAtom, serverConfigProps.isSearchScopeChildrenAsDefault],
-    [elasticsearchMaxBodyLengthToIndexAtom, serverConfigProps.elasticsearchMaxBodyLengthToIndex],
-    [isRomUserAllowedToCommentAtom, serverConfigProps.isRomUserAllowedToComment],
-    [drawioUriAtom, serverConfigProps.drawioUri],
-    [isAllReplyShownAtom, serverConfigProps.isAllReplyShown],
-    [showPageSideAuthorsAtom, serverConfigProps.showPageSideAuthors],
-    [isContainerFluidAtom, serverConfigProps.isContainerFluid],
-    [isEnabledStaleNotificationAtom, serverConfigProps.isEnabledStaleNotification],
-    [disableLinkSharingAtom, serverConfigProps.disableLinkSharing],
-    [isIndentSizeForcedAtom, serverConfigProps.isIndentSizeForced],
-    [isEnabledAttachTitleHeaderAtom, serverConfigProps.isEnabledAttachTitleHeader],
-    [isSearchServiceConfiguredAtom, serverConfigProps.isSearchServiceConfigured],
-    [isSearchServiceReachableAtom, serverConfigProps.isSearchServiceReachable],
-    [isSlackConfiguredAtom, serverConfigProps.isSlackConfigured],
-    [isAclEnabledAtom, serverConfigProps.isAclEnabled],
-    [isUploadAllFileAllowedAtom, serverConfigProps.isUploadAllFileAllowed],
-    [isUploadEnabledAtom, serverConfigProps.isUploadEnabled],
-    [isBulkExportPagesEnabledAtom, serverConfigProps.isBulkExportPagesEnabled],
-    [isPdfBulkExportEnabledAtom, serverConfigProps.isPdfBulkExportEnabled],
-    [isLocalAccountRegistrationEnabledAtom, serverConfigProps.isLocalAccountRegistrationEnabled],
-    [rendererConfigAtom, serverConfigProps.rendererConfig],
+  useHydrateAtoms(serverConfigs == null || rendererConfigs == null ? [] : [
+    [aiEnabledAtom, serverConfigs.aiEnabled],
+    [limitLearnablePageCountPerAssistantAtom, serverConfigs.limitLearnablePageCountPerAssistant],
+    [isUsersHomepageDeletionEnabledAtom, serverConfigs.isUsersHomepageDeletionEnabled],
+    [defaultIndentSizeAtom, serverConfigs.adminPreferredIndentSize],
+    [isSearchScopeChildrenAsDefaultAtom, serverConfigs.isSearchScopeChildrenAsDefault],
+    [elasticsearchMaxBodyLengthToIndexAtom, serverConfigs.elasticsearchMaxBodyLengthToIndex],
+    [isRomUserAllowedToCommentAtom, serverConfigs.isRomUserAllowedToComment],
+    [drawioUriAtom, serverConfigs.drawioUri],
+    [isAllReplyShownAtom, serverConfigs.isAllReplyShown],
+    [showPageSideAuthorsAtom, serverConfigs.showPageSideAuthors],
+    [isContainerFluidAtom, serverConfigs.isContainerFluid],
+    [isEnabledStaleNotificationAtom, serverConfigs.isEnabledStaleNotification],
+    [disableLinkSharingAtom, serverConfigs.disableLinkSharing],
+    [isIndentSizeForcedAtom, serverConfigs.isIndentSizeForced],
+    [isEnabledAttachTitleHeaderAtom, serverConfigs.isEnabledAttachTitleHeader],
+    [isSearchServiceConfiguredAtom, serverConfigs.isSearchServiceConfigured],
+    [isSearchServiceReachableAtom, serverConfigs.isSearchServiceReachable],
+    [isSlackConfiguredAtom, serverConfigs.isSlackConfigured],
+    [isAclEnabledAtom, serverConfigs.isAclEnabled],
+    [isUploadAllFileAllowedAtom, serverConfigs.isUploadAllFileAllowed],
+    [isUploadEnabledAtom, serverConfigs.isUploadEnabled],
+    [isBulkExportPagesEnabledAtom, serverConfigs.isBulkExportPagesEnabled],
+    [isPdfBulkExportEnabledAtom, serverConfigs.isPdfBulkExportEnabled],
+    [isLocalAccountRegistrationEnabledAtom, serverConfigs.isLocalAccountRegistrationEnabled],
+    [rendererConfigAtom, rendererConfigs],
   ]);
 };

+ 0 - 0
apps/app/src/states/hydrate/sidebar.ts → apps/app/src/states/sidebar/hydrate.ts