エディタのフォームバリデーションAPI実装とアップロード中のフォーム送信防止

basecamp/lexxy

LexicalEditorElementElementInternals Validity APIに完全対応し、ファイルアップロード中はフォーム送信を無効にする仕組みが追加されました。エクステンション側からもバリデーション状態を設定できる拡張可能な設計が採用されています。

背景

WebコンポーネントはElementInternalsを通じてネイティブのフォームバリデーションに参加できますが、LexicalEditorElementはこのAPIを十分に活用していませんでした。特にファイルアップロード中にフォームを送信できてしまう問題があり、アップロードが完了していない状態でデータが送信されるリスクがありました。

これまでの実装ではrequired属性への対応とバリデーション状態の設定が部分的であり、エクステンション(プラグイン)側からバリデーション状態を操作する手段もありませんでした。アップロードの進行状況も、ノードツリー全体を検索することで把握していたため、効率面でも課題がありました。

技術的な変更

エディタ要素のValidity API対応

src/elements/editor.js#validityMap型)が追加され、エクステンションごとのバリデーション状態を独立して管理できる構造になりました。

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.jssetEditorValidityメソッドが追加され、すべてのエクステンションがエディタ要素のバリデーション状態を設定できるようになりました。

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) => {})に変更することで、thisAttachmentsExtensionインスタンスを参照できるようになっています。#handleUploadMutations内でthis.setEditorValidityを呼ぶために必要な変更です。

まとめ

本PRはElementInternals Validity APIへの対応を通じて、WebコンポーネントとしてのLexicalEditorElementのフォーム統合を完成させました。エクステンションスコープのバリデーション状態管理という拡張可能な基盤を整えたことで、アタッチメント以外のエクステンションも同じAPIでフォームバリデーションに参加できるようになっています。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
879ae9cb

この記事は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リンク記法の正確性

ファイル名付きシンタックスハイライトやPR番号のリンク記法が正しく使用されています。

対象読者への適合性 ✓ PASS

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

専門用語が適切に使用され、過度な説明がなく、専門知識を持つエンジニアという対象読者に適合しています。

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

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

各セクション、各パラグラフが「総論→各論」の構造で書かれており、トピックセンテンスが明確で可読性が非常に高いです。

Diff内容との照合 ✓ PASS

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

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

技術用語の正確性 ✓ PASS

技術用語の正確な使用

ElementInternals, Validity API, registerMutationListenerなど、関連する技術用語が正確かつ適切な文脈で使用されています。

説明の技術的正確性 ✓ PASS

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

カウンタによるアップロード管理の効率性や、アロー関数による`this`の束縛など、コード変更の技術的な意図や影響が正確に解説されています。

事実の突合 ✓ PASS

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

記事内のすべての主張は、PRのタイトル、説明、Diffの内容によって裏付けられており、ハルシネーションは検出されませんでした。

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

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

PR番号(#1060)やファイルパスなどの固有名詞はすべて正確に記載されています。

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

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

記事のタイトル「エディタのフォームバリデーションAPI実装とアップロード中のフォーム送信防止」は、PRの主題を的確に要約しています。

外部知識の正確性 ✓ PASS

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

記事内容はPR情報に限定されており、裏付けのない外部知識(バージョンサポート情報など)の追加はありません。

時間表現の正確性 ✓ PASS

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

時間表現に関する歪曲や不正確な記述は見られませんでした。