From 2960ef95bb1df1026050d32d3c80f6b37360b0b1 Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Thu, 21 May 2026 15:54:34 -0700 Subject: [PATCH] fix(server): clearCookies({name}) should not transiently delete other cookies BrowserContext.clearCookies(options) currently wipes every cookie via doClearCookies() and then re-adds the ones that did not match the filter. Pages that subscribe to cookieStore.change observe a transient deletion of the kept cookies during the gap between the wipe and the readd, which is enough to trip route-guards, useSyncExternalStore-style auth state machines, and similar. When a filter (name, domain, or path) is set, expire only the matching cookies in place by calling addCookies with expires=0; the no-filter path still delegates to doClearCookies() as before. No per-browser code is changed. Reported and diagnosed by @jasikpark in #40953. --- .../src/server/browserContext.ts | 27 ++++++++---- .../browsercontext-clearcookies.spec.ts | 42 +++++++++++++++++++ 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index 1124acb9f9faf..68069adcf68ef 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -309,8 +309,11 @@ export abstract class BrowserContext extends Sdk } async clearCookies(options: {name?: string | RegExp, domain?: string | RegExp, path?: string | RegExp}): Promise { - const currentCookies = await this._cookies(); - await this.doClearCookies(); + const hasFilter = options.name !== undefined || options.domain !== undefined || options.path !== undefined; + if (!hasFilter) { + await this.doClearCookies(); + return; + } const matches = (cookie: channels.NetworkCookie, prop: 'name' | 'domain' | 'path', value: string | RegExp | undefined) => { if (!value) @@ -322,13 +325,23 @@ export abstract class BrowserContext extends Sdk return cookie[prop] === value; }; - const cookiesToReadd = currentCookies.filter(cookie => { - return !matches(cookie, 'name', options.name) - || !matches(cookie, 'domain', options.domain) - || !matches(cookie, 'path', options.path); + const currentCookies = await this._cookies(); + const cookiesToExpire = currentCookies.filter(cookie => { + return matches(cookie, 'name', options.name) + && matches(cookie, 'domain', options.domain) + && matches(cookie, 'path', options.path); }); - await this.addCookies(cookiesToReadd); + if (!cookiesToExpire.length) + return; + + await this.addCookies(cookiesToExpire.map(cookie => ({ + name: cookie.name, + value: '', + domain: cookie.domain, + path: cookie.path, + expires: 0, + }))); } setHTTPCredentials(progress: Progress, httpCredentials?: types.Credentials): Promise { diff --git a/tests/library/browsercontext-clearcookies.spec.ts b/tests/library/browsercontext-clearcookies.spec.ts index 1e4725797c5fe..bd493e9a86424 100644 --- a/tests/library/browsercontext-clearcookies.spec.ts +++ b/tests/library/browsercontext-clearcookies.spec.ts @@ -164,3 +164,45 @@ it('should remove cookies by name and domain', async ({ context, page, server }) await page.goto(server.CROSS_PROCESS_PREFIX); expect(await page.evaluate('document.cookie')).toBe('cookie1=1'); }); + +it('should not transiently delete non-matching cookies when filtering', { + annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/40953' }, +}, async ({ context, page, server, browserName }) => { + it.skip(browserName !== 'chromium', 'cookieStore API is only available in Chromium'); + + await context.addCookies([{ + name: 'keep_me', + value: '1', + domain: new URL(server.PREFIX).hostname, + path: '/', + }, + { + name: 'delete_me', + value: '2', + domain: new URL(server.PREFIX).hostname, + path: '/', + } + ]); + await page.goto(server.PREFIX); + + await page.evaluate(() => { + (window as any).__cookieEvents = []; + (window as any).cookieStore.addEventListener('change', (event: any) => { + for (const changed of event.changed) + (window as any).__cookieEvents.push({ kind: 'changed', name: changed.name }); + for (const deleted of event.deleted) + (window as any).__cookieEvents.push({ kind: 'deleted', name: deleted.name }); + }); + }); + + await context.clearCookies({ name: 'delete_me' }); + + // Flush microtasks so any change events fired during clearCookies are observed. + await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 50))); + + const events: { kind: string, name: string }[] = await page.evaluate(() => (window as any).__cookieEvents); + + // The kept cookie must never appear in a deletion event. + expect(events.filter(e => e.kind === 'deleted' && e.name === 'keep_me')).toEqual([]); + expect(await page.evaluate('document.cookie')).toBe('keep_me=1'); +});