Browse Source

WIP: refactor getServerSideProps

Yuki Takei 8 months ago
parent
commit
2ded7120f5

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

@@ -5,12 +5,11 @@ import EventEmitter from 'events';
 
 import { isIPageInfo } from '@growi/core';
 import type {
-  IDataWithMeta, IPageInfo, IPagePopulatedToShowRevision,
+  IDataWithMeta,
 } from '@growi/core';
 import {
-  isClient, pagePathUtils, pathUtils,
+  isClient,
 } from '@growi/core/dist/utils';
-import ExtensibleCustomError from 'extensible-custom-error';
 import type {
   GetServerSideProps, GetServerSidePropsContext,
 } from 'next';
@@ -22,19 +21,12 @@ import superjson from 'superjson';
 import { BasicLayout } from '~/components/Layout/BasicLayout';
 import { PageView } from '~/components/PageView/PageView';
 import { DrawioViewerScript } from '~/components/Script/DrawioViewerScript';
-import type { CrowiRequest } from '~/interfaces/crowi-request';
-import { RegistrationMode } from '~/interfaces/registration-mode';
-import type { RendererConfig } from '~/interfaces/services/renderer';
-import type { ISidebarConfig } from '~/interfaces/sidebar-config';
-import type { PageModel, PageDocument } from '~/server/models/page';
-import type { PageRedirectModel } from '~/server/models/page-redirect';
 import { useEditorModeClassName } from '~/services/layout/use-editor-mode-class-name';
 import { useHydrateSidebarAtoms } from '~/states/hydrate/sidebar';
 import {
-  useCurrentPageData, useFetchCurrentPage, useCurrentPageId, useCurrentPagePath, usePageNotFound,
+  useCurrentPageData, useFetchCurrentPage, useCurrentPageId, useCurrentPagePath, usePageNotFound, usePageNotCreatable,
 } from '~/states/page';
 import { useHydratePageAtoms } from '~/states/page/hydrate';
-import { ServerConfigurationInitialProps, useHydrateServerConfigurationAtoms } from '~/states/server-configurations/hydrate';
 import {
   useDisableLinkSharing,
   useRendererConfig,
@@ -46,28 +38,21 @@ import {
 import { useEditingMarkdown } from '~/stores/editor';
 import { useRedirectFrom } from '~/stores/page-redirect';
 import { useSetupGlobalSocket, useSetupGlobalSocketForPage } from '~/stores/websocket';
-import { useCurrentPageYjsData, useSWRMUTxCurrentPageYjsData } from '~/stores/yjs';
-import loggerFactory from '~/utils/logger';
+import { useSWRMUTxCurrentPageYjsData } from '~/stores/yjs';
 
+import { getServerSidePropsForInitial, getServerSidePropsForSameRoute } from './[[...path]]/server-side-props';
+import type {
+  Props, InitialProps, SameRouteEachProps, IPageToShowRevisionWithMeta,
+} from './[[...path]]/types';
 import type { NextPageWithLayout } from './_app.page';
-import {
-  CommonEachProps, CommonInitialProps, getServerSideCommonInitialProps,
-} from './utils/commons';
-
 import { NextjsRoutingType, detectNextjsRoutingType } from './utils/nextjs-routing-utils';
-import type { UserUISettingsProps } from './utils/user-ui-settings';
-import { PageTitleCustomizationProps, generateCustomTitleForPage, getServerSidePageTitleCustomizationProps } from './utils/page-title-customization';
-import { SSRProps, getServerSideSSRProps } from './utils/ssr';
-import { addActivity } from './utils/activity';
-import { SupportedAction, SupportedActionType } from '~/interfaces/activity';
-
+import { generateCustomTitleForPage } from './utils/page-title-customization';
 
 declare global {
   // eslint-disable-next-line vars-on-top, no-var
   var globalEmitter: EventEmitter;
 }
 
-
 const GrowiContextualSubNavigationSubstance = dynamic(() => import('~/client/components/Navbar/GrowiContextualSubNavigation'), { ssr: false });
 
 const GrowiPluginsActivator = dynamic(() => import('~/features/growi-plugin/client/components').then(mod => mod.GrowiPluginsActivator), { ssr: false });
@@ -89,15 +74,6 @@ const ConflictDiffModal = dynamic(() => import('~/client/components/PageEditor/C
 
 const EditablePageEffects = dynamic(() => import('~/client/components/Page/EditablePageEffects').then(mod => mod.EditablePageEffects), { ssr: false });
 
-
-const logger = loggerFactory('growi:pages:all');
-
-const {
-  isPermalink: _isPermalink, isCreatablePage,
-} = pagePathUtils;
-const { removeHeadingSlash } = pathUtils;
-
-type IPageToShowRevisionWithMeta = IDataWithMeta<IPagePopulatedToShowRevision & PageDocument, IPageInfo>;
 type IPageToShowRevisionWithMetaSerialized = IDataWithMeta<string, string>;
 
 superjson.registerCustom<IPageToShowRevisionWithMeta, IPageToShowRevisionWithMetaSerialized>(
@@ -136,57 +112,6 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
   );
 };
 
-type InitialProps = CommonInitialProps & SSRProps & UserUISettingsProps & {
-  pageWithMeta: IPageToShowRevisionWithMeta | null,
-
-  sidebarConfig: ISidebarConfig,
-  rendererConfig: RendererConfig,
-
-  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,
-}
-
-type SameRouteEachProps = CommonEachProps & PageTitleCustomizationProps & {
-  redirectFrom?: string;
-
-  isIdenticalPathPage?: boolean,
-  isForbidden: boolean,
-  isNotCreatable: boolean,
-
-  templateTagData?: string[],
-  templateBodyData?: string,
-}
-
-type Props = SameRouteEachProps | (InitialProps & SameRouteEachProps);
-
 const isInitialProps = (props: Props): props is (InitialProps & SameRouteEachProps) => {
   return props.nextjsRoutingPage === NextjsRoutingType.INITIAL;
 };
@@ -199,17 +124,13 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
 
   const router = useRouter();
 
-  // useOwnerOfCurrentPage(props.pageUser != null ? JSON.parse(props.pageUser) : null);
   useRedirectFrom(props.redirectFrom ?? null);
-  useIsSharedUser(false); // this page cann't be routed for '/share'
+  useIsSharedUser(false); // this page can't be routed for '/share'
   useIsSearchPage(false);
 
-  // Initialize server configuration atoms with props data
-  if (isInitialProps(props)) {
-    // Initialize Jotai atoms with initial data
-    useHydratePageAtoms(props.pageWithMeta?.data);
-    useHydrateServerConfigurationAtoms(props);
-  }
+  // Initialize Jotai atoms with initial data - must be called unconditionally
+  const pageData = isInitialProps(props) ? props.pageWithMeta?.data : undefined;
+  useHydratePageAtoms(pageData);
 
   const [currentPage] = useCurrentPageData();
   const [pageId, setCurrentPageId] = useCurrentPageId();
@@ -226,31 +147,31 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
   useSetupGlobalSocket();
   useSetupGlobalSocketForPage(pageId);
 
-  // Store initial data (When revisionBody is not SSR)
+  // Handle same-route navigation: fetch page data when needed
   useEffect(() => {
+    // Skip if we have initial props with complete data
     if (isInitialProps(props) && !props.skipSSR) {
       return;
     }
 
-    if (pageId != null && currentPage?.revision?._id != null && !isNotFound) {
+    // For same-route or when skipSSR is true, fetch page data client-side
+    if (pageId != null) {
       const mutatePageData = async() => {
         setCurrentPageId(pageId);
         const pageData = await fetchCurrentPage();
         mutateEditingMarkdown(pageData?.revision?.body);
       };
 
-      // If skipSSR is true, use the API to retrieve page data.
-      // Because pageWIthMeta does not contain revision.body
       mutatePageData();
     }
-  }, [pageId, currentPage?.revision?._id, isNotFound]);
+  }, [pageId, fetchCurrentPage, mutateEditingMarkdown, props, setCurrentPageId]);
 
   // Load current yjs data
   useEffect(() => {
     if (pageId != null && currentPage?.revision?._id != null && !isNotFound) {
       mutateCurrentPageYjsDataFromApi();
     }
-  }, [currentPage, mutateCurrentPageYjsDataFromApi, isNotFound, currentPage?.revision?._id]);
+  }, [currentPage, mutateCurrentPageYjsDataFromApi, isNotFound, currentPage?.revision?._id, pageId]);
 
   // sync pathname by Shallow Routing https://nextjs.org/docs/routing/shallow-routing
   useEffect(() => {
@@ -269,13 +190,8 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
     }
   }, [mutateEditingMarkdown, currentPage?.revision?.body, props.currentPathname]);
 
-  // useEffect(() => {
-  //   mutateCurrentPageYjsData(props.yjsData);
-  // }, [mutateCurrentPageYjsData, props.yjsData]);
-
   // If the data on the page changes without router.push, pageWithMeta remains old because getServerSideProps() is not executed
   // So preferentially take page data from useSWRxCurrentPage
-  // const pagePath = currentPage?.path ?? pageWithMeta?.data.path ?? props.currentPathname;
   const pagePath = currentPagePath ?? props.currentPathname;
 
   const title = generateCustomTitleForPage(props, pagePath);
@@ -304,7 +220,6 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
   );
 };
 
-
 const BasicLayoutWithEditor = ({ children }: { children?: ReactNode }): JSX.Element => {
   const editorModeClassName = useEditorModeClassName();
   return <BasicLayout className={editorModeClassName}>{children}</BasicLayout>;
@@ -315,10 +230,11 @@ type LayoutProps = Props & {
 }
 
 const Layout = ({ children, ...props }: LayoutProps): JSX.Element => {
-  if (isInitialProps(props)) {
-    // Hydrate sidebar atoms with server-side data
-    useHydrateSidebarAtoms(props.sidebarConfig, props.userUISettings);
-  }
+  // Hydrate sidebar atoms with server-side data - must be called unconditionally
+  const sidebarConfig = isInitialProps(props) ? props.sidebarConfig : { isSidebarCollapsedMode: false };
+  const userUISettings = isInitialProps(props) ? props.userUISettings : undefined;
+  useHydrateSidebarAtoms(sidebarConfig, userUISettings);
+
   return <BasicLayoutWithEditor>{children}</BasicLayoutWithEditor>;
 };
 
@@ -348,289 +264,18 @@ Page.getLayout = function getLayout(page: React.ReactElement<Props>) {
   );
 };
 
-
-function getPageIdFromPathname(currentPathname: string): string | null {
-  return _isPermalink(currentPathname) ? removeHeadingSlash(currentPathname) : null;
-}
-
-class MultiplePagesHitsError extends ExtensibleCustomError {
-
-  pagePath: string;
-
-  constructor(pagePath: string) {
-    super(`MultiplePagesHitsError occured by '${pagePath}'`);
-    this.pagePath = pagePath;
-  }
-
-}
-
-async function injectPageData(context: GetServerSidePropsContext, props: Props): Promise<void> {
-  const { model: mongooseModel } = await import('mongoose');
-
-  const req: CrowiRequest = context.req as CrowiRequest;
-  const { crowi } = req;
-  const { revisionId } = req.query;
-
-  const Page = crowi.model('Page') as PageModel;
-  const PageRedirect = mongooseModel('PageRedirect') as PageRedirectModel;
-  const { pageService, configManager } = crowi;
-
-  let currentPathname = props.currentPathname;
-
-  const pageId = getPageIdFromPathname(currentPathname);
-  const isPermalink = _isPermalink(currentPathname);
-
-  const { user } = req;
-
-  if (!isPermalink) {
-    // check redirects
-    const chains = await PageRedirect.retrievePageRedirectEndpoints(currentPathname);
-    if (chains != null) {
-      // overwrite currentPathname
-      currentPathname = chains.end.toPath;
-      props.currentPathname = currentPathname;
-      // set redirectFrom
-      props.redirectFrom = chains.start.fromPath;
-    }
-
-    // check whether the specified page path hits to multiple pages
-    const count = await Page.countByPathAndViewer(currentPathname, user, null, true);
-    if (count > 1) {
-      throw new MultiplePagesHitsError(currentPathname);
-    }
-  }
-
-  const pageWithMeta = await pageService.findPageAndMetaDataByViewer(pageId, currentPathname, user, true); // includeEmpty = true, isSharedPage = false
-  const { data: page, meta } = pageWithMeta ?? {};
-
-  // add user to seen users
-  if (page != null && user != null) {
-    await page.seen(user);
-  }
-
-  props.pageWithMeta = null;
-
-  // populate & check if the revision is latest
-  if (page != null) {
-    page.initLatestRevisionField(revisionId);
-    props.isLatestRevision = page.isLatestRevision();
-    const ssrMaxRevisionBodyLength = configManager.getConfig('app:ssrMaxRevisionBodyLength');
-    props.skipSSR = await skipSSR(page, ssrMaxRevisionBodyLength);
-    const populatedPage = await page.populateDataToShowRevision(props.skipSSR); // shouldExcludeBody = skipSSR
-
-    props.pageWithMeta = {
-      data: populatedPage,
-      meta,
-    };
-  }
-}
-
-async function injectRoutingInformation(context: GetServerSidePropsContext, props: Props): Promise<void> {
-  const req: CrowiRequest = context.req as CrowiRequest;
-  const { crowi } = req;
-  const Page = crowi.model('Page') as PageModel;
-
-  const { currentPathname } = props;
-  const pageId = getPageIdFromPathname(currentPathname);
-  const isPermalink = _isPermalink(currentPathname);
-
-  const page = props.pageWithMeta?.data;
-
-  if (props.isIdenticalPathPage) {
-    props.isNotCreatable = true;
-  }
-  else if (page == null) {
-    props.isNotFound = true;
-    props.isNotCreatable = !isCreatablePage(currentPathname);
-    // check the page is forbidden or just does not exist.
-    const count = isPermalink ? await Page.count({ _id: pageId }) : await Page.count({ path: currentPathname });
-    props.isForbidden = count > 0;
-  }
-  else {
-    props.isNotFound = page.isEmpty;
-    props.isNotCreatable = false;
-    props.isForbidden = false;
-    // /62a88db47fed8b2d94f30000 ==> /path/to/page
-    if (isPermalink && page.isEmpty) {
-      props.currentPathname = page.path;
-    }
-
-    // /path/to/page ==> /62a88db47fed8b2d94f30000
-    if (!isPermalink && !page.isEmpty) {
-      const isToppage = pagePathUtils.isTopPage(props.currentPathname);
-      if (!isToppage) {
-        props.currentPathname = `/${page._id}`;
-      }
-    }
-
-    if (!props.skipSSR) {
-      props.yjsData = await crowi.pageService.getYjsData(page._id.toString());
-    }
-  }
-}
-
-const getServerSideConfigurationProps: GetServerSideProps<ServerConfigurationInitialProps> = async (context: GetServerSidePropsContext) => {
-  const req: CrowiRequest = context.req as CrowiRequest;
-  const { crowi } = req;
-  const {
-    configManager, searchService, aclService, fileUploadService,
-    slackIntegrationService, passportService,
-  } = 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'),
-      },
-      rendererConfig: {
-        isEnabledLinebreaks: configManager.getConfig('markdown:isEnabledLinebreaks'),
-        isEnabledLinebreaksInComments: configManager.getConfig('markdown:isEnabledLinebreaksInComments'),
-        isEnabledMarp: configManager.getConfig('customize:isEnabledMarp'),
-        adminPreferredIndentSize: configManager.getConfig('markdown:adminPreferredIndentSize'),
-        isIndentSizeForced: configManager.getConfig('markdown:isIndentSizeForced'),
-
-        drawioUri: configManager.getConfig('app:drawioUri'),
-        plantumlUri: configManager.getConfig('app:plantumlUri'),
-
-        // XSS Options
-        isEnabledXssPrevention: configManager.getConfig('markdown:rehypeSanitize:isEnabledPrevention'),
-        sanitizeType: configManager.getConfig('markdown:rehypeSanitize:option'),
-        customTagWhitelist: configManager.getConfig('markdown:rehypeSanitize:tagNames'),
-        customAttrWhitelist: configManager.getConfig('markdown:rehypeSanitize:attributes') != null
-          ? JSON.parse(configManager.getConfig('markdown:rehypeSanitize:attributes'))
-          : undefined,
-        highlightJsStyleBorder: configManager.getConfig('customize:highlightJsStyleBorder'),
-      },
-    },
-  }
-}
-
-
-const getAction = (props: Props): SupportedActionType => {
-  if (props.isNotCreatable) {
-    return SupportedAction.ACTION_PAGE_NOT_CREATABLE;
-  }
-  if (props.isForbidden) {
-    return SupportedAction.ACTION_PAGE_FORBIDDEN;
-  }
-  if (props.isNotFound) {
-    return SupportedAction.ACTION_PAGE_NOT_FOUND;
-  }
-  if (pagePathUtils.isUsersHomepage(props.pageWithMeta?.data.path ?? '')) {
-    return SupportedAction.ACTION_PAGE_USER_HOME_VIEW;
-  }
-  return SupportedAction.ACTION_PAGE_VIEW;
-};
-
 const NEXT_JS_ROUTING_PAGE = '[[...path]]';
 
-export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
-  const req = context.req as CrowiRequest;
-  const { user } = req;
-
+export const getServerSideProps: GetServerSideProps<Props> = async(context: GetServerSidePropsContext) => {
   // detect Next.js routing type
   const nextjsRoutingType = detectNextjsRoutingType(context, NEXT_JS_ROUTING_PAGE);
 
-  console.log('=== getServerSideProps ===', { nextjsRoutingType });
-
-  let props: Props;
-
   if (nextjsRoutingType === NextjsRoutingType.INITIAL) {
-    // props will be (InitialProps & SameRouteEachProps)
-
-    // await injectPageData(context, props);
-    // await injectRoutingInformation(context, props);
-    // await getServerSideCommonInitialProps(context),
-    // await getServerSidePageTitleCustomizationProps(context),
-    // await getServerSideConfigurationProps(context),
-    // await getServerSideSSRProps(context, page, ['translation']),
-    // await addActivity(context, getAction(props));
+    return getServerSidePropsForInitial(context);
   }
-  else {
-    // props will be SameRouteEachProps
-  }
-
 
-  /** Deprecated codes (start): */
-
-  // // check for presence
-  // // see: https://github.com/vercel/next.js/issues/19271#issuecomment-730006862
-  // if (!('props' in commonPropsResult) || !('props' in pageTitleCustomizeationPropsResult)) {
-  //   throw new Error('invalid getSSP result');
-  // }
-
-  // const props: Props = {
-  //   ...commonPropsResult.props,
-  //   ...pageTitleCustomizeationPropsResult.props,
-  //   nextjsRoutingPage: NEXT_JS_ROUTING_PAGE,
-  // } as Props;
-
-  // if (props.redirectDestination != null) {
-  //   return {
-  //     redirect: {
-  //       permanent: false,
-  //       destination: props.redirectDestination,
-  //     },
-  //   };
-  // }
-
-  // if (user != null) {
-  //   props.currentUser = user.toObject();
-  // }
-
-  // try {
-  //   await injectPageData(context, props);
-  // }
-  // catch (err) {
-  //   if (err instanceof MultiplePagesHitsError) {
-  //     props.isIdenticalPathPage = true;
-  //   }
-  //   else {
-  //     throw err;
-  //   }
-  // }
-
-  // await injectRoutingInformation(context, props);
-  // injectServerConfigurations(context, props);
-  // await getServerSideSSRProps(context, props, ['translation']);
-
-  // addActivity(context, getAction(props));
-  /** Deprecated codes (end): */
-
-  // return {
-  //   props,
-  // };
+  // Lightweight props for same-route navigation
+  return getServerSidePropsForSameRoute(context);
 };
 
 export default Page;

+ 72 - 0
apps/app/src/pages/[[...path]]/configuration-props.ts

@@ -0,0 +1,72 @@
+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) => {
+  const req: CrowiRequest = context.req as CrowiRequest;
+  const { crowi } = req;
+  const {
+    configManager, searchService, aclService, fileUploadService,
+    slackIntegrationService, passportService,
+  } = 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'),
+      },
+      rendererConfig: {
+        isEnabledLinebreaks: configManager.getConfig('markdown:isEnabledLinebreaks'),
+        isEnabledLinebreaksInComments: configManager.getConfig('markdown:isEnabledLinebreaksInComments'),
+        isEnabledMarp: configManager.getConfig('customize:isEnabledMarp'),
+        adminPreferredIndentSize: configManager.getConfig('markdown:adminPreferredIndentSize'),
+        isIndentSizeForced: configManager.getConfig('markdown:isIndentSizeForced'),
+
+        drawioUri: configManager.getConfig('app:drawioUri'),
+        plantumlUri: configManager.getConfig('app:plantumlUri'),
+
+        // XSS Options
+        isEnabledXssPrevention: configManager.getConfig('markdown:rehypeSanitize:isEnabledPrevention'),
+        sanitizeType: configManager.getConfig('markdown:rehypeSanitize:option'),
+        customTagWhitelist: configManager.getConfig('markdown:rehypeSanitize:tagNames'),
+        customAttrWhitelist: configManager.getConfig('markdown:rehypeSanitize:attributes') != null
+          ? JSON.parse(configManager.getConfig('markdown:rehypeSanitize:attributes'))
+          : undefined,
+        highlightJsStyleBorder: configManager.getConfig('customize:highlightJsStyleBorder'),
+      },
+    },
+  };
+};

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

@@ -0,0 +1,185 @@
+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.js';
+
+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.
+}

+ 137 - 0
apps/app/src/pages/[[...path]]/server-side-props.ts

@@ -0,0 +1,137 @@
+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.js';
+import { injectPageDataForInitial, injectSameRoutePageData } from './page-data-injectors.js';
+import type { InitialProps, SameRouteEachProps } from './types.js';
+import { getAction } from './utils.js';
+
+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, includeConfiguration = true) {
+  const propResults = await Promise.all([
+    getServerSideCommonInitialProps(context),
+    getServerSidePageTitleCustomizationProps(context),
+    getServerSideUserUISettingsProps(context),
+    ...(includeConfiguration ? [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, true);
+
+  const props: InitialProps & SameRouteEachProps = {
+    ...collectedProps,
+    nextjsRoutingPage: NEXT_JS_ROUTING_PAGE,
+    isNotFound: false,
+    isForbidden: false,
+    isNotCreatable: false,
+    isIdenticalPathPage: false, // Will be set by injectPageDataForInitial
+  } 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);
+
+  // Handle SSR configuration
+  if (props.pageWithMeta?.data != null) {
+    const ssrPropsResult = await getServerSideSSRProps(context, props.pageWithMeta.data, ['translation']);
+    if ('props' in ssrPropsResult) {
+      Object.assign(props, ssrPropsResult.props);
+    }
+  }
+  else {
+    props.skipSSR = true;
+    const nextI18NextConfig = await createNextI18NextConfig(context, ['translation']);
+    props._nextI18Next = nextI18NextConfig._nextI18Next;
+  }
+
+  await addActivity(context, getAction(props));
+
+  return { props };
+}
+
+export async function getServerSidePropsForSameRoute(context: GetServerSidePropsContext): Promise<GetServerSidePropsResult<SameRouteEachProps>> {
+  // Get minimal props for same-route navigation
+  const pageTitleResult = await getServerSidePageTitleCustomizationProps(context);
+  if (!('props' in pageTitleResult)) {
+    throw new Error('invalid getSSP result');
+  }
+
+  const { appTitle, customTitleTemplate } = pageTitleResult.props as PageTitleCustomizationProps;
+  const req = context.req as CrowiRequest;
+
+  const props: SameRouteEachProps = {
+    appTitle,
+    customTitleTemplate,
+    currentPathname: decodeURIComponent(context.resolvedUrl?.split('?')[0] ?? '/'),
+    nextjsRoutingPage: NEXT_JS_ROUTING_PAGE,
+    csrfToken: req.csrfToken?.() ?? '',
+    isMaintenanceMode: req.crowi.configManager.getConfig('app:isMaintenanceMode'),
+    isIdenticalPathPage: false, // Will be set by injectSameRoutePageData
+  };
+
+  // Handle user
+  const { user } = req;
+  if (user != null) {
+    props.currentUser = user.toObject();
+  }
+
+  // Page data injection - now handles isIdenticalPathPage internally
+  await injectSameRoutePageData(context, props);
+
+  return { props };
+}

+ 70 - 0
apps/app/src/pages/[[...path]]/types.ts

@@ -0,0 +1,70 @@
+import type {
+  IDataWithMeta, IPageInfo, IPagePopulatedToShowRevision,
+} from '@growi/core';
+
+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 { SSRProps } from '~/pages/utils/ssr';
+import type { UserUISettingsProps } from '~/pages/utils/user-ui-settings';
+import type { PageDocument } from '~/server/models/page';
+
+export type IPageToShowRevisionWithMeta = IDataWithMeta<IPagePopulatedToShowRevision & PageDocument, IPageInfo>;
+
+export type InitialProps = CommonInitialProps & SSRProps & UserUISettingsProps & {
+  pageWithMeta: IPageToShowRevisionWithMeta | null,
+
+  sidebarConfig: ISidebarConfig,
+  rendererConfig: RendererConfig,
+
+  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,
+
+  // Page state information determined on server-side
+  isNotFound: boolean,
+  isForbidden: boolean,
+  isNotCreatable: boolean,
+}
+
+export type SameRouteEachProps = CommonEachProps & PageTitleCustomizationProps & {
+  redirectFrom?: string;
+
+  isIdenticalPathPage: boolean,
+
+  templateTagData?: string[],
+  templateBodyData?: string,
+}
+
+export type Props = SameRouteEachProps | (InitialProps & SameRouteEachProps);
+
+// Helper types for extended props
+export type ExtendedInitialProps = InitialProps & SameRouteEachProps;

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

@@ -0,0 +1,39 @@
+import { pagePathUtils } from '@growi/core/dist/utils';
+import ExtensibleCustomError from 'extensible-custom-error';
+
+import { SupportedAction } from '~/interfaces/activity';
+import type { SupportedActionType } from '~/interfaces/activity';
+
+import type { IPageToShowRevisionWithMeta } from './types';
+
+export class MultiplePagesHitsError extends ExtensibleCustomError {
+
+  pagePath: string;
+
+  constructor(pagePath: string) {
+    super(`MultiplePagesHitsError occured by '${pagePath}'`);
+    this.pagePath = pagePath;
+  }
+
+}
+
+export const getAction = (props: {
+  isNotCreatable: boolean;
+  isForbidden: boolean;
+  isNotFound: boolean;
+  pageWithMeta?: IPageToShowRevisionWithMeta | null;
+}): SupportedActionType => {
+  if (props.isNotCreatable) {
+    return SupportedAction.ACTION_PAGE_NOT_CREATABLE;
+  }
+  if (props.isForbidden) {
+    return SupportedAction.ACTION_PAGE_FORBIDDEN;
+  }
+  if (props.isNotFound) {
+    return SupportedAction.ACTION_PAGE_NOT_FOUND;
+  }
+  if (pagePathUtils.isUsersHomepage(props.pageWithMeta?.data.path ?? '')) {
+    return SupportedAction.ACTION_PAGE_USER_HOME_VIEW;
+  }
+  return SupportedAction.ACTION_PAGE_VIEW;
+};

+ 36 - 3
apps/app/src/states/page/fetch-current-page.ts

@@ -7,7 +7,9 @@ import { useAtomCallback } from 'jotai/utils';
 import { apiv3Get } from '~/client/util/apiv3-client';
 import { useShareLinkId } from '~/stores-universal/context';
 
-import { currentPageIdAtom, currentPageDataAtom } from './internal-atoms';
+import {
+  currentPageIdAtom, currentPageDataAtom, pageNotFoundAtom, pageNotCreatableAtom,
+} from './internal-atoms';
 
 
 /**
@@ -50,11 +52,42 @@ export const useFetchCurrentPage = (): {
         const newData = response.data.page;
         set(currentPageDataAtom, newData);
 
+        // Reset routing state when page is successfully fetched
+        set(pageNotFoundAtom, false);
+
         return newData;
       }
       catch (err) {
-        // TODO: Handle error properly
-        // ref: https://redmine.weseek.co.jp/issues/169797
+        // Handle page not found errors for same-route navigation
+        if (err instanceof Error) {
+          const errorMessage = err.message.toLowerCase();
+
+          // Check if it's a 404 or forbidden error
+          if (errorMessage.includes('not found') || errorMessage.includes('404')) {
+            set(pageNotFoundAtom, true);
+            set(pageNotCreatableAtom, false); // Will be determined by path analysis
+            set(currentPageDataAtom, undefined);
+
+            // For same route, we need to determine if page is creatable
+            // This should match the logic in injectPageDataForInitial
+            if (isClient()) {
+              const currentPath = window.location.pathname;
+              const { pagePathUtils } = await import('@growi/core/dist/utils');
+              const isCreatable = pagePathUtils.isCreatablePage(currentPath);
+              set(pageNotCreatableAtom, !isCreatable);
+            }
+
+            return null;
+          }
+
+          if (errorMessage.includes('forbidden') || errorMessage.includes('403')) {
+            set(pageNotFoundAtom, false);
+            set(pageNotCreatableAtom, true); // Forbidden means page exists but not accessible
+            set(currentPageDataAtom, undefined);
+            return null;
+          }
+        }
+
         setError(new Error('Failed to fetch current page'));
         return null;
       }

+ 5 - 0
apps/app/src/states/page/hooks.ts

@@ -9,6 +9,7 @@ import {
   currentPageDataAtom,
   currentPagePathAtom,
   pageNotFoundAtom,
+  pageNotCreatableAtom,
   latestRevisionAtom,
   // New atoms for enhanced functionality
   remoteRevisionIdAtom,
@@ -39,6 +40,10 @@ export const usePageNotFound = (): UseAtom<typeof pageNotFoundAtom> => {
   return useAtom(pageNotFoundAtom);
 };
 
+export const usePageNotCreatable = (): UseAtom<typeof pageNotCreatableAtom> => {
+  return useAtom(pageNotCreatableAtom);
+};
+
 export const useLatestRevision = (): UseAtom<typeof latestRevisionAtom> => {
   return useAtom(latestRevisionAtom);
 };

+ 3 - 0
apps/app/src/states/page/index.ts

@@ -11,7 +11,10 @@ export {
   useCurrentPageData,
   useCurrentPagePath,
   usePageNotFound,
+  usePageNotCreatable,
   useLatestRevision,
+  useTemplateTags,
+  useTemplateBody,
   // Remote revision hooks (replacements for stores/remote-latest-page.ts)
   useRemoteRevisionId,
   useRemoteRevisionBody,