画像アップロード時のレイアウトシフトをローカルプレビューで解消
リッチテキストエディタ Lexxy において、画像アップロード完了後に発生していたレイアウトシフト(一瞬の点滅)を、ローカルプレビューURLをサーバーレスポンスまで維持することで解消しました。
背景
アップロード完了時に AttachmentNodeConversion が実行されると、ブラウザはサーバーから画像を再ダウンロードする必要があり、その間に一時的な点滅が発生していました。具体的には、ActionTextAttachmentUploadNode でアップロードが完了すると、ActionTextAttachmentNode への変換が行われます。この変換時、ノードに設定される src がローカルの blob: URLからサーバー側のActive Storage URLに切り替わるため、ブラウザが画像を再取得するまでの間、エディタ上で画像が一時的に消える状態が生じていました。
この問題は、ローカルで保持している画像データを変換後も引き続き利用することで回避できます。変換前のノードは既にアップロードするファイルオブジェクトを持っており、URL.createObjectURL() を使えばサーバーレスポンスを待たずにローカルプレビューを提供できます。
技術的な変更
ActionTextAttachmentNode に previewSrc フィールドを追加し、サーバー画像が読み込まれるまでの間、ローカル blob: URLを表示に使用できるようにしました。
コンストラクタのシグネチャが拡張され、previewSrc と uploadError の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)
// ...
}
これにより、ActionTextAttachmentNode は previewSrc が設定されている場合はローカル blob: URLで画像を表示し、サーバー画像の読み込み完了後にサイレントで差し替える動作が実現されます。
テスト戦略
この変更を検証するために、mockActiveStorageUploads ヘルパーが拡張され、Blob取得レスポンスを任意のタイミングで解放できる仕組みが追加されました。delayBlobResponses: true を渡すと、GET /rails/active_storage/blobs/* リクエストが calls.releaseBlobResponses() を呼ぶまで保留状態になります。
追加された2つのテストケースは、それぞれ異なる観点を検証しています:
-
「サーバー画像が届くまでローカルプレビューが表示される」: サーバーレスポンス遅延中に
srcがblob:URLであることを確認し、解放後にActive Storage URLへの切り替えを検証する -
「サーバー応答前に添付ファイルを削除してもクラッシュしない」: ノードが削除された後に
releaseBlobResponses()を呼んでもエラーが発生しないことを確認する
後者のテストは、previewSrc の blob: URLを保持したまま非同期で画像の読み込みを待機している状態でノードが削除される、というエッジケースを明示的にカバーしており、非同期処理とDOMライフサイクルのずれによる潜在的なクラッシュを防いでいます。
設計判断
プレビューとサーバー画像の差し替えをサイレントに行う設計 が採用されました。previewSrc があれば先に表示し、サーバー画像の読み込み完了後にDOMを静かに更新することで、ユーザーには変換が起きていることを意識させません。
また、ActionTextAttachmentUploadNode のプライベートメソッドだった #createDOMForError が親クラス ActionTextAttachmentNode の createDOMForError に移動されています。これは uploadError フィールドを親クラスに追加したことで、エラー表示のロジックも親クラスで一元管理できるようになったためです。プライベートメソッドを親クラスに移動することで、コードの重複を排除しつつ継承階層の責務を整理しています。
eslint.config.js への Image: "readonly" の追加も、新しく Image コンストラクタを使用するコードに対応した付随的な変更です。
ローカルプレビューの生成に URL.createObjectURL() を用いる手法は、ブラウザネイティブAPIを活用したシンプルな解法であり、追加の依存関係を必要としません。isPreviewableImage && this.file の条件ガードにより、プレビュー不可能なファイルや既にファイルオブジェクトが存在しないケースに対しても安全に動作します。
まとめ
アップロード完了後の blob: URLを ActionTextAttachmentNode に引き継ぐという小さな変更により、サーバー画像到着前の一時的な空白表示が解消されました。テストヘルパーにレスポンス遅延制御機構を組み込んで非同期タイミングを厳密に検証する手法は、エディタのような複雑な非同期状態を持つコンポーネントのテスト設計として参考になります。