Просмотр исходного кода

feat: implement PageContentRenderer and integrate it into PageView and ShareLinkPageView

Yuki Takei 1 месяц назад
Родитель
Сommit
7d3711108e

+ 1 - 0
.kiro/specs/reduce-modules-loaded/analysis-ledger.md

@@ -28,6 +28,7 @@ Measured via `ChunkModuleStatsPlugin` in `next.config.utils.js`. The `initial` c
 | + null-loader: i18next-fs-backend, bunyan, bunyan-format | 8.5 | **1,572** | 4,720 | 6,292 | 9,007 | 2026-02-20 |
 | + validator → isMongoId regex in LinkEditModal | 8.6 | **1,572** | 4,608 (-112) | 6,180 (-112) | 8,895 (-112) | 2026-02-20 |
 | + react-hotkeys → tinykeys migration | 8.7 | **1,573** (+1) | 4,516 (-92) | 6,089 (-91) | 8,802 (-93) | 2026-02-24 |
+| + markdown pipeline → next/dynamic({ ssr: true }) | 8.8 | **1,073** (-500) | 5,016 (+500) | 6,089 (0) | 8,803 (+1) | 2026-02-24 |
 
 > **Note**: Originally reported baseline was 51.5s, but automated measurement on the same machine consistently shows ~31s. The 51.5s figure may reflect cold cache, different system load, or an earlier codebase state.
 

+ 8 - 0
.kiro/specs/reduce-modules-loaded/tasks.md

@@ -211,6 +211,14 @@ The following loop repeats until the user declares completion:
   - Result: initial: 1,573 (+1) / async-only: 4,516 (-92) / total: 6,089 (-91) / compiled: 8,802 (-93)
   - _Requirements: 4.1, 6.1_
 
+- [x] 8.8 Loop iteration 6: markdown rendering pipeline → next/dynamic({ ssr: true })
+  - Created `PageContentRenderer` wrapper component encapsulating `RevisionRenderer` + `generateSSRViewOptions`
+  - Converted `PageContentRenderer` to `next/dynamic({ ssr: true })` in both `PageView.tsx` and `ShareLinkPageView.tsx`
+  - Moves entire markdown pipeline (react-markdown, katex, remark-gfm, rehype-katex, mdast-util-to-markdown, etc.) to async chunks while preserving SSR rendering
+  - Added `PageContentRenderer.spec.tsx` with 3 tests (null markdown, generated options, explicit options)
+  - Result: initial: 1,073 (-500, -31.8%) / async-only: 5,016 (+500) / total: 6,089 (unchanged) / compiled: 8,803 (+1)
+  - _Requirements: 7.2, 6.1_
+
 - [ ] 8.N Loop iteration N: (next iteration — measure, analyze, propose, implement)
 
 ## Phase 3: Next.js Version Upgrade Evaluation (Deferred)

+ 113 - 0
apps/app/src/components/PageView/PageContentRenderer.spec.tsx

@@ -0,0 +1,113 @@
+import { render } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+
+import type { RendererOptions } from '~/interfaces/renderer-options';
+import { RehypeSanitizeType } from '~/interfaces/services/rehype-sanitize';
+import type { RendererConfig } from '~/interfaces/services/renderer';
+
+const { mockGeneratedOptions, MockRevisionRenderer } = vi.hoisted(() => {
+  const mockGeneratedOptions: RendererOptions = {
+    remarkPlugins: [],
+    rehypePlugins: [],
+    components: {},
+  };
+  const MockRevisionRenderer = vi.fn(({ markdown }: { markdown: string }) => (
+    <div data-testid="revision-renderer">{markdown}</div>
+  ));
+  return { mockGeneratedOptions, MockRevisionRenderer };
+});
+
+// Mock the server renderer to avoid importing the full markdown pipeline in tests
+vi.mock('~/services/renderer/renderer', () => ({
+  generateSSRViewOptions: vi.fn(() => mockGeneratedOptions),
+}));
+
+// Mock RevisionRenderer to capture the props it receives
+vi.mock('./RevisionRenderer', () => ({
+  default: MockRevisionRenderer,
+}));
+
+import { generateSSRViewOptions } from '~/services/renderer/renderer';
+
+import { PageContentRenderer } from './PageContentRenderer';
+
+const mockRendererConfig: RendererConfig = {
+  isEnabledLinebreaks: true,
+  isEnabledLinebreaksInComments: true,
+  adminPreferredIndentSize: 4,
+  isIndentSizeForced: false,
+  highlightJsStyleBorder: false,
+  isEnabledMarp: false,
+  isEnabledXssPrevention: true,
+  sanitizeType: RehypeSanitizeType.RECOMMENDED,
+  drawioUri: '',
+  plantumlUri: '',
+};
+
+describe('PageContentRenderer', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
+  it('renders nothing when markdown is null', () => {
+    const { container } = render(
+      <PageContentRenderer
+        rendererConfig={mockRendererConfig}
+        pagePath="/test"
+        markdown={null}
+      />,
+    );
+
+    expect(container.innerHTML).toBe('');
+    expect(generateSSRViewOptions).not.toHaveBeenCalled();
+    expect(MockRevisionRenderer).not.toHaveBeenCalled();
+  });
+
+  it('generates options from rendererConfig and passes them to RevisionRenderer', () => {
+    render(
+      <PageContentRenderer
+        rendererConfig={mockRendererConfig}
+        pagePath="/test"
+        markdown="# Hello"
+      />,
+    );
+
+    expect(generateSSRViewOptions).toHaveBeenCalledWith(
+      mockRendererConfig,
+      '/test',
+    );
+    expect(MockRevisionRenderer).toHaveBeenCalledWith(
+      expect.objectContaining({
+        rendererOptions: mockGeneratedOptions,
+        markdown: '# Hello',
+      }),
+      expect.anything(),
+    );
+  });
+
+  it('uses provided rendererOptions without generating new ones', () => {
+    const customOptions: RendererOptions = {
+      remarkPlugins: [],
+      rehypePlugins: [],
+      components: { p: 'span' as never },
+    };
+
+    render(
+      <PageContentRenderer
+        rendererOptions={customOptions}
+        rendererConfig={mockRendererConfig}
+        pagePath="/test"
+        markdown="**bold**"
+      />,
+    );
+
+    expect(generateSSRViewOptions).not.toHaveBeenCalled();
+    expect(MockRevisionRenderer).toHaveBeenCalledWith(
+      expect.objectContaining({
+        rendererOptions: customOptions,
+        markdown: '**bold**',
+      }),
+      expect.anything(),
+    );
+  });
+});

+ 30 - 0
apps/app/src/components/PageView/PageContentRenderer.tsx

@@ -0,0 +1,30 @@
+import type { JSX } from 'react';
+
+import type { RendererOptions } from '~/interfaces/renderer-options';
+import type { RendererConfig } from '~/interfaces/services/renderer';
+import { generateSSRViewOptions } from '~/services/renderer/renderer';
+
+import RevisionRenderer from './RevisionRenderer';
+
+type Props = {
+  rendererOptions?: RendererOptions;
+  rendererConfig: RendererConfig;
+  pagePath: string;
+  markdown: string | null;
+};
+
+export const PageContentRenderer = ({
+  rendererOptions,
+  rendererConfig,
+  pagePath,
+  markdown,
+}: Props): JSX.Element | null => {
+  if (markdown == null) {
+    return null;
+  }
+
+  const options =
+    rendererOptions ?? generateSSRViewOptions(rendererConfig, pagePath);
+
+  return <RevisionRenderer rendererOptions={options} markdown={markdown} />;
+};

+ 8 - 6
apps/app/src/components/PageView/PageView.tsx

@@ -15,7 +15,6 @@ import { useSlidesByFrontmatter } from '@growi/presentation/dist/services';
 import { PagePathNavTitle } from '~/components/Common/PagePathNavTitle';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import { useShouldExpandContent } from '~/services/layout/use-should-expand-content';
-import { generateSSRViewOptions } from '~/services/renderer/renderer';
 import {
   useCurrentPageData,
   useCurrentPageId,
@@ -30,7 +29,6 @@ import { UserInfo } from '../User/UserInfo';
 import { PageAlerts } from './PageAlerts/PageAlerts';
 import { PageContentFooter } from './PageContentFooter';
 import { PageViewLayout } from './PageViewLayout';
-import RevisionRenderer from './RevisionRenderer';
 
 // biome-ignore-start lint/style/noRestrictedImports: no-problem dynamic import
 const NotCreatablePage = dynamic(
@@ -86,6 +84,10 @@ const SlideRenderer = dynamic(
     ),
   { ssr: false },
 );
+const PageContentRenderer = dynamic(
+  () => import('./PageContentRenderer').then((mod) => mod.PageContentRenderer),
+  { ssr: true },
+);
 // biome-ignore-end lint/style/noRestrictedImports: no-problem dynamic import
 
 type Props = {
@@ -203,8 +205,6 @@ const PageViewComponent = (props: Props): JSX.Element => {
     }
 
     const markdown = page.revision.body;
-    const rendererOptions =
-      viewOptions ?? generateSSRViewOptions(rendererConfig, pagePath);
 
     return (
       <>
@@ -214,8 +214,10 @@ const PageViewComponent = (props: Props): JSX.Element => {
           {isSlide != null ? (
             <SlideRenderer marp={isSlide.marp} markdown={markdown} />
           ) : (
-            <RevisionRenderer
-              rendererOptions={rendererOptions}
+            <PageContentRenderer
+              rendererOptions={viewOptions}
+              rendererConfig={rendererConfig}
+              pagePath={pagePath}
               markdown={markdown}
             />
           )}

+ 13 - 5
apps/app/src/components/ShareLinkPageView/ShareLinkPageView.tsx

@@ -6,14 +6,12 @@ import { PagePathNavTitle } from '~/components/Common/PagePathNavTitle';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { IShareLinkHasId } from '~/interfaces/share-link';
 import { useShouldExpandContent } from '~/services/layout/use-should-expand-content';
-import { generateSSRViewOptions } from '~/services/renderer/renderer';
 import { useCurrentPageData, usePageNotFound } from '~/states/page';
 import { useViewOptions } from '~/stores/renderer';
 import loggerFactory from '~/utils/logger';
 
 import { PageContentFooter } from '../PageView/PageContentFooter';
 import { PageViewLayout } from '../PageView/PageViewLayout';
-import RevisionRenderer from '../PageView/RevisionRenderer';
 import ShareLinkAlert from './ShareLinkAlert';
 
 const logger = loggerFactory('growi:components:ShareLinkPageView');
@@ -37,6 +35,13 @@ const SlideRenderer = dynamic(
     ),
   { ssr: false },
 );
+const PageContentRenderer = dynamic(
+  () =>
+    import('../PageView/PageContentRenderer').then(
+      (mod) => mod.PageContentRenderer,
+    ),
+  { ssr: true },
+);
 // biome-ignore-end lint/style/noRestrictedImports: no-problem dynamic import
 
 type Props = {
@@ -103,14 +108,17 @@ export const ShareLinkPageView = memo((props: Props): JSX.Element => {
       );
     }
 
-    const rendererOptions =
-      viewOptions ?? generateSSRViewOptions(rendererConfig, pagePath);
     const markdown = page.revision.body;
 
     return isSlide != null ? (
       <SlideRenderer marp={isSlide.marp} markdown={markdown} />
     ) : (
-      <RevisionRenderer rendererOptions={rendererOptions} markdown={markdown} />
+      <PageContentRenderer
+        rendererOptions={viewOptions}
+        rendererConfig={rendererConfig}
+        pagePath={pagePath}
+        markdown={markdown}
+      />
     );
   }, [
     isExpired,