Skip to content

Build DOM prototypes from a member layout the process shares - #132

Merged
FlorianRappl merged 3 commits into
AngleSharp:develfrom
lahma:jsobject-shape
Jul 29, 2026
Merged

Build DOM prototypes from a member layout the process shares#132
FlorianRappl merged 3 commits into
AngleSharp:develfrom
lahma:jsobject-shape

Conversation

@lahma

@lahma lahma commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

A DOM prototype was an ObjectInstance subclass of ours, and the engine refuses such an object as a prototype-method inline-cache holder by design: a host subclass keeps its own-property set outside the engine, so no engine-side version counter can witness that a name is still owned by it (jint#2823). Every warm member read on a node therefore had to re-walk to the prototype and re-resolve the member. That is the one cost the whole optimisation campaign never moved — el.tagName sat still through #117, #121 and #130.

Prototypes are now instantiated from a Jint JsObjectShape: an immutable member layout keyed by (type, library set) and held for the whole process, over which each engine gets an object of its own. The layout is engine storage, so the inline-cache carve-out admits it and the cache serves the warm read. Two things follow for free: reflecting over a type tree happens once per process instead of once per engine, and existence questions are answered off the layout without materialising the member they are about.

What moved

Idle machine, net8.0, Release, BenchmarkDotNet, both columns on released Jint 4.15.2. Access rows use --launchCount 5 so cross-process code layout lands in the StdDev rather than in the delta, and are quoted per read with the LoopOnly baseline subtracted; that baseline agreed to 1.3% between the columns.

warm access, per read devel this PR
el.tagName 150.7 ns 104.7 ns −30.5%
el.hasChildNodes() 158.7 ns 111.7 ns −29.6%
el.marker = k; n += el.marker 124.0 ns 101.9 ns −17.8%
attrs['title'] 158.0 ns 154.7 ns −2.1%
el.id = 's0' 193.5 ns 193.0 ns parity

list[0], 0 in list, list.length, el.hasOwnProperty(...) and el[Symbol.toStringTag] are unchanged within noise — each moves in both directions across repeated runs. Allocation is byte-identical on every row, verified per operation with GC.GetAllocatedBytesForCurrentThread over thirteen shapes of access rather than read off the benchmark's rounded column.

whole document per invocation devel this PR
bare scripted document 1.291 ms / 578.68 KB 244.8 µs / 213.54 KB 5.3× / 2.7×
document that builds and queries a DOM 3.993 ms / 1999.26 KB 566.5 µs / 510.00 KB 7.0× / 3.9×

The setup figures are the shared layout paying off where it should: the second document in a process no longer re-describes a type the first one already described. For scale, the bare-document row on the last commit before the Jint 4.14 bump read 96.48 ms / 33.47 MB — the 33 MB per scripted window that started this campaign. (Its scripted-document row is not comparable: list[0] threw EntryPointNotFoundException on that code, so the script aborted early. Two access rows are unmeasurable there for the same reason.)

Both tables measure the shapes commit alone. The two commits added afterwards for Jint 4.15.3 are not in them, and neither is claimed to move a row.

Why the Jint floor moves

On 4.15.1 the attrs['title'] row above was 7.5% slower, not 2% faster, and that was the one real cost of this change. WebIDL says an object with a named property getter consults it only for names the interface does not already define, so every named lookup on a DOM collection asks prototype.HasProperty(name) — and the answer is almost always "no", the deepest possible miss over the whole chain. Jint's probe lane could only answer a hit from the shared layout; on a miss it declined and fell through to GetOwnProperty, which repeated the key conversion and the same index lookup, once per chain level.

It was the chain walk and nothing else: a variant that looks up an absent name and builds no descriptor at all (attrs['nope']) showed the same +7.7% on its own.

That was a Jint-side gap, reported with the measurements and a profile as jint#2857, fixed in jint#2858 and released in 4.15.2 — which is why the floor moved there rather than staying at the API minimum.

It moves once more, to 4.15.3, and this time for an ordinary API reason: PropertyDescriptor.CreateLazy is used by the shipped assembly (below). That is the stronger of the two kinds of reason, so the perf argument above no longer has to carry the floor on its own.

I deliberately did not work around the 4.15.1 gap in the binding — the workaround would have been a second answer to "does this interface define this name", and the right one already exists.

What moved where

  • Process-shared, keyed by (type, library set) — the member layout, the indexers, and the event members. The library set is part of the key because loading AngleSharp.Css genuinely changes an element's member set; shapes are cheap to have several of.
  • On the prototype, as JsObjectShape host state — the engine and the constructor object. constructor is a per-realm slot resolved on first read, so naming a type no longer forces its prototype's members into existence, and reading constructor off an instance still lands on the object script reads off the window.
  • On the node — the event handler. DomEventInstance was per engine and doubled as the key a node filed its handler under; it is now a process-shared DomEventDefinition, so only the handler itself stays per node.
  • Derived from the receiver — every member implementation's engine. A shared delegate cannot close over one.

DomPrototypeInstance is gone.

The window, which is the exception

A window's members are the one set a script reaches without naming a receiver: they are copied onto the global object, and a bare alert('hi') calls with this undefined, so a shared implementation has nothing to derive an engine from. The window prototype's methods therefore keep a function per engine (a per-realm slot). Its accessors are shared — an accessor read always has a receiver, the global object itself for a bare document, and that reaches the shaped window prototype. btoa === window.btoa still holds, and there is a test for it.

Reproduced on purpose

Registration used to write members onto a half-built prototype and skip any name its chain already carried, which silently dropped a DOM member named after one of Object.prototype's. A builder has no object to probe, so the rule is reproduced from a declared-name set instead — and the names are read off the engine rather than copied into the source, so it stays tied to what Object.prototype actually carries.

As of AngleSharp 1.6 nothing collides, so the rule currently drops nothing. NoDomMemberCollidesWithAnObjectPrototypeName pins that: if AngleSharp ever names a member toString — the plausible one, since a browser really does put it on HTMLAnchorElement — it fails, and the choice between keeping the quirk and fixing it gets made rather than made by accident.

What Jint 4.15.3 added, and what it is used for here

Engine.Advanced.HasSharedShape — adopted, and it turns an observation into an assertion. The shaping claim above was only checkable through GetObjectRepresentation, which Jint documents as explicitly not part of its compatibility contract. HasSharedShape is: it answers "does this object share its property layout with its siblings", and a host may pin the answer. That matters here more than in most bindings, because instantiating from a member layout falls back to the ordinary per-object dictionary silently and correctly when it cannot shape an object — nothing about a document would look wrong if a DOM prototype stopped being shaped, only every warm member read on every node would get slow again, which is the whole point of this PR. PrototypesAreShapedAndTheProxiesAreNot asserts it for the element, collection and window prototypes, and asserts the opposite for the two proxies: a host object of ours shares its layout with nothing, which is exactly the refusal the prototypes had to be moved out of.

Host-contract verification — wired on, for every run. The verifiers are the checks that catch a host object answering one of the engine's extension points in a way that contradicts another. This binding answers four of them — TryGetOwnPropertyValue, ProbeOwnProperty, HasIndex, and the semantics derived from not overriding Get — and the engine trusts each one without ever re-checking it, so a wrong answer is silent: a member vanishes from every enumeration, or a read that should have found an attribute resolves on the prototype instead. They used to be compiled out of Release, so reaching them meant building Jint from source in Debug, which is a thing a suite does once and then quietly stops doing. Since 4.15.3 the shipped package reads the Jint.EnableHostContractVerification AppContext switch, so this suite now sets it from a [ModuleInitializer] — the only hook early enough, since the gate behind the switch is read once at type initialization. TheVerifiersAreRunningForThisSuite proves the ordering from behaviour rather than from Jint's internal gate: a host whose probe contradicts its own descriptors throws exactly when something is verifying. Every CI run is now a verified run, against the very package the library ships against.

PropertyDescriptor.CreateLazy — adopted, one level away from where it was looked for. Not for the prototype members: a shaped prototype already materialises each member on first touch, and Jint documents CreateLazy as the wrong tool for shaped storage — storing any raw descriptor under a string key moves such an object to the dictionary representation permanently. Where it does belong is the type-name globals. Every exported type is published as a property of the window and copied onto the global object, so HTMLDivElement, Image and the rest were all backed by one hand-written descriptor carrying CustomJsValue, because the constructor object behind a name is only built once script reads it. That flag is permanent on such a descriptor, and the global-identifier cache declines a descriptor that could still compute a different value next read — it has no way to learn that a custom value became a constant. CreateLazy is the same deferral written so the engine can see it end: the flag drops the moment the value exists, and the name rejoins the cache. Attributes and everything script can observe are unchanged, which DeferredConstructorTests covers in seventeen tests.

Engine.Advanced.AddLazyGlobal (post-construction) — declined. Every global here is known while the engine is still being built, and the globals are the window's own descriptors shared onto the global object, so one value resolves once for both; a post-construction install would be a second binding for the same name with nothing to gain.

Options.AddImmutableCrossing — declined. It memoises reads of a wrapped CLR object the host promises never changes, and every CLR object crossing this boundary is a live DOM node whose properties change under the script that is reading them.

Not touched

The proxies' own-property hooks from #130/#131TryGetOwnPropertyValue, ProbeOwnProperty, HasIndex and the ArrayLikeObject collection path — are unchanged. What did change on the proxies is where they read the indexers from, and that they now remember it instead of type-testing per lookup.

The nuspec floor moves to [4.15.3,5.0.0), for the API reason given above rather than only the measured one. The tests need 4.15.3 independently: HasSharedShape and a Release build that can verify host contracts are both 4.15.3.

Testing

243 tests green on net8.0, net462 and net472, in both Release (the CI configuration) and Debug — and, unlike the earlier runs of this branch, every one of those runs has the host-contract verifiers on, against released Jint 4.15.3 rather than against a Debug build of Jint from a local clone. That is now the default rather than a thing done once by hand.

New: shape sharing across two engines (same DomShape instance, distinct prototype objects, both answering HasSharedShape), the shaped-versus-host assertion described above, per-engine prototype isolation, patch-and-delete correctness across the shared layout's deopt, bare window members, the reproduced skip quirk, a GC test proving the process-held shapes retain no engine, and the verification pin.

Also UnreadConstructorStillExists, which pins the one member declared as a slot that produces its value on first read: its existence has to be answerable while it still has no value, and a wrong "missing" there is silent — it drops the property from in, hasOwnProperty and every enumeration with nothing failing. hasOwnProperty is the assertion that catches it (in finds Object.prototype's own constructor either way). It is load-bearing: it fails against a build of Jint that answers the unmaterialised slot as missing.

The parked chore/jint-4.15.1-semantics-pin branch is folded in. Its prototype assertion had to change: the prototype is no longer a type of ours, so it is pinned by the shared layout instead — which is the property actually carrying the win.

@FlorianRappl FlorianRappl added this to the v1.0 milestone Jul 28, 2026
A DOM prototype was an ObjectInstance subclass of ours, and the engine
refuses such an object as a prototype-method inline-cache holder by
design: a host subclass keeps its own-property set outside the engine,
so no engine-side version can witness that a name is still owned by it.
Every warm member read on a node therefore re-walked to the prototype
and re-resolved the member, which is the one cost three rounds of
optimization never moved.

Prototypes are now instantiated from a JsObjectShape: an immutable
member layout keyed by (type, library set) and held for the whole
process, over which each engine gets an object of its own. The layout is
engine storage, so the carve-out admits it and the cache serves the warm
read. Reflecting over a type tree also happens once per process rather
than once per engine, and existence questions are answered off the
layout without materialising the member they are about.

Everything a prototype carried that belongs to one engine moved onto the
object as host state - the engine, the constructor - and everything that
belongs to the type moved into the shared description: the indexers, and
the event members, whose identity is now the key a node files its
handler under. Member implementations no longer close over an engine and
derive it from the receiver instead. Each proxy remembers what its
prototype knows and checks it against the prototype in force, so the
hottest path costs a field load rather than two type tests.

The window is the exception. Its members are copied onto the global
object and a bare "alert('hi')" calls with no receiver at all, so its
methods keep a function per engine. Its accessors stay shared: an
accessor read always has a receiver, the global object itself for a bare
"document", and that reaches the shaped window prototype.

The registration quirk is reproduced deliberately: a member named after
one of Object.prototype's is skipped, because that is what probing a
half-built prototype used to do. A test pins that nothing in AngleSharp
currently collides, so the choice is made rather than made by accident.

The proxies' own-property hooks are untouched.

Measured on an idle machine against Jint 4.15.2, per read with the loop
baseline subtracted: el.tagName 150.7ns -> 104.7ns, el.hasChildNodes()
158.7ns -> 111.7ns, a script-assigned property 124.0ns -> 101.9ns, an
accessor write at parity, allocation byte-identical on every row. One
whole scripted document costs 1.291ms/578.68KB -> 244.8us/213.54KB.

The Jint floor moves to 4.15.2 for a reason that is not an API one:
JsObjectShape is complete in 4.15.0, but a name absent from a shaped
prototype chain was slower to answer than one absent from a dictionary
chain until jint#2858, and WebIDL makes this binding ask exactly that on
every named lookup a collection serves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@lahma
lahma marked this pull request as ready for review July 29, 2026 07:26
lahma and others added 2 commits July 29, 2026 14:14
Every exported type is published as a property of the window, and every one of
those properties is copied onto the global object, so `HTMLDivElement` and
`Image` and the other names a script may read bare are all backed by the same
descriptor. It was a hand-written one carrying `CustomJsValue`, because the
constructor object behind a name is only built once script reads it - a document
names a handful of the types an assembly exposes.

That flag is permanent on such a descriptor, and Jint's global-identifier cache
declines a descriptor that could still compute a different value on the next
read. It has no way to learn that a custom value became a constant, so the name
stayed uncached for the rest of the process even after the one read that fixed
its value forever.

`PropertyDescriptor.CreateLazy` (Jint 4.15.3) is the same deferral written so
the engine can see it end: the descriptor drops the flag the moment it holds a
value and rejoins the caches that decline it. The attributes are unchanged -
enumerable, neither writable nor configurable - and so is everything a script can
observe, which `DeferredConstructorTests` covers in seventeen tests.

The floor moves to 4.15.3 for that: it is an API the shipped assembly now uses,
which is a stronger reason than the measured one that moved it to 4.15.2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two things this binding depends on were, until Jint 4.15.3, only observable
through a diagnostic or not at all.

`Engine.Advanced.HasSharedShape` is a supported predicate for "did the shaping
actually happen", where `GetObjectRepresentation` is explicitly not part of
Jint's compatibility contract. Instantiating a prototype from a member layout
falls back to the ordinary per-object dictionary silently and correctly when it
cannot shape an object, so nothing about a document would look wrong if a DOM
prototype stopped being shaped - only every warm member read on every node would
get slower again, which is the entire point of the previous commit. The three
prototype kinds now assert it, and the two proxies assert the opposite: a host
object of ours shares its layout with nothing, which is the refusal the
prototypes had to be moved out of.

The host-contract verifiers are the checks that catch a host object answering one
of the engine's extension points in a way that contradicts another. This binding
answers four of them - `TryGetOwnPropertyValue`, `ProbeOwnProperty`, `HasIndex`,
and the semantics derived from not overriding `Get` - and the engine trusts every
one without re-checking, so a wrong answer is silent: a member disappears from
every enumeration, or a read that should have found an attribute resolves on the
prototype. They used to be compiled out of Release, so reaching them meant
building Jint from source in Debug, which is a thing a suite does once and then
stops doing. Since 4.15.3 the shipped package reads an AppContext switch, so this
run - against the very package the library ships against - is a verified one.

The switch has to be set before the first use of any Jint type, because the gate
behind it is read once at type initialization. A module initializer is the only
hook early enough by construction, and it needs a declared attribute on net462
and net472, whose BCL carries none. `TheVerifiersAreRunningForThisSuite` proves
the ordering from behaviour rather than from Jint's internal gate: a host whose
probe contradicts its own descriptors throws exactly when something is verifying.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@FlorianRappl FlorianRappl left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really great stuff here!

@FlorianRappl
FlorianRappl merged commit fc95022 into AngleSharp:devel Jul 29, 2026
5 checks passed
@lahma
lahma deleted the jsobject-shape branch July 29, 2026 11:23
@lahma

lahma commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

My integration campaign for Jint 4.15 draws to an end. Thanks for being one of the test subjects when shaping the integration APIs for performance and clarity 😄

@FlorianRappl

Copy link
Copy Markdown
Contributor

It was quite a ride but the result is very respectable.

With the renderer coming along quite well we will soon see all this combined - imho there are a lot of possibilities opening up...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants