エディタのフォームバリデーションAPI実装とアップロード中のフォーム送信防止
LexicalEditorElementがElementInternals Validity APIに完全対応し、ファイルアップロード中はフォーム送信を無効にする仕組みが追加されました。エクステンション側からもバリデーション状態を設定できる拡張可能な設計が採用されています。
背景
WebコンポーネントはElementInternalsを通じてネイティブのフォームバリデーションに参加できますが、LexicalEditorElementはこのAPIを十分に活用していませんでした。特にファイルアップロード中にフォームを送信できてしまう問題があり、アップロードが完了していない状態でデータが送信されるリスクがありました。
これまでの実装ではrequired属性への対応とバリデーション状態の設定が部分的であり、エクステンション(プラグイン)側からバリデーション状態を操作する手段もありませんでした。アップロードの進行状況も、ノードツリー全体を検索することで把握していたため、効率面でも課題がありました。
技術的な変更
エディタ要素のValidity API対応
src/elements/editor.jsに#validity(Map型)が追加され、エクステンションごとのバリデーション状態を独立して管理できる構造になりました。
attributeChangedCallbackが分割され、各属性の変更ハンドラが専用メソッドとして切り出されています。required属性の変更時には#setValidityを直接呼ぶのではなく、#requestValidityRefreshを呼ぶように変更されています。
- if (name === "required" && this.isConnected) {
- this.#validationTextArea.required = this.hasAttribute("required")
- this.#setValidity()
- }
+ requiredChangedCallback() {
+ if (this.isConnected) this.#requestValidityRefresh()
+ }
また、nextFrameヘルパーがインポートされ、バリデーション状態のリフレッシュを非同期(次フレーム)に遅延させる設計が取られています。
エクステンションからのバリデーション設定
src/extensions/lexxy_extension.jsにsetEditorValidityメソッドが追加され、すべてのエクステンションがエディタ要素のバリデーション状態を設定できるようになりました。
setEditorValidity(flags, message) {
this.editorElement.setElementValidity(this, flags, message)
}
エクステンション自身(this)をキーとして渡すことで、エディタ要素側は#validity Mapにエクステンションごとの状態を格納できます。複数のエクステンションが独立してバリデーション状態を管理しつつ、エディタ全体の有効性は各エクステンションの状態を集約して判定する設計です。
アップロード中のフォーム送信防止
src/extensions/attachments_extension.jsでは、アップロード中のノード数をカウントする#uploadsCountフィールドが追加されました。以前はアップロードの進行状況をノードツリー全体から検索していましたが、registerMutationListenerでノードの生成・破棄イベントを監視してカウンタをインクリメント/デクリメントする方式に変更されています。
#handleUploadMutations(mutations) {
const previousUploadsCount = this.#uploadsCount
for (const [ , mutation ] of mutations) {
if (mutation === "created") {
this.#uploadsCount++
} else if (mutation === "destroyed") {
this.#uploadsCount--
}
}
if (this.#uploadsCount !== previousUploadsCount) {
this.#setUploadsValidity()
}
}
#setUploadsValidity() {
if (this.#uploadsCount) {
this.setEditorValidity({ customError: true }, UPLOADS_BUSY_MESSAGE)
} else {
this.setEditorValidity({})
}
}
アップロード中はcustomError: trueフラグと"Please wait for all files to upload"メッセージで無効状態を設定し、すべてのアップロードが完了するとクリアされます。カウンタ変動があった場合のみバリデーション更新を呼び出すため、不要な更新処理を抑制しています。
ブラウザテストの追加
test/browser/tests/attachments/form_validity.test.jsが新規追加され、アップロードライフサイクル全体を通じたバリデーション状態の変化がPlaywrightで検証されています。
test("editor is invalid while an upload is in flight and valid after it completes", async ({ page, editor }) => {
const calls = await mockActiveStorageUploads(page, { delayDirectUploadResponse: true })
await editor.send("Hello")
await expect.poll(() => editor.locator.evaluate((el) => el.checkValidity())).toBe(true)
await editor.uploadFile("test/fixtures/files/example.png")
await expect(page.locator("figure.attachment progress")).toBeVisible({ timeout: 10_000 })
await expect.poll(() => editor.locator.evaluate((el) => el.checkValidity())).toBe(false)
expect(await editor.locator.evaluate((el) => el.internals.validationMessage)).toBe(
"Please wait for all files to upload",
)
await calls.releaseDirectUploadResponses()
// ...
await expect.poll(() => editor.locator.evaluate((el) => el.checkValidity())).toBe(true)
})
Active Storageのアップロードをモックして応答を遅延させることで、アップロード中の無効状態と完了後の有効状態の両方を確認しています。
設計判断
エクステンション単位でのバリデーション状態の分離が本PRの核心的な設計判断です。エディタ全体で単一のバリデーション状態を持つのではなく、#validity Mapによってエクステンションごとに独立した状態を管理することで、複数のエクステンションが互いに干渉せずにバリデーションロジックを実装できます。
アップロードカウンタのアプローチも重要な判断です。ノードツリー全体を検索する方式と比較して、registerMutationListenerによるイベント駆動のカウンタ管理はDOMの状態に依存せず、変更があったときのみ処理が走ります。PR説明文にも「ツリー全体の検索を防ぐためにカウンタを使用」と明記されており、意図的な設計です。
さらに、registerメソッドをオブジェクトメソッド(register(editor) {})からアロー関数(register: (editor) => {})に変更することで、thisがAttachmentsExtensionインスタンスを参照できるようになっています。#handleUploadMutations内でthis.setEditorValidityを呼ぶために必要な変更です。
まとめ
本PRはElementInternals Validity APIへの対応を通じて、WebコンポーネントとしてのLexicalEditorElementのフォーム統合を完成させました。エクステンションスコープのバリデーション状態管理という拡張可能な基盤を整えたことで、アタッチメント以外のエクステンションも同じAPIでフォームバリデーションに参加できるようになっています。