microsoft / microsoft/playwright-dotnet
[Bug]: WaitForURLAsync ignores WaitUntil = Commit when the URL already matches, and waits for `load` instead
Nobody has claimed this yet.
- Dominant language
- C#
- Stars
- 3k
- Forks
- 304
- Avg merge
- 20h 47m
- Merged PRs (30d)
- 6
Description
Version
1.62.0
Steps to reproduce
A self-contained console program; no web server is needed, both pages are fulfilled from a route. Each page holds an image that is never answered, so neither page ever reaches load — the case WaitUntil = Commit exists for. The same call, with the same option, is made twice: once before the navigation and once after it.
commit-repro.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net8.0;net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Playwright" Version="1.62.0" />
</ItemGroup>
</Project>
Program.cs
using System.Diagnostics;
using Microsoft.Playwright;
using var playwright = await Playwright.CreateAsync();
await using var browser = await playwright.Chromium.LaunchAsync();
var page = await browser.NewPageAsync();
await page.RouteAsync("**/*", async route =>
{
var path = new Uri(route.Request.Url).AbsolutePath;
if (path is "/one" or "/two")
{
await route.FulfillAsync(new()
{
ContentType = "text/html",
Body = $"<h1>{path}</h1><a href='/two'>to two</a><img src='/never.png'>"
});
}
// Anything else is left unanswered, which is what keeps `load` from ever firing.
});
await page.GotoAsync("http://repro.test/one", new() { WaitUntil = WaitUntilState.Commit });
var failed = false;
// 1. Asked BEFORE the navigation: the URL does not match yet, so this is WaitForNavigationAsync,
// which honours Commit.
var clock = Stopwatch.StartNew();
var before = page.WaitForURLAsync("**/two", new() { WaitUntil = WaitUntilState.Commit, Timeout = 3000 });
await page.ClickAsync("a");
try
{
await before;
Console.WriteLine($"asked before the navigation: returned after {clock.ElapsedMilliseconds} ms");
}
catch (TimeoutException)
{
failed = true;
Console.WriteLine($"asked before the navigation: TIMED OUT after {clock.ElapsedMilliseconds} ms");
}
// 2. Asked AFTER it: the URL already matches, so this is WaitForLoadStateAsync(ToLoadState(Commit)),
// and ToLoadState(Commit) is null, which WaitForLoadStateAsync reads as Load.
Console.WriteLine($"page.Url is now {page.Url}");
clock.Restart();
try
{
await page.WaitForURLAsync("**/two", new() { WaitUntil = WaitUntilState.Commit, Timeout = 3000 });
Console.WriteLine($"asked after the navigation: returned after {clock.ElapsedMilliseconds} ms");
}
catch (TimeoutException timeout)
{
failed = true;
Console.WriteLine($"asked after the navigation: TIMED OUT after {clock.ElapsedMilliseconds} ms");
Console.WriteLine(timeout.Message);
}
return failed ? 1 : 0;
dotnet build -c Release
dotnet bin/Release/net8.0/commit-repro.dll
Expected behavior
Both calls return as soon as the navigation to /two has committed. 'commit' is documented as "consider operation to be finished when network response is received and the document started loading", and that has happened by the time page.Url is /two:
asked before the navigation: returned after … ms
page.Url is now http://repro.test/two
asked after the navigation: returned after 0 ms
Actual behavior
The call made after the navigation waits for load, which this page never reaches, and times out. Every run, on both target frameworks:
asked before the navigation: returned after 524 ms
page.Url is now http://repro.test/two
asked after the navigation: TIMED OUT after 3031 ms
Timeout 3000ms exceeded.
(The 524 ms is the process's first ClickAsync, not the wait.)
Additional context
The two paths of Frame.WaitForURLAsync (src/Playwright/Core/Frame.cs) treat the option differently.
When the URL already matches, it returns
WaitForLoadStateAsync(ToLoadState(options?.WaitUntil), new() { Timeout = options?.Timeout });
ToLoadState maps Load, DOMContentLoaded and NetworkIdle, and returns null for anything else — LoadState has no Commit member to map to. WaitForLoadStateAsync(null) then falls back to its default, WaitUntilState.Load. So on this path Commit silently becomes Load.
When the URL does not match yet, WaitForNavigationInternalAsync compares the requested state with _loadStates by value string, commit is in _loadStates by then, and the option is honoured. Which of the two a caller gets depends only on whether the navigation had committed when the call was made — after a ClickAsync on a link it always has, because the click waits for the navigation it started to commit.
The JavaScript client does not have this: waitForURL hands options.waitUntil to waitForLoadState, whose accepted set includes 'commit', and _loadStates holds commit, so it returns at once.
A fix that mirrors the JavaScript client would be to let the internal load-state wait take a WaitUntilState, so that Commit is checked against _loadStates like the others. The smaller one is for WaitForURLAsync to return a completed task when the URL matches and WaitUntil is Commit: Frame assigns Url in its constructor, from the initializer, and in OnNavigated, so a matching URL is a committed one.
This was found while diagnosing a lost load in the same method, reported separately: on that path Commit looked like a way to avoid waiting for load, and turned out not to be one.
Environment
- OS: Windows 11 (10.0.26200), x64
- .NET SDK 10.0.400; run on Microsoft.NETCore.App 8.0.1 and 10.0.11, same result
- Microsoft.Playwright 1.62.0 from nuget.org
- Browser: Chromium 151.0.7922.34 (Playwright build 1234), headless
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in src/Playwright/Core/Frame.cs at WaitForURLAsync and compare the already-matching path with WaitForNavigationInternalAsync, using Program.cs from the commit-repro.csproj reproduction. Done means the post-navigation WaitUntilState.Commit call returns immediately instead of falling back to load, while the before-navigation case and existing behavior remain intact.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- csharp
- Domain
- testing
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 78/100