Time and the event loop
editEach top-level browsing context has an event loop (BrowsingContext.EventLoop), shared by its frames: the tasks, the microtasks, the timers and the animation frames of its documents run on it. Your application runs it when it wants: a page does nothing between two calls.
Run the tasks
| Method | Runs |
|---|---|
| RunUntilIdle() | The tasks and the due timers, until nothing is left to run now |
| RunUntilIdleAsync() | The same, and waits for the operations in progress: the network requests, the documents being loaded |
| WaitForNextTimerAsync() | Waits until the next timer or animation frame is due; returns false when there is none |
| RunTask() | One task |
To run a page until it has nothing left to do, timers included:
using var engine = new JavaScriptScriptEngine(new JavaScriptScriptEngineOptions
{
Timeout = TimeSpan.FromSeconds(5), // per script or callback
});
using var context = new BrowsingContext(new BrowsingContextOptions
{
ScriptEngine = engine,
DocumentLoader = DocumentLoader.Create(), // optional: external scripts, modules, fetch, XMLHttpRequest, WebSocket, EventSource...
UnhandledScriptError = (_, error) => Console.Error.WriteLine(error),
ConsoleMessage = (_, message) => Console.WriteLine(message.Text), // console.log(), console.count()...
});
var document = await context.NavigateAsync("https://example.com/", _ => """
<title>Example</title>
<ul id=list></ul>
<script>
setTimeout(() => document.getElementById('list').innerHTML = '<li>a<li>b', 100);
</script>
""");
// Run the tasks and the timers until there is nothing left
do { await context.EventLoop.RunUntilIdleAsync(); } while (await context.EventLoop.WaitForNextTimerAsync());
Assert.Equal("<li>a</li><li>b</li>", document.GetElementById("list")!.InnerHtml);
// Evaluate code in the realm of a window
var title = engine.Evaluate(context.Window, "document.title");
Assert.Equal("Example", title.AsString());A page that keeps a timer (setInterval) or an infinite animation never becomes idle: bound the time you run it, with the CancellationToken of these methods or a number of iterations.
Control the clock
BrowsingContextOptions.TimeProvider is the clock of the event loop: the timers, the animation frames, the CSS animations and transitions and performance.now() follow it. The clock of Date is the TimeProvider of the script engine options. With a fake clock, such as the FakeTimeProvider of Microsoft.Extensions.TimeProvider.Testing, tests run without waiting and give the same results every time:
var clock = new FakeTimeProvider(); // Microsoft.Extensions.TimeProvider.Testing
using var engine = new JavaScriptScriptEngine();
using var context = new BrowsingContext(new BrowsingContextOptions { ScriptEngine = engine, TimeProvider = clock });
var document = await context.NavigateAsync("https://example.com/", _ => """
<p id="ticks">0</p>
<script>setInterval(() => document.getElementById('ticks').textContent++, 1000);</script>
""");
// The timers follow the clock of the event loop: advance it instead of waiting
for (var i = 0; i < 3; i++)
{
clock.Advance(TimeSpan.FromSeconds(1));
context.EventLoop.RunUntilIdle(); // runs the callbacks of the timers that are due
}
Assert.Equal("3", document.GetElementById("ticks")!.TextContent);Advancing the clock queues the timers that are due; run the event loop to run their callbacks. Advance the clock step by step when a callback starts another timer.
Rendering updates
The animation frames are timers of the event loop, at a fixed rate. Each one runs the steps of a rendering update: the animations and their events, the requestAnimationFrame() callbacks, the styles, the resize and intersection observers. A screenshot or a geometry API does not wait for them: the layout is computed when it is read. A screenshot also finds the elements with content-visibility: auto that are close to the viewport before it paints, so they are painted with their contents even before the first animation frame; the contentvisibilityautostatechange events are fired by the next tasks.
Freeze and discard the page
A browser freezes the tabs that are in the background to save resources, and discards them when the memory is low. Set IsFrozen to do the same: the documents of the page and of its frames get the freeze event, then their tasks and their timers wait until the page is resumed, which fires the resume event. The tasks are deferred, not lost.
// Freezes the tab: the freeze event is fired, then the tasks and the timers of the documents wait
document.BrowsingContext!.IsFrozen = true;Discard() unloads the page and loads it again, as a browser does when the user goes back to a discarded tab: document.wasDiscarded is true in the new document. The pages are also frozen while they are in the back/forward cache, and the frames whose permissions policy disables execution-while-not-rendered (or execution-while-out-of-viewport) are frozen while they are hidden (or out of the viewport).
Long tasks
A task that runs scripts for 50 ms or more is a long task (Long Tasks API): it is reported as a PerformanceLongTaskTiming to the longtask performance observers of the windows of the page. Observe them from your application to find the scripts that block a page:
var window = document.DefaultView!;
var longTasks = new List<string>();
var observer = new PerformanceObserver(window, (entries, _, _) =>
{
foreach (var entry in entries.GetEntriesByType("longtask").Cast<PerformanceLongTaskTiming>())
{
// e.g. "self: 60 ms" for a script of the window, "same-origin-descendant: 60 ms (iframe #ad)" for an iframe
var container = entry.Attribution[0];
longTasks.Add(container.ContainerType == "window" ? $"{entry.Name}: {entry.Duration} ms" : $"{entry.Name}: {entry.Duration} ms ({container.ContainerType} #{container.ContainerId})");
}
});
observer.Observe(new PerformanceObserverInit { Type = "longtask", Buffered = true });The name of an entry tells where the scripts ran, seen from the window that observes it: self, a frame of the same origin (same-origin-ancestor, same-origin-descendant, same-origin, whose container is in Attribution), a cross-origin frame, or multiple-contexts. With the system clock, a task is timed from its first script to its end. A fake clock is never read for the long tasks, as each read can advance it: a task lasts from the first to the last time its scripts read the time (performance.now(), the time stamps of the events), so a script that waits on performance.now() for 60 ms makes a long task. The tasks that run no script are not reported, and the parsing of the document of Navigate() counts as one task.
Run the event loop on your own thread
A host that runs a page on its own thread (as the Playwright server does) sets EventLoop.WorkSignal: the callback is invoked, from any thread, when a task or a timer is queued, so that the thread wakes up and calls RunTask() and PromoteDueTimers(). The callback must not run the event loop itself.