Read the DOM through the host object surface Jint 4.15 adds - #130
Merged
Conversation
lahma
force-pushed
the
perf/jint-4.15-host-hooks
branch
from
July 27, 2026 14:03
b62f217 to
388f078
Compare
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
force-pushed
the
perf/jint-4.15-host-hooks
branch
from
July 27, 2026 17:15
ce98caa to
088d95e
Compare
lahma
marked this pull request as ready for review
July 28, 2026 07:13
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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
Update Jint to 4.15.0— the bump. One thing improves with no code change: an object thatoverrides
GetOwnPropertybut notGetis now recognised as having ordinary read semantics, soa dotted DOM read probes the node once instead of twice.
Answer the engine's own-property questions without building descriptors— a node answerswhat does it read as, does it exist and what describes it separately, instead of building a
PropertyDescriptorfor all three.Project DOM collections as array-likes the engine can read directly— node lists, HTMLcollections, 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 themanyway and threw it away.
TryGetOwnPropertyValuehands the value over with no descriptor in between — every read ascript performs, including the ones the member lane never sees (
list[i],Reflect.get, the baseof a method call). Returning
falsefrom it is not "I could not produce the value", it is anauthoritative 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.
ProbeOwnPropertyanswers existence and enumerability — whatin,hasOwnProperty,Object.keys,Object.assign, spread andJSON.stringifyactually ask.Both must agree with
GetOwnPropertyfor the same key at the same instant; the engine re-verifiesneither, 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:
Stringbefore the type is known to have an indexer at all, andmost DOM types have none. A symbol used to compose
"Symbol(…)"on every probe, and library codeprobes
Symbol.toStringTagconstantly;HasPropertycheck is handed the key it was given instead of building aJsStringfor it. That one matters more than it looks: 4.15 stops letting the prototype-methodcache 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
lengthaforloop tests. Without the fix theupgrade 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,TryGetIndexand a containment test — and the engine derives the wholeproperty 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 theArray.prototypegenerics runper element — stops projecting an element, wrapping it in a JS value and looking it up in the
identity cache just to throw it away.
Lengthand the indexed getter are the one place in this binding where reflection was too slow toleave alone:
with the descriptor gone, a
MethodInfo.Invokeand itsobject[]would have been all that was leftof 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-implementsT IReadOnlyList<T>.this[Int32]explicitly and that one is private and abstract — so the attributeddeclaration identifies the collection while the public method of the same name on the concrete class
is what runs. That is the case
Indexeralready 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
IDomProxyinstead, 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.fromand destructuring work on a collection now;Object.keysandfor...inreport the indices;delete c[0]anddefinePropertyon an index are refused;Array.isArraystays false and the prototype chain is untouched, soinstanceofandSymbol.toStringTagare exactly as before.One deviation worth stating plainly:
lengthbecomes an own property of the collection ratherthan an inherited one, so
hasOwnProperty('length')answerstruewhere a browser answersfalse.It is the model the base class defines, it is what an
Arrayreports 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.
devel+ 4.14.0list[0]list[999](out of range)attrs['id']list.length0 in listlist.hasOwnProperty(0)Array.prototype.forEach.call(list, …)Object.keys(list)el[Symbol.toStringTag]el.tagNamePer document, same method:
devel+ 4.14.01+1querySelectorAll, walk attributes, dispatch an event)Read honestly:
el.tagNamedoes not move, and that is expected. A node still has to look in its own propertybag before the engine goes to the prototype; the hook does that work instead of
GetOwnPropertydoing it. What halves on 4.15 is how often it happens, and that is free on the bump.
list[0]is the JS value conversion and the identity-cache lookup forthe wrapped node, not the indexer any more.
Object.keys(list)costs more, and should. Ondevelit returns[]— a collection'sindices 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.
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 didnot 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
Instantiatereturns an engine-internal type; 4.15answers 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
toStringbecause the chain is stillObject.prototype) can be reproduced by tracking declared names in the builder, resolved once onthe 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.tagNameandel.appendChildre-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.
DomPrototypeInstancestops being the prototype object, so the prototypecache, both proxies, the constructor, the deferred
constructorwrite, the indexers andDomEventInstance(whose per-node handler state is keyed by an engine-affine object that would haveto 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— thePropertyFlag.CustomJsValuepattern #121 already uses here — capturesmost 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;lengthon acollection 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-
falsecontract is about,since nothing in the engine changes when the DOM does); an accessor installed with
Object.definePropertyis still invoked; a non-enumerable is seen byhasOwnPropertyand skippedby
Object.keys; an assigned property survivesJSON.stringifyandObject.assign.Nine pin the collection model:
Array.isArraystaying false, the DOM identity and prototype chainsurviving, iteration, spread,
Array.prototypegenerics, index enumeration,deletebeing 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 liststill agrees withlist[i].The phase-1 tests pass against unmodified
develwith 4.14.0 too — they are guards, not assertionsof new behaviour. The collection tests necessarily do not; that commit changes what script sees, as
described above.
226/226 on
net462,net472andnet8.0, warning-clean, in Debug and Release, against thereleased 4.15.0 package; the library also builds clean on
netstandard2.0and thenet10.0targetthat 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'sGetOwnPropertyoverride leaving indices andlengthto thebase, 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 aDebug 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.