← Back to microsoft/playwright
microsoft / playwright · Issue No. 41348
1.60
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
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);
}
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.
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
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.
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.
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();
});
}
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(); }
}
Relay reads this issue against the repository's contribution signals: the files it is likely to touch, how the maintainers triage work this size, and what the first contribution would exercise.
The full analysis for this issue is still being assembled. Until then, the description above and the thread on GitHub are the most reliable context.