Yuki Takei 1 неделя назад
Родитель
Сommit
04ad0feed6
1 измененных файлов с 216 добавлено и 0 удалено
  1. 216 0
      .kiro/specs/presentation/mutation-risk-analysis.md

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

@@ -0,0 +1,216 @@
+# 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)" セクション