Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
# 1.2.1

Released on Saturday, November 7 2026.

- Fixed a script inserted by script (e.g. `head.appendChild(script)` with its text) running after the script inserting it instead of before the insertion returns (MediaWiki ran the modules cached in `localStorage` twice: "module already implemented")
- Fixed `Function.prototype.toString()` giving `function name() { [native code] }` for the functions of scripts instead of their source (MediaWiki stores its modules as the source of their functions); `JsScriptingOptions.RetainFunctionSourceText` turns keeping the source off

# 1.2.0

Released on Friday, November 6 2026.
Expand Down
16 changes: 16 additions & 0 deletions docs/general/01-Basics.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,22 @@ var configuration = Configuration.Default
});
```

### Keep the source of functions

Like in browsers, `Function.prototype.toString()` gives the source of a function written in a
script, which some pages rely on (e.g. MediaWiki stores the code of its modules this way). This
keeps the source of the scripts in memory as long as their functions live; setting
`JsScriptingOptions.RetainFunctionSourceText` to `false` turns it off, and `toString()` then gives
`function name() { [native code] }`.

```cs
var configuration = Configuration.Default
.WithJs(new JsScriptingOptions
{
RetainFunctionSourceText = false,
});
```

## Execute JavaScript

HTML `<script>` elements execute while AngleSharp processes the document. For a script you
Expand Down
2 changes: 1 addition & 1 deletion src/AngleSharp.Js.Docs/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@anglesharp/js",
"version": "1.2.0",
"version": "1.2.1",
"preview": true,
"description": "The doclet for the AngleSharp.Js documentation.",
"keywords": [
Expand Down
60 changes: 60 additions & 0 deletions src/AngleSharp.Js.Tests/FunctionSourceTextTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
namespace AngleSharp.Js.Tests
{
using AngleSharp.Js;
using NUnit.Framework;
using System.Threading.Tasks;

[TestFixture]
public class FunctionSourceTextTests
{
[Test]
public async Task FunctionOfAScriptGivesItsSource()
{
// MediaWiki stores its modules in localStorage as the source of their functions
var result = await ScriptEvalTests.EvaluateComplexScriptAsync(
"function f(a, b) { return a + b; } var arrow = (x) => x * 2; class A { m() { return 1; } }",
ScriptEvalTests.SetResult("[String(f), String(arrow), String(A.prototype.m)].join('|')"));

// the result is read as HTML
Assert.AreEqual("function f(a, b) { return a + b; }|(x) =&gt; x * 2|m() { return 1; }", result);
}

[Test]
public async Task FunctionOfEvalAndTheFunctionConstructorGivesItsSource()
{
var result = await ScriptEvalTests.EvaluateComplexScriptAsync(
ScriptEvalTests.SetResult("[String(eval('(function g() { return 3; })')), new Function('a', 'return a').toString().indexOf('return a') >= 0].join('|')"));

Assert.AreEqual("function g() { return 3; }|true", result);
}

[Test]
public async Task SourceOfAFunctionCanBeEvaluatedAgain()
{
var result = await ScriptEvalTests.EvaluateComplexScriptAsync(
"var stored = String(function (x) { return x + 1; });",
ScriptEvalTests.SetResult("eval('(' + stored + ')')(41)"));

Assert.AreEqual("42", result);
}

[Test]
public async Task BuiltInFunctionGivesNativeCode()
{
var result = await ScriptEvalTests.EvaluateComplexScriptAsync(ScriptEvalTests.SetResult("String(Math.max)"));

StringAssert.Contains("[native code]", result);
}

[Test]
public async Task SourceIsNotKeptIfTurnedOff()
{
var config = Configuration.Default
.WithJs(new JsScriptingOptions { RetainFunctionSourceText = false })
.WithEventLoop();
var document = await BrowsingContext.New(config).OpenAsync(m => m.Content("<script>function f() { return 1; }</script>"));

StringAssert.Contains("[native code]", document.ExecuteScript("String(f)")?.ToString());
}
}
}
41 changes: 41 additions & 0 deletions src/AngleSharp.Js.Tests/ScriptEvalTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,47 @@ public async Task RunMainScriptFromHtml5Test()
Assert.AreNotEqual("undefined", result);
}

[Test]
public async Task InsertedScriptRunsBeforeAppendChildReturns()
{
// MediaWiki runs the modules cached in localStorage like this (domEval), and asks for
// the ones still missing right after
var result = await EvaluateComplexScriptAsync(
"var order = []; var s = document.createElement('script'); s.text = 'order.push(1)'; document.head.appendChild(s); order.push(2); s.remove(); document.body.appendChild(s); order.push(3);",
SetResult("order.join()"));
Assert.AreEqual("1,2,3", result);
}

[Test]
public async Task ScriptInsertedByAListenerRunsBeforeAppendChildReturns()
{
var result = await EvaluateComplexScriptAsync(
"var order = []; document.addEventListener('x', function () { var s = document.createElement('script'); s.text = 'order.push(1)'; document.head.appendChild(s); order.push(2); }); document.dispatchEvent(new Event('x'));",
SetResult("order.join()"));
Assert.AreEqual("1,2", result);
}

[Test]
public async Task InsertedScriptRunsOnlyOnce()
{
// AngleSharp evaluates the script later as well, which is skipped
var cfg = Configuration.Default.WithJs().WithEventLoop();
var html = "<!doctype html><script>window.count = 0; var s = document.createElement('script'); s.text = 'count++'; document.head.appendChild(s); document.body.appendChild(s);</script>";
var document = await BrowsingContext.New(cfg).OpenAsync(m => m.Content(html));
await Task.Delay(300);

Assert.AreEqual(1.0, document.ExecuteScript("count"));
}

[Test]
public async Task ErrorOfAnInsertedScriptDoesNotStopTheOneInsertingIt()
{
var result = await EvaluateComplexScriptAsync(
"var order = []; var s = document.createElement('script'); s.text = 'order.push(1); throw new Error(\"inner\")'; document.head.appendChild(s); order.push(2);",
SetResult("order.join()"));
Assert.AreEqual("1,2", result);
}

[Test]
public async Task QueryUserAgentShouldMatchAgent()
{
Expand Down
16 changes: 10 additions & 6 deletions src/AngleSharp.Js/Cache/ScriptCache.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ namespace AngleSharp.Js.Cache
/// <summary>
/// Caches the parsed and analyzed form of scripts. A prepared script is
/// documented by Jint as reusable and thread-safe, so the same one can serve
/// every document that runs the same source with the same regex timeout - a
/// every document that runs the same source with the same parsing options (the regex
/// timeout, and if the source of functions is kept) - a
/// page loading a library ends up parsing it once instead of once per document.
/// </summary>
static class ScriptCache
Expand All @@ -28,9 +29,9 @@ static class ScriptCache
/// source could not be prepared and should be handed to the engine as text,
/// so that the engine reports the syntax error itself.
/// </summary>
public static Prepared<Script> GetOrCreate(String source, String sourceUrl, Position position, TimeSpan regexTimeout)
public static Prepared<Script> GetOrCreate(String source, String sourceUrl, Position position, TimeSpan regexTimeout, Boolean retainFunctionSourceText = false)
{
var key = new ScriptKey(source, sourceUrl, position, regexTimeout);
var key = new ScriptKey(source, sourceUrl, position, regexTimeout, retainFunctionSourceText);

if (_scripts.TryGetValue(key, out var prepared))
{
Expand All @@ -41,7 +42,7 @@ public static Prepared<Script> GetOrCreate(String source, String sourceUrl, Posi
{
prepared = Engine.PrepareScript(source, sourceUrl, options: new ScriptPreparationOptions
{
ParsingOptions = new ScriptParsingOptions { RegexTimeout = regexTimeout, SourceOffset = position },
ParsingOptions = new ScriptParsingOptions { RegexTimeout = regexTimeout, SourceOffset = position, RetainFunctionSourceText = retainFunctionSourceText },
});
}
catch (ScriptPreparationException)
Expand All @@ -63,9 +64,11 @@ public static Prepared<Script> GetOrCreate(String source, String sourceUrl, Posi
private readonly String _sourceUrl;
private readonly Position _position;
private readonly TimeSpan _regexTimeout;
private readonly Boolean _retainFunctionSourceText;

public ScriptKey(String source, String sourceUrl, Position position, TimeSpan regexTimeout)
public ScriptKey(String source, String sourceUrl, Position position, TimeSpan regexTimeout, Boolean retainFunctionSourceText)
{
_retainFunctionSourceText = retainFunctionSourceText;
_source = source ?? throw new ArgumentNullException(nameof(source));
_sourceUrl = sourceUrl;
_position = position;
Expand All @@ -76,7 +79,8 @@ public Boolean Equals(ScriptKey other) =>
String.Equals(_source, other._source, StringComparison.Ordinal) &&
String.Equals(_sourceUrl, other._sourceUrl, StringComparison.Ordinal) &&
_position.Equals(other._position) &&
_regexTimeout == other._regexTimeout;
_regexTimeout == other._regexTimeout &&
_retainFunctionSourceText == other._retainFunctionSourceText;

public override Boolean Equals(Object obj) => obj is ScriptKey other && Equals(other);

Expand Down
30 changes: 12 additions & 18 deletions src/AngleSharp.Js/EngineInstance.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ sealed class EngineInstance

private readonly Engine _engine;
private readonly TimeSpan _regexTimeout;
private readonly Boolean _retainFunctionSourceText;
private readonly PrototypeCache _prototypes;
private readonly ReferenceCache _references;
private readonly ConditionalWeakTable<Object, SameObjectCache> _sameObjects;
Expand Down Expand Up @@ -85,6 +86,9 @@ public EngineInstance(IWindow window, IDictionary<String, Object> assignments, I
// Left alone, the JS call stack is the native one, and a runaway recursion
// takes the process down with an uncatchable StackOverflowException.
engineOptions.Constraints.MaxExecutionStackCount = options.MaxCallStackDepth > 0 ? options.MaxCallStackDepth : StackGuardDisabled;
// for what the engine parses itself (eval, new Function, modules)
engineOptions.RetainFunctionSourceText = options.RetainFunctionSourceText;
_retainFunctionSourceText = options.RetainFunctionSourceText;
var creator = window.Document.Context.GetService<EngineCreator>();
_engine = (creator is null ? new Engine(engineOptions) : creator.Invoke(window, engineOptions))
?? throw new InvalidOperationException("The engine creator must return a Jint engine.");
Expand Down Expand Up @@ -134,6 +138,11 @@ public EngineInstance(IWindow window, IDictionary<String, Object> assignments, I

public DomNodeInstance Window => _window;

/// <summary>
/// Gets the document the engine runs the scripts of.
/// </summary>
public IDocument Document => _document;

/// <summary>
/// The engine of the script that called into the DOM last on this thread.
/// </summary>
Expand Down Expand Up @@ -383,22 +392,7 @@ internal void RunTask(Action action)

private void Settle(Action settle)
{
void Run()
{
lock (_engine)
{
EnterScript();

try
{
settle();
}
finally
{
LeaveScript();
}
}
}
void Run() => RunTask(settle);

var loop = GetEventLoop();

Expand Down Expand Up @@ -657,10 +651,10 @@ private JsValue RunScriptCore(String source, String type, String sourceUrl, Posi
{
if (MimeTypeNames.IsJavaScript(type))
{
var prepared = ScriptCache.GetOrCreate(source, sourceUrl, position, _regexTimeout);
var prepared = ScriptCache.GetOrCreate(source, sourceUrl, position, _regexTimeout, _retainFunctionSourceText);
// An invalid result means the source did not parse; hand it to the
// engine as text so the syntax error is reported as usual.
return prepared.IsValid ? _engine.Evaluate(prepared) : _engine.Evaluate(source, sourceUrl, new ScriptParsingOptions { SourceOffset = position, RegexTimeout = _regexTimeout });
return prepared.IsValid ? _engine.Evaluate(prepared) : _engine.Evaluate(source, sourceUrl, new ScriptParsingOptions { SourceOffset = position, RegexTimeout = _regexTimeout, RetainFunctionSourceText = _retainFunctionSourceText });
}
else if (type.Isi("importmap"))
{
Expand Down
20 changes: 18 additions & 2 deletions src/AngleSharp.Js/Extensions/EngineExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ namespace AngleSharp.Js
{
using AngleSharp.Attributes;
using AngleSharp.Dom;
using AngleSharp.Html.Dom;
using AngleSharp.Js.Cache;
using Jint;
using Jint.Native;
Expand Down Expand Up @@ -592,6 +593,15 @@ public static JsValue CallStatic(this EngineInstance instance, MethodInfo method
}
}

// a script inserted by the call runs before it returns, like in browsers
private static void RunInsertedScripts(EngineInstance instance, List<IHtmlScriptElement> inserted)
{
if (inserted != null)
{
InsertedScripts.RunPrepared(instance, instance.Document, inserted);
}
}

public static JsValue Call(this EngineInstance instance, MethodInfo method, JsValue thisObject, JsValue[] arguments)
{
if (method != null)
Expand Down Expand Up @@ -626,14 +636,20 @@ public static JsValue Call(this EngineInstance instance, MethodInfo method, JsVa
newArgs[0] = (JsValue)nodeInstance;
Array.Copy(arguments, 0, newArgs, 1, arguments.Length);
var parameters = instance.BuildArgs(method, newArgs);
return description.ToGiven(method.Invoke(null, parameters)).ToJsValue(instance, description.ReturnType);
var inserted = InsertedScripts.FindUnstarted(null, parameters);
var result = description.ToGiven(method.Invoke(null, parameters)).ToJsValue(instance, description.ReturnType);
RunInsertedScripts(instance, inserted);
return result;
}
else
{
var target = nodeInstance.Value;
var bound = BindToTarget(method, target);
var parameters = instance.BuildArgs(bound, arguments);
return description.ToGiven(bound.Invoke(target, parameters)).ToJsValue(instance, description.ReturnType);
var inserted = InsertedScripts.FindUnstarted(target, parameters);
var result = description.ToGiven(bound.Invoke(target, parameters)).ToJsValue(instance, description.ReturnType);
RunInsertedScripts(instance, inserted);
return result;
}
}
catch (TargetInvocationException ex)
Expand Down
Loading
Loading