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

+ 20 - 0
apps/app/docs/plan/jotai-migration.md

@@ -107,3 +107,23 @@ export const useHydrateFeatureAtoms = (initialData: InitialData) => {
 - **SSR対応**: `useHydrateAtoms`(公式パターン)
 - **SSR対応**: `useHydrateAtoms`(公式パターン)
 - **永続化**: 既存の`scheduleToPut`機構と連携
 - **永続化**: 既存の`scheduleToPut`機構と連携
 - **TypeScript**: 型推論とタイプセーフティ
 - **TypeScript**: 型推論とタイプセーフティ
+
+---
+
+## 6. 今後の対応予定
+
+### 動的ルーティング時に更新が必要な値
+以下の値は Next.js の dynamic routing によるページ遷移時に値の更新が必要な可能性があります:
+
+- **currentPathname** - ページ遷移時に現在のパスが変更される
+- **isIdenticalPath** - ページごとに異なる値を持つ
+- **isForbidden** - ページのアクセス権限がページごとに異なる
+- **isNotCreatable** - ページの作成可能性がページごとに異なる
+- **csrfToken** - セキュリティ要件によってはページ遷移時に更新が必要(既に対応済み)
+
+これらの値については、現在のSWR実装から段階的にJotaiへの移行を検討する必要があります。
+移行時には以下の点を考慮する必要があります:
+
+1. **ページ遷移時の同期**: `useEffect` と `useRouter` を使用した値の同期
+2. **初期値の設定**: `useHydrateAtoms` による SSR からの初期値設定
+3. **永続化の必要性**: 一時的な状態かユーザー設定として永続化が必要かの判断

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

@@ -28,6 +28,7 @@ import type { CrowiRequest } from '~/interfaces/crowi-request';
 import { RegistrationMode } from '~/interfaces/registration-mode';
 import { RegistrationMode } from '~/interfaces/registration-mode';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { ISidebarConfig } from '~/interfaces/sidebar-config';
 import type { ISidebarConfig } from '~/interfaces/sidebar-config';
+import type { IUserUISettings } from '~/interfaces/user-ui-settings';
 import type { CurrentPageYjsData } from '~/interfaces/yjs';
 import type { CurrentPageYjsData } from '~/interfaces/yjs';
 import type { PageModel, PageDocument } from '~/server/models/page';
 import type { PageModel, PageDocument } from '~/server/models/page';
 import type { PageRedirectModel } from '~/server/models/page-redirect';
 import type { PageRedirectModel } from '~/server/models/page-redirect';
@@ -38,14 +39,13 @@ import {
   useCurrentPageData, useFetchCurrentPage, useCurrentPageId, useCurrentPagePath,
   useCurrentPageData, useFetchCurrentPage, useCurrentPageId, useCurrentPagePath,
 } from '~/states/page';
 } from '~/states/page';
 import {
 import {
-  useCurrentUser,
   useIsForbidden, useIsSharedUser,
   useIsForbidden, useIsSharedUser,
   useIsEnabledStaleNotification, useIsIdenticalPath,
   useIsEnabledStaleNotification, useIsIdenticalPath,
   useIsSearchServiceConfigured, useIsSearchServiceReachable, useDisableLinkSharing,
   useIsSearchServiceConfigured, useIsSearchServiceReachable, useDisableLinkSharing,
   useDefaultIndentSize, useIsIndentSizeForced,
   useDefaultIndentSize, useIsIndentSizeForced,
   useIsAclEnabled, useIsSearchPage, useIsEnabledAttachTitleHeader,
   useIsAclEnabled, useIsSearchPage, useIsEnabledAttachTitleHeader,
-  useCsrfToken, useIsSearchScopeChildrenAsDefault, useIsEnabledMarp, useCurrentPathname,
-  useIsSlackConfigured, useRendererConfig, useGrowiCloudUri,
+  useIsSearchScopeChildrenAsDefault, useIsEnabledMarp,
+  useIsSlackConfigured, useRendererConfig,
   useIsAllReplyShown, useShowPageSideAuthors, useIsContainerFluid, useIsNotCreatable,
   useIsAllReplyShown, useShowPageSideAuthors, useIsContainerFluid, useIsNotCreatable,
   useIsUploadAllFileAllowed, useIsUploadEnabled, useIsBulkExportPagesEnabled,
   useIsUploadAllFileAllowed, useIsUploadEnabled, useIsBulkExportPagesEnabled,
   useElasticsearchMaxBodyLengthToIndex,
   useElasticsearchMaxBodyLengthToIndex,
@@ -61,8 +61,9 @@ import { useCurrentPageYjsData, useSWRMUTxCurrentPageYjsData } from '~/stores/yj
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import type { NextPageWithLayout } from './_app.page';
 import type { NextPageWithLayout } from './_app.page';
-import type { CommonProps } from './utils/commons';
+import type { CommonProps, PageTitleCustomizationProps } from './utils/commons';
 import {
 import {
+  getServerSidePageTitleCustomizationProps,
   getNextI18NextConfig, getServerSideCommonProps, generateCustomTitleForPage, skipSSR, addActivity,
   getNextI18NextConfig, getServerSideCommonProps, generateCustomTitleForPage, skipSSR, addActivity,
 } from './utils/commons';
 } from './utils/commons';
 
 
@@ -141,9 +142,10 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   );
   );
 };
 };
 
 
-type Props = CommonProps & {
+type Props = CommonProps & PageTitleCustomizationProps & {
   pageWithMeta: IPageToShowRevisionWithMeta | null,
   pageWithMeta: IPageToShowRevisionWithMeta | null,
   // pageUser?: any,
   // pageUser?: any,
+  redirectDestination?: string,
   redirectFrom?: string;
   redirectFrom?: string;
 
 
   // shareLinkId?: string;
   // shareLinkId?: string;
@@ -169,6 +171,7 @@ type Props = CommonProps & {
   isRomUserAllowedToComment: boolean,
   isRomUserAllowedToComment: boolean,
 
 
   sidebarConfig: ISidebarConfig,
   sidebarConfig: ISidebarConfig,
+  userUISettings: IUserUISettings,
 
 
   isSlackConfigured: boolean,
   isSlackConfigured: boolean,
   // isMailerSetup: boolean,
   // isMailerSetup: boolean,
@@ -210,12 +213,6 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
 
 
   const router = useRouter();
   const router = useRouter();
 
 
-  useCurrentUser(props.currentUser ?? null);
-
-  // commons
-  useCsrfToken(props.csrfToken);
-  useGrowiCloudUri(props.growiCloudUri);
-
   // page
   // page
   useIsContainerFluid(props.isContainerFluid);
   useIsContainerFluid(props.isContainerFluid);
   // useOwnerOfCurrentPage(props.pageUser != null ? JSON.parse(props.pageUser) : null);
   // useOwnerOfCurrentPage(props.pageUser != null ? JSON.parse(props.pageUser) : null);
@@ -267,8 +264,6 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
   const revisionId = pageWithMeta?.data.revision?._id;
   const revisionId = pageWithMeta?.data.revision?._id;
   const revisionBody = pageWithMeta?.data.revision?.body;
   const revisionBody = pageWithMeta?.data.revision?.body;
 
 
-  useCurrentPathname(props.currentPathname);
-
   // Initialize Jotai atoms with initial data
   // Initialize Jotai atoms with initial data
   useHydratePageAtoms(pageWithMeta?.data);
   useHydratePageAtoms(pageWithMeta?.data);
 
 
@@ -637,15 +632,19 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
   const req = context.req as CrowiRequest;
   const req = context.req as CrowiRequest;
   const { user } = req;
   const { user } = req;
 
 
-  const result = await getServerSideCommonProps(context);
+  const commonPropsResult = await getServerSideCommonProps(context);
+  const pageTitleCustomizeationPropsResult = await getServerSidePageTitleCustomizationProps(context);
 
 
   // check for presence
   // check for presence
   // see: https://github.com/vercel/next.js/issues/19271#issuecomment-730006862
   // see: https://github.com/vercel/next.js/issues/19271#issuecomment-730006862
-  if (!('props' in result)) {
+  if (!('props' in commonPropsResult) || !('props' in pageTitleCustomizeationPropsResult)) {
     throw new Error('invalid getSSP result');
     throw new Error('invalid getSSP result');
   }
   }
 
 
-  const props: Props = result.props as Props;
+  const props: Props = {
+    ...commonPropsResult.props,
+    ...pageTitleCustomizeationPropsResult.props,
+  } as Props;
 
 
   if (props.redirectDestination != null) {
   if (props.redirectDestination != null) {
     return {
     return {

+ 8 - 10
apps/app/src/pages/_app.page.tsx

@@ -13,15 +13,15 @@ import * as nextI18nConfig from '^/config/next-i18next.config';
 
 
 import { GlobalFonts } from '~/components/FontFamily/GlobalFonts';
 import { GlobalFonts } from '~/components/FontFamily/GlobalFonts';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
-import {
-  useAppTitle, useConfidential, useGrowiVersion, useSiteUrl, useIsDefaultLogo, useForcedColorScheme,
-} from '~/stores-universal/context';
+import { useAutoUpdateGlobalAtoms } from '~/states/auto-update/global';
+import { useHydrateGlobalAtoms } from '~/states/hydrate/global';
 import { swrGlobalConfiguration } from '~/utils/swr-utils';
 import { swrGlobalConfiguration } from '~/utils/swr-utils';
 
 
 import { getLocaleAtServerSide, type CommonProps } from './utils/commons';
 import { getLocaleAtServerSide, type CommonProps } from './utils/commons';
+import { registerTransformerForObjectId } from './utils/objectid-transformer';
+
 import '~/styles/prebuilt/vendor.css';
 import '~/styles/prebuilt/vendor.css';
 import '~/styles/style-app.scss';
 import '~/styles/style-app.scss';
-import { registerTransformerForObjectId } from './utils/objectid-transformer';
 
 
 // eslint-disable-next-line @typescript-eslint/ban-types
 // eslint-disable-next-line @typescript-eslint/ban-types
 export type NextPageWithLayout<P = {}, IP = P> = NextPage<P, IP> & {
 export type NextPageWithLayout<P = {}, IP = P> = NextPage<P, IP> & {
@@ -56,12 +56,10 @@ function GrowiApp({ Component, pageProps, userLocale }: GrowiAppProps): JSX.Elem
   }, []);
   }, []);
 
 
   const commonPageProps = pageProps as CommonProps;
   const commonPageProps = pageProps as CommonProps;
-  useAppTitle(commonPageProps.appTitle);
-  useSiteUrl(commonPageProps.siteUrl);
-  useConfidential(commonPageProps.confidential);
-  useGrowiVersion(commonPageProps.growiVersion);
-  useIsDefaultLogo(commonPageProps.isDefaultLogo);
-  useForcedColorScheme(commonPageProps.forcedColorScheme);
+
+  // Hydrate global atoms with server-side data
+  useHydrateGlobalAtoms(commonPageProps);
+  useAutoUpdateGlobalAtoms(commonPageProps);
 
 
   // Use the layout defined at the page level, if available
   // Use the layout defined at the page level, if available
   const getLayout = Component.getLayout ?? (page => page);
   const getLayout = Component.getLayout ?? (page => page);

+ 54 - 36
apps/app/src/pages/utils/commons.ts

@@ -16,33 +16,37 @@ import { detectLocaleFromBrowserAcceptLanguage } from '~/server/util/locale-util
 import { getGrowiVersion } from '~/utils/growi-version';
 import { getGrowiVersion } from '~/utils/growi-version';
 
 
 export type CommonProps = {
 export type CommonProps = {
-  namespacesRequired: string[], // i18next
   currentPathname: string,
   currentPathname: string,
+  currentUser?: IUserHasId,
   appTitle: string,
   appTitle: string,
   siteUrl: string | undefined,
   siteUrl: string | undefined,
-  confidential: string,
-  customTitleTemplate: string,
   csrfToken: string,
   csrfToken: string,
-  isContainerFluid: boolean,
+  confidential: string,
   growiVersion: string,
   growiVersion: string,
   isMaintenanceMode: boolean,
   isMaintenanceMode: boolean,
-  redirectDestination: string | null,
   isDefaultLogo: boolean,
   isDefaultLogo: boolean,
   growiCloudUri: string | undefined,
   growiCloudUri: string | undefined,
-  isAccessDeniedForNonAdminUser?: boolean,
-  currentUser?: IUserHasId,
   forcedColorScheme?: ColorScheme,
   forcedColorScheme?: ColorScheme,
-  userUISettings?: IUserUISettings
+  // namespacesRequired: string[], // i18next
+  // isContainerFluid: boolean,
+  // redirectDestination: string | null,
+  // isAccessDeniedForNonAdminUser?: boolean,
+  // userUISettings?: IUserUISettings
 } & Partial<SSRConfig>;
 } & Partial<SSRConfig>;
 
 
+export type PageTitleCustomizationProps = {
+  appTitle: string,
+  customTitleTemplate: string,
+};
+
 // eslint-disable-next-line max-len
 // eslint-disable-next-line max-len
 export const getServerSideCommonProps: GetServerSideProps<CommonProps> = async(context: GetServerSidePropsContext) => {
 export const getServerSideCommonProps: GetServerSideProps<CommonProps> = async(context: GetServerSidePropsContext) => {
-  const getModelSafely = await import('~/server/util/mongoose-utils').then(mod => mod.getModelSafely);
+  // const getModelSafely = await import('~/server/util/mongoose-utils').then(mod => mod.getModelSafely);
 
 
   const req = context.req as CrowiRequest;
   const req = context.req as CrowiRequest;
   const { crowi, user } = req;
   const { crowi, user } = req;
   const {
   const {
-    appService, configManager, customizeService, attachmentService,
+    appService, configManager, attachmentService,
   } = crowi;
   } = crowi;
 
 
   const url = new URL(context.resolvedUrl, 'http://example.com');
   const url = new URL(context.resolvedUrl, 'http://example.com');
@@ -55,48 +59,62 @@ export const getServerSideCommonProps: GetServerSideProps<CommonProps> = async(c
     currentUser = user.toObject();
     currentUser = user.toObject();
   }
   }
 
 
-  // Redirect destination for page transition by next/link
-  let redirectDestination: string | null = null;
-  if (!crowi.aclService.isGuestAllowedToRead() && currentUser == null) {
-    redirectDestination = '/login';
-  }
-  else if (!isMaintenanceMode && currentPathname === '/maintenance') {
-    redirectDestination = '/';
-  }
-  else if (isMaintenanceMode && !currentPathname.match('/admin/*') && !(currentPathname === '/maintenance')) {
-    redirectDestination = '/maintenance';
-  }
-  else {
-    redirectDestination = null;
-  }
+  // // Redirect destination for page transition by next/link
+  // let redirectDestination: string | null = null;
+  // if (!crowi.aclService.isGuestAllowedToRead() && currentUser == null) {
+  //   redirectDestination = '/login';
+  // }
+  // else if (!isMaintenanceMode && currentPathname === '/maintenance') {
+  //   redirectDestination = '/';
+  // }
+  // else if (isMaintenanceMode && !currentPathname.match('/admin/*') && !(currentPathname === '/maintenance')) {
+  //   redirectDestination = '/maintenance';
+  // }
+  // else {
+  //   redirectDestination = null;
+  // }
 
 
   const isCustomizedLogoUploaded = await attachmentService.isBrandLogoExist();
   const isCustomizedLogoUploaded = await attachmentService.isBrandLogoExist();
   const isDefaultLogo = crowi.configManager.getConfig('customize:isDefaultLogo') || !isCustomizedLogoUploaded;
   const isDefaultLogo = crowi.configManager.getConfig('customize:isDefaultLogo') || !isCustomizedLogoUploaded;
   const forcedColorScheme = crowi.customizeService.forcedColorScheme;
   const forcedColorScheme = crowi.customizeService.forcedColorScheme;
 
 
   // retrieve UserUISett ings
   // retrieve UserUISett ings
-  const UserUISettings = getModelSafely<UserUISettingsDocument>('UserUISettings');
-  const userUISettings = user != null && UserUISettings != null
-    ? await UserUISettings.findOne({ user: user._id }).exec()
-    : req.session.uiSettings; // for guests
+  // const UserUISettings = getModelSafely<UserUISettingsDocument>('UserUISettings');
+  // const userUISettings = user != null && UserUISettings != null
+  //   ? await UserUISettings.findOne({ user: user._id }).exec()
+  //   : req.session.uiSettings; // for guests
 
 
   const props: CommonProps = {
   const props: CommonProps = {
-    namespacesRequired: ['translation'],
+    // namespacesRequired: ['translation'],
     currentPathname,
     currentPathname,
+    currentUser,
     appTitle: appService.getAppTitle(),
     appTitle: appService.getAppTitle(),
     siteUrl: configManager.getConfig('app:siteUrl'), // DON'T USE growiInfoService.getSiteUrl()
     siteUrl: configManager.getConfig('app:siteUrl'), // DON'T USE growiInfoService.getSiteUrl()
     confidential: appService.getAppConfidential() || '',
     confidential: appService.getAppConfidential() || '',
-    customTitleTemplate: customizeService.customTitleTemplate,
     csrfToken: req.csrfToken(),
     csrfToken: req.csrfToken(),
-    isContainerFluid: configManager.getConfig('customize:isContainerFluid') ?? false,
+    // isContainerFluid: configManager.getConfig('customize:isContainerFluid') ?? false,
     growiVersion: getGrowiVersion(),
     growiVersion: getGrowiVersion(),
     isMaintenanceMode,
     isMaintenanceMode,
-    redirectDestination,
-    currentUser,
+    // redirectDestination,
     isDefaultLogo,
     isDefaultLogo,
     forcedColorScheme,
     forcedColorScheme,
     growiCloudUri: configManager.getConfig('app:growiCloudUri'),
     growiCloudUri: configManager.getConfig('app:growiCloudUri'),
-    userUISettings: userUISettings?.toObject?.() ?? userUISettings,
+    // userUISettings: userUISettings?.toObject?.() ?? userUISettings,
+  };
+
+  return { props };
+};
+
+export const getServerSidePageTitleCustomizationProps: GetServerSideProps<PageTitleCustomizationProps> = async(context: GetServerSidePropsContext) => {
+  const req = context.req as CrowiRequest;
+  const { crowi } = req;
+  const {
+    appService, customizeService,
+  } = crowi;
+
+  const props: PageTitleCustomizationProps = {
+    appTitle: appService.getAppTitle(),
+    customTitleTemplate: customizeService.customTitleTemplate,
   };
   };
 
 
   return { props };
   return { props };
@@ -159,7 +177,7 @@ export const getNextI18NextConfig = async(
  * @param props
  * @param props
  * @param title
  * @param title
  */
  */
-export const generateCustomTitle = (props: CommonProps, title: string): string => {
+export const generateCustomTitle = (props: PageTitleCustomizationProps, title: string): string => {
   return props.customTitleTemplate
   return props.customTitleTemplate
     .replace('{{sitename}}', props.appTitle)
     .replace('{{sitename}}', props.appTitle)
     .replace('{{pagepath}}', title)
     .replace('{{pagepath}}', title)
@@ -171,7 +189,7 @@ export const generateCustomTitle = (props: CommonProps, title: string): string =
  * @param props
  * @param props
  * @param pagePath
  * @param pagePath
  */
  */
-export const generateCustomTitleForPage = (props: CommonProps, pagePath: string): string => {
+export const generateCustomTitleForPage = (props: PageTitleCustomizationProps, pagePath: string): string => {
   const dPagePath = new DevidedPagePath(pagePath, true, true);
   const dPagePath = new DevidedPagePath(pagePath, true, true);
 
 
   return props.customTitleTemplate
   return props.customTitleTemplate

+ 34 - 0
apps/app/src/states/auto-update/global.ts

@@ -0,0 +1,34 @@
+import { useEffect, useLayoutEffect } from 'react';
+
+import type { CommonProps } from '../../pages/utils/commons';
+import {
+  useCurrentPathname,
+  useCurrentUser,
+  useCsrfToken,
+} from '../global';
+
+/**
+ * Hook for auto-updating global UI state atoms with server-side data
+ *
+ * @param commonProps - Server-side common properties from getServerSideCommonProps
+ */
+export const useAutoUpdateGlobalAtoms = (commonProps: CommonProps): void => {
+  // Update pathname and user atoms
+  const [, setCurrentPathname] = useCurrentPathname();
+  useLayoutEffect(() => {
+    setCurrentPathname(commonProps.currentPathname);
+  }, [setCurrentPathname, commonProps.currentPathname]);
+
+  // Update user atom
+  const [, setCurrentUser] = useCurrentUser();
+  useLayoutEffect(() => {
+    setCurrentUser(commonProps.currentUser);
+  }, [setCurrentUser, commonProps.currentUser]);
+
+  // Update CSRF token atom
+  const [, setCsrfToken] = useCsrfToken();
+  useEffect(() => {
+    setCsrfToken(commonProps.csrfToken);
+  }, [setCsrfToken, commonProps.csrfToken]);
+
+};

+ 64 - 0
apps/app/src/states/context.ts

@@ -0,0 +1,64 @@
+import { atom, useAtom } from 'jotai';
+
+import { currentUserAtom, growiCloudUriAtom } from './global';
+import type { UseAtom } from './ui/helper';
+
+/**
+ * Computed atom for checking if current user is a guest user
+ * Depends on currentUser atom
+ */
+const isGuestUserAtom = atom((get) => {
+  const currentUser = get(currentUserAtom);
+  return currentUser?._id == null;
+});
+
+export const useIsGuestUser = (): UseAtom<typeof isGuestUserAtom> => {
+  return useAtom(isGuestUserAtom);
+};
+
+/**
+ * Computed atom for checking if current user is a read-only user
+ * Depends on currentUser and isGuestUser atoms
+ */
+const isReadOnlyUserAtom = atom((get) => {
+  const currentUser = get(currentUserAtom);
+  const isGuestUser = get(isGuestUserAtom);
+
+  return !isGuestUser && !!currentUser?.readOnly;
+});
+
+export const useIsReadOnlyUser = (): UseAtom<typeof isReadOnlyUserAtom> => {
+  return useAtom(isReadOnlyUserAtom);
+};
+
+/**
+ * Computed atom for checking if current user is an admin
+ * Depends on currentUser atom
+ */
+const isAdminAtom = atom((get) => {
+  const currentUser = get(currentUserAtom);
+  return currentUser?.admin ?? false;
+});
+
+export const useIsAdmin = (): UseAtom<typeof isAdminAtom> => {
+  return useAtom(isAdminAtom);
+};
+
+/**
+ * Computed atom for GROWI documentation URL
+ * Depends on growiCloudUri atom
+ */
+const growiDocumentationUrlAtom = atom((get) => {
+  const growiCloudUri = get(growiCloudUriAtom);
+
+  if (growiCloudUri != null) {
+    const url = new URL('/help', growiCloudUri);
+    return url.toString();
+  }
+
+  return 'https://docs.growi.org';
+});
+
+export const useGrowiDocumentationUrl = (): UseAtom<typeof growiDocumentationUrlAtom> => {
+  return useAtom(growiDocumentationUrlAtom);
+};

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

@@ -0,0 +1,70 @@
+import type { ColorScheme, IUserHasId } from '@growi/core';
+import { atom, useAtom } from 'jotai';
+
+import type { UseAtom } from './ui/helper';
+
+// CSRF Token atom (no persistence needed as it's server-provided)
+export const csrfTokenAtom = atom<string>('');
+export const useCsrfToken = (): UseAtom<typeof csrfTokenAtom> => {
+  return useAtom(csrfTokenAtom);
+};
+
+// App current pathname atom (no persistence needed as it's server-provided)
+export const currentPathnameAtom = atom<string>('');
+export const useCurrentPathname = (): UseAtom<typeof currentPathnameAtom> => {
+  return useAtom(currentPathnameAtom);
+};
+
+// Current User atom (no persistence needed as it's server-provided)
+export const currentUserAtom = atom<IUserHasId | undefined>();
+export const useCurrentUser = (): UseAtom<typeof currentUserAtom> => {
+  return useAtom(currentUserAtom);
+};
+
+// App Title atom (no persistence needed as it's server-provided)
+export const appTitleAtom = atom<string>('');
+export const useAppTitle = (): UseAtom<typeof appTitleAtom> => {
+  return useAtom(appTitleAtom);
+};
+
+// Site URL atom (no persistence needed as it's server-provided)
+export const siteUrlAtom = atom<string | undefined>(undefined);
+export const useSiteUrl = (): UseAtom<typeof siteUrlAtom> => {
+  return useAtom(siteUrlAtom);
+};
+
+// Confidential atom (no persistence needed as it's server-provided)
+export const confidentialAtom = atom<string>('');
+export const useConfidential = (): UseAtom<typeof confidentialAtom> => {
+  return useAtom(confidentialAtom);
+};
+
+// GROWI Version atom (no persistence needed as it's server-provided)
+export const growiVersionAtom = atom<string>('');
+export const useGrowiVersion = (): UseAtom<typeof growiVersionAtom> => {
+  return useAtom(growiVersionAtom);
+};
+
+// Maintenance Mode atom (no persistence needed as it's server-provided)
+export const isMaintenanceModeAtom = atom<boolean>(false);
+export const useIsMaintenanceMode = (): UseAtom<typeof isMaintenanceModeAtom> => {
+  return useAtom(isMaintenanceModeAtom);
+};
+
+// Default Logo atom (no persistence needed as it's server-provided)
+export const isDefaultLogoAtom = atom<boolean>(true);
+export const useIsDefaultLogo = (): UseAtom<typeof isDefaultLogoAtom> => {
+  return useAtom(isDefaultLogoAtom);
+};
+
+// GROWI Cloud URI atom (no persistence needed as it's server-provided)
+export const growiCloudUriAtom = atom<string | undefined>(undefined);
+export const useGrowiCloudUri = (): UseAtom<typeof growiCloudUriAtom> => {
+  return useAtom(growiCloudUriAtom);
+};
+
+// Forced Color Scheme atom (no persistence needed as it's server-provided)
+export const forcedColorSchemeAtom = atom<ColorScheme | undefined>(undefined);
+export const useForcedColorScheme = (): UseAtom<typeof forcedColorSchemeAtom> => {
+  return useAtom(forcedColorSchemeAtom);
+};

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

@@ -0,0 +1,39 @@
+import { useHydrateAtoms } from 'jotai/utils';
+
+import type { CommonProps } from '../../pages/utils/commons';
+import {
+  currentPathnameAtom,
+  currentUserAtom,
+  csrfTokenAtom,
+  appTitleAtom,
+  siteUrlAtom,
+  confidentialAtom,
+  growiVersionAtom,
+  isMaintenanceModeAtom,
+  isDefaultLogoAtom,
+  growiCloudUriAtom,
+  forcedColorSchemeAtom,
+} from '../global';
+
+/**
+ * Hook for hydrating global UI state atoms with server-side data
+ * This should be called early in the app component to ensure atoms are properly initialized before rendering
+ *
+ * @param commonProps - Server-side common properties from getServerSideCommonProps
+ */
+export const useHydrateGlobalAtoms = (commonProps: CommonProps): void => {
+  // Hydrate global atoms with server-side data
+  useHydrateAtoms([
+    [currentPathnameAtom, commonProps.currentPathname],
+    [currentUserAtom, commonProps.currentUser],
+    [csrfTokenAtom, commonProps.csrfToken],
+    [appTitleAtom, commonProps.appTitle],
+    [siteUrlAtom, commonProps.siteUrl],
+    [confidentialAtom, commonProps.confidential],
+    [growiVersionAtom, commonProps.growiVersion],
+    [isMaintenanceModeAtom, commonProps.isMaintenanceMode],
+    [isDefaultLogoAtom, commonProps.isDefaultLogo],
+    [growiCloudUriAtom, commonProps.growiCloudUri],
+    [forcedColorSchemeAtom, commonProps.forcedColorScheme],
+  ]);
+};

+ 2 - 4
apps/app/src/states/page/hooks.ts

@@ -1,9 +1,7 @@
-import type { IPagePopulatedToShowRevision, IUserHasId } from '@growi/core';
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { useAtom } from 'jotai';
 import { useAtom } from 'jotai';
 
 
-import { useCurrentPathname } from '~/stores-universal/context';
-
+import { useCurrentPathname } from '../global';
 import type { UseAtom } from '../ui/helper';
 import type { UseAtom } from '../ui/helper';
 
 
 import {
 import {
@@ -78,7 +76,7 @@ export const useRemoteRevisionLastUpdatedAt = (): UseAtom<typeof remoteRevisionL
  */
  */
 export const useCurrentPagePath = (): readonly [string | undefined] => {
 export const useCurrentPagePath = (): readonly [string | undefined] => {
   const [currentPagePath] = useAtom(currentPagePathAtom);
   const [currentPagePath] = useAtom(currentPagePathAtom);
-  const { data: currentPathname } = useCurrentPathname();
+  const [currentPathname] = useCurrentPathname();
 
 
   if (currentPagePath != null) {
   if (currentPagePath != null) {
     return [currentPagePath];
     return [currentPagePath];

+ 8 - 109
apps/app/src/stores-universal/context.tsx

@@ -1,15 +1,15 @@
 import type EventEmitter from 'events';
 import type EventEmitter from 'events';
 
 
 import { AcceptedUploadFileType } from '@growi/core';
 import { AcceptedUploadFileType } from '@growi/core';
-import type { ColorScheme, IUserHasId } from '@growi/core';
 import { useSWRStatic } from '@growi/core/dist/swr';
 import { useSWRStatic } from '@growi/core/dist/swr';
 import type { SWRResponse } from 'swr';
 import type { SWRResponse } from 'swr';
-import useSWR from 'swr';
 import useSWRImmutable from 'swr/immutable';
 import useSWRImmutable from 'swr/immutable';
 
 
 import type { SupportedActionType } from '~/interfaces/activity';
 import type { SupportedActionType } from '~/interfaces/activity';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 
 
+import { useIsGuestUser, useIsReadOnlyUser } from '../states/context';
+
 import { useContextSWR } from './use-context-swr';
 import { useContextSWR } from './use-context-swr';
 
 
 declare global {
 declare global {
@@ -20,30 +20,6 @@ declare global {
 type Nullable<T> = T | null;
 type Nullable<T> = T | null;
 
 
 
 
-export const useCsrfToken = (initialData?: string): SWRResponse<string, Error> => {
-  return useContextSWR<string, Error>('csrfToken', initialData);
-};
-
-export const useAppTitle = (initialData?: string): SWRResponse<string, Error> => {
-  return useContextSWR('appTitle', initialData);
-};
-
-export const useSiteUrl = (initialData?: string): SWRResponse<string, Error> => {
-  return useContextSWR<string, Error>('siteUrl', initialData);
-};
-
-export const useConfidential = (initialData?: string): SWRResponse<string, Error> => {
-  return useContextSWR('confidential', initialData);
-};
-
-export const useCurrentUser = (initialData?: Nullable<IUserHasId>): SWRResponse<Nullable<IUserHasId>, Error> => {
-  return useContextSWR('currentUser', initialData);
-};
-
-export const useCurrentPathname = (initialData?: string): SWRResponse<string, Error> => {
-  return useContextSWR('currentPathname', initialData);
-};
-
 export const useIsIdenticalPath = (initialData?: boolean): SWRResponse<boolean, Error> => {
 export const useIsIdenticalPath = (initialData?: boolean): SWRResponse<boolean, Error> => {
   return useContextSWR<boolean, Error>('isIdenticalPath', initialData, { fallbackData: false });
   return useContextSWR<boolean, Error>('isIdenticalPath', initialData, { fallbackData: false });
 };
 };
@@ -73,7 +49,7 @@ export const useRegistrationWhitelist = (initialData?: Nullable<string[]>): SWRR
 };
 };
 
 
 export const useIsSearchPage = (initialData?: Nullable<boolean>) : SWRResponse<Nullable<boolean>, Error> => {
 export const useIsSearchPage = (initialData?: Nullable<boolean>) : SWRResponse<Nullable<boolean>, Error> => {
-  return useContextSWR<Nullable<any>, Error>('isSearchPage', initialData);
+  return useContextSWR<Nullable<boolean>, Error>('isSearchPage', initialData);
 };
 };
 
 
 export const useIsAclEnabled = (initialData?: boolean) : SWRResponse<boolean, Error> => {
 export const useIsAclEnabled = (initialData?: boolean) : SWRResponse<boolean, Error> => {
@@ -92,7 +68,7 @@ export const useElasticsearchMaxBodyLengthToIndex = (initialData?: number) : SWR
   return useContextSWR('elasticsearchMaxBodyLengthToIndex', initialData);
   return useContextSWR('elasticsearchMaxBodyLengthToIndex', initialData);
 };
 };
 
 
-export const useIsMailerSetup = (initialData?: boolean): SWRResponse<boolean, any> => {
+export const useIsMailerSetup = (initialData?: boolean): SWRResponse<boolean, Error> => {
   return useContextSWR('isMailerSetup', initialData);
   return useContextSWR('isMailerSetup', initialData);
 };
 };
 
 
@@ -136,15 +112,11 @@ export const useAuditLogAvailableActions = (initialData?: Array<SupportedActionT
   return useContextSWR<Array<SupportedActionType>, Error>('auditLogAvailableActions', initialData);
   return useContextSWR<Array<SupportedActionType>, Error>('auditLogAvailableActions', initialData);
 };
 };
 
 
-export const useGrowiVersion = (initialData?: string): SWRResponse<string, any> => {
-  return useContextSWR('growiVersion', initialData);
-};
-
-export const useIsEnabledStaleNotification = (initialData?: boolean): SWRResponse<boolean, any> => {
+export const useIsEnabledStaleNotification = (initialData?: boolean): SWRResponse<boolean, Error> => {
   return useContextSWR('isEnabledStaleNotification', initialData);
   return useContextSWR('isEnabledStaleNotification', initialData);
 };
 };
 
 
-export const useRendererConfig = (initialData?: RendererConfig): SWRResponse<RendererConfig, any> => {
+export const useRendererConfig = (initialData?: RendererConfig): SWRResponse<RendererConfig, Error> => {
   return useContextSWR('growiRendererConfig', initialData);
   return useContextSWR('growiRendererConfig', initialData);
 };
 };
 
 
@@ -184,22 +156,10 @@ export const useCustomizeTitle = (initialData?: string): SWRResponse<string, Err
   return useContextSWR('CustomizeTitle', initialData);
   return useContextSWR('CustomizeTitle', initialData);
 };
 };
 
 
-export const useIsDefaultLogo = (initialData?: boolean): SWRResponse<boolean, Error> => {
-  return useContextSWR('isDefaultLogo', initialData);
-};
-
 export const useIsCustomizedLogoUploaded = (initialData?: boolean): SWRResponse<boolean, Error> => {
 export const useIsCustomizedLogoUploaded = (initialData?: boolean): SWRResponse<boolean, Error> => {
   return useSWRStatic('isCustomizedLogoUploaded', initialData);
   return useSWRStatic('isCustomizedLogoUploaded', initialData);
 };
 };
 
 
-export const useForcedColorScheme = (initialData?: ColorScheme): SWRResponse<ColorScheme, Error> => {
-  return useContextSWR('forcedColorScheme', initialData);
-};
-
-export const useGrowiCloudUri = (initialData?: string): SWRResponse<string, Error> => {
-  return useContextSWR('growiCloudUri', initialData);
-};
-
 export const useGrowiAppIdForGrowiCloud = (initialData?: number): SWRResponse<number, Error> => {
 export const useGrowiAppIdForGrowiCloud = (initialData?: number): SWRResponse<number, Error> => {
   return useContextSWR('growiAppIdForGrowiCloud', initialData);
   return useContextSWR('growiAppIdForGrowiCloud', initialData);
 };
 };
@@ -238,50 +198,9 @@ export const useIsEnableUnifiedMergeView = (initialData?: boolean): SWRResponse<
  *                     Computed contexts
  *                     Computed contexts
  *********************************************************** */
  *********************************************************** */
 
 
-export const useIsGuestUser = (): SWRResponse<boolean, Error> => {
-  const { data: currentUser, isLoading } = useCurrentUser();
-
-  return useSWRImmutable(
-    isLoading ? null : ['isGuestUser', currentUser?._id],
-    ([, currentUserId]) => currentUserId == null,
-    { fallbackData: currentUser?._id == null },
-  );
-};
-
-export const useIsReadOnlyUser = (): SWRResponse<boolean, Error> => {
-  const { data: currentUser, isLoading: isCurrentUserLoading } = useCurrentUser();
-  const { data: isGuestUser, isLoading: isGuestUserLoding } = useIsGuestUser();
-
-  const isLoading = isCurrentUserLoading || isGuestUserLoding;
-  const isReadOnlyUser = !isGuestUser && !!currentUser?.readOnly;
-
-  return useSWRImmutable(
-    isLoading ? null : ['isReadOnlyUser', isReadOnlyUser, currentUser?._id],
-    () => isReadOnlyUser,
-    { fallbackData: isReadOnlyUser },
-  );
-};
-
-export const useIsAdmin = (): SWRResponse<boolean, Error> => {
-  const { data: currentUser, isLoading } = useCurrentUser();
-
-  return useSWR(
-    isLoading ? null : ['isAdminUser', currentUser?._id, currentUser?.admin],
-    ([, , isAdmin]) => isAdmin ?? false,
-    {
-      fallbackData: currentUser?.admin ?? false,
-      keepPreviousData: true,
-      // disable all revalidation but revalidateIfStale
-      revalidateOnMount: false,
-      revalidateOnFocus: false,
-      revalidateOnReconnect: false,
-    },
-  );
-};
-
 export const useIsEditable = (): SWRResponse<boolean, Error> => {
 export const useIsEditable = (): SWRResponse<boolean, Error> => {
-  const { data: isGuestUser } = useIsGuestUser();
-  const { data: isReadOnlyUser } = useIsReadOnlyUser();
+  const [isGuestUser] = useIsGuestUser();
+  const [isReadOnlyUser] = useIsReadOnlyUser();
   const { data: isForbidden } = useIsForbidden();
   const { data: isForbidden } = useIsForbidden();
   const { data: isNotCreatable } = useIsNotCreatable();
   const { data: isNotCreatable } = useIsNotCreatable();
   const { data: isIdenticalPath } = useIsIdenticalPath();
   const { data: isIdenticalPath } = useIsIdenticalPath();
@@ -311,23 +230,3 @@ export const useAcceptedUploadFileType = (): SWRResponse<AcceptedUploadFileType,
     },
     },
   );
   );
 };
 };
-
-// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-export const useGrowiDocumentationUrl = () => {
-  const { data: growiCloudUri } = useGrowiCloudUri();
-
-  return useSWR(
-    ['documentationUrl', growiCloudUri],
-    ([, growiCloudUri]) => {
-      const url = growiCloudUri != null
-        ? new URL('/help', growiCloudUri)
-        : new URL('https://docs.growi.org');
-      return url.toString();
-    },
-    {
-      fallbackData: 'https://docs.growi.org',
-      revalidateOnFocus: false,
-      revalidateOnReconnect: false,
-    },
-  );
-};