Yuki Takei 7 hónapja
szülő
commit
f05a260a36

+ 1 - 1
apps/app/src/client/components/TableOfContents.tsx

@@ -4,7 +4,7 @@ import { pagePathUtils } from '@growi/core/dist/utils';
 import ReactMarkdown from 'react-markdown';
 
 import { useCurrentPagePath } from '~/states/page';
-import { useTocOptions } from '~/stores/renderer';
+import { useTocOptions } from '~/states/ui/toc';
 import loggerFactory from '~/utils/logger';
 
 import { StickyStretchableScroller } from './StickyStretchableScroller';

+ 136 - 0
apps/app/src/states/ui/toc.ts

@@ -0,0 +1,136 @@
+import { atom, useAtomValue, useSetAtom } from 'jotai';
+import { useCallback, useEffect, useState, type RefObject } from 'react';
+import type { HtmlElementNode } from 'rehype-toc';
+
+import type { RendererOptions } from '~/interfaces/renderer-options';
+import type { RendererConfigExt } from '~/interfaces/services/renderer';
+import { useCurrentPagePath } from '~/states/page';
+import { useRendererConfig } from '~/states/server-configurations';
+import { useNextThemes } from '~/stores-universal/use-next-themes';
+
+import type { generateTocOptions } from '~/client/services/renderer/renderer';
+
+// ============================================================================
+// INTERNAL ATOMS (Implementation details, not exported)
+// ============================================================================
+
+/**
+ * Internal atom: TOC node RefObject storage
+ * Uses RefObject pattern for mutable DOM element references
+ * This is an implementation detail and should not be used directly
+ */
+const tocNodeRefAtom = atom<RefObject<HtmlElementNode> | null>(null);
+
+// ============================================================================
+// PUBLIC ATOMS (Main API for TOC state management)
+// ============================================================================
+
+/**
+ * Main TOC node atom: Extracts actual HtmlElementNode from RefObject
+ * This is the primary atom for accessing the current TOC node
+ */
+export const tocNodeAtom = atom((get) => {
+  const tocNodeRef = get(tocNodeRefAtom);
+  return tocNodeRef?.current ?? null;
+});
+
+/**
+ * Derived atom: TOC readiness check
+ * Returns true when TOC node is available
+ */
+export const tocNodeReadyAtom = atom((get) => {
+  const tocNode = get(tocNodeAtom);
+  return tocNode != null;
+});
+
+// ============================================================================
+// PERFORMANCE OPTIMIZATION
+// ============================================================================
+
+// Cache for dynamic import to avoid repeated loading
+let generateTocOptionsCache: typeof generateTocOptions | null = null;
+
+// ============================================================================
+// PUBLIC HOOKS (API for components)
+// ============================================================================
+
+/**
+ * Hook to get the current TOC node
+ * Returns the HtmlElementNode if available, or null
+ */
+export const useTocNode = (): HtmlElementNode | null => {
+  return useAtomValue(tocNodeAtom);
+};
+
+/**
+ * Hook to set the current TOC node
+ * Accepts HtmlElementNode and handles RefObject wrapping internally
+ */
+export const useSetTocNode = () => {
+  const setTocNodeRef = useSetAtom(tocNodeRefAtom);
+
+  const setTocNode = useCallback((newNode: HtmlElementNode) => {
+    // Create a RefObject wrapper for the HtmlElementNode
+    const nodeRef: RefObject<HtmlElementNode> = { current: newNode };
+    setTocNodeRef(nodeRef);
+  }, [setTocNodeRef]);
+
+  return setTocNode;
+};
+
+/**
+ * Core hook: TOC options with external dependencies
+ * Uses dynamic import for better performance
+ */
+export const useTocOptions = () => {
+  const currentPagePath = useCurrentPagePath();
+  const rendererConfig = useRendererConfig();
+  const { isDarkMode } = useNextThemes();
+  const tocNode = useAtomValue(tocNodeAtom);
+
+  const [state, setState] = useState<{ data?: RendererOptions; isLoading: boolean; error?: Error }>({
+    data: undefined, isLoading: false, error: undefined
+  });
+
+  useEffect(() => {
+    if (!currentPagePath || !rendererConfig) {
+      setState({ data: undefined, isLoading: false, error: undefined });
+      return;
+    }
+
+    if (!tocNode) {
+      setState({ data: undefined, isLoading: true, error: undefined });
+      return;
+    }
+
+    setState(prev => ({ ...prev, isLoading: true, error: undefined }));
+
+    (async () => {
+      try {
+        if (!generateTocOptionsCache) {
+          const { generateTocOptions } = await import('~/client/services/renderer/renderer');
+          generateTocOptionsCache = generateTocOptions;
+        }
+
+        const data = generateTocOptionsCache({ ...rendererConfig, isDarkMode }, tocNode);
+        setState({ data, isLoading: false, error: undefined });
+      } catch (err) {
+        setState({ data: undefined, isLoading: false, error: err instanceof Error ? err : new Error('TOC options generation failed') });
+      }
+    })();
+  }, [currentPagePath, rendererConfig, isDarkMode, tocNode]);
+
+  return state;
+};
+
+/**
+ * Hook for readiness check (combines atom + external deps)
+ * Only use this if you need the full readiness check including external deps
+ */
+export const useTocOptionsReady = (): boolean => {
+  const currentPagePath = useCurrentPagePath();
+  const rendererConfig = useRendererConfig();
+  const tocNode = useAtomValue(tocNodeAtom);
+
+  return !!(currentPagePath && rendererConfig && tocNode);
+};

+ 10 - 34
apps/app/src/stores/renderer.tsx

@@ -8,11 +8,10 @@ import type { RendererOptions } from '~/interfaces/renderer-options';
 import type { RendererConfigExt } from '~/interfaces/services/renderer';
 import { useCurrentPagePath } from '~/states/page';
 import { useRendererConfig } from '~/states/server-configurations';
+import { useSetTocNode } from '~/states/ui/toc';
 import { useNextThemes } from '~/stores-universal/use-next-themes';
 import loggerFactory from '~/utils/logger';
 
-import { useCurrentPageTocNode } from './ui';
-
 const logger = loggerFactory('growi:cli:services:renderer');
 
 const useRendererConfigExt = (): RendererConfigExt | null => {
@@ -29,11 +28,11 @@ const useRendererConfigExt = (): RendererConfigExt | null => {
 export const useViewOptions = (): SWRResponse<RendererOptions, Error> => {
   const currentPagePath = useCurrentPagePath();
   const rendererConfig = useRendererConfigExt();
-  const { mutate: mutateCurrentPageTocNode } = useCurrentPageTocNode();
+  const setTocNode = useSetTocNode();
 
   const storeTocNodeHandler = useCallback((toc: HtmlElementNode) => {
-    mutateCurrentPageTocNode(toc, { revalidate: false });
-  }, [mutateCurrentPageTocNode]);
+    setTocNode(toc);
+  }, [setTocNode]);
 
   const isAllDataValid = currentPagePath != null && rendererConfig != null;
   const customGenerater = getGrowiFacade().markdownRenderer?.optionsGenerators?.customGenerateViewOptions;
@@ -42,7 +41,7 @@ export const useViewOptions = (): SWRResponse<RendererOptions, Error> => {
     isAllDataValid
       ? ['viewOptions', currentPagePath, rendererConfig, customGenerater]
       : null,
-    async([, currentPagePath, rendererConfig]) => {
+    async ([, currentPagePath, rendererConfig]) => {
       if (customGenerater != null) {
         return customGenerater(currentPagePath, rendererConfig, storeTocNodeHandler);
       }
@@ -58,29 +57,6 @@ export const useViewOptions = (): SWRResponse<RendererOptions, Error> => {
   );
 };
 
-export const useTocOptions = (): SWRResponse<RendererOptions, Error> => {
-  const currentPagePath = useCurrentPagePath();
-  const rendererConfig = useRendererConfigExt();
-  const { data: tocNode } = useCurrentPageTocNode();
-
-  const isAllDataValid = currentPagePath != null && rendererConfig != null && tocNode != null;
-
-  return useSWR(
-    isAllDataValid
-      ? ['tocOptions', currentPagePath, tocNode, rendererConfig]
-      : null,
-    async([, , tocNode, rendererConfig]) => {
-      const { generateTocOptions } = await import('~/client/services/renderer/renderer');
-      return generateTocOptions(rendererConfig, tocNode);
-    },
-    {
-      keepPreviousData: true,
-      revalidateOnFocus: false,
-      revalidateOnReconnect: false,
-    },
-  );
-};
-
 export const usePreviewOptions = (): SWRResponse<RendererOptions, Error> => {
   const currentPagePath = useCurrentPagePath();
   const rendererConfig = useRendererConfigExt();
@@ -92,7 +68,7 @@ export const usePreviewOptions = (): SWRResponse<RendererOptions, Error> => {
     isAllDataValid
       ? ['previewOptions', rendererConfig, currentPagePath, customGenerater]
       : null,
-    async([, rendererConfig, pagePath]) => {
+    async ([, rendererConfig, pagePath]) => {
       if (customGenerater != null) {
         return customGenerater(rendererConfig, pagePath);
       }
@@ -118,7 +94,7 @@ export const useCommentForCurrentPageOptions = (): SWRResponse<RendererOptions,
     isAllDataValid
       ? ['commentPreviewOptions', rendererConfig, currentPagePath]
       : null,
-    async([, rendererConfig, currentPagePath]) => {
+    async ([, rendererConfig, currentPagePath]) => {
       const { generateSimpleViewOptions } = await import('~/client/services/renderer/renderer');
       return generateSimpleViewOptions(
         rendererConfig,
@@ -145,7 +121,7 @@ export const useSelectedPagePreviewOptions = (pagePath: string, highlightKeyword
     isAllDataValid
       ? ['selectedPagePreviewOptions', rendererConfig, pagePath, highlightKeywords]
       : null,
-    async([, rendererConfig, pagePath, highlightKeywords]) => {
+    async ([, rendererConfig, pagePath, highlightKeywords]) => {
       const { generateSimpleViewOptions } = await import('~/client/services/renderer/renderer');
       return generateSimpleViewOptions(rendererConfig, pagePath, highlightKeywords);
     },
@@ -168,7 +144,7 @@ export const useCustomSidebarOptions = (config?: SWRConfiguration): SWRResponse<
     isAllDataValid
       ? ['customSidebarOptions', rendererConfig]
       : null,
-    async([, rendererConfig]) => {
+    async ([, rendererConfig]) => {
       const { generateSimpleViewOptions } = await import('~/client/services/renderer/renderer');
       return generateSimpleViewOptions(rendererConfig, '/');
     },
@@ -197,7 +173,7 @@ export const usePresentationViewOptions = (): SWRResponse<RendererOptions, Error
     isAllDataValid
       ? ['presentationViewOptions', currentPagePath, rendererConfig]
       : null,
-    async([, currentPagePath, rendererConfig]) => {
+    async ([, currentPagePath, rendererConfig]) => {
       const { generatePresentationViewOptions } = await import('~/client/services/renderer/renderer');
       return generatePresentationViewOptions(rendererConfig, currentPagePath);
     },

+ 1 - 12
apps/app/src/stores/ui.tsx

@@ -1,12 +1,11 @@
 import {
-  type RefObject, useCallback,
+  useCallback,
   useLayoutEffect,
 } from 'react';
 
 import { useSWRStatic } from '@growi/core/dist/swr';
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { useRouter } from 'next/router';
-import type { HtmlElementNode } from 'rehype-toc';
 import {
   type SWRResponse,
 } from 'swr';
@@ -31,16 +30,6 @@ const { isTrashTopPage, isUsersTopPage } = pagePathUtils;
 const logger = loggerFactory('growi:stores:ui');
 
 
-/** **********************************************************
- *                     Storing objects to ref
- *********************************************************** */
-
-export const useCurrentPageTocNode = (): SWRResponse<HtmlElementNode, any> => {
-  const currentPagePath = useCurrentPagePath();
-
-  return useStaticSWR(['currentPageTocNode', currentPagePath]);
-};
-
 /** **********************************************************
  *                          SWR Hooks
  *                      for switching UI