⚒️

Stage 3 Iterator Helpers におけるハッキーな Break the Web 解決策

2023/12/08に公開

Stage 3 Iterator Helpers による Break the Web

2023年12月現在 Stage 3 Iterator Helpers という提案が進行中です。

https://zenn.dev/pixiv/articles/062461b79e0d8f

さて、この Iterator Helpers で残念なことに Break the Web が起きてしまいました。その原因が複雑で、なおかつその解決策もハッキーで面白いものだったため解説しようと思います。

https://github.com/tc39/proposal-iterator-helpers/issues/286

Break the Web とは

JavaScript の言語仕様である ECMAScript の提案プロセスにはステージがあります。仕様書が完成し Stage 3 になると JavaScript エンジンが実装し始め、各ブラウザベータ版に搭載されます。そこで特に問題なければ言語仕様に入りますが、いくつかのサイトが正しく動作しない場合 Break the Web として提案が差し戻されることがあります。

例えば2023年11月に Stage 4 となり ES2024 の仕様となった Array Grouping の提案は元々 Array.prototype.group メソッドを追加する提案でしたが、Break the Web を引き起こしたため Object.groupByMap.groupBy のスタティックメソッドに改められました。

https://docs.google.com/presentation/d/1f11_k371JdUG1NdNbaW-qKHRFtlX8v64fM1qt41VNio/edit#slide=id.gc6f73a04f_0_0

一方で影響が小さい場合は無視されることもあります。ES2019 Array.prototype.flat では highchart.js のラベルが正常に表示されない問題が報告されましたが、大きい問題ではないとされそのまま進められました。

https://bugs.chromium.org/p/chromium/issues/detail?id=888128

Override Mistake と呼ばれる仕様バグ

そもそも ECMAScript には Override Mistake と呼ばれる仕様バグが存在します。これはオブジェクトの [[Prototype]] が上書き不可能なデータプロパティを持つとき、それと同じキーを持つプロパティを代入演算子で定義しようとする際に発生します。

// オブジェクト prototype を作成
const prototype = {};

// オブジェクト prototype に上書き不可能な foo プロパティを定義する
Object.defineProperty(prototype, "foo", {
  value: "bar",
  writable: false,
  configurable: true,
  enumerable: true,
});

// prototype を [[Prototype]] にもつオブジェクト obj を作成
const obj = Object.create(prototype);

// obj に代入演算子を作って foo プロパティの定義を試みると例外発生
obj.foo = "baz"; // => throws TypeError

// Object.defineProperty を使えば例外は発生しない
Object.defineProperty(obj, "foo", {
  value: "baz",
  writable: true,
  configurable: true,
  enumerable: true,
});

// 既に定義されているプロパティを変更する際には例外は発生しない
obj.foo = "fuga";

この仕様バグは一見問題にならなそうな軽微なものに見えます。しかし例えばプロトタイプ汚染を防止するために Object.prototype をフリーズした際に Object.prototype が元々持つデータプロパティが上書き不可能となり顕在化します。

Object.freeze(Object.prototype);

const obj = {};
obj.constructor = function () {}; // throws TypeError

Normative Change として修正を試みられましたが、残念ながらその修正自体に Web 互換性の問題があるということで却下されています。

https://github.com/tc39/ecma262/pull/1307

https://github.com/tc39/ecma262/pull/1320

この Override Mistake は Stage 1 Secure ECMAScript という提案でも言及されています。

https://github.com/tc39/proposal-ses#override-mistake

Iterator Helpers が %IteratorPrototype% にプロパティを追加

Array.prototype.values などのメソッドや Generator Functions からイテレーターを作った場合、そのオブジェクトは %IteratorPrototype% というオブジェクトを継承します。

https://tc39.es/ecma262/multipage/control-abstraction-objects.html#sec-%iteratorprototype%-object

// %ArrayIteratorPrototype%
const ArrayIteratorPrototype = Object.getPrototypeOf([1, 2, 3].values());

// %ArrayIteratorPrototype% の [[Prototype]] は %IteratorPrototype% になっている
const IteratorPrototype = Object.getPrototypeOf(ArrayIteratorPrototype);

この %IteratorPrototype% は現状 Symbol.iterator プロパティしか持っていませんが、Iterator Helpers の提案によって Iterator.prototype としてアクセス出来るようになり constructor(と Symbol.toStringTag)プロパティが定義されることになっています。

これによりユーザーが定義するクラスで Iterator クラスを継承できるようになり、mapfilter などのメソッドを扱えるようになります。

class MyIterator extends Iterator {
  next() {}
  return() {}
}

問題となったサイトの構成

問題となったサイトは airgap.js によってプロトタイプ汚染を避けるために %IteratorPrototype% をフリーズしていました。

これにより Iterator Helpers をブラウザベータ版で有効化した際に新たに定義された %IteratorPrototype%constructor プロパティが上書き不可能となります。

そして Babel などで Generator Functions をトランスパイルするためによく使われていた regenerator-runtime の古いバージョンが使われていました。そのバージョンでは %IteratorPrototype% がフリーズされることが考慮されておらず、Override Mistake の対応が入っていませんでした。

https://github.com/facebook/regenerator/pull/411

const Gp = GeneratorFunctionPrototype.prototype =
  Generator.prototype = Object.create(IteratorPrototype);

// constructor プロパティで Override Mistake が発生!!
GeneratorFunction.prototype = Gp.constructor = GeneratorFunctionPrototype;
GeneratorFunctionPrototype.constructor = GeneratorFunction;

これらの合わせ技によって Break the Web が起きてしまいました。

解決策

Iterator Helpers が %IteratorPrototype% に新たにデータプロパティとして constructor(と Symbol.toStringTag)を定義したことによって Override Mistake を引き起こしてしまいました。提案として constructor プロパティを定義しないわけにはいきませんが、フリーズされたときに上書き不可能にされてしまうと困ります。

ここでデータプロパティではなくアクセサプロパティとしてこの constructor を定義することを考えてみます。フリーズされてもアクセサプロパティは上書き不可能にはならないため例外は投げません。そしてセッターが実行されたときの挙動を制御することで他のデータプロパティと近い動作をさせることが出来ます。

……というわけで Normative Change が出されました。

https://github.com/tc39/proposal-iterator-helpers/pull/287

この Normative Change は2023年11月の TC39 meeting で承認され、Iterator Helpers の提案に取り込まれました。

締め

Web 互換性のためにかなりハッキーで面白い修正が取り込まれたので取り上げてみました。

私は個人的に Scrapbox で TC39 の動向を追っています。ECMAScript の提案を追うのは割と楽しいので皆さんもよかったらどうぞ。

https://scrapbox.io/petamoriken/

Discussion