Skip to content

WebApi: every operation carries WebIDL's property attributes, not ECMA-262's - #3272

Merged
lahma merged 5 commits into
sebastienros:mainfrom
lahma:feat/3239-webidl-attributes
Aug 23, 2026
Merged

lahma merged 5 commits into
sebastienros:mainfrom
lahma:feat/3239-webidl-attributes

Conversation

@lahma

@lahma lahma commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

Closes #3239, on the terms settled in its adjudication comment: a deliberate per-declaration sweep with an explicit decision for the two singletons, not a blanket generator default.

WebIDL §3.7.6 gives a regular operation { writable, enumerable, configurable }. Jint's web-API operations carried ECMA-262's rule for a built-in function instead — not by choice, but because JsFunctionAttribute had no Flags property until #3238.

The census was wrong twice, and this is the corrected one

The issue said 188 operations of 193. grep counted three hits inside doc comments: there are 190 real declarations, so the sweep is 183.

member kind total already right changed exempt
Operation ([JsFunction]) 190 5 183 2 (scheduler)
Attribute ([JsAccessor]) 187 184 2 1 (navigator.userAgent)
Constant, constructor, @@toStringTag, @@iterator 207 207 0 —

185 members across 46 files. Largest movers: console 18, SubtleCrypto.prototype 12, URLSearchParams.prototype 12, Headers/FormData 10 each. Static operations moved too — Response.error/json/redirect, AbortSignal.abort/any/timeout, URL.canParse/parse, ReadableStream.from.

Node 24 agrees member for member wherever it implements the interface. One deviation reported honestly rather than glossed: Object.keys(AbortSignal) is [] in Node (hand-written internals; its WebIDL-generated bindings do follow the rule), and Node omits Event.prototype's constants entirely.

scheduler and navigator keep ECMAScript's attributes

Both are interface instances with no interface object and no interface prototype object, so their members are own properties of the singleton. WebIDL's rule presumes the member sits on the prototype, where it is invisible from the instance.

Node is decisive: Object.keys(navigator) → [], Reflect.ownKeys(navigator) → [], and userAgent is an enumerable accessor on Navigator.prototype. Flipping Jint's would answer ["userAgent"] and ["postTask","yield"] — strictly less browser-accurate. console is the counter-example proving the rule: a WebIDL namespace, whose members genuinely are own properties, so its 18 operations did flip.

The reasoning now lives in the two classes' doc comments, which also lose a stale claim — that they carry "the same pair console, crypto and performance carry", false since #3250 and #3266. The real fix is filed as #3270, which is also what would close the __proto__ containment hazard #3266 identified for these two.

The structural test is the artefact that keeps this true

WebIdlPropertyAttributeTests builds an engine with every WebApiFeatures flag plus a refusing WorkerProvider, diffs the global against a bare engine's, and walks by CLR namespace — 131 objects, 816 members — classifying each by WebIDL member kind and holding it to that kind's attributes. Four facts:

  • the sweep itself;
  • every generated host is reached — found by reflecting for CreateProperties_Generated, so the list cannot go stale; 79 of 81, with the two worker global scopes recorded as unreachable and re-checked in both directions; plus a checked > 500 non-vacuity floor;
  • every recorded divergence is real — the WPT exclusion-table discipline, so a later fix cannot leave a stale exemption behind;
  • the two singletons keep empty own keys — the property the divergences rest on.

Adding a web-API member needs no edit here. The class doc is a table of kind → attributes → declaration spelling with a spec anchor each. Only an iterable declaration (4 seeds today) or a deliberate divergence (3 today) ever adds a line.

Verified against unfixed code: 185 failures, exactly the 183 + 2 that changed.

Nine existing tests updated, not the seven the census predicted

Seven in Jint.Tests — CryptoTests, SubtleCryptoTests ×2, SubtleCryptoAesWrapTests, PerformanceTests, EncodingTests, StorageTests (whose for...in now yields one,two,clear,getItem,key,removeItem,setItem,length, the same six prototype names Node sees).

And two the census missed, both in Jint.Tests.PublicInterface: WebApiSubtleCryptoTests.IsAReadOnlyNonEnumerableAttribute, and WebApiEventTests.GivesEveryMemberTheAttributesWebIdlAsksFor — which had pinned the old answer with "Pinned so that changing it is a deliberate act." This is that deliberate act.

Twenty host-class doc paragraphs claiming "one documented simplification: the operations are non-enumerable" are removed, along with the matching clause in the WPT triage record.

Cost: 0 B, bit-identical

1000 iterations after 200 warmup, three rounds each side, identical to the decimal:

before after
new Engine() 9,256.0 B 9,256.0 B
new Engine(o => o.UseWebApis()) 24,280.0 B 24,280.0 B
…+ 15 web-API property tables materialized 60,912.0 B 60,912.0 B

The mechanism was re-verified rather than taken on trust: HasNoEnumerableOwnStringKeys() is virtual → false, overridden only by ArrayInstance, JsObject and ObjectPrototype, so FixedSlotsAllNonEnumerable is unreachable for every Jint/WebApi/ type, and its only consumer already answered false for these.

Verification

Zero files under Jint/Native/ are touched — and test262 proves it: 102,495 passed / 0 failed on both this branch and an upstream/main baseline on the same machine. WPT 422/422 both ways, not one row moved, exclusion table untouched.

Solution builds Release across all TFMs including net462/netstandard2.x. Jint.Tests 10,514 (net10.0) / 7,256 (net472); Jint.Tests.PublicInterface 2,493 / 1,899; both repeated with JINT_HOST_CONTRACT_VERIFICATION=1.

One caveat reported rather than smoothed: of five test262 runs, four were clean and one intermediate run reported 2 failures whose identity was not captured, with the two runs immediately after clean. Two other agents were running. The change cannot reach test262 — it enables no web APIs and Jint/WebApi/ is unreferenced there — so this is the known load flake, but it is an observation and not nothing.

Two follow-ups filed

JsFunctionAttribute's doc was deliberately not expanded — it lives in a source-generator raw string reproduced in 54 Verify snapshots, so a 17-line addition would have been ~1,200 lines of duplicated comment churn dominating the diff. The guidance went to AGENTS.md as a fourth Web APIs convention instead.

🤖 Generated with Claude Code

lahma and others added 5 commits August 23, 2026 09:50
…A-262's

WebIDL gives a regular operation `{ [[Writable]]: true, [[Enumerable]]: true,
[[Configurable]]: true }` (https://webidl.spec.whatwg.org/#es-operations), and
the same "define the operations" steps cover a static operation, a stringifier,
a `toJSON`, an iterable declaration's `entries`/`keys`/`values`/`forEach`, and a
namespace object's members. Enumerable is the half that matters: it is the
opposite of ECMA-262's rule for a built-in function property, which is what the
source generator defaults to because every host under `Jint/Native/` wants it.

183 `[JsFunction]` declarations across 46 files now opt in. Five already did —
the `next`/`return` of the four iterator prototype objects, fixed by sebastienros#3238 and
sebastienros#3247 when a corpus row happened to assert them — so 188 of 188 operations are
now right. Two `[JsAccessor]` declarations join them, `Crypto.prototype.subtle`
and `Performance.prototype.timeOrigin`, the last two attributes in the subtree
that were not already `Configurable | Enumerable`.

This removes an incoherence rather than creating one. The attribute and constant
rules were already implemented deliberately — 184 of 187 accessors and all 72
constants were correct — so `Object.keys(Response.prototype)` answered 9 while
`getOwnPropertyNames` answered 17, and a `for...in` over a `Response` yielded
nine attribute names and not one operation. Node 24 is the oracle throughout and
agrees member for member: `SubtleCrypto.prototype.digest`,
`TextDecoder.prototype.decode`, `Performance.prototype.now`, `Response.json`,
`URL.canParse`, `ReadableStream.from` and `Storage.prototype`'s six are all
enumerable there.

`navigator` and `scheduler` are deliberately untouched; the next commit says why.
`Jint/Native/` is untouched, and test262 is unmoved at 102,495 passed / 0 failed.

Cost is zero, measured rather than assumed: `new Engine()` allocates 9,256.0 B,
`new Engine(o => o.UseWebApis())` 24,280.0 B, and the same engine with fifteen
web-API objects' property tables materialized 60,912.0 B — bit-identical before
and after, three rounds each. The flags are one enum argument on the same call in
both storage paths, and the one enumerability-gated fast path
(`BuiltinShape.FixedSlotsAllNonEnumerable` via `HasNoEnumerableOwnStringKeysCore`)
is unreachable here: `HasNoEnumerableOwnStringKeys` is overridden only by
`ArrayInstance`, `JsObject` and `ObjectPrototype`.

The twenty "one documented simplification against WebIDL: the operations are
non-enumerable" paragraphs those host files carried are removed, because they no
longer describe anything. What survives is the part of each that is still true —
`console`'s and `crypto`'s and `performance`'s data-property install where WebIDL
asks for a `[Replaceable]` accessor pair, and `Storage.prototype.length` being a
real accessor pair.

Closes sebastienros#3239.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHnbgQGW5QgfatxL6DgGEd
… reason is in the code

The one part of the sweep that must not be automated. `NavigatorInstance` and
`SchedulerInstance` have no interface object and no interface prototype object,
so `userAgent`, `postTask` and `yield` are own properties of the singleton rather
than of a prototype. WebIDL's enumerability rule assumes the member is where the
specification puts it, and there the enumerability is invisible from the
instance: Node 24 reports `userAgent` as an enumerable accessor on
`Navigator.prototype` and `Object.keys(navigator)` as `[]`, because the instance
has no own properties at all; a browser answers `Object.keys(scheduler)` the same
way for the same reason.

So flipping these three would make `Object.keys(navigator)` report
`["userAgent"]` and `Object.keys(scheduler)` report `["postTask", "yield"]`,
which no implementation does. Non-enumerable here is what reproduces the browser
answer, and what fixes them properly is the interface object — as sebastienros#3250 did for
`Crypto`, `SubtleCrypto` and `Performance` — not a flag. That is a separate
change, and this is exactly why the sweep is a reviewed per-declaration opt-in
rather than a namespace-scoped generator default: a blanket flip would move these
two objects further from a browser rather than closer.

`console` is the counter-example that proves the rule rather than a precedent
against it: it is a WebIDL namespace, whose members genuinely are own properties
of the namespace object, so its eighteen operations are enumerable here exactly
as they are in Node, where `Object.keys(console).length` is 25.

Both exemptions are recorded in `WebIdlPropertyAttributeTests.Divergences`, which
fails on an entry that has stopped diverging — so whoever gives these two an
interface object has to delete the entry rather than inherit a stale exemption.

The stale "the same pair console, crypto and performance carry" sentence both
files opened with goes too: console got a private prototype in sebastienros#3266, and crypto
and performance got real interface objects in sebastienros#3250.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHnbgQGW5QgfatxL6DgGEd
The artefact that keeps the sweep true. `WebIdlPropertyAttributeTests` builds an
engine with every web-API feature on, walks every object reachable from the
principal realm's global — 131 objects, 816 members — classifies each member by
the WebIDL member kind it is, and holds it to that kind's property attributes.
Nothing is enumerated by hand, so a member added tomorrow is checked tomorrow;
against the previous commits reverted it reports exactly the 185 members that
changed, 183 operations and 2 attributes.

Four assertions, three of which exist to stop the matrix rotting:

* the sweep itself;
* every `[JsObject]` host under `Jint/WebApi/` must be visited — found by
  reflecting for the method the generator emits on one, so the list cannot go
  stale, with the two worker global scopes recorded as out of reach from the
  principal realm and re-checked in both directions;
* every recorded divergence must still diverge, the WPT exclusion table's
  discipline: a fix cannot leave an exemption behind;
* `Object.keys(navigator)` and `Object.keys(scheduler)` are `[]` while
  `Object.keys(console)` is not — the property the two divergences rest on,
  stated where a reader will find it.

The class doc is the table to read when adding a member: WebIDL member kind,
attributes, and the declaration that produces them, with the spec anchor for
each. A green matrix is not a conformance certificate and says so — it checks
that a member carries its kind's attributes, never that the member is on the
right object, which is the very distinction the two divergences turn on.

Nine existing pins move to the browser-correct expectation. Six asserted an
operation or an attribute was non-enumerable and now assert the opposite —
`CryptoTests.GivesItsMembersTheAttributesWebIdlGivesAnOperation`,
`SubtleCryptoTests.IsAReadOnlyEnumerableAttributeOnTheCryptoPrototype`,
`SubtleCryptoTests.ExposesAllTwelveOperations`,
`SubtleCryptoAesWrapTests.ExposesAllTwelveOperationsWithTheirIdlNameAndArity`,
`PerformanceTests.ExposesTimeOriginAsAReadOnlyAccessor`,
`EncodingTests.ExposesAttributesAndOperationsWithTheirWebIdlPropertyAttributes` —
three of them renamed, because the old names asserted the old answer. Each keeps
its `Object.keys(instance)` is `[]` half, which is what shows the enumerability
lands on the prototype and not on the object a script holds.

`StorageTests.ForInVisitsTheStoredKeysAndTheEnumerableAttribute` now sees the
five operations beside `length`, which is what Node 24 sees; the two visit the
same six names in a different order, because Node builds its prototype in IDL
declaration order where Jint's generator sorts a host's members by JS name.

In `Jint.Tests.PublicInterface`, which has no `InternalsVisibleTo` and therefore
proves the surface from a third party's side,
`WebApiEventTests.GivesEveryMemberTheAttributesWebIdlAsksFor` had pinned the old
answer with "changing it is a deliberate act". This is that act — and it gains
the `Object.keys(EventTarget.prototype)` reading plus the `constructor`
back-reference, which stays non-enumerable and is why that list is three names
and not four.

One comment correction rides along: `ReadableStreamAsyncIterationTests` said
`ReadableStream.prototype[@@asynciterator]` is non-enumerable "because it is an
interface member, and those stay non-enumerable". Interface members no longer
stay non-enumerable; that symbol is non-enumerable because WebIDL's iterable
declarations say so, which is now the whole of the reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHnbgQGW5QgfatxL6DgGEd
…stienros#3195 triage record

The record explaining why the async-iterator corpus row moved ended by noting
that `ReadableStream.prototype[@@asynciterator]` was already right, "non-enumerable
because it is an interface member rather than an iterator-prototype method". The
first half is still true and the reason is not: interface members are enumerable
now, and that symbol is non-enumerable because WebIDL's iterable declarations give
it `{ [[Writable]]: true, [[Enumerable]]: false, [[Configurable]]: true }`.

The corpus itself is untouched and unmoved — 422/422 before and after the sweep,
no row in either direction, so no exclusion-table entry changes either.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHnbgQGW5QgfatxL6DgGEd
…ntion

The subtree already had three conventions written down — the whole-file
`#if NET8_0_OR_GREATER`, the install from `Options.Apply` as lazy globals, and the
principal realm only. The fourth is now true of every member and worth stating
where the next web-API PR will read it: WebIDL's attributes rather than
ECMA-262's, the four flag spellings that produce them, the matrix test that
enforces them, and — the part that is not mechanical — why this is deliberately
not a namespace-scoped generator default, with `navigator` and `scheduler` named
as the two objects a blanket flip would get wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHnbgQGW5QgfatxL6DgGEd
@lahma
lahma merged commit c52ea79 into sebastienros:main Aug 23, 2026
5 checks passed
@lahma
lahma deleted the feat/3239-webidl-attributes branch August 23, 2026 07:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Every web-API operation is non-enumerable, where WebIDL makes regular operations enumerable

1 participant