Skip to content

Read the DOM through the host object surface Jint 4.15 adds - #130

Merged
FlorianRappl merged 4 commits into
AngleSharp:develfrom
lahma:perf/jint-4.15-host-hooks
Jul 28, 2026
Merged

Read the DOM through the host object surface Jint 4.15 adds#130
FlorianRappl merged 4 commits into
AngleSharp:develfrom
lahma:perf/jint-4.15-host-hooks

Conversation

@lahma

@lahma lahma commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Updates the engine to Jint 4.15.0, released today, and adopts the host-object surface it adds
for exactly this kind of binding.

Three commits, each standing on its own:

  1. Update Jint to 4.15.0 — the bump. One thing improves with no code change: an object that
    overrides GetOwnProperty but not Get is now recognised as having ordinary read semantics, so
    a dotted DOM read probes the node once instead of twice.
  2. Answer the engine's own-property questions without building descriptors — a node answers
    what does it read as, does it exist and what describes it separately, instead of building a
    PropertyDescriptor for all three.
  3. Project DOM collections as array-likes the engine can read directly — node lists, HTML
    collections, token lists and named node maps become what they are, and the engine reads them
    without coming back through the binding layer.

1. Three questions, one descriptor

A node used to be asked three different things about a property name and all three arrived at
[[GetOwnProperty]]. The two that only want an answer, not a descriptor, got one built for them
anyway and threw it away.

TryGetOwnPropertyValue hands the value over with no descriptor in between — every read a
script performs, including the ones the member lane never sees (list[i], Reflect.get, the base
of a method call). Returning false from it is not "I could not produce the value", it is an
authoritative this node has no own property of that name: the engine trusts it, does not probe
again, and continues on the prototype. That is exactly what the discarded descriptor used to prove.

ProbeOwnProperty answers existence and enumerability — what in, hasOwnProperty,
Object.keys, Object.assign, spread and JSON.stringify actually ask.

Both must agree with GetOwnProperty for the same key at the same instant; the engine re-verifies
neither, and a wrong "missing" silently drops a property from every operation above. All three
therefore go through one shared indexer lookup and cannot drift apart.

Two allocations fall out of the same rewrite, because the lookup now takes the property key rather
than its string form:

  • a key is no longer turned into a String before the type is known to have an indexer at all, and
    most DOM types have none. A symbol used to compose "Symbol(…)" on every probe, and library code
    probes Symbol.toStringTag constantly;
  • the string indexer's HasProperty check is handed the key it was given instead of building a
    JsString for it. That one matters more than it looks: 4.15 stops letting the prototype-method
    cache serve a warm read off a host prototype — correctly, since nothing in the engine can
    witness that a projected member has not appeared — so this path now runs on every read of an
    ordinary member of an indexed collection, the length a for loop tests. Without the fix the
    upgrade would have been a regression there.

2. Collections are array-likes

A node list, an HTML collection, a token list and a named node map are all the same thing to
script: a live, read-only, indexed view. Every read of one used to go through the general node
proxy — parse the key, reflect the numeric indexer off the prototype, invoke it, wrap the result in
a descriptor — and an index past the end was signalled by a CLR exception, so a bounds test cost
about a kilobyte.

4.15 has a base class for exactly this shape. A collection supplies the members WebIDL says such a
thing has — Length, TryGetIndex and a containment test — and the engine derives the whole
property model from them. Nothing is cached, so the view stays live.

Containment is the length comparison and nothing else, so every question that only wants a yes or a
no — in, hasOwnProperty, the key enumerations, the hole test the Array.prototype generics run
per element — stops projecting an element, wrapping it in a JS value and looking it up in the
identity cache just to throw it away.

Length and the indexed getter are the one place in this binding where reflection was too slow to
leave alone:
with the descriptor gone, a MethodInfo.Invoke and its object[] would have been all that was left
of an indexed read. They are compiled once per type instead. What carries the DOM attributes and
what can be called are not always the same member — IHtmlCollection<T> re-implements
T IReadOnlyList<T>.this[Int32] explicitly and that one is private and abstract — so the attributed
declaration identifies the collection while the public method of the same name on the concrete class
is what runs. That is the case Indexer already documents; it cost me a debugging round.

There was one proxy class for every DOM object and there are now two, which cannot share a base:
they agree on IDomProxy instead, which is what every caller outside the proxies actually wanted.

What script sees changes, all of it toward what a browser does:

  • for...of, spread, Array.from and destructuring work on a collection now;
  • Object.keys and for...in report the indices;
  • delete c[0] and defineProperty on an index are refused;
  • Array.isArray stays false and the prototype chain is untouched, so instanceof and
    Symbol.toStringTag are exactly as before.

One deviation worth stating plainly: length becomes an own property of the collection rather
than an inherited one, so hasOwnProperty('length') answers true where a browser answers false.
It is the model the base class defines, it is what an Array reports too, and reads, in,
enumerability and the value are all unchanged. It is the only place this PR moves away from browser
behaviour, and I am happy to revisit it if you would rather not have it.

Numbers

Allocation per read, net8.0 Release, 200,000 iterations, loop baseline subtracted, minimum of two
runs. Structural — the same on any machine. Timing is deliberately not quoted; there is a
BenchmarkDotNet harness I kept local and can post if you want it.

operation devel + 4.14.0 this branch
list[0] 326.6 B 132.1 B −60%
list[999] (out of range) 1086.1 B 0.5 B −99.9%
attrs['id'] 1021.6 B 278.4 B −73%
list.length 51.9 B 0.0 B −100%
0 in list 326.6 B 0.0 B −100%
list.hasOwnProperty(0) 472.0 B 0.5 B −99.9%
Array.prototype.forEach.call(list, …) 1724.5 B 568.2 B −67%
Object.keys(list) 172.5 B 317.7 B see below
el[Symbol.toStringTag] 124.0 B 0.0 B −100%
el.tagName 59.4 B 59.4 B

Per document, same method:

devel + 4.14.0 this branch
document, no script 92.7 KB 92.7 KB
document + 1+1 641.6 KB 641.4 KB
… + touching one DOM type 1013.5 KB 1013.4 KB
… + a realistic script (build 50 nodes, querySelectorAll, walk attributes, dispatch an event) 2154.4 KB 1915.5 KB

Read honestly:

  • el.tagName does not move, and that is expected. A node still has to look in its own property
    bag before the engine goes to the prototype; the hook does that work instead of GetOwnProperty
    doing it. What halves on 4.15 is how often it happens, and that is free on the bump.
  • The remaining 132 B on list[0] is the JS value conversion and the identity-cache lookup for
    the wrapped node, not the indexer any more.
  • Object.keys(list) costs more, and should. On devel it returns [] — a collection's
    indices were not own properties, so there was nothing to enumerate. It now returns ["0", …],
    which is both correct and strictly more work. The row is in the table so the number is not a
    surprise, not because anything regressed.
  • Document setup is untouched by this PR — the first three rows below are unchanged, and only
    the fourth, which actually walks collections, moves.

What this does not do — prototypes are still built per engine

4.15 also ships JsObjectShape, a process-shared member layout for host-built prototypes. I did
not adopt it here, and since it is the obvious next question I want to give the real answer rather
than leave it looking overlooked.

It is now feasible. My earlier reading was that a shaped prototype could not carry the indexers,
the constructor and the type token, because Instantiate returns an engine-internal type; 4.15
answers that with per-instance host state, so that objection is gone. The other two dissolve on
inspection as well: the member set depending on the engine's library set is handled by keying the
shape cache on (type, library-set signature) rather than on the type alone — shapes are cheap to
have several of — and the registration loop probing a half-built object (if (!HasProperty(name)),
which today also silently skips a DOM member named toString because the chain is still
Object.prototype) can be reproduced by tracking declared names in the builder, resolved once on
the first build and baked into the shared shape.

It is also worth doing, for a reason that is not the one in the original audit. That audit measured
~33 MB and ~110 ms per document on 4.1.0; after #117 and #121 it is 641 KB for a document running
a trivial script — ~549 KB of it engine construction — and ~372 KB for the first DOM type a script
touches. Real, but 50× smaller than the figure shapes were scoped against. The stronger argument is
the one thing nothing else can fix: a host prototype is never eligible to hold an entry in Jint's
prototype-method inline cache, so every el.tagName and el.appendChild re-walks the chain,
which is why that row has not moved once across this whole series. A shaped prototype is eligible,
and because this binding flattens the whole type tree onto each prototype the member is always on the
direct prototype, which is exactly what that cache covers.

What it is not is a slice. DomPrototypeInstance stops being the prototype object, so the prototype
cache, both proxies, the constructor, the deferred constructor write, the indexers and
DomEventInstance (whose per-node handler state is keyed by an engine-affine object that would have
to become process-shared) all move together, and a chain that is half shaped and half not is worse to
maintain than either. It wants its own PR with its own numbers — and its main benefit is CPU, not
allocation, so it needs a timing harness this one deliberately does not quote.

So: not in this PR, on purpose, and not because of a missing API. If you would rather have the
cheaper 80% of the allocation first, per-member lazy materialisation on the existing
DomPrototypeInstance — the PropertyFlag.CustomJsValue pattern #121 already uses here — captures
most of that ~372 KB without touching the object model at all, and does nothing for the cache.

Tests

19 added to DomTests, all script-level.

Ten pin the agreement between the three answers a node gives: an indexed entry reads, is an own
property and answers in; an index past the end fails all three the same way; length on a
collection that has indexers is not an index; a symbol key is not an index; a named entry starts
resolving the moment the attribute exists (the case the authoritative-false contract is about,
since nothing in the engine changes when the DOM does); an accessor installed with
Object.defineProperty is still invoked; a non-enumerable is seen by hasOwnProperty and skipped
by Object.keys; an assigned property survives JSON.stringify and Object.assign.

Nine pin the collection model: Array.isArray staying false, the DOM identity and prototype chain
surviving, iteration, spread, Array.prototype generics, index enumeration, delete being refused,
expandos still working, a named node map reading both ways, and — since existence is now answered
from the length rather than by reading the element — that i in list still agrees with list[i].

The phase-1 tests pass against unmodified devel with 4.14.0 too — they are guards, not assertions
of new behaviour. The collection tests necessarily do not; that commit changes what script sees, as
described above.

226/226 on net462, net472 and net8.0, warning-clean, in Debug and Release, against the
released 4.15.0 package; the library also builds clean on netstandard2.0 and the net10.0 target
that just landed on devel.

Separately, the whole suite was run against a Debug build of Jint while this was being written.
That matters because the contracts these commits sign up to — the two own-property hooks agreeing
with GetOwnProperty, a collection's GetOwnProperty override leaving indices and length to the
base, and the containment test agreeing with reading the element — are checked by
[Conditional("DEBUG")] verifiers that are compiled out of the shipped package. Running against a
Debug engine is the only way to exercise them, and they earned their keep: they caught two real
mistakes of mine, and I confirmed they bite by breaking a hook on purpose first (17 failures) before
running it green.

@lahma
lahma force-pushed the perf/jint-4.15-host-hooks branch from b62f217 to 388f078 Compare July 27, 2026 14:03
@lahma lahma changed the title Answer the engine's own-property questions without building descriptors Read the DOM through the host object surface Jint 4.15 adds Jul 27, 2026
lahma and others added 3 commits July 27, 2026 20:09
The release brings the host-object hooks the DOM proxies use in the next
commit, and one change that applies without any code here: an object that
overrides GetOwnProperty but not Get is now recognised as having ordinary
read semantics, so a dotted DOM read probes the node once instead of twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A node is asked three different things about a property name, and until now
all three arrived at GetOwnProperty and got a PropertyDescriptor built for
them - including the two that only ever throw it away.

Jint 4.15 splits them up, and the node answers each directly:

* TryGetOwnPropertyValue hands the value over, so an indexed read no longer
  allocates a descriptor for the engine to unwrap and drop. Returning false
  is an authoritative "no own property of that name", which is exactly what
  the discarded descriptor used to prove.
* ProbeOwnProperty answers existence and enumerability, which is what "in",
  hasOwnProperty, Object.assign, spread and JSON.stringify actually want. An
  indexed entry no longer has to be converted into a JsValue to be counted.

The three share one indexer lookup so that they cannot disagree - the answer
one gives has to be the answer the others would give at the same instant, and
a Debug build of Jint checks that on every read.

The lookup itself now takes the property key rather than its string form, and
two things fall out of that:

* a key is no longer turned into a String before the type is known to have an
  indexer at all, and most DOM types have none. A symbol used to compose
  "Symbol(...)" on every probe, and library code probes Symbol.toStringTag
  constantly - 83.7 to 2.9 bytes per read;
* the string indexer's HasProperty check is handed the key it was given
  instead of building a JsString for it. That is on the path of every read of
  an ordinary member of an indexed collection - the length a loop tests - and
  it is 64 bytes a read, which 4.15 exposes because a host prototype is no
  longer eligible for the engine's prototype cache.

Behaviour is unchanged. The eight tests added to DomTests pass on devel with
4.14.0 as well; they pin the agreement between the three answers, which is the
thing this commit could get wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A node list, an HTML collection, a token list and a named node map are all the
same thing to script - a live, read-only, indexed view - and until now every read
of one went through the general node proxy: parse the key, reflect the numeric
indexer off the prototype, invoke it, wrap the result in a descriptor. An index
past the end was signalled by a CLR exception, so a bounds test cost about a
kilobyte.

Jint 4.15 has a base class for exactly this shape. A collection supplies the
members WebIDL says such a thing has - Length, TryGetIndex, and a containment
test - and the engine derives the whole property model from them. Nothing is
cached, so the view stays live.

Containment is the length comparison and nothing else, so every question that
only wants a yes or a no - "in", hasOwnProperty, the key enumerations, the hole
test the Array.prototype generics run per element - stops projecting an element,
wrapping it in a JS value and looking it up in the identity cache just to drop it
again.

Length and the indexed getter are the one place in this binding where reflection
was too slow to leave alone: with the descriptor gone, a MethodInfo.Invoke and its
object[] would have been all that was left of an indexed read. They are compiled
once per type instead. What carries the DOM attributes and what can be called are
not always the same member - IHtmlCollection<T> re-implements
"T IReadOnlyList<T>.this[Int32]" explicitly, and that one is private and abstract
- so the attributed declaration identifies the collection while the public method
of the same name on the concrete class is what runs.

Deciding which proxy to build is a property of the type - an indexed getter plus
a length, the same pair SetIndexer looks for - so it is resolved once per type
for the whole process and never repeats.

There was one proxy class for every DOM object and there are now two, which
cannot share a base: they agree on IDomProxy instead, which is what every caller
outside the proxies actually wanted. Named entries - document.forms.login,
el.attributes.title - are not part of the array-like model and stay on the string
indexer, with indices and length delegated to the base as that base requires. A
named entry whose indexer has no setter is described by a plain value now rather
than by an accessor pair: with nothing to forward a write to, the pair only cost
two function objects per read.

Measured, allocation per operation, net8.0, loop baseline subtracted:

| operation                        | devel + 4.14.0 | this branch |
| -------------------------------- | -------------- | ----------- |
| list[0]                          |        326.6 B |     132.1 B |
| list[999] out of range           |       1086.1 B |       0.5 B |
| attrs['id']                      |       1021.6 B |     278.4 B |
| list.length                      |         51.9 B |       0.0 B |
| 0 in list                        |        326.6 B |       0.0 B |
| list.hasOwnProperty(0)           |        472.0 B |       0.5 B |
| Array.prototype.forEach.call(..) |       1724.5 B |     568.2 B |

What script sees changes in four ways, all toward what a browser does: for...of,
spread, Array.from and destructuring work on a collection; Object.keys and
for...in report the indices; delete c[0] and defineProperty on an index are
refused; and Array.isArray stays false with the prototype chain untouched, so
instanceof and Symbol.toStringTag are exactly as before.

One deviation is worth stating plainly: "length" becomes an own property of the
collection rather than an inherited one, so hasOwnProperty('length') answers true
where a browser answers false. It is the model the base class defines, it is what
an Array reports too, and reads, "in", enumerability and the value are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@lahma
lahma force-pushed the perf/jint-4.15-host-hooks branch from ce98caa to 088d95e Compare July 27, 2026 17:15
@lahma
lahma marked this pull request as ready for review July 28, 2026 07:13
@lahma

lahma commented Jul 28, 2026

Copy link
Copy Markdown
Contributor Author

Jint 4.15.0 is now on nuget.org, so this is no longer blocked on an unreleased dependency — marking ready for review. CI should restore cleanly; if a runner cached the earlier 404, a re-run will clear it.

@FlorianRappl
FlorianRappl merged commit 626c26d into AngleSharp:devel Jul 28, 2026
5 checks passed
@FlorianRappl FlorianRappl added this to the v1.0 milestone Jul 28, 2026
@lahma
lahma deleted the perf/jint-4.15-host-hooks branch July 28, 2026 07:31
FlorianRappl pushed a commit that referenced this pull request Jul 28, 2026
… the package needs

Two follow-ups to #130.

The collection proxy's GetOwnProperty asked the string indexer about a name the
array-like base had already answered: an index past the end is an authoritative
miss - the value and existence hooks commit to it, the engine trusts them and
does not ask again - so an element whose id happens to be numeric surfaced as a
descriptor for a key that reads as undefined, answers false to "in" and
hasOwnProperty, and never enumerates. WebIDL resolves the same collision the
same way: an object supporting indexed properties never serves an array-index
name from its named getter. The named lookup now steps aside for canonical
array-index keys, classified exactly as the engine classifies them.

The package manifest still declared Jint [4.0.0,5.0.0), but the assembly now
derives from a base class and overrides hooks that exist only in 4.15.0, and
NuGet resolves the lowest version a range admits - a consumer taking the
package defaults got an engine the types in it cannot load against. The floor
is now the version the code is actually written to.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uV6H9cTntzsoKiaJRBn4f
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