<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>DOM</title><link>https://blog.ast.moe/tags/dom/</link><description>Recent posts from 五里霧中</description><generator>Hugo</generator><item><title>OmoikaneのDOMと例外通知を実装したら、コピーとGCの寿命まで調べることになった</title><link>https://blog.ast.moe/blog/2026-10-10/</link><pubDate>Sat, 10 Oct 2026 17:42:29 +0900</pubDate><guid>https://blog.ast.moe/blog/2026-10-10/</guid><description>&lt;p&gt;Omoikaneに、&lt;code&gt;before()&lt;/code&gt;や&lt;code&gt;replaceChildren()&lt;/code&gt;といったDOM操作と、&lt;code&gt;window.onerror&lt;/code&gt;、未処理Promise rejectionの通知を追加した。&lt;a href="https://github.com/ieee0824/omoikane/pull/1322"&gt;PR #1322&lt;/a&gt;の話である。最初の入口は、ページが普通に呼ぶAPIが足りないという、分かりやすい問題だった。ところが実装と検証を進めると、HTMLテキスト挿入の兄弟一覧コピー、UTF-16追記の全文再構築、不要なiframe Realm生成、PromiseのGC root不足まで直すことになった。&lt;/p&gt;
&lt;p&gt;名前の付いたAPIを増やすだけなら、記事ももう少し短くできる。しかし今回の差分は、APIが返す値だけでなく、その途中で走るユーザーコード、イベントの届く時刻、退役した文書の所有者、GCが見える参照までつながっている。&lt;code&gt;replaceChildren()&lt;/code&gt;が新しい木を作る瞬間と、&lt;code&gt;unhandledrejection&lt;/code&gt;のコールバックがGCを起こす瞬間は、同じブラウザの同じオブジェクト群を別の方向から触る。そこを省いて「対応しました」と書いても、何を守って実装したのかが伝わらない。&lt;/p&gt;
&lt;p&gt;今回はかなり長い記録にした。それぞれの機能について、何が足りなかったのか、入力をどの順番で処理するのか、どの観測結果をテストで固定したのかを説明する。性能の節では、処理時間の実測と、コピー量から分かることも分ける。個別の修正に興味があれば、次の入口から読める。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://blog.ast.moe/blog/2026-10-10/#dom-html"&gt;DOM操作、HTML解析、Sanitizer、選択範囲&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://blog.ast.moe/blog/2026-10-10/#error-notification"&gt;ページとWorkerの例外通知、Promise rejection&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://blog.ast.moe/blog/2026-10-10/#gc-iframe"&gt;Promise.finally、GC、Realm、iframeの寿命と遷移&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://blog.ast.moe/blog/2026-10-10/#ordinary-path-performance"&gt;通常経路のコピーとUTF-16追記&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://blog.ast.moe/blog/2026-10-10/#measurements-and-ci"&gt;局所計測とCIで起きたこと&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://blog.ast.moe/blog/2026-10-10/#remaining-work"&gt;残している課題&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="この記事で説明する実装の時点"&gt;この記事で説明する実装の時点&lt;/h2&gt;
&lt;p&gt;説明の基準は、2026年10月10日にマージした&lt;a href="https://github.com/ieee0824/omoikane/tree/994f17ccf01d56b3bcf14e1840e065808dc7e625"&gt;&lt;code&gt;994f17cc&lt;/code&gt;&lt;/a&gt;である。執筆時に確認した&lt;code&gt;main&lt;/code&gt;もこのコミットだった。PRの最終headは&lt;code&gt;7ad562bf&lt;/code&gt;、比較元は&lt;code&gt;e69ee73c&lt;/code&gt;で、APIに表示しきれなかった大きなファイルについても、両時点のソースを照合した。記事中のコードとテストのリンクは、基本的にマージ時点のコミットへ固定している。&lt;/p&gt;
&lt;p&gt;計測値は、&lt;a href="https://github.com/ieee0824/omoikane/pull/1322"&gt;PR本文&lt;/a&gt;と&lt;a href="https://github.com/ieee0824/omoikane/pull/1322#issuecomment-6095224621"&gt;調査経緯の最終コメント&lt;/a&gt;、関連Issueに保存した報告に基づく。この記事のためにブラウザの全テストや性能比較をもう一度実行した結果ではない。ソースから説明できる処理、報告された実測、まだ検証していない改善案を混ぜないようにする。&lt;/p&gt;
&lt;p&gt;変更前に足りなかったDOM APIは&lt;a href="https://github.com/ieee0824/omoikane/issues/1284"&gt;Issue #1284&lt;/a&gt;、ページ内のエラー通知は&lt;a href="https://github.com/ieee0824/omoikane/issues/1289"&gt;Issue #1289&lt;/a&gt;で整理した。後者はCDPの&lt;code&gt;Runtime.exceptionThrown&lt;/code&gt;とは別の経路である。外からブラウザを操作するクライアントが例外を知ることと、ページ自身の&lt;code&gt;addEventListener('error', ...)&lt;/code&gt;へ通知が来ることは同じではない。ページ内の監視処理が動くためには、ブラウザがそのページのglobalへイベントを届ける必要がある。&lt;/p&gt;
&lt;p&gt;また、ここでいうBoaはOmoikaneの&lt;code&gt;engine/boa/&lt;/code&gt;へ組み込んだ実装を指す。Omoikaneで加えた修正を、そのまま上流Boaの任意のリリースの状態として説明するつもりはない。以前の&lt;a href="https://blog.ast.moe/blog/2026-09-30/"&gt;getter返値のroot不足&lt;/a&gt;や&lt;a href="https://blog.ast.moe/blog/2026-10-08-2/"&gt;ephemeronと世代別GCの不具合&lt;/a&gt;とつながるところはあるが、今回の&lt;code&gt;Promise.finally&lt;/code&gt;の問題は、別の値、別の区間の保持漏れである。&lt;/p&gt;
&lt;h2 id="apiの追加が下の層へ広がった理由"&gt;APIの追加が下の層へ広がった理由&lt;/h2&gt;
&lt;p&gt;先に用語を揃えておく。Realmは、global objectや&lt;code&gt;Array&lt;/code&gt;、&lt;code&gt;Date&lt;/code&gt;などの組み込みオブジェクトを持つJavaScriptの実行環境の単位である。iframeの内外ではRealmが分かれ、同じ名前のconstructorでも別のオブジェクトになる。originは、その間でどの情報へアクセスできるかを判断する別の境界だ。今回の&lt;a href="https://github.com/ieee0824/omoikane/blob/994f17ccf01d56b3bcf14e1840e065808dc7e625/engine/boa/core/engine/src/realm.rs"&gt;Realmの型&lt;/a&gt;には、この環境とhost側の所有情報を結び付ける入口がある。&lt;/p&gt;
&lt;p&gt;wrapperは、Rust側のnative nodeをJavaScriptへ見せるオブジェクトを指す。identityを保つというのは、同じ内容の別objectを作るのでなく、&lt;code&gt;===&lt;/code&gt;で同じものとして比較できるようにすることだ。GCのrootは、生存している値を探す起点である。Rustのローカルにhandleが残ることと、そのhandleがrootとして登録されていることは別なので、後半ではどの区間でrootを持つかを細かく見る。&lt;/p&gt;
&lt;p&gt;ブラウザのDOMは、Rustの木構造だけで完結しない。ページのJavaScriptから見えるwrapper、native nodeのidentity、所有Document、Realm、イベントリスナー、Range、MutationObserver、custom elementのreactionが重なっている。木の中で同じnodeを動かしても、removeして別nodeを作った場合と同じ観測にはならない。逆に、同じwrapperが残っていても、その背後の所有Documentを新しいactive Documentへ勝手に付け替えてよいわけではない。&lt;/p&gt;
&lt;p&gt;例外通知にも同じ事情がある。Rust側でエラーを受け取った時点で、元のscriptのRealmやsource位置がすでに復元されていることがある。そこで単に「現在の文書へエラーイベントを投げる」と、例外の発生元、cross-originのmuted errors、Workerのownerがずれる。Promise rejectionでは、rejectした瞬間と、未処理だと確定するcheckpointも違う。その間にhandlerが付けば、通知してはいけない場合がある。&lt;/p&gt;
&lt;p&gt;性能修正は、その意味を保ったまま余分な仕事を消す変更になった。末尾の一件を得るために全childrenをcloneしなくてよい。履歴URLを読むためだけに子Realmを初期化しなくてよい。処理済みmoduleのASTを、評価可能な状態と一緒にいつまでも持たなくてよい。ただし、どれも本当に不要な保持や生成なのかを確かめる必要がある。コピーをなくした結果、GCから必要な値が消えたり、author getterを呼ぶ順番が変わったりすれば、別の不具合を作る。&lt;/p&gt;
&lt;p&gt;以下では、まずページから見えるDOMとHTMLの機能を説明し、次に通知、GCとiframe、性能と検証の順に進む。途中に置く短いコードは、特に断らない限り、挙動を説明するための例である。実際に計測に使ったfixtureや回帰テストと同一だと扱う場合は、そのテストへ直接リンクする。&lt;/p&gt;
&lt;p&gt;&lt;a id="dom-html"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2 id="domの便利なメソッドにも挿入の規則を全部通す"&gt;DOMの便利なメソッドにも、挿入の規則を全部通す&lt;/h2&gt;
&lt;p&gt;DOM側の出発点はIssue #1284だった。10月7日の確認ではChildNode・ParentNodeの操作、moveBefore、adoptNode、shadow treeを含むparse・serialize APIが足りなかった。名前を増やすだけなら簡単そうだが、文字列変換、例外、MutationObserver、live Range、custom element、iframeの寿命まで一緒に動く。 &lt;a href="https://github.com/ieee0824/omoikane/issues/1284"&gt;Issue #1284&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;まず公開先を整理した。&lt;code&gt;prepend()&lt;/code&gt;、&lt;code&gt;replaceChildren()&lt;/code&gt;、&lt;code&gt;moveBefore()&lt;/code&gt;などのParentNodeのメソッドはElement、Document、DocumentFragmentへ置く。&lt;code&gt;before()&lt;/code&gt;、&lt;code&gt;after()&lt;/code&gt;、&lt;code&gt;replaceWith()&lt;/code&gt;というChildNode側のメソッドはElement、CharacterData、DocumentTypeへ置く。全部を基底のNodeへ足すと、存在しないはずの場所でも呼べてしまう。今回の&lt;a href="https://github.com/ieee0824/omoikane/blob/994f17ccf01d56b3bcf14e1840e065808dc7e625/src/js/dom_bootstrap.js#L7845"&gt;プロトタイプへの配置&lt;/a&gt;は、この区分をそのまま表している。&lt;/p&gt;
&lt;h3 id="nodeか文字列かを木を動かす前に決める"&gt;Nodeか文字列かを、木を動かす前に決める&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;before()&lt;/code&gt;などは、Nodeだけでなく文字列も複数受け取れる。次のコードは説明用の例だ。&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-javascript" data-lang="javascript"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;const&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;parent&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; document.&lt;span style="color:#a6e22e"&gt;createElement&lt;/span&gt;(&lt;span style="color:#e6db74"&gt;&amp;#39;div&amp;#39;&lt;/span&gt;);
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#66d9ef"&gt;const&lt;/span&gt; &lt;span style="color:#a6e22e"&gt;marker&lt;/span&gt; &lt;span style="color:#f92672"&gt;=&lt;/span&gt; document.&lt;span style="color:#a6e22e"&gt;createElement&lt;/span&gt;(&lt;span style="color:#e6db74"&gt;&amp;#39;span&amp;#39;&lt;/span&gt;);
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;parent&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;append&lt;/span&gt;(&lt;span style="color:#a6e22e"&gt;marker&lt;/span&gt;);
&lt;/span&gt;&lt;/span&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;marker&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;before&lt;/span&gt;(&lt;span style="color:#e6db74"&gt;&amp;#39;head&amp;#39;&lt;/span&gt;, &lt;span style="color:#66d9ef"&gt;null&lt;/span&gt;, &lt;span style="color:#66d9ef"&gt;undefined&lt;/span&gt;);
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;ここでは三つのTextノードがmarkerの前にできる。内容は順に&lt;code&gt;head&lt;/code&gt;、&lt;code&gt;null&lt;/code&gt;、&lt;code&gt;undefined&lt;/code&gt;になる。&lt;code&gt;null&lt;/code&gt;を「何も挿入しない」と扱うAPIではない。一方、すでにNodeである値は新しいNodeへ変換せず、同じオブジェクトを移動する。&lt;code&gt;marker.before(existing)&lt;/code&gt;の前後で、existingを変数に保持していたコードは同じNodeを指し続ける。&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/ieee0824/omoikane/blob/994f17ccf01d56b3bcf14e1840e065808dc7e625/src/js/dom_bootstrap.js#L4644"&gt;&lt;code&gt;convertMutationArguments&lt;/code&gt;&lt;/a&gt;は、引数を正規のNodeとして識別できればそのまま残し、それ以外をDOMStringへ変換する。ここで公開の&lt;code&gt;String()&lt;/code&gt;をそのまま使うだけだと、Symbolを文字列にできてしまう。DOMStringへの変換ではSymbolにTypeErrorを投げるため、&lt;code&gt;toDOMString&lt;/code&gt;でその条件を明示している。&lt;/p&gt;
&lt;p&gt;変換のタイミングも意味がある。引数のオブジェクトには&lt;code&gt;toString()&lt;/code&gt;があり、その中で任意のJavaScriptを実行できる。一つ目の引数Nodeを先に元の親から外し、二つ目の文字列変換が失敗すると、呼び出しは例外になったのに最初のNodeだけ動いた状態になる。今回の実装は、文字列への変換を先に一通り終えてから、Nodeを組み立てる処理へ進む。&lt;/p&gt;
&lt;p&gt;親を持たない要素のbefore、after、replaceWithでも、挿入しないだけでDOMString変換は行う。detachedのテストはtoStringが一回呼ばれ、SymbolでTypeErrorになることを三つのAPIで確認する。 &lt;a href="https://github.com/ieee0824/omoikane/blob/994f17ccf01d56b3bcf14e1840e065808dc7e625/tests/p1_dom_modern.rs#L156"&gt;detachedのテスト&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;変換済みの引数をNodeへまとめるのは&lt;code&gt;mutationArgumentsNode&lt;/code&gt;だ。文字列を呼び出し先のDocumentでTextへ変換し、一つだけならそのNodeを返す。複数ならDocumentFragmentを作って順に入れる。ここで同じNodeを二度渡しても、同じNodeが二個の兄弟になれるわけではない。後の挿入で前の位置から動くため、最後の位置に一つだけ残る。&lt;/p&gt;
&lt;p&gt;この点を&lt;a href="https://github.com/ieee0824/omoikane/blob/994f17ccf01d56b3bcf14e1840e065808dc7e625/tests/p1_dom_modern.rs#L137"&gt;兄弟操作のテスト&lt;/a&gt;は&lt;code&gt;a.replaceWith(a, 'replacement', a)&lt;/code&gt;で確認している。同じaをコピーして増やすのではなく、&lt;code&gt;replacement&lt;/code&gt;のTextと、元のaのidentityを持つNodeが並ぶ。HTML文字列に直してからparseし直す実装では、このidentityは保てない。&lt;/p&gt;
&lt;h3 id="beforeとafterは参照兄弟を途中で失う"&gt;beforeとafterは、参照兄弟を途中で失う&lt;/h3&gt;
&lt;p&gt;&lt;code&gt;before()&lt;/code&gt;は自分の直前、&lt;code&gt;after()&lt;/code&gt;は自分の直後へ挿入する。説明だけなら一行で済むが、引数に自分や隣の兄弟が入ると、その「直前」「直後」が動く。説明用に、親の子がa、b、cの順に並ぶ木を考える。&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"&gt;&lt;code class="language-javascript" data-lang="javascript"&gt;&lt;span style="display:flex;"&gt;&lt;span&gt;&lt;span style="color:#a6e22e"&gt;b&lt;/span&gt;.&lt;span style="color:#a6e22e"&gt;before&lt;/span&gt;(&lt;span style="color:#a6e22e"&gt;a&lt;/span&gt;, &lt;span style="color:#a6e22e"&gt;b&lt;/span&gt;, &lt;span style="color:#e6db74"&gt;&amp;#39;x&amp;#39;&lt;/span&gt;);
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;複数引数をFragmentへまとめる間に、aもbも元の親から外れる。元のbを参照Nodeにしたまま最後に&lt;code&gt;insertBefore&lt;/code&gt;すれば、もう親の子でなくなっているので失敗する。呼び出しが指定した位置を、引数をまとめる前の木から決めておく必要がある。&lt;/p&gt;
&lt;p&gt;&lt;a href="https://github.com/ieee0824/omoikane/blob/994f17ccf01d56b3bcf14e1840e065808dc7e625/src/js/dom_bootstrap.js#L4783"&gt;&lt;code&gt;mutateSibling&lt;/code&gt;&lt;/a&gt;は、変換済み引数に含まれるNodeのidを集める。&lt;code&gt;before&lt;/code&gt;なら直前の兄弟から前へ、&lt;code&gt;after&lt;/code&gt;や&lt;code&gt;replaceWith&lt;/code&gt;なら直後の兄弟から後ろへ進み、引数に含まれない兄弟を探す。その兄弟は引数のFragment化で動かないので、挿入位置の目印として使える。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;before&lt;/code&gt;の場合は、残る直前の兄弟の次を挿入先とする。残る兄弟がなければ親の先頭に入れる。&lt;code&gt;after&lt;/code&gt;の場合は、残る直後の兄弟をそのまま参照先とする。参照Node自身が挿入するNodeなら、その次の兄弟へずらす。これがないと、自分の前へ自分を入れる処理の扱いが崩れる。&lt;/p&gt;</description></item></channel></rss>