Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## [Unreleased]

### Fixed
- Closing a browser context releases it, its pages and their route handlers instead of keeping them for the browser's lifetime

## [1.5.0] - 2026-09-20

### Added
Expand Down
8 changes: 8 additions & 0 deletions src/Browser/Browser.php
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,14 @@ public function contexts(): array
return $this->contexts;
}

/**
* @internal called by BrowserContext::close()
*/
public function forgetContext(BrowserContextInterface $context): void
{
$this->contexts = array_values(array_filter($this->contexts, static fn (BrowserContextInterface $c): bool => $c !== $context));
}

public function browserType(): BrowserType
{
return $this->browserType;
Expand Down
45 changes: 41 additions & 4 deletions src/Browser/BrowserContext.php
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ public function dispatchEvent(string $eventName, array $params): void
$pageId = $params['pageId'] ?? null;
if (is_string($pageId)) {
unset($this->pages[$pageId]);
$this->removeEventDispatcher($pageId);
}

return;
Expand Down Expand Up @@ -237,10 +238,46 @@ public function close(): void
$this->saveAutoTrace();
}

$this->transport->send([
'action' => 'context.close',
'contextId' => $this->contextId,
]);
try {
$this->transport->send([
'action' => 'context.close',
'contextId' => $this->contextId,
]);
} finally {
foreach (array_keys($this->pages) as $pageId) {
$this->removeEventDispatcher($pageId);
}
$this->removeEventDispatcher($this->contextId);
$this->pages = [];
$this->routeHandlers = [];

if ($this->browser instanceof Browser) {
$this->browser->forgetContext($this);
}
}
}

/**
* @internal called by Page::waitForPopup()
*/
public function registerPage(PageInterface $page): void
{
$this->pages[$page->getPageIdForTransport()] = $page;
}

/**
* @internal called by Page::close()
*/
public function forgetPage(PageInterface $page): void
{
unset($this->pages[$page->getPageIdForTransport()]);
}

private function removeEventDispatcher(string $id): void
{
if (method_exists($this->transport, 'removeEventDispatcher')) {
$this->transport->removeEventDispatcher($id);
}
}

public function isClosed(): bool
Expand Down
15 changes: 14 additions & 1 deletion src/Page/Page.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
namespace Playwright\Page;

use Playwright\API\APIRequestContextInterface;
use Playwright\Browser\BrowserContext;
use Playwright\Browser\BrowserContextInterface;
use Playwright\Clock\ClockInterface;
use Playwright\Configuration\PlaywrightConfig;
Expand Down Expand Up @@ -639,6 +640,13 @@ public function close(): void
$this->sendCommand('close');

$this->isClosed = true;

if (method_exists($this->transport, 'removeEventDispatcher')) {
$this->transport->removeEventDispatcher($this->pageId);
}
if ($this->context instanceof BrowserContext) {
$this->context->forgetPage($this);
}
}

public function isClosed(): bool
Expand Down Expand Up @@ -1257,7 +1265,12 @@ public function waitForPopup(callable $action, array|WaitForPopupOptions $option
throw new TimeoutException('No popup was created within the timeout period');
}

return new self($this->transport, $this->context, $popupPageId, $this->config, $this->logger);
$popup = new self($this->transport, $this->context, $popupPageId, $this->config, $this->logger);
if ($this->context instanceof BrowserContext) {
$this->context->registerPage($popup);
}

return $popup;
}

/**
Expand Down
5 changes: 5 additions & 0 deletions src/Transport/JsonRpc/JsonRpcTransport.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ public function addEventDispatcher(string $id, EventDispatcherInterface $dispatc
$this->eventDispatchers[$id] = $dispatcher;
}

public function removeEventDispatcher(string $id): void
{
unset($this->eventDispatchers[$id]);
}

public function connect(): void
{
if ($this->connected) {
Expand Down
79 changes: 79 additions & 0 deletions tests/Mocks/EventDispatcherTransport.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<?php

declare(strict_types=1);

/*
* This file is part of the community-maintained Playwright PHP project.
* It is not affiliated with or endorsed by Microsoft.
*
* (c) 2025-Present - Playwright PHP - https://github.com/playwright-php
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Playwright\Tests\Mocks;

use Playwright\Event\EventDispatcherInterface;
use Playwright\Transport\TransportInterface;

/**
* Decorates a transport with the event dispatcher registry of JsonRpcTransport.
*/
final class EventDispatcherTransport implements TransportInterface
{
/** @var array<string, EventDispatcherInterface> */
private array $eventDispatchers = [];

public function __construct(private readonly TransportInterface $decorated)
{
}

public function addEventDispatcher(string $id, EventDispatcherInterface $dispatcher): void
{
$this->eventDispatchers[$id] = $dispatcher;
}

public function removeEventDispatcher(string $id): void
{
unset($this->eventDispatchers[$id]);
}

/**
* @return array<string, EventDispatcherInterface>
*/
public function getEventDispatchers(): array
{
return $this->eventDispatchers;
}

public function connect(): void
{
$this->decorated->connect();
}

public function disconnect(): void
{
$this->decorated->disconnect();
}

public function send(array $message): array
{
return $this->decorated->send($message);
}

public function sendAsync(array $message): void
{
$this->decorated->sendAsync($message);
}

public function isConnected(): bool
{
return $this->decorated->isConnected();
}

public function processEvents(): void
{
$this->decorated->processEvents();
}
}
167 changes: 167 additions & 0 deletions tests/Unit/Browser/BrowserContextCloseTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
<?php

declare(strict_types=1);

/*
* This file is part of the community-maintained Playwright PHP project.
* It is not affiliated with or endorsed by Microsoft.
*
* (c) 2025-Present - Playwright PHP - https://github.com/playwright-php
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Playwright\Tests\Unit\Browser;

use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\TestCase;
use Playwright\Browser\Browser;
use Playwright\Browser\BrowserContext;
use Playwright\Configuration\PlaywrightConfig;
use Playwright\Exception\TransportException;
use Playwright\Page\Page;
use Playwright\Tests\Mocks\EventDispatcherTransport;
use Playwright\Transport\MockTransport;

#[CoversClass(Browser::class)]
#[CoversClass(BrowserContext::class)]
#[CoversClass(Page::class)]
final class BrowserContextCloseTest extends TestCase
{
private MockTransport $mockTransport;
private EventDispatcherTransport $transport;
private Browser $browser;

protected function setUp(): void
{
$this->mockTransport = new MockTransport();
$this->mockTransport->connect();
$this->transport = new EventDispatcherTransport($this->mockTransport);
$this->browser = new Browser($this->transport, 'browser_1', 'ctx_default', '1.0', new PlaywrightConfig());
}

public function testCloseForgetsTheContextAndReleasesItsDispatchers(): void
{
$context = $this->openContext('ctx_1');
$this->mockTransport->queueResponse(['pageId' => 'page_1']);
$context->newPage();
$this->mockTransport->queueResponse([]);
$context->route('**/*', static function (): void {});

$this->assertSame([$this->browser->context(), $context], $this->browser->contexts());
$this->assertSame(['ctx_default', 'ctx_1', 'page_1'], array_keys($this->transport->getEventDispatchers()));

$this->mockTransport->queueResponse([]);
$context->close();

$this->assertSame([$this->browser->context()], $this->browser->contexts());
$this->assertSame(['ctx_default'], array_keys($this->transport->getEventDispatchers()));
$this->assertSame([], $context->pages());
$this->assertSame(
['newContext', 'context.newPage', 'context.route', 'context.close'],
array_column($this->mockTransport->getSentMessages(), 'action'),
);
}

public function testClosedContextsAreGarbageCollected(): void
{
$references = [];
for ($i = 0; $i < 3; ++$i) {
$context = $this->openContext('ctx_'.$i);
$this->mockTransport->queueResponse(['pageId' => 'page_'.$i]);
$page = $context->newPage();
$this->mockTransport->queueResponse([]);
$page->route('**/*', static fn () => $context);
$this->mockTransport->queueResponse([]);
$context->route('**/*', static fn () => $page);
$this->mockTransport->queueResponse(['popupPageId' => 'popup_'.$i]);
$popup = $page->waitForPopup(static fn () => null);
$this->mockTransport->queueResponse([]);
$popup->route('**/*', static fn () => $context);

$this->mockTransport->queueResponse([]);
$context->close();

$references[] = \WeakReference::create($context);
}
unset($context, $page, $popup);

gc_collect_cycles();

foreach ($references as $reference) {
$this->assertNull($reference->get());
}
}

public function testCloseReleasesTheContextWhenTheCloseCommandFails(): void
{
$context = $this->openContext('ctx_1');
$this->mockTransport->queueResponse(['pageId' => 'page_1']);
$context->newPage();

$this->mockTransport->queueResponse(new TransportException('context.close failed'));
try {
$context->close();
$this->fail('Expected the close command to fail.');
} catch (TransportException) {
}

$this->assertSame([$this->browser->context()], $this->browser->contexts());
$this->assertSame(['ctx_default'], array_keys($this->transport->getEventDispatchers()));
$this->assertSame([], $context->pages());
}

public function testPopupIsTrackedByItsContext(): void
{
$context = $this->openContext('ctx_1');
$this->mockTransport->queueResponse(['pageId' => 'page_1']);
$page = $context->newPage();
$this->mockTransport->queueResponse(['popupPageId' => 'popup_1']);

$popup = $page->waitForPopup(static fn () => null);

$this->assertSame([$page, $popup], $context->pages());

$this->mockTransport->queueResponse([]);
$context->close();

$this->assertSame(['ctx_default'], array_keys($this->transport->getEventDispatchers()));
}

public function testPageCloseReleasesThePage(): void
{
$context = $this->openContext('ctx_1');
$this->mockTransport->queueResponse(['pageId' => 'page_1']);
$page = $context->newPage();

$this->mockTransport->queueResponse([]);
$page->close();

$this->assertSame([], $context->pages());
$this->assertSame(['ctx_default', 'ctx_1'], array_keys($this->transport->getEventDispatchers()));
$this->assertSame(
['newContext', 'context.newPage', 'page.close'],
array_column($this->mockTransport->getSentMessages(), 'action'),
);
}

public function testPageClosedEventReleasesThePageDispatcher(): void
{
$context = $this->openContext('ctx_1');
$this->mockTransport->queueResponse(['pageId' => 'page_1']);
$context->newPage();

$context->dispatchEvent('pageClosed', ['pageId' => 'page_1']);

$this->assertSame([], $context->pages());
$this->assertSame(['ctx_default', 'ctx_1'], array_keys($this->transport->getEventDispatchers()));
}

private function openContext(string $contextId): BrowserContext
{
$this->mockTransport->queueResponse(['contextId' => $contextId]);

return $this->browser->newContext();
}
}
Loading
Loading