Skip to content

[Bug]: TargetClosedException thrown after clicking "Yes" on MS OAuth prompt in 1.60 (Regression from 1.58) #41348

Closed as not planned

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

  1. Launch a Chromium/Edge browser instance via Playwright.CreateAsync().
  2. Navigate to an application protected by Microsoft SSO.
  3. Click the application's login button to trigger the redirect to login.microsoftonline.com.
  4. Fill in the email and password fields.
  5. Click "Sign In".
  6. Wait for the "Stay signed in?" prompt to appear.
  7. Click the "Yes" button.
  8. 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("[email protected]").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
Image Image Image Image Image

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:

Image

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("[email protected]").FillAsync("[email protected]");
            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='[email protected]'/><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

  1. dgozman commented on Jun 18, 2026

    @dgozman
    Collaborator

    Karim Nabli (@karimnabli) Could you please share a full self-contained reproducible, instead of an isolated chunk of code?

  2. karimnabli commented on Jun 18, 2026

    @karimnabli
    Author

    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.

  3. dgozman commented on Jun 19, 2026

    @dgozman
    Collaborator

    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 RenderDocument feature 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.

  4. karimnabli commented on Jun 19, 2026

    @karimnabli
    Author

    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.

  5. karimnabli commented on Aug 4, 2026

    @karimnabli
    Author

    I have updated the C# code: this code should fail all the times.

  6. dgozman commented on Aug 20, 2026

    @dgozman
    Collaborator

    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.

  7. NikkTod commented on Aug 24, 2026

    @NikkTod

    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

  8. karimnabli commented on Aug 30, 2026

    @karimnabli
    Author

    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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions