コードブロック内ハイライトの保存・再編集サイクルでの消失バグを修正

basecamp/lexxy

Lexicalのコード再トークナイザが<mark>ハイライトスタイルを破壊する問題を、3段階のワークアラウンドで解消しました。ハイライト済みのコードブロックを保存して再編集しても、スタイルが失われなくなります。

背景

プレーンテキストのコードブロック内でテキストをハイライトすると、エディタ上では正しく表示されるものの、保存して再編集すると書式が失われるというバグが報告されていました(Fizzy card #3388)。

根本原因は、LexicalのHTML読み込み処理にあります。コード再トークナイザsetValueの更新サイクル内でTextNodeトランスフォームとして動作し、<mark>要素でスタイルが付与されたTextNodeを、スタイルを持たない新鮮なCodeHighlightNodeに置き換えてしまいます。この置き換えによって<mark>由来のハイライトスタイルが完全に失われていました。

この問題を修正するには、Lexicalの内部処理に起因する3つの独立した障壁を乗り越える必要がありました。

技術的な変更

修正は「HTMLインポート時にハイライト範囲を退避 → 再トークナイズ後に再適用」という2フェーズのアプローチで実現されています。

フェーズ1: ハイライト範囲の退避

src/extensions/highlight_extension.jspendingCodeHighlightsというWeakMapを導入し、HTMLインポート中に<mark>要素から抽出したハイライト範囲を一時保存します。WeakMapを使うことでエディタインスタンスをキーとし、エディタが破棄された際にエントリが自動的にガベージコレクトされます。

// Stores pending highlight ranges extracted during HTML import, keyed by CodeNode key.
// After the code retokenizer creates fresh CodeHighlightNodes, a mutation listener
// reads this map and re-applies the highlight styles. Scoped per editor instance
// so entries don't leak across editors or outlive a torn-down editor.
const pendingCodeHighlights = new WeakMap()

フェーズ2: 再トークナイズ後の再適用

再トークナイザが完了した後、CodeNodeのミューテーションリスナーがpending状態のハイライトを検出し、discreteかつskipTransformsな更新としてスタイルを再適用します。

return mergeRegister(
  // ... 既存のトランスフォーム ...
  editor.registerMutationListener(CodeNode, (mutations) => {
    $applyPendingCodeHighlights(editor, mutations)
  }, { skipInitialization: true })
)

この修正には、Lexical内部の3つの障壁に対するワークアラウンドが含まれています。

障壁1: html.importコンバータの衝突

Lexicalは拡張機能のhtml.importコンバータをObject.assignでマージするため、同一タグ名(pre)に対するコンバータは一つしか登録できません。修正では$registerPreConversion(editor)を用いて、エディタの内部キャッシュ_htmlConversionsに直接コンバータを登録します。<mark>要素が存在しない場合はnullを返してデフォルトのCodeNodeコンバータに処理を委ねます。

障壁2: 再トークナイザによるスタイル破壊

skipTransformsタグを付けたdiscrete更新でハイライトを再適用することで、再トークナイザが再び起動してスタイルを上書きする無限ループを防いでいます。

障壁3: splitText()TextNodeを生成する問題

ハイライト境界でのCodeHighlightNodeの分割にsplitText()を使うと、戻り値がCodeHighlightNodeではなく素のTextNodeになります。修正では分割後に正しいハイライトタイプとスタイルを持つCodeHighlightNodeインスタンスを手動で生成して置き換えます。

レンダリングビューへの対応

src/helpers/code_highlighting_helper.jsにも変更が加えられています。Prismによるシンタックスハイライト処理前にextractHighlightRanges()<mark>範囲を退避し、Prismの処理後にapplyHighlightRanges()で再適用することで、保存済みコンテンツのレンダリング時にもハイライトが保持されます。

// Extract highlight ranges before Prism destroys <mark> elements
const highlights = extractHighlightRanges(preElement)

const highlightedHtml = Prism.highlight(code, grammar, language)
const codeElement = createElement("code", { "data-language": language, innerHTML: highlightedHtml })

if (highlights.length > 0) {
  applyHighlightRanges(codeElement, highlights)
}

設計判断

非同期の2フェーズ設計が採用されました。ハイライト範囲の保存と再適用を同一の更新サイクル内で完結させようとすると、再トークナイザのトランスフォームと競合します。WeakMapによる一時保存とミューテーションリスナーによる後続適用に分割することで、Lexicalの更新サイクルの制約を回避しています。

コンバータが<mark>要素を持たない<pre>に対してnullを返す設計も注目に値します。この「責務の委譲」パターンにより、既存のCodeNodeコンバータとの共存を実現し、_htmlConversionsへの直接登録という内部APIへの依存を最小限の影響範囲に限定しています。

また、AGENTS.mdに「Action Textのパーシステンス検証」セクションが追加され、エディタHTMLがDOMPurify(クライアント)・Loofah(サーバー)・highlightCode()(レンダリング)の3層を通過することを明示しました。インラインスタイルやノードタイプを変更する際には、このフルラウンドトリップを必ず検証するルールが組織的な知識として文書化されました。

まとめ

Lexicalの内部処理(Object.assignによるコンバータ衝突、再トークナイザによるスタイル破壊、splitText()の型問題)という3つの制約を、WeakMapを用いた2フェーズ設計で回避した修正です。特定の内部APIへの依存は最小限に抑えつつ、エディタ・保存・レンダリング・再編集の全サイクルでコードブロックのハイライトが保持されるようになりました。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
d7b7a1f0

この記事はAIによって自動生成されています。内容の正確性については、必ずソースコードやPRを確認してください。

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
Gemini 2.5 Pro for DiffDaily

Review Criteria:

記事構成 ✓ PASS

Title, Context, Technical Detailの存在と明確さ

リード文(総論)→セクション群(各論)→まとめ(結論)の3部構成が明確に適用されています。背景、技術詳細、設計判断の各セクションが適切に配置され、記事全体の流れが非常に分かりやすいです。

カスタムMarkdown構文 ✓ PASS

シンタックスハイライト・GitHubリンク記法の正確性

ファイル名付きシンタックスハイライト(```javascript:filepath)およびGitHubのPRリンク記法が正しく使用されています。

対象読者への適合性 ✓ PASS

エンジニア向けの適切な技術レベルと表現

Lexicalの内部動作や非同期処理に関する専門的な内容であり、対象読者であるエンジニアに適した技術レベルと表現で記述されています。

パラグラフ・ライティング ✓ PASS

トピックセンテンス・1段落1トピック・段落長

各セクションが総論→各論で構成され、各段落はトピックセンテンスで始まるなど、パラグラフ・ライティングの原則に完全に準拠しています。可読性が非常に高いです。

Diff内容との照合 ✓ PASS

コードブロックとDiff内容の一致

記事内で引用されているコードブロックは、提供されたDiff情報と完全に一致しており、正確に内容を反映しています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

「WeakMap」「ミューテーションリスナー」「discrete更新」など、PRで言及されている技術用語を文脈に沿って正確に使用しています。

説明の技術的正確性 ✓ PASS

技術的主張の正確性と論理性

Lexicalの3つの内部的な障壁とその回避策に関する説明は、PR Descriptionの技術詳細と完全に一致しており、技術的に正確かつ論理的です。

事実の突合 ✓ PASS

PR情報による主張の裏付け(ハルシネーション検出)

記事内のすべての主張(バグの原因、解決策、設計判断など)は、提供されたPR情報(Description、Diff)によって裏付けられており、ハルシネーションは検出されませんでした。

数値・固有名詞の確認 ✓ PASS

PR番号・コミットID・バージョン等の正確性

PR番号(#894)や関連する課題番号(Fizzy card #3388)が正確に記載されています。

タイトル・説明との一致 ✓ PASS

記事タイトル・説明とPR内容の一致

記事のタイトルは、PRのタイトル「Fix code block highlight loss during save/re-edit round-trip」の内容を日本語で正確に要約しています。

外部知識の正確性 ✓ PASS

PRに記載のない外部知識(LTS、サポート状況など)の不使用

記事で言及されているDOMPurifyやLoofahなどの技術スタックは、AGENTS.mdのDiffで明記されており、PR情報に基づかない外部知識の捏造はありません。

時間表現の正確性 ✓ PASS

時間表現がPR情報と一致しているか

完了した変更に対して「〜しました」「〜されています」といった適切な時制が使われており、PR情報との時間表現の齟齬はありません。