Skip to content

Release 1.0.0 - #129

Open
FlorianRappl wants to merge 128 commits into
mainfrom
devel
Open

Release 1.0.0#129
FlorianRappl wants to merge 128 commits into
mainfrom
devel

Conversation

@FlorianRappl

Copy link
Copy Markdown
Contributor

Types of Changes

Prerequisites

Please make sure you can check the following two boxes:

  • I have read the CONTRIBUTING document
  • My code follows the code style of this project

Contribution Type

What types of changes does your code introduce? Put an x in all the boxes that apply:

  • Bug fix (non-breaking change which fixes an issue, please reference the issue id)
  • New feature (non-breaking change which adds functionality, make sure to open an associated issue first)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • My change requires a change to the documentation
  • I have updated the documentation accordingly
  • I have added tests to cover my changes
  • All new and existing tests passed

Description

Note that I mentioned "Breaking change" even though I don't think anything will break when updating to this release. In general people will be amazed how much is now working... at least that's my hope.

Shoutout to everyone who made this possible. Years ago I thought this project is a dead-end and finished - now I think this might be the foundation for something really great. Without people like @lahma, @arekdygas, and @tomvanenckevort the project could not have been brought to this level. Much appreciated everyone!

(PR is there to be merged once final tests and cosmetics are in place - if you see anything or want to improve it just open a PR to devel)

FlorianRappl and others added 30 commits November 25, 2021 22:45
Added support for JS modules and importmaps
* small cleanup to API usage
* add missing license expression
* treat warnings as errors to catch obsolete members
* enable deterministic build
FlorianRappl and others added 29 commits July 27, 2026 16:05
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>
Read the DOM through the host object surface Jint 4.15 adds
… 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
Keep array-index keys out of the named getter, and declare the Jint the package needs
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>
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>
Build DOM prototypes from a member layout the process shares
Jint has implemented ES2015 through ES2025 for several releases now, so
describing it as "fully ECMAScript 5 compatible" understates what scripts
this binding can actually run. The features list gains the module, import
map and Web Worker support that has since landed, and names the library
fixtures the test suite exercises.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replaces the hand-maintained nuspec with SDK packaging, so the dependency
groups follow the real TargetFrameworks. The nuspec had drifted: it declared
net6.0 and net7.0 groups that are not built, and no group for net10.0, which
is shipped.

Drops Microsoft.SourceLink.GitHub — SourceLink ships in the .NET SDK since
8.0.100 and the repository metadata (branch, commit) now lands in the package
without it.

Centralizes package versions in src/Directory.Packages.props. AngleSharp and
Jint are stated as ranges because `dotnet pack` publishes them verbatim; the
2.0 / 5.0 ceilings the nuspec carried are preserved, and the ceiling now holds
even when CI overrides the floor via -AngleSharpVersion.

Points the test project at net10.0 and drops the netstandard2.0 pin on its
ProjectReference, so tests exercise the library build they ship against
rather than always the netstandard2.0 one.

Also converts the solution to .slnx (which drops four orphaned project-config
blocks), moves both AssemblyInfo.cs files into the csproj, adds a global.json,
and upgrades NUKE to 10.1.0.

The produced package is byte-comparable to the one devel produces, apart from
the net10.0 group being added, the dead net6.0/net7.0 groups being removed,
and SourceLink metadata being present. Assembly identity is unchanged:
Version=1.0.0.0, PublicKeyToken=e83494dcdc6d31ea.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replace the ES5-era claims in the README
Reverts the .slnx conversion per review: the format change is not worth
narrowing the tooling contributors can use, and the benefit was small.

Keeps the part that was actually broken — four ProjectConfiguration blocks
referencing project GUIDs that no longer exist in the solution.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Removes the global.json added earlier in this branch, per review: the absence
is intentional, not an oversight, and AGENTS.md now says so rather than
describing it as a gap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two things the core repo already does better, found while porting this change
to AngleSharp/AngleSharp#1272.

Drops the AssemblyVersion pin. Published AngleSharp bumps AssemblyVersion with
every minor (1.5.0.0, 1.6.0.0, 1.7.0.0), so freezing it here would have made
the two packages follow opposite policies. Restores <Version> as the fallback
for builds that bypass the NUKE script, matching the core layout. The built
assembly is unchanged either way at 1.0.0.0.

Replaces the GITHUB_ACTIONS environment gate for ContinuousIntegrationBuild
with .SetContinuousIntegrationBuild(IsServerBuild) on Compile and pack, which
is what nuke/Build.cs in the core repo does. NUKE's host detection is not tied
to one CI provider, and the core repo's published symbols prove the mechanism
works: AngleSharp 1.6.0 has zero absolute paths.

Verified: package metadata and dependency ranges are byte-identical to the
previous state of this branch apart from the SourceLink commit hash.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Suggestion: modernize the project files and generate the package with dotnet pack
NUKE is unmaintained and its transitive dependency graph keeps producing advisories.
Fallout is the maintained hard fork, so the move is mostly a namespace swap plus the
change from a bootstrapped build project to Fallout's tool-manifest model.

`fallout-migrate` handled the mechanical part: `Nuke.*` -> `Fallout.*`,
`NukeBuild` -> `FalloutBuild`, the `<Nuke*>` MSBuild properties, and `.nuke/` ->
`.fallout/`. The rest needed hand work:

- `Nuke.Common.ProjectModel` is not a 1:1 map — `Solution` and `Project` now live in
  `Fallout.Solutions`.
- `build.ps1` / `build.sh` become thin shims. They still provision an SDK, but instead
  of building and running `_build.csproj` they end with `dotnet tool restore` +
  `dotnet fallout`, so the orchestrator version is pinned in
  `.config/dotnet-tools.json` (`fallout.cli`, not the unpublished
  `Fallout.GlobalTools` the docs name). The `NUKE_ENTERPRISE_TOKEN` feedz block is gone.
- `nuke/` -> `build/`, which is where Fallout's CLI resolves `_build.csproj` by
  convention when `.fallout/parameters.json` does not name one.
- Dropped the `NuGet.Packaging` CVE pin — Fallout 11.0.18 resolves a clean 6.14.3. The
  `System.Security.Cryptography.Xml` pin stays: 11.0.18 still ships 10.0.6, and the fix
  upstream is committed but unreleased.

`build.ps1`, `build.sh` and `.gitignore` are normally synced from AngleSharp.GitBase; the
migration cannot avoid touching them, so those edits need folding back upstream.

Verified with `.\build.ps1 RunUnitTests -AngleSharpVersion 1.5.0`: 286 tests pass on
net10.0, net462 and net472, and `-Target` plus parameter passing behave as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
All three actions were a major behind: `actions/checkout` v5 → v7,
`actions/setup-dotnet` v5 → v6, `actions/setup-node` v5 → v7. Each of those majors
is an ESM migration plus dependency upgrades, with no input changes affecting this
workflow. checkout v7 additionally blocks checking out fork PRs under
`pull_request_target`/`workflow_run`, which this workflow does not use — it triggers
on `push` and `pull_request`.

Runtime versions are deliberately left alone. `dotnet-version: 10.0.x` is already the
current LTS, supported to November 2028. `node-version` stays at 20.x: it only feeds
the doclet publish, whose npm dependencies are old enough that moving the runtime
risks breaking them for no benefit here.

Also replaced the `::set-output` workflow command, deprecated since 2022 and no longer
functional, with an append to `$GITHUB_OUTPUT`. It is what feeds
`needs.can_document.outputs.value`, so the documentation job's condition could not
evaluate true while it stayed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Migrate the build orchestrator from NUKE to Fallout, and refresh the CI workflow actions
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants