画像アップロード時のレイアウトシフトをローカルプレビューで解消

basecamp/lexxy

リッチテキストエディタ Lexxy において、画像アップロード完了後に発生していたレイアウトシフト(一瞬の点滅)を、ローカルプレビューURLをサーバーレスポンスまで維持することで解消しました。

背景

アップロード完了時に AttachmentNodeConversion が実行されると、ブラウザはサーバーから画像を再ダウンロードする必要があり、その間に一時的な点滅が発生していました。具体的には、ActionTextAttachmentUploadNode でアップロードが完了すると、ActionTextAttachmentNode への変換が行われます。この変換時、ノードに設定される src がローカルの blob: URLからサーバー側のActive Storage URLに切り替わるため、ブラウザが画像を再取得するまでの間、エディタ上で画像が一時的に消える状態が生じていました。

この問題は、ローカルで保持している画像データを変換後も引き続き利用することで回避できます。変換前のノードは既にアップロードするファイルオブジェクトを持っており、URL.createObjectURL() を使えばサーバーレスポンスを待たずにローカルプレビューを提供できます。

技術的な変更

ActionTextAttachmentNodepreviewSrc フィールドを追加し、サーバー画像が読み込まれるまでの間、ローカル blob: URLを表示に使用できるようにしました。

コンストラクタのシグネチャが拡張され、previewSrcuploadError の2つのフィールドが追加されました。uploadError はこれまで ActionTextAttachmentUploadNode のプライベートメソッドで処理されていましたが、親クラスの createDOM に移動されました。

変更前:

constructor({ tagName, sgid, src, previewable, altText, caption, contentType, fileName, fileSize, width, height }, key) {
  // ...
  this.src = src
  // ...
}

変更後:

constructor({ tagName, sgid, src, previewSrc, previewable, altText, caption, contentType, fileName, fileSize, width, height, uploadError }, key) {
  // ...
  this.src = src
  this.previewSrc = previewSrc
  // ...
  this.uploadError = uploadError
}

ActionTextAttachmentUploadNode 側では、showUploadedAttachment メソッドでノード変換を行う際に URL.createObjectURL(this.file) でローカルプレビューURLを生成し、AttachmentNodeConversion に渡すよう変更されました。

showUploadedAttachment(blob) {
  const previewSrc = this.isPreviewableImage && this.file ? URL.createObjectURL(this.file) : null

  const replacementNode = this.#toActionTextAttachmentNodeWith(blob, previewSrc)
  this.replace(replacementNode)
  // ...
}

これにより、ActionTextAttachmentNodepreviewSrc が設定されている場合はローカル blob: URLで画像を表示し、サーバー画像の読み込み完了後にサイレントで差し替える動作が実現されます。

テスト戦略

この変更を検証するために、mockActiveStorageUploads ヘルパーが拡張され、Blob取得レスポンスを任意のタイミングで解放できる仕組みが追加されました。delayBlobResponses: true を渡すと、GET /rails/active_storage/blobs/* リクエストが calls.releaseBlobResponses() を呼ぶまで保留状態になります。

追加された2つのテストケースは、それぞれ異なる観点を検証しています:

  • 「サーバー画像が届くまでローカルプレビューが表示される」: サーバーレスポンス遅延中に srcblob: URLであることを確認し、解放後にActive Storage URLへの切り替えを検証する
  • 「サーバー応答前に添付ファイルを削除してもクラッシュしない」: ノードが削除された後に releaseBlobResponses() を呼んでもエラーが発生しないことを確認する

後者のテストは、previewSrcblob: URLを保持したまま非同期で画像の読み込みを待機している状態でノードが削除される、というエッジケースを明示的にカバーしており、非同期処理とDOMライフサイクルのずれによる潜在的なクラッシュを防いでいます。

設計判断

プレビューとサーバー画像の差し替えをサイレントに行う設計 が採用されました。previewSrc があれば先に表示し、サーバー画像の読み込み完了後にDOMを静かに更新することで、ユーザーには変換が起きていることを意識させません。

また、ActionTextAttachmentUploadNode のプライベートメソッドだった #createDOMForError が親クラス ActionTextAttachmentNodecreateDOMForError に移動されています。これは uploadError フィールドを親クラスに追加したことで、エラー表示のロジックも親クラスで一元管理できるようになったためです。プライベートメソッドを親クラスに移動することで、コードの重複を排除しつつ継承階層の責務を整理しています。

eslint.config.js への Image: "readonly" の追加も、新しく Image コンストラクタを使用するコードに対応した付随的な変更です。

ローカルプレビューの生成に URL.createObjectURL() を用いる手法は、ブラウザネイティブAPIを活用したシンプルな解法であり、追加の依存関係を必要としません。isPreviewableImage && this.file の条件ガードにより、プレビュー不可能なファイルや既にファイルオブジェクトが存在しないケースに対しても安全に動作します。

まとめ

アップロード完了後の blob: URLを ActionTextAttachmentNode に引き継ぐという小さな変更により、サーバー画像到着前の一時的な空白表示が解消されました。テストヘルパーにレスポンス遅延制御機構を組み込んで非同期タイミングを厳密に検証する手法は、エディタのような複雑な非同期状態を持つコンポーネントのテスト設計として参考になります。

記事メタデータ

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

この記事は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)およびPR番号のリンク記法([PR #931](URL))が正しく使用されています。

対象読者への適合性 ✓ PASS

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

専門用語(レイアウトシフト, blob: URL, DOM, 非同期処理など)を前提知識として使用しており、対象読者であるエンジニアに適した技術レベルと表現です。

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

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

各セクションが総論→各論の構成になっており、各段落もトピックセンテンスで始まるため、非常に読みやすいです。1段落1トピックの原則も守られています。

Diff内容との照合 ✓ PASS

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

記事内のコードブロックは、提供されたDiffの内容(コンストラクタの変更、showUploadedAttachmentメソッドの変更)を正確に反映しています。ファイルパスの指定も正しいです。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

PR情報に含まれる`ActionTextAttachmentNode`, `AttachmentNodeConversion`, `blob: URL`などの技術用語が正確に使用されています。

説明の技術的正確性 ✓ PASS

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

レイアウトシフトの原因(サーバーからの画像再ダウンロード)と解決策(ローカルプレビューURLの維持)に関する技術的な説明は、PRのDescriptionおよびDiffの内容と完全に整合しており、論理的かつ正確です。

事実の突合 ✓ PASS

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

記事内のすべての主張(previewSrcフィールドの追加、テストヘルパーの拡張、エラー処理ロジックの移動など)は、PRのDescriptionおよびDiffによって裏付けられています。ハルシネーションは検出されませんでした。

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

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

PR番号(#931)が正確に記載されています。

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

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

記事のタイトル「画像アップロード時のレイアウトシフトをローカルプレビューで解消」は、PRのタイトル「Keep local preview URL until server response」の内容を的確に要約しており、主題と一致しています。

外部知識の正確性 ✓ PASS

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

記事には、PR情報に記載のない外部知識(バージョンのサポート状況、リリース日程など)は含まれていません。内容はPRに忠実です。

時間表現の正確性 ✓ PASS

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

記事内の時間表現は、PRで行われた変更を正確に記述しており、事実との矛盾はありません。