Explorar el Código

memolize rendererOptions

Yuki Takei hace 1 mes
padre
commit
8134692fe6

+ 35 - 0
.kiro/specs/presentation/design.md

@@ -185,6 +185,8 @@ type SlidesProps = {
 - Render slide content via ReactMarkdown with section extraction
 - Apply Marp container CSS from pre-extracted constants (no runtime Marp dependency)
 - Use `MARP_CONTAINER_CLASS_NAME` from shared constants module
+- Treat the incoming `rendererOptions` as a read-only shared reference (see Risks & Mitigations); derive a new options object via `useMemo` and never mutate the input
+- Guard for `rendererOptions == null` (and missing `remarkPlugins` / `components`) with an early return; the `?` on `PresentationOptions.rendererOptions` is intentional and reflects SWR loading state
 
 **Dependencies**
 - Inbound: Slides — rendering delegation (P0)
@@ -282,3 +284,36 @@ export const presentationMarpit: Marp;
 - Validation: Script exits with error if CSS extraction produces empty output
 - Risks: Marp options must stay synchronized with `growi-marpit.ts`
 
+## Risks & Mitigations
+
+### `rendererOptions` undefined during SWR loading
+
+Callers in `apps/app` obtain `rendererOptions` from `usePresentationViewOptions()`, which is an SWR hook. During the loading window the value is `undefined`. `PresentationOptions.rendererOptions` is therefore declared optional, and `GrowiSlides` performs an early-return null guard.
+
+Defense is layered across four points; removing any single layer has historically caused regressions (PR #11110 / Redmine #183154):
+
+| Layer | Where | Purpose |
+|---|---|---|
+| Type signature | `consts/index.ts` — `rendererOptions?: ReactMarkdownOptions` | Force callers to acknowledge the loading state at compile time |
+| Caller guard | `apps/app` `SlideRenderer.tsx`, `PagePresentationModal.tsx` — no `as ReactMarkdownOptions` casts, must propagate `undefined` honestly | Prevent the loading `undefined` from masquerading as a value |
+| Component guard | `GrowiSlides.tsx` — early return when `rendererOptions == null` | Last line of defense for inline `slide: true` route, which has no caller-side `<RendererErrorMessage />` |
+| E2E | `apps/app/playwright/20-basic-features/presentation.spec.ts` — reload step | Exercises the SWR loading path that unit tests with mocked modules cannot reach |
+
+**Do not remove the optional `?`, the null guard, or add an `as` cast on grounds of "the type is required" — the optionality is intentional and reflects runtime reality.**
+
+### `rendererOptions` is a shared SWR cache reference
+
+The same `rendererOptions` object is returned by SWR to both `SlideRenderer` and `PagePresentationModal`. `GrowiSlides` must **not** mutate it; doing so leaks state across components and causes pushed remark plugins to accumulate across re-renders (incompatible with React StrictMode and concurrent rendering).
+
+Derive a new options object with `useMemo`, spreading `remarkPlugins` and `components` into fresh arrays/objects before adding `extractSections.remarkPlugin` and the section component. Keep the memo dependency list aligned with all derived inputs (`rendererOptions`, `isDarkMode`, `disableSeparationByHeader`, `presentation`).
+
+### Revalidation Triggers
+
+Re-run validation if any of the following change:
+
+- `PresentationOptions.rendererOptions` type (especially making it required again)
+- `usePresentationViewOptions` loading semantics
+- Null guards in `GrowiSlides`, `SlideRenderer`, or `PagePresentationModal`
+- Any `as ReactMarkdownOptions` cast added in `@growi/presentation` callers
+- Marp library major upgrade (regenerate `marpit-base-css.vendor-styles.prebuilt.ts`)
+

+ 0 - 216
.kiro/specs/presentation/mutation-risk-analysis.md

@@ -1,216 +0,0 @@
-# Mutation Risk Analysis — `GrowiSlides.tsx` の `rendererOptions` 破壊的変更
-
-> 注: spec.json の `language: en` に従い最終的には英訳予定。validation-post-impl.md の P3 項目を深掘りした実装者向け資料。
-
-## 該当コード
-
-[packages/presentation/src/client/components/GrowiSlides.tsx:35-44](packages/presentation/src/client/components/GrowiSlides.tsx#L35-L44):
-
-```tsx
-rendererOptions.remarkPlugins.push([
-  extractSections.remarkPlugin,
-  {
-    isDarkMode,
-    disableSeparationByHeader,
-  },
-]);
-rendererOptions.components.section = presentation
-  ? PresentationRichSlideSection
-  : RichSlideSection;
-```
-
-これは **render 関数の中で props で受け取ったオブジェクトを破壊的に書き換えている**。React の基本原則違反だが、それだけでなく、この `rendererOptions` の出自がさらに事態を悪化させる。
-
-## 背景: `rendererOptions` の正体
-
-[apps/app/src/stores/renderer.tsx:210-240](apps/app/src/stores/renderer.tsx#L210-L240):
-
-```ts
-export const usePresentationViewOptions = () =>
-  useSWR(
-    ['presentationViewOptions', currentPagePath, rendererConfig],
-    async (...) => generatePresentationViewOptions(...)
-  );
-```
-
-- **SWR は同じキーに対して同じオブジェクト参照を返す** (キャッシュが invalidate されるまで)
-- そのオブジェクトは [SlideRenderer.tsx](apps/app/src/client/components/Page/SlideRenderer.tsx) と [PagePresentationModal.tsx](apps/app/src/client/components/PagePresentationModal/PagePresentationModal.tsx) の**両方**から参照される
-- つまり `rendererOptions` は **GrowiSlides の所有物ではない**。アプリ全体で共有されるシングルトン的なリソースである
-
-この前提で、ミューテーションが引き起こす問題を具体的にみていく。
-
----
-
-## バグシナリオ
-
-### バグ 1: 再レンダごとの `remarkPlugin` 多重積み
-
-GrowiSlides は何らかのきっかけで再レンダされる (親が再レンダ、props 変更、isDarkMode 変更、etc.)。
-
-```
-初回 render:    remarkPlugins = [..., extractSections] (1個)
-2 回目 render:  remarkPlugins = [..., extractSections, extractSections] (2個)
-3 回目 render:  remarkPlugins = [..., extractSections, extractSections, extractSections] (3個)
-...
-N 回目:        N 個積まれる
-```
-
-`extractSections.remarkPlugin` は名前のとおり markdown AST から `<section>` を抽出する変換プラグイン。これが **N 回連続で同じ AST に適用される**。結果として:
-
-- セクション分割が二重・三重に行われる (`<section>` の中にさらに `<section>` が入る等)
-- 描画速度が再レンダごとに線形劣化する
-- React DevTools で「数回操作したらスライドの構造が壊れる」現象が起きうる
-
-**実害が小さく見える理由**: 多くの場合 GrowiSlides はマウント直後に SWR データが落ち着いて再レンダが止まる短いライフサイクルしかない。だが下記 2/3 と組み合わさると顕在化する。
-
-### バグ 2: 他コンポーネントへの漏れ出し (共有参照汚染)
-
-SWR が同じオブジェクトを `PagePresentationModal` にも返す。つまり:
-
-```
-[ユーザー操作]                          [rendererOptions の中身]
-1. /Sandbox/foo (普通のページ)          remarkPlugins = [...default]
-2. プレゼンモーダルを開く               PagePresentationModal が <Presentation> 経由で
-                                       GrowiSlides を render
-                                       → push! remarkPlugins = [...default, extractSections]
-3. モーダル閉じる                      remarkPlugins = [...default, extractSections]
-                                       (元に戻らない)
-4. SWR キャッシュは生きてる             同じオブジェクトに extractSections が残ったまま
-5. もう一度モーダルを開く               → push! [...default, extractSections, extractSections]
-```
-
-**深刻な側面**: GrowiSlides は自分が render されるたびに「他コンポーネントが使っているオブジェクト」を変更してしまう。`PagePresentationModal` の閉じる/開くを繰り返すと remarkPlugins がどんどん肥大化する。
-
-さらに **`components.section` の書き換え** も同様:
-
-```ts
-rendererOptions.components.section = presentation
-  ? PresentationRichSlideSection
-  : RichSlideSection;
-```
-
-`<Slides presentation>` で開いた後にどこかで `<Slides>` (`presentation` 無し) が render されると、`section` が `RichSlideSection` に変わる。逆も同様。**どちらが「最後に書いた者」かで挙動が変わる** = 順序依存の非決定性が混入する。
-
-### バグ 3: `isDarkMode` トグル時の古いオプション残留
-
-`extractSections.remarkPlugin` は **オプション付きで** push される:
-
-```ts
-remarkPlugins.push([extractSections.remarkPlugin, { isDarkMode, disableSeparationByHeader }]);
-```
-
-このタプルは「**push した時点の** `isDarkMode` の値を捕獲」する。後で再 push されたタプルは新しい値を持つが、**前のタプルは消えない**。
-
-```
-ライト時 render:  push([plugin, { isDarkMode: false }])
-ユーザがダークに切替 → 再 render
-ダーク時 render:  push([plugin, { isDarkMode: false }, [plugin, { isDarkMode: true }])
-                  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
-                  古いタプルが残存
-```
-
-ReactMarkdown はこれら全部を順に実行する。`extractSections` プラグインが darkmode 別に分岐していると **2 回別々のテーマで分割される** → 結果不定 (どちらが「勝つ」かはプラグインの実装次第)。
-
-### バグ 4: React StrictMode の二重 render で症状倍速化
-
-開発時 (`React.StrictMode` 有効) は **意図的に render を 2 回呼ぶ** ことで副作用の純粋性を検査する。今回のコードは render 内に副作用 (push, 代入) があるため、StrictMode 環境では **1 回のマウントで 2 回 push される**。本番より早く症状が顕在化するか、逆に「dev で挙動が違う」謎現象として報告される可能性がある。
-
-### バグ 5: Concurrent Rendering / Suspense との衝突
-
-React 18+ の concurrent rendering では render 関数が **中断・再開される** ことがある。中断された render でも push は実行済みになり、再開された render でもう一度 push される。React の前提 (「render は純粋であるべき」) が破られているので、Concurrent 機能を使う将来の最適化と相性が悪い。
-
-### バグ 6: `688c260d91` の遠因にもなりうる
-
-ミューテーション前提のコードは、見る人に「`rendererOptions` は書き換えてよい (= 自分のもの) 」という誤解を与える。これは型シグネチャの「必須」と相まって、`rendererOptions == null` ガードが「死んでる」ように見える要因の一つ。実際 [GrowiSlides.tsx:31](packages/presentation/src/client/components/GrowiSlides.tsx#L31) の biome-ignore コメント変遷:
-
-- 旧: `"This is for type checking only. The actual code will never reach here."`
-- 新: `"early return when rendererOptions is null"`
-
-ここで「rendererOptions は必須 = 書き換えてもよい」という認知バイアスが、ガード除去という別判断を後押しした構造的問題が見える。
-
----
-
-## なぜ「いまのところ動いているように見える」のか
-
-実害が出にくいタイミング:
-
-- **マウント直後の SWR data 確定タイミング**: 大抵 1〜2 回の render で安定。N=2 程度ならプラグインの冪等性で気付かれない。
-- **`extractSections.remarkPlugin` の冪等性**: もし二回実行しても結果が同じ AST に収束する実装になっていれば症状が見えない (が、保証されているとは限らない)。
-- **SWR キャッシュの寿命**: ページ遷移などでキャッシュが破棄されれば一旦リセットされる。
-
-逆に **顕在化条件**:
-
-- スライドページにいる間にテーマ切替を繰り返す
-- プレゼンモーダルを開閉する
-- 同一ページ内で `<Slides>` が複数マウントされる (将来追加された場合)
-- React StrictMode を本番でも有効化する
-- Suspense / Concurrent 機能を本格的に使う
-
----
-
-## 推奨される修正
-
-```tsx
-// useMemo で新オブジェクトを派生させる
-const slideRendererOptions = useMemo(() => {
-  if (rendererOptions == null) return null;
-  return {
-    ...rendererOptions,
-    remarkPlugins: [
-      ...(rendererOptions.remarkPlugins ?? []),
-      [extractSections.remarkPlugin, { isDarkMode, disableSeparationByHeader }],
-    ],
-    components: {
-      ...rendererOptions.components,
-      section: presentation ? PresentationRichSlideSection : RichSlideSection,
-    },
-  };
-}, [rendererOptions, isDarkMode, disableSeparationByHeader, presentation]);
-
-if (slideRendererOptions == null) return <></>;
-
-return (
-  <>
-    <Head><style>{css}</style></Head>
-    <div className={`slides ${MARP_CONTAINER_CLASS_NAME}`}>
-      <ReactMarkdown {...slideRendererOptions}>
-        {children ?? '## No Contents'}
-      </ReactMarkdown>
-    </div>
-  </>
-);
-```
-
-ポイント:
-
-- **新オブジェクト**を作って `ReactMarkdown` に渡す → 元の SWR キャッシュは無傷
-- **`useMemo` の依存配列**で再計算条件を明示 → 同じ入力なら同じ出力 (純粋性回復)
-- **テーマ切替時は新しい派生オブジェクトに丸ごと切り替わる** → 古い `{ isDarkMode: false }` タプルは消える
-
----
-
-## まとめ
-
-| 観点 | 現状 | 影響 |
-|---|---|---|
-| 純粋性 | render 内で副作用 | StrictMode / Concurrent と非互換 |
-| 所有権 | 共有参照を改変 | 他コンポーネントへ漏れ出し |
-| 蓄積 | 再 render ごとに push | プラグイン多重実行、性能劣化 |
-| 状態管理 | 古いオプションが残る | テーマ切替で結果が不定 |
-| 認知 | 「書き換えてよい」誤認 | 型ガード除去の遠因 |
-
-要するに、**「動いているように見える」のは SWR の再フェッチが頻発しないことと extractSections の冪等性に暗黙に依存している** だけで、その依存が破れる条件は将来増える一方 (Concurrent, StrictMode 本番化, 機能追加で同一ページ上に複数の `<Slides>`、etc.)。今回の rendererOptions null バグと同様、「型/コード上は動くから問題なさそう」という外観の裏で、実は条件が揃うと爆発する地雷を内蔵している、というのが「バグの温床」と表現した理由。
-
----
-
-## Cross-Reference
-
-- 親レポート: [validation-post-impl.md](./validation-post-impl.md) — Section "Remediation" の P3 を本書で詳細化
-- 関連コード:
-  - [packages/presentation/src/client/components/GrowiSlides.tsx](packages/presentation/src/client/components/GrowiSlides.tsx)
-  - [packages/presentation/src/client/consts/index.ts](packages/presentation/src/client/consts/index.ts) (型シグネチャ)
-  - [apps/app/src/stores/renderer.tsx](apps/app/src/stores/renderer.tsx) (SWR キャッシュ供給元)
-  - [apps/app/src/client/components/Page/SlideRenderer.tsx](apps/app/src/client/components/Page/SlideRenderer.tsx) (呼び出し側)
-  - [apps/app/src/client/components/PagePresentationModal/PagePresentationModal.tsx](apps/app/src/client/components/PagePresentationModal/PagePresentationModal.tsx) (もう一方の呼び出し側 — 共有参照の漏れ出し先)
-- 関連 .claude rules:
-  - `.claude/rules/coding-style.md` — "Immutability (CRITICAL)" セクション

+ 1 - 1
.kiro/specs/presentation/spec.json

@@ -1,7 +1,7 @@
 {
   "feature_name": "presentation",
   "created_at": "2026-03-05T12:00:00Z",
-  "updated_at": "2026-03-23T00:00:00Z",
+  "updated_at": "2026-05-12T00:00:00Z",
   "language": "en",
   "phase": "implementation-complete",
   "cleanup_completed": true,

+ 0 - 184
.kiro/specs/presentation/validation-post-impl.md

@@ -1,184 +0,0 @@
-# Post-Implementation Validation — Regression and Boundary Review
-
-> 注: spec.json の `language: en` に従い最終的には英訳予定。現状は実装者へのフィードバック材料としての日本語ドラフト。
-
-## Context
-
-- **対象 spec**: presentation (`phase: implementation-complete`, `cleanup_completed: true`, 完了日 2026-03-23)
-- **きっかけ**: PR #11110 (Redmine #183154) — `slide: true` フロントマター付きページがクラッシュする回帰
-- **回帰発生コミット**: `688c260d91` "fix type cheking" (2026-04-15) — spec 完了**後**
-- **回帰修正コミット**: `6988253b93` "restore null guard for rendererOptions in GrowiSlides"
-- **スコープ**: spec 完了後に発覚した境界違反 (Boundary Violation) を整理し、再発防止のための残課題を実装者に引き渡す
-
-## Decision: **NO-GO (boundary integrity)**
-
-PR #11110 のクラッシュ修正そのものは正しいが、回帰を許した構造的問題が複数残っており、同じ削除判断が将来再び行われ得る。spec の "Functional Equivalence" (要件 3) が、SWR ローディング状態という非機能経路で破綻していた事実を踏まえ、以下の追加対応を完了するまで「presentation feature は安定」とは言えない。
-
-## Mechanical Results
-
-| 項目 | 結果 |
-|---|---|
-| PR #11110 の新規ユニットテスト | ✅ PASS (`pnpm vitest run GrowiSlides.spec` — 3/3) |
-| TBD/TODO/FIXME (feature 境界内) | ✅ CLEAN |
-| Secret grep | ✅ CLEAN |
-| Smoke boot | ⚠️ MANUAL_REQUIRED (`slide: true` 経路を実ブラウザで確認する自動テストが存在しない) |
-
-## 回帰の構造
-
-### 発生経路
-
-1. **#10152 (2024)**: `useRendererConfig()` 呼び忘れで `rendererOptions == null` になる事故への対策として、[PagePresentationModal](apps/app/src/client/components/PagePresentationModal/PagePresentationModal.tsx) に `<RendererErrorMessage />` ガードを追加。**ただしモーダル経路のみ**。`SlideRenderer.tsx` (インライン `slide: true` 経路) は対象外。
-2. **当 spec の本実装**: `GrowiSlides.tsx` に最後の砦として `rendererOptions == null || ...` ガードが存在していた。これは SWR ローディング中の `undefined` を吸収する役目も果たしていた (副次的に)。
-3. **`688c260d91`**: 型定義 `PresentationOptions.rendererOptions: ReactMarkdownOptions` が必須を主張していたため、`rendererOptions == null` は「型上到達不能」と判断されガードを除去。biome-ignore コメントも "The actual code will never reach here." となっていた。
-4. **クラッシュ顕在化**: `SlideRenderer.tsx` で `usePresentationViewOptions()` から得た `undefined` を `as ReactMarkdownOptions` でキャストし `<Slides>` に渡しているため、SWR データ取得完了前に `undefined.remarkPlugins` でクラッシュ。
-
-### 根本原因 (4 重の安全網がすべて欠けていた)
-
-| 防御層 | 状態 | 問題 |
-|---|---|---|
-| 型シグネチャ | ❌ | `consts/index.ts:7` で `rendererOptions: ReactMarkdownOptions` (必須)。実態は SWR ローディング中 `undefined` |
-| 呼び出し側ガード | ❌ | `SlideRenderer.tsx:21` で `as ReactMarkdownOptions` キャストにより型情報を上書き |
-| コンポーネント内ガード | ⚠️ → ✅ | PR #11110 で復元済み |
-| E2E / Playwright | ❌ | `presentation.spec.ts` はモーダル経路のみ。`slide: true` ページの SWR ローディング経路は未カバー |
-
-## Boundary Audit
-
-### B1. Type/Runtime 乖離 (Boundary Commitment 違反)
-
-**Design 3.3 (`PresentationOptions`)** で `rendererOptions: ReactMarkdownOptions` を必須として宣言しているが、呼び出し側 (`apps/app`) では SWR の loading 状態で `undefined` を渡している。境界仕様と実呼び出しが食い違っており、これが `688c260d91` の誤判断を生んだ。
-
-**Owner**: presentation package (型シグネチャの責務)
-
-### B2. 非対称な防御 (Cross-Task Integration)
-
-`PagePresentationModal` 経路だけが `{!isLoading && rendererOptions == null && <RendererErrorMessage />}` のガードを持ち、`SlideRenderer` 経路は無防備。両方とも `usePresentationViewOptions()` を呼ぶ同型のクライアントだが、ローディングへの構えが揃っていない。
-
-**Owner**: apps/app (呼び出し側 — spec 範囲外だが本 feature の安定動作を成立させるために必須)
-
-### B3. 不変性違反 (PR 範囲外, ただし要追跡)
-
-[GrowiSlides.tsx:35-44](packages/presentation/src/client/components/GrowiSlides.tsx#L35-L44):
-
-```ts
-rendererOptions.remarkPlugins.push([extractSections.remarkPlugin, ...]);
-rendererOptions.components.section = presentation ? ... : ...;
-```
-
-`.claude/rules/coding-style.md` の immutability ルール違反。再レンダ毎に `remarkPlugin` が push され続けるはず。今回の PR スコープ外だが、`rendererOptions` を共有参照として外部から渡している以上、本来は新オブジェクトを作るべき。
-
-**Owner**: presentation package
-
-## Coverage Gaps
-
-| 要件 | カバレッジ |
-|---|---|
-| Req 1. Module Separation | ✅ (vite build で確認可能) |
-| Req 2. Build-Time CSS Extraction | ✅ |
-| Req 3. **Functional Equivalence** | ⚠️ **`slide: true` 経路のローディング時挙動が非テスト**。PR #11110 のユニットテストで一部緩和されたが、E2E は依然欠落 |
-| Req 4. Build Integrity | ✅ |
-
-## Test Gaps
-
-### PR #11110 のユニットテストの限界
-
-- 検証しているのは「`rendererOptions` が null/undefined の時に throw しない」のみ
-- 正常系 (rendererOptions が完備されたとき正しくレンダされる) は未検証 → ガード分岐の `||` を 1 つ削除してもテストは全 green のままになる可能性
-- mock が重い (`next/head`, `marpit-base-css.vendor-styles.prebuilt`, `extract-sections`, `RichSlideSection` を全部 mock) ため、実際のレンダパスのバグは捕まらない
-
-### Playwright 不足
-
-実装者レポートでは「スライドモードのテストには専用ページのテストデータが必要なため Playwright での追加は断念」とあるが、既存の [`saving.spec.ts`](apps/app/playwright/23-editor/saving.spec.ts) が「ページ作成 → エディタで内容入力 → 保存 → ビューで確認」のパターンを既に確立しており、これを使えば実装可能。
-
-#### 推奨される Playwright シナリオ
-
-```ts
-// apps/app/playwright/20-basic-features/presentation.spec.ts に追加
-
-test('Slide page (slide: true frontmatter) renders without crashing', async ({ page }) => {
-  await page.goto('/Sandbox/slide-test');
-
-  // 1) エディタで slide: true フロントマター入りの内容を保存
-  await page.getByTestId('editor-button').click();
-  await expect(page.getByTestId('grw-editor-navbar-bottom')).toBeVisible();
-  await appendTextToEditorUntilContains(page,
-    '---\nslide: true\n---\n# Slide 1\n---\n# Slide 2'
-  );
-  await page.keyboard.press('Control+s');
-
-  // 2) ビュー → スライドが見えること
-  await page.getByTestId('view-button').click();
-  await expect(page.locator('.marpit')).toBeVisible();
-  await expect(page.getByRole('heading', { level: 1, name: 'Slide 1' })).toBeVisible();
-
-  // 3) リロード → SWR ローディング経路を踏ませてクラッシュしないこと
-  await page.reload();
-  await expect(page.locator('.marpit')).toBeVisible();
-});
-```
-
-**ポイント**: 3 のリロード後検証が今回のバグの本丸 (SWR loading 中の `undefined` 通過) を再現する。PR #11110 のユニットテストでは到達しない実ブラウザ経路。
-
-## Remediation (実装者向けフィードバック)
-
-優先度順:
-
-### P0 — 型と実態を揃える (再発の唯一の本質的予防)
-
-[packages/presentation/src/client/consts/index.ts:7](packages/presentation/src/client/consts/index.ts#L7) の `rendererOptions` を optional に変更:
-
-```ts
-export type PresentationOptions = {
-  rendererOptions?: ReactMarkdownOptions;  // undefined を型レベルで認める
-  // ...
-};
-```
-
-その上で:
-- [apps/app SlideRenderer.tsx:21](apps/app/src/client/components/Page/SlideRenderer.tsx#L21) の `as ReactMarkdownOptions` キャストを削除
-- [PagePresentationModal.tsx](apps/app/src/client/components/PagePresentationModal/PagePresentationModal.tsx) の `as ReactMarkdownOptions` も同様に削除し、既存の null チェックに型を従わせる
-- `GrowiSlides.spec.tsx` の `undefined as any` / `null as any` も `undefined` だけで通るようになり、テストの本気度が上がる
-
-これで `688c260d91` のような「型上不要だから削除」判断が型エラーで止まる。
-
-### P1 — Playwright で `slide: true` 経路のスモークを追加
-
-上記シナリオを `presentation.spec.ts` に追記。とくに **reload 後の表示確認** を必ず含める (SWR loading 経路のため)。
-
-### P2 — ユニットテストに正常系を追加
-
-`GrowiSlides.spec.tsx` に「`rendererOptions` が完備された時に `<ReactMarkdown>` が呼ばれる」ケースを追加。これがないとガード条件式自体の改悪を検知できない。
-
-### P3 — Immutability 違反の修正 (別 issue 推奨)
-
-[GrowiSlides.tsx:35-44](packages/presentation/src/client/components/GrowiSlides.tsx#L35-L44) のミューテーションを `useMemo` などで新オブジェクト生成に置き換え。本 PR スコープ外だが、`rendererOptions` を共有参照として扱う前提の以上、bug の温床。
-
-## Revalidation Triggers (再発火条件)
-
-以下が起きたら本検証を再実行:
-
-- `PresentationOptions` の型変更
-- `usePresentationViewOptions` の loading 制御変更
-- `GrowiSlides` / `SlideRenderer` / `PagePresentationModal` のいずれかの null ガード除去
-- `@growi/presentation` 内の `as` キャスト追加
-- Marp ライブラリのメジャー更新 (CSS 再生成と合わせて)
-
-## Summary Table
-
-| Dim | Status | Owner |
-|---|---|---|
-| Tests (current PR) | PASS | LOCAL |
-| Smoke boot for `slide: true` | MANUAL_REQUIRED | LOCAL + UPSTREAM (apps/app Playwright) |
-| Cross-task contracts (type vs runtime) | VIOLATION | LOCAL (presentation package) |
-| Shared state consistency (`rendererOptions` undefined handling) | INCONSISTENT | UPSTREAM (apps/app — `SlideRenderer` vs `PagePresentationModal`) |
-| Boundary audit | 3 violations (B1/B2/B3) | Mixed |
-| Requirements 1, 2, 4 | COVERED | — |
-| Requirement 3 (Functional Equivalence) | PARTIAL — SWR loading path not covered | LOCAL + UPSTREAM |
-| Architecture drift from design.md | None (component graph intact) | — |
-| Dependency direction | OK | — |
-| **OVERALL OWNERSHIP** | **LOCAL + UPSTREAM** | spec の境界仕様 (型) を LOCAL で締め、apps/app 側 (UPSTREAM) で呼び出し対称性と E2E を補強 |
-
-## Notes
-
-- spec.json の `phase` は `implementation-complete` のままにする (本検証は事後フィードバックであり、再 cleanup の対象)。
-- 上記 P0/P1/P2/P3 をフォロー issue 化してから、最終 cleanup でこのファイルを英訳・要点化して `research.md` に追補するか `design.md` の "Risks & Mitigations" に統合するのが良い。
-- 履歴参照: PR #10152 (Redmine #153963, 2024) — 本件と同根の `rendererOptions == null` 問題に対するモーダル経路だけの対策。当時ユニットテストは付かなかった。今回 spec 完了後の `688c260d91` がガードを消した時、この 2024 年の知見が ADR / spec / コメントに残っていなかったため再発を許した。

+ 29 - 18
packages/presentation/src/client/components/GrowiSlides.tsx

@@ -1,6 +1,7 @@
-import type { JSX } from 'react';
+import { type JSX, useMemo } from 'react';
 import Head from 'next/head';
 import ReactMarkdown from 'react-markdown';
+import type { PluggableList } from 'unified';
 
 import { MARP_CONTAINER_CLASS_NAME, type PresentationOptions } from '../consts';
 import {
@@ -23,26 +24,36 @@ export const GrowiSlides = (props: Props): JSX.Element => {
   const { options, children, presentation } = props;
   const { rendererOptions, isDarkMode, disableSeparationByHeader } = options;
 
-  if (
-    rendererOptions == null ||
-    rendererOptions.remarkPlugins == null ||
-    rendererOptions.components == null
-  ) {
+  // Derive a new options object instead of mutating `rendererOptions`:
+  // it is a shared SWR cache reference also consumed by PagePresentationModal,
+  // so mutation here would leak into other components and accumulate on re-render.
+  const slideRendererOptions = useMemo(() => {
+    if (
+      rendererOptions == null ||
+      rendererOptions.remarkPlugins == null ||
+      rendererOptions.components == null
+    ) {
+      return null;
+    }
+    const remarkPlugins: PluggableList = [
+      ...rendererOptions.remarkPlugins,
+      [extractSections.remarkPlugin, { isDarkMode, disableSeparationByHeader }],
+    ];
+    return {
+      ...rendererOptions,
+      remarkPlugins,
+      components: {
+        ...rendererOptions.components,
+        section: presentation ? PresentationRichSlideSection : RichSlideSection,
+      },
+    };
+  }, [rendererOptions, isDarkMode, disableSeparationByHeader, presentation]);
+
+  if (slideRendererOptions == null) {
     // biome-ignore lint/complexity/noUselessFragments: early return when rendererOptions is null
     return <></>;
   }
 
-  rendererOptions.remarkPlugins.push([
-    extractSections.remarkPlugin,
-    {
-      isDarkMode,
-      disableSeparationByHeader,
-    },
-  ]);
-  rendererOptions.components.section = presentation
-    ? PresentationRichSlideSection
-    : RichSlideSection;
-
   const css = presentation ? PRESENTATION_MARPIT_CSS : SLIDE_MARPIT_CSS;
   return (
     <>
@@ -50,7 +61,7 @@ export const GrowiSlides = (props: Props): JSX.Element => {
         <style>{css}</style>
       </Head>
       <div className={`slides ${MARP_CONTAINER_CLASS_NAME}`}>
-        <ReactMarkdown {...rendererOptions}>
+        <ReactMarkdown {...slideRendererOptions}>
           {children ?? '## No Contents'}
         </ReactMarkdown>
       </div>