Repository navigation
Description
Version
1.60
Steps to reproduce
Environment
- Playwright Version: 1.60.0 (Regression, worked perfectly in 1.58.0)
- Language: C# / .NET 10.0
- Operating System: Windows 10/11
- Browser: Microsoft Edge / Chromium
Decription
When automating the Microsoft Entra ID (Azure AD) login flow, clicking the "Yes" button on the "Stay signed in?" prompt causes a TargetClosedException immediately after the click.
This is a strict regression: the exact same code, same application, and same flow work perfectly in Playwright 1.58. Furthermore, performing the exact same steps manually in the browser works perfectly without the page closing. It appears Playwright 1.60's stricter page/frame lifecycle tracking is incorrectly flagging the cross-origin OAuth redirect as a closed/detached target.
Steps to Reproduce
- Launch a Chromium/Edge browser instance via Playwright.CreateAsync().
- Navigate to an application protected by Microsoft SSO.
- Click the application's login button to trigger the redirect to login.microsoftonline.com.
- Fill in the email and password fields.
- Click "Sign In".
- Wait for the "Stay signed in?" prompt to appear.
- Click the "Yes" button.
- Attempt to wait for the next page state (e.g., WaitForLoadStateAsync or WaitForSelectorAsync).
public static async Task MSLogin(this IPage page, string eMail, string password)
{
await page.GetByLabel("someone@example.com").FillAsync(eMail);
await page.GetByText("Next").ClickAsync();
await page.GetByLabel("Password").FillAsync(password);
await page.GetByText("Sign In").ClickAsync();
// Wait for the "Stay signed in?" prompt
var yesButton = page.GetByText("Yes");
await yesButton.WaitForAsync(new() { State = WaitForSelectorState.Visible, Timeout = 10000 });
// Clicking "Yes" triggers the final cross-origin redirect back to the app
await yesButton.ClickAsync();
// BUG: In 1.60, this line (or the click itself) throws TargetClosedException.
// In 1.58, this successfully waits for the dashboard to load.
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
}Expected behavior
The page should successfully navigate through the final OAuth redirect and load the application dashboard, just as it does in Playwright 1.58 and when performed manually.
Actual behavior
Playwright throws an exception immediately after the "Yes" button is clicked or when the subsequent wait command is executed:
Microsoft.Playwright.PlaywrightException
HResult=0x80131500
Message=Target page, context or browser has been closed
Source=Microsoft.Playwright
StackTrace:
at Microsoft.Playwright.Core.Waiter.<WaitForPromiseAsync>d__20`1.MoveNext()
at Microsoft.Playwright.Core.Frame.<WaitForLoadStateAsync>d__48.MoveNext()
...
Inner Exception 1:
TargetClosedException: Target page, context or browser has been closed
Additional context
Manual Execution: If I perform the exact same login steps manually in a standard Edge or Chrome browser incognito/private mode or normal (navigating to the application, entering credentials, and clicking "Yes" on the "Stay signed in?" prompt), the application redirects to the dashboard perfectly. The crash only occurs when Playwright 1.60 automates the click on the "Yes" button.
Workaround: The only current workaround is to downgrade the NuGet package back to 1.58.0.
Environment
Playwright Version: 1.60.0 (Regression, worked perfectly in 1.58.0)
Language: C# / .NET 10.0
Operating System: Windows 10/11
Browser: Microsoft Edge / Chromium
IDE: Visual Studio 2026
after click on Yes the browser close immediately.
Additional Analysis
The root cause location: packages/playwright-core/src/server/chromium/crPage.ts
The Problem: Handeling of target datchement -> page close
What playwright does:
this code
this._client.send('Page.enable').catch(e => null).then(() => {
if (!childFrameSession._swappedIn)
this._page.frameManager.frameDetached(event.targetId!);
childFrameSession.dispose();
});->this code assumes that
detachedFromTarget =>
either:
(A) remote → local swap (_swappedIn = true)
(B) real frame detach → dispose
But Azure OAuth introduce a third case
(C) main-frame cross-process navigation (target replacement)
In that case:
_swappedIn is still FALSE
frameAttached has not yet arrived
BUT navigation is valid : the code falls into (B) incorrectly → kills the page
A possible fix is to replace this
this._client.send('Page.enable').catch(e => null).then(() => {
if (!childFrameSession._swappedIn)
this._page.frameManager.frameDetached(event.targetId!);
childFrameSession.dispose();
});with this
_onDetachedFromTarget(event: Protocol.Target.detachedFromTargetPayload) {
const targetId = event.targetId!;
const childFrameSession = this._targetIdToFrameSession.get(targetId);
if (!childFrameSession)
return;
// FIX PART 1: Synchronous early exit if already swapped
if (childFrameSession._swappedIn) {
childFrameSession.dispose();
return;
}
this._client.send('Page.enable').catch(() => null).then(() => {
// FIX PART 2: Re-check _swappedIn after the microtask delay
if (childFrameSession._swappedIn) {
childFrameSession.dispose();
return;
}
// FIX PART 3: The core race-condition guard.
// If the session currently mapped to this targetId is NO LONGER
// the childFrameSession that triggered this detachment, it means
// a new session (from a cross-process navigation) has already
// taken over this targetId. We must NOT call frameDetached.
if (this._targetIdToFrameSession.get(targetId) !== childFrameSession) {
childFrameSession.dispose();
return;
}
// Safe to proceed with normal detachment
this._page.frameManager.frameDetached(targetId);
childFrameSession.dispose();
});
}Reproduce the bug
make sure that the correct version is used
<PackageReference Include="Microsoft.Playwright" Version="1.60.0" />
using System.Net;
using System.Net.Sockets;
using System.Text;
using Microsoft.Playwright;
class Program
{
static async Task Main(string[] args)
{
var server = new MockOAuthServer();
await server.StartAsync();
Console.WriteLine($"[Mock] App: http://localhost:{server.AppPort} | IdP: http://localhost:{server.IdpPort}");
try
{
var playwright = await Playwright.CreateAsync();
await using var browser = await playwright.Chromium.LaunchAsync(new()
{
Headless = false,
Channel = "msedge",
Args = new[] { "--auth-server-allowlist=\"_\"" }
});
var page = await browser.NewPageAsync();
await page.GotoAsync($"http://localhost:{server.AppPort}/");
await page.ClickAsync("text=Login"); // Redirects to IdP
// Simulate the MS Login flow
await page.GetByLabel("someone@example.com").FillAsync("test@example.com");
await page.GetByText("Next").ClickAsync();
await page.GetByLabel("Password").FillAsync("password");
await page.GetByText("Sign In").ClickAsync();
var yesButton = page.GetByText("Yes");
await yesButton.WaitForAsync(new() { State = WaitForSelectorState.Visible });
// BUG TRIGGER: Clicking "Yes" triggers a cross-origin 302 redirect to an external site (Bing/Yahoo).
// In Playwright 1.60, this cross-origin navigation from a mocked server can throw TargetClosedException.
// We wait for the URL to change to Bing.com or yahoo.com to ensure the navigation actually started and completed
var navTask = page.WaitForURLAsync("**/*yahoo.com*");
await yesButton.ClickAsync();
await navTask;
Console.WriteLine("[V] SUCCESS: Page survived the cross-origin redirect to Bing/Yahoo!");
}
catch (Exception ex)
{
Console.WriteLine($"[X] FAILED: {ex.GetType().Name} - {ex.Message}");
}
finally
{
server.Stop();
}
}
}
public class MockOAuthServer
{
private TcpListener _appListener, _idpListener;
private CancellationTokenSource _cts = new();
public int AppPort { get; private set; }
public int IdpPort { get; private set; }
public async Task StartAsync()
{
_appListener = new TcpListener(IPAddress.Loopback, 0); _appListener.Start();
AppPort = ((IPEndPoint)_appListener.LocalEndpoint).Port;
_idpListener = new TcpListener(IPAddress.Loopback, 0); _idpListener.Start();
IdpPort = ((IPEndPoint)_idpListener.LocalEndpoint).Port;
_ = Task.Run(() => AcceptClients(_appListener, true));
_ = Task.Run(() => AcceptClients(_idpListener, false));
await Task.Delay(200);
}
private async Task AcceptClients(TcpListener listener, bool isApp)
{
while (!_cts.Token.IsCancellationRequested)
{
try { var client = await listener.AcceptTcpClientAsync(); _ = Task.Run(() => HandleClient(client, isApp)); }
catch { break; }
}
}
private async Task HandleClient(TcpClient client, bool isApp)
{
using (client) using (var stream = client.GetStream())
{
var buffer = new byte[8192];
var bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length);
if (bytesRead == 0) return;
var request = Encoding.UTF8.GetString(buffer, 0, bytesRead);
var path = request.Split('\n')[0].Split(' ')[1];
string headers = "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nConnection: close\r\n\r\n";
string body = "";
if (isApp)
{
if (path == "/dashboard") body = "<html><body><h1>Dashboard</h1></body></html>";
else body = $"<html><body><a href='http://localhost:{IdpPort}/login'>Login</a></body></html>";
}
else
{
if (path.StartsWith("/login") && !request.Contains("POST"))
body = "<html><body><form action='/login' method='POST'><input name='email' aria-label='someone@example.com'/><button>Next</button></form></body></html>";
else if (path.StartsWith("/login") && request.Contains("POST"))
body = "<html><body><form action='/password' method='POST'><input type='password' name='password' aria-label='Password'/><button>Sign In</button></form></body></html>";
else if (path.StartsWith("/password"))
body = "<html><body><div>Stay signed in?</div><form action='/redirect' method='POST'><button name='choice' value='yes'>Yes</button></form></body></html>";
else if (path.StartsWith("/redirect"))
{
// Redirect to Bing.com/Yahoo.com to force a true cross-origin navigation
headers = "HTTP/1.1 302 Found\r\nLocation: https://www.yahoo.com/\r\nConnection: close\r\n\r\n";
body = "";
}
}
var responseBytes = Encoding.UTF8.GetBytes(headers + body);
await stream.WriteAsync(responseBytes, 0, responseBytes.Length);
}
}
public void Stop() { _cts.Cancel(); _appListener?.Stop(); _idpListener?.Stop(); }
}
Activity
dgozman commentedon Jun 18, 2026
Karim Nabli (@karimnabli) Could you please share a full self-contained reproducible, instead of an isolated chunk of code?
karimnabli commentedon Jun 18, 2026
Dmitry Gozman (@dgozman) I have added the code to reporduce the issue.
I have as well provided some of my investigations finding and a proposed fix.
thanks.
dgozman commentedon Jun 19, 2026
Karim Nabli (@karimnabli) In your source, there is a call to
QAPlaywrightInstance.LaunchChromiumBrowser. I am not sure what exactly does this call do, so I cannot reproduce the issue. To be able to reproduce, we need a self-contained repro.That said, I think this could be related to
RenderDocumentfeature being enabled/disabled. Playwright currently disables it, because not all things are working correctly yet. Perhaps the way you launch the browser is different from what Playwright does, and that could explain the issue? For example, passing custom arguments can affect things.karimnabli commentedon Jun 19, 2026
Dmitry Gozman (@dgozman)
I completely understand your reluctance to execute code containing an unfamiliar method name.
To demystify this seemingly opaque QAPlaywrightInstance.LaunchChromiumBrowser for you, I can assure you it is not a black box. It is merely a thin, three-line wrapper that executes the exact standard Playwright API calls: Playwright.CreateAsync(), Chromium.LaunchAsync(), and NewPageAsync(). I realize that encountering a simple abstraction layer can sometimes obscure the underlying mechanics, so I hope this clarification puts your mind at ease. (check the updated code).
I have removed all abstractions, all custom libraries, and all complex logic, leaving only the barest skeleton required to trigger the exact TargetClosedException regression in 1.60. You can copy, paste, and run this without any problem.
I trust this elementary example will finally allow you to reproduce the lifecycle detachment issue without the friction of navigating a custom library.
I look forward to reviewing your patch for the crPage.ts target detachment race condition.
karimnabli commentedon Aug 4, 2026
I have updated the C# code: this code should fail all the times.
dgozman commentedon Aug 20, 2026
I have spent more time trying to reproduce this. It does not reproduce for me on 1.58 node.js, 1.60 node.js, 1.58 .net, 1.60 .net versions. The 302 redirect succeeds, the page sits there for 30 seconds, but there is no
TargetClosedException.Note: I had to use
**yahoo.com**glob instead of**/*yahoo.com*.Given the inability to reproduce, and no viable way to write a test or produce logs, I have to close this issue. Please don't hesitate to file a new one if you get a reliable repro.
NikkTod commentedon Aug 24, 2026
hey Dmitry Gozman (@dgozman), unfortunately I also face the same issue with the MS OAuth page on 1.62.1
Clicking on the sign in button crashes the browser, I am using browser verison 151.0.4129.107
await expect(this.signInButton()).toBeVisible();
await this.passwordField().fill(password);
await this.signInButton().click();
// 💥 browser/page disappears here
karimnabli commentedon Aug 30, 2026
Dear Dmitry Gozman (@dgozman), Pavel Feldman (@pavelfeldman) I think NikkTod just confirmed the existence of the issue.
I will provide additional proof of the bug this week.
Will you consider please to re-open the bug?