Skip to content

WebApi: Navigator and Scheduler get a real interface object and prototype - #3275

Merged
lahma merged 1 commit into
sebastienros:mainfrom
lahma:work/3270
Aug 23, 2026
Merged

lahma merged 1 commit into
sebastienros:mainfrom
lahma:work/3270

Conversation

@lahma

@lahma lahma commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator

Closes #3270. Navigator and Scheduler get the interface object and interface prototype object WebIDL gives them, the two singletons' members move onto those prototypes, and the three exemptions #3272 had to carve out disappear — WebIdlPropertyAttributeTests.Divergences is now [].

The shape, per interface

Both take the usual triple: interface object → [JsObject(UseShape = true)] : Prototype → a plain ObjectInstance singleton with no own properties at all.

  • Navigator — NavigatorConstructor → NavigatorPrototype (constructor, @@toStringTag = "Navigator", userAgent as Configurable | Enumerable) → JsNavigator, which carries no state and exists to be the brand.
  • Scheduler — SchedulerConstructor → SchedulerPrototype (constructor, @@toStringTag, postTask/yield as ConfigurableEnumerableWritable) → JsScheduler, which holds the SchedulerQueue and TimerQueue. That is the split WebIDL draws and the one JsPerformance already uses: the members are the interface's, the queues are the instance's, and each member brand-checks its receiver and then operates on it — so an extracted postTask behaves exactly as a browser's does.

Neither is constructible. HTML's [Exposed=Window] interface Navigator { } and the draft's [Exposed=(Window,Worker)] interface Scheduler { … } declare no constructor operation, so per WebIDL §3.7.1 each is a function that throws — TypeError: Illegal constructor, which is Node 24's exact message. TaskController and TaskPriorityChangeEvent in the same draft do declare one, which is why they stay constructible and this does not.

Why they are globals despite [Exposed=Window]. That question was settled when the singletons were installed: navigator carries the same [Exposed=Window] declaration and is a Jint global anyway, because WinterTC asks a non-browser runtime for globalThis.navigator.userAgent. Having decided the instance belongs, withholding the interface object leaves navigator instanceof Navigator unwritable while the object it names sits one link up the chain — the half-truth #3250 exists to remove. Node 24's global is not a Window either and exposes both. For Scheduler the draft status was already priced in: the feature installs three interface objects today, and withholding the fourth would make this the one API whose singleton cannot answer scheduler instanceof Scheduler while its own controller and signal can. The interface object has to exist regardless — Scheduler.prototype.constructor is it — so a global only settles reachability by name, and Chrome, the only implementation, exposes it.

Both installs follow #3250: lazy non-clobbering LazyPropertyDescriptor<Engine> on the principal realm's global, interface-object attributes {w:true, e:false, c:true}, never in a ShadowRealm, behind the singleton's own feature flag.

Against Node 24, run live: 16 of 18 identical

typeof Navigator, name/length, Reflect.ownKeys(navigator) → [], Object.keys(navigator) → [], both prototype links, instanceof, no own Symbol.hasInstance, constructor === Navigator, userAgent an accessor with get/no-set/enumerable:true/configurable:true on Navigator.prototype, new Navigator() → TypeError: Illegal constructor, constructor {w:t,e:f,c:t}, prototype {w:f,e:f,c:f}, containment.

Two differ, both deliberate:

Jint Node 24 why
Object.keys(Navigator.prototype) ["userAgent"] adds hardwareConcurrency, locks, language, languages, platform absent-rather-than-faked, unchanged policy — now re-pinned against the prototype rather than the instance
Object.prototype.toString.call(navigator) [object Navigator] [object Object] Node's Navigator is an ordinary JS class with no @@toStringTag; WebIDL's class-string rule and a browser both say [object Navigator], and Jint's pre-existing answer is preserved by moving the tag to the prototype

Node has no scheduler at all, so there the oracle is the WICG IDL plus Chrome's shape.

The containment hazard #3266 identified is closed

Before, both singletons were BuiltinShapeObjects whose [[Prototype]] was Object.prototype, so patching "the scheduler's prototype" patched Object.prototype. Same script, before → after:

before after
({}).poisonedByNavigator 42 undefined
'poisonedByNavigator' in {} true false
[].poisonedByScheduler 43 undefined
Object.prototype own poisonedBy* true false
getPrototypeOf(navigator) === Object.prototype true false

Pinned by PatchingNavigatorsPrototypeDoesNotReachObjectPrototype, the scheduler twin, and GivesEachEngineItsOwnNavigatorPrototype (per-realm).

Cost: +16 B on a default engine

GC.GetAllocatedBytesForCurrentThread over 2,000 constructions, bit-identical on every repeat:

before after Δ
new Engine() 9,256 B 9,272 B +16 B — two reference fields on Intrinsics
new Engine(o => o.UseWebApis()) 24,280 B 24,392 B +112 B (+0.46%)
UseWebApis(Navigator) 11,176 B 11,288 B +112 B
UseWebApis() + read navigator.userAgent 27,440 B 27,912 B +472 B

Nothing materializes until a script names one: CostsNothingUntilItIsRead covers both new names (descriptor still CustomJsValue, _value == null), and NavigatorTests.IsAnEnumerableDataPropertyOfTheGlobal was strengthened to assert the same of the singleton's own descriptor. The +472 B is what a script that actually reads a member pays for a prototype it did not have before.

Verified failing first

Tests written and run against unfixed code: 20 failed / 129 passed, across NavigatorTests ×5, SchedulerTests ×4, InterfaceObjectExposureTests ×9, WebIdlPropertyAttributeTests ×2. Representative:

navigator.userAgent is a WebIDL Attribute and should be
  { writable: n/a, enumerable: true, configurable: true }, but is { … enumerable: false … }
Reflect.ownKeys(navigator).length … to be 0.0 because navigator, but found 2.0
typeof Navigator — actual "undefined", expected "function"

After: 149 / 149.

Suites

Release solution build clean on all five TFMs (1 warning = the pre-existing MSB3277 in Jint.Tests.CommonScripts/net472, identical to baseline).

suite net10.0 net472
Jint.Tests 10,532 / 4 skipped / 0 failed 7,256 / 4 / 0
Jint.Tests verifying 10,532 / 4 / 0 7,256 / 4 / 0
Jint.Tests.PublicInterface 2,503 / 9 / 0 1,909 / 9 / 0
Jint.Tests.PublicInterface verifying 2,507 / 5 / 0 1,913 / 5 / 0
Jint.Tests.CommonScripts 28 / 0 / 0 28 / 0 / 0
Jint.Tests.SourceGenerators 54 / 0 / 0 —
Jint.Tests.Test262 102,495 passed / 0 failed —

WPT 422 / 422 both sides, no row moved — and "before" was measured rather than assumed, from a git archive export of the base commit built in a scratch directory. No row could have moved silently: the exclusion table refuses an entry that has started passing and the _minimumTests floors refuse a file that registered fewer, so a green run on both sides is itself the assertion. The corpus census in Vendor/README.md is unchanged. The only vendored files naming Navigator are the two workers/WorkerNavigator* suites, both still excluded under NeedsDeclinedWorkerGlobals.

The WinterTC conformance tables in README.md did not move, checked rather than assumed: §5.1's interface list — transcribed verbatim in WinterTcMinimumCommonApiTests.CommonInterfaces, the pin behind the table — names neither Navigator nor Scheduler, and the only navigator row is §5.2's navigator.userAgent, whose provider is unchanged. Both new names went into the separate per-flag table an embedder actually reads.

Deliberately left undone

  • No WorkerNavigator. A worker still gets its parent's navigator; divergences 6 and 7 in Design: Web Workers via a host-supplied worker provider #3167's ledger and their two WPT exclusions stand.
  • The [Replaceable] simplification stays — navigator and scheduler remain ordinary enumerable data properties of the global rather than the accessor pair, the same simplification console, crypto, performance and self carry. Node's navigator descriptor is an accessor; that is the one remaining shape difference.
  • Scheduler.prototype's brand check is not in APrototypeMemberBrandChecksItsReceiver, because a promise-returning operation rejects rather than throws. The pre-existing SchedulerTests.NeitherOperationEverThrows covers that half, and the omission is documented on the test.

🤖 Generated with Claude Code

…type (sebastienros#3270)

Both were interface *instances* built without either, so their members were
own properties of the singleton — the last two [JsObject] hosts under
Jint/WebApi/ that were neither an interface prototype object, an interface
object, nor a WebIDL namespace. Three consequences closed at once.

The WebIDL attribute exemption. sebastienros#3239 had to leave navigator.userAgent,
scheduler.postTask and scheduler.yield with an ECMAScript built-in's
attributes, because WebIDL's enumerability rule assumes the member sits on
the interface prototype object, where the enumerability is invisible from
the instance. Flipping the flags where they were would have made
Object.keys(navigator) answer ["userAgent"], which no implementation does.
Moving the members is what fixes it, so the three entries in
WebIdlPropertyAttributeTests.Divergences are deleted and that list is now
empty; EveryRecordedDivergenceIsReal is what would have caught a stale one.

The containment hazard sebastienros#3266 identified for these two and sebastienros#3257 left open.
Measured before the change, in the REPL:

  navigator.__proto__.poisoned = 42   ({}).poisoned   42     Object.prototype own property: true
  scheduler.__proto__.poisoned = 43   ({}).poisoned   43     Object.prototype own property: true

and after: undefined and false for both, with the value still reachable
through the singleton, which is what a patching library is after.

The own-key lists. Reflect.ownKeys(navigator) was ["userAgent",
Symbol(Symbol.toStringTag)]; it is [] now, as it is in Node 24 and a
browser. Same for scheduler.

Node 24 is the oracle for Navigator and agrees line for line — typeof
Navigator "function", name/length Navigator/0, the prototype chain, the
constructor property's attributes, userAgent an enumerable accessor with no
setter on Navigator.prototype, and "TypeError: Illegal constructor" from
new Navigator(). Two lines differ, both deliberately: Jint's
Navigator.prototype carries only userAgent, everything else being absent
rather than faked; and Object.prototype.toString.call(navigator) stays
[object Navigator] as WebIDL's class-string rule and a browser require,
where Node answers [object Object] because its Navigator is an ordinary
class. Node has no scheduler at all, so for that one the oracle is the WICG
draft's IDL plus Chrome.

Constructibility, checked per spec rather than assumed. Navigator is
[Exposed=Window] and declares no constructor operation
(https://html.spec.whatwg.org/multipage/system-state.html#the-navigator-object);
Scheduler is [Exposed=(Window, Worker)] and declares none either
(https://wicg.github.io/scheduling-apis/#sec-scheduler), unlike
TaskController and TaskPriorityChangeEvent beside it. Neither constructs.

Both interface objects are exposed as globals, following sebastienros#3250's pattern
exactly: a lazy, non-clobbering descriptor on the principal realm's global
object, WebIDL's interface-object attributes, never in a ShadowRealm.
Navigator's [Exposed=Window] is not a reason to withhold it — the instance
carries the same declaration and is a Jint global anyway because WinterTC
asks for globalThis.navigator.userAgent, and Node's global is not a Window
either yet exposes both. Scheduler joins the three interface objects its own
feature already installs.

Cost, measured with GC.GetAllocatedBytesForCurrentThread over 2,000
constructions, bit-identical on every repeat:

  new Engine()                                 9,256 ->  9,272 B   +16
  new Engine(o => o.UseWebApis())             24,280 -> 24,392 B  +112
  UseWebApis() + Execute("1+1")               26,464 -> 26,576 B  +112
  UseWebApis(Navigator)                       11,176 -> 11,288 B  +112
  UseWebApis(Scheduler)                       12,032 -> 12,144 B  +112
  UseWebApis() + read navigator.userAgent     27,440 -> 27,912 B  +472
  UseWebApis() + read scheduler.postTask      27,584 -> 28,056 B  +472

The +16 B on a default engine is two reference fields on Intrinsics. The
+112 B is the two extra lazy global descriptors and the storage they land
in; nothing materializes until a script names one, which
InterfaceObjectExposureTests.CostsNothingUntilItIsRead now pins for both
names and NavigatorTests pins for the singleton's own descriptor. The +472 B
is what a script that actually reads a member pays for the interface object
and prototype it did not have before.

WPT: 422/422 before, 422/422 after, corpus untouched. No row moved, and none
could have moved silently — the exclusion table refuses an entry that has
started passing, so a green run is the assertion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FHnbgQGW5QgfatxL6DgGEd
@lahma
lahma merged commit 8de2541 into sebastienros:main Aug 23, 2026
5 checks passed
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.

Navigator and Scheduler have no interface object, which is why two of their members cannot carry WebIDL's attributes

1 participant