From 2960ef95bb1df1026050d32d3c80f6b37360b0b1 Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Thu, 21 May 2026 15:54:34 -0700 Subject: [PATCH 1/6] 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'); +}); From 43cef10f0c229e9edf9fdc323673c32855f521bf Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Tue, 26 May 2026 15:09:47 +0200 Subject: [PATCH 2/6] test: regressions for clearCookies filter with __Secure- and partitioned cookies Adds coverage for two cases the new filtered-clearCookies path in #40955 does not handle: __Secure- prefixed cookies (rejected by Chromium because the expire payload omits secure:true) and partitioned (CHIPS) cookies (silently survive because partitionKey is dropped from the expire payload). --- .../browsercontext-clearcookies.spec.ts | 60 ++++++++++++++----- 1 file changed, 46 insertions(+), 14 deletions(-) diff --git a/tests/library/browsercontext-clearcookies.spec.ts b/tests/library/browsercontext-clearcookies.spec.ts index bd493e9a86424..94a659d6eed74 100644 --- a/tests/library/browsercontext-clearcookies.spec.ts +++ b/tests/library/browsercontext-clearcookies.spec.ts @@ -167,9 +167,7 @@ it('should remove cookies by name and domain', async ({ context, page, server }) 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'); - +}, async ({ context, page, server }) => { await context.addCookies([{ name: 'keep_me', value: '1', @@ -185,24 +183,58 @@ it('should not transiently delete non-matching cookies when filtering', { ]); await page.goto(server.PREFIX); - await page.evaluate(() => { - (window as any).__cookieEvents = []; - (window as any).cookieStore.addEventListener('change', (event: any) => { + const eventsHandle = await page.evaluateHandle(() => { + const events: { kind: string, name: string }[] = []; + cookieStore.addEventListener('change', event => { for (const changed of event.changed) - (window as any).__cookieEvents.push({ kind: 'changed', name: changed.name }); + events.push({ kind: 'changed', name: changed.name }); for (const deleted of event.deleted) - (window as any).__cookieEvents.push({ kind: 'deleted', name: deleted.name }); + events.push({ kind: 'deleted', name: deleted.name }); }); + return events; }); await context.clearCookies({ name: 'delete_me' }); + await expect.poll(() => eventsHandle.jsonValue()).toContainEqual({ kind: 'deleted', name: 'delete_me' }); - // Flush microtasks so any change events fired during clearCookies are observed. - await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 50))); + expect(await eventsHandle.jsonValue()).not.toContainEqual({ kind: 'deleted', name: 'keep_me' }); + expect(await page.evaluate('document.cookie')).toBe('keep_me=1'); +}); - const events: { kind: string, name: string }[] = await page.evaluate(() => (window as any).__cookieEvents); +it.describe('clearCookies with filter preserves cookie identity', () => { + it.use({ ignoreHTTPSErrors: true }); - // 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'); + it('should remove __Secure- prefixed cookies by name', async ({ context, httpsServer }) => { + await context.addCookies([ + { name: '__Secure-delete_me', value: '1', domain: httpsServer.HOSTNAME, path: '/', secure: true, sameSite: 'None' }, + { name: 'keep_me', value: '2', domain: httpsServer.HOSTNAME, path: '/', secure: true, sameSite: 'None' }, + ]); + expect((await context.cookies()).map(c => c.name).sort()).toEqual(['__Secure-delete_me', 'keep_me']); + + await context.clearCookies({ name: '__Secure-delete_me' }); + expect(await context.cookies()).toEqual([expect.objectContaining({ name: 'keep_me' })]); + }); + + it('should remove partitioned cookies by name', async ({ context, httpsServer, browserName }) => { + it.skip(browserName !== 'chromium', 'Partitioned cookies (CHIPS) are Chromium-specific'); + await context.addCookies([ + { + name: 'delete_me', + value: '1', + domain: httpsServer.HOSTNAME, + path: '/', + secure: true, + sameSite: 'None', + partitionKey: `https://${httpsServer.HOSTNAME}`, + }, + ]); + const before = await context.cookies(); + expect(before).toEqual([expect.objectContaining({ + name: 'delete_me', + partitionKey: `https://${httpsServer.HOSTNAME}`, + })]); + + await context.clearCookies({ name: 'delete_me' }); + expect(await context.cookies()).toHaveLength(0); + }); }); From a7519f50deb634edea2846d49891510e98c2fc88 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Tue, 26 May 2026 15:17:07 +0200 Subject: [PATCH 3/6] test: add __Host- regression for clearCookies filter Same root cause as the __Secure- regression: without secure/sameSite in the expire payload, Chromium rejects the cookie with "Invalid cookie fields". --- tests/library/browsercontext-clearcookies.spec.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/library/browsercontext-clearcookies.spec.ts b/tests/library/browsercontext-clearcookies.spec.ts index 94a659d6eed74..773d76f5783ca 100644 --- a/tests/library/browsercontext-clearcookies.spec.ts +++ b/tests/library/browsercontext-clearcookies.spec.ts @@ -215,6 +215,17 @@ it.describe('clearCookies with filter preserves cookie identity', () => { expect(await context.cookies()).toEqual([expect.objectContaining({ name: 'keep_me' })]); }); + it('should remove __Host- prefixed cookies by name', async ({ context, httpsServer }) => { + await context.addCookies([ + { name: '__Host-delete_me', value: '1', url: httpsServer.PREFIX, secure: true, sameSite: 'None' }, + { name: 'keep_me', value: '2', url: httpsServer.PREFIX, secure: true, sameSite: 'None' }, + ]); + expect((await context.cookies()).map(c => c.name).sort()).toEqual(['__Host-delete_me', 'keep_me']); + + await context.clearCookies({ name: '__Host-delete_me' }); + expect(await context.cookies()).toEqual([expect.objectContaining({ name: 'keep_me' })]); + }); + it('should remove partitioned cookies by name', async ({ context, httpsServer, browserName }) => { it.skip(browserName !== 'chromium', 'Partitioned cookies (CHIPS) are Chromium-specific'); await context.addCookies([ From 88384e65b3102b61bb61ec14d5f850b8290ebb39 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Tue, 26 May 2026 15:17:08 +0200 Subject: [PATCH 4/6] fix(server): preserve cookie attributes when expiring on filtered clearCookies Spread the original cookie into the expire payload instead of picking name/domain/path. Without secure/sameSite/partitionKey/_crHasCrossSiteAncestor, Chromium rejects __Secure- and __Host- prefixed cookies with "Invalid cookie fields" and silently leaves partitioned (CHIPS) cookies in place. --- packages/playwright-core/src/server/browserContext.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/playwright-core/src/server/browserContext.ts b/packages/playwright-core/src/server/browserContext.ts index 68069adcf68ef..590a2b1be98c2 100644 --- a/packages/playwright-core/src/server/browserContext.ts +++ b/packages/playwright-core/src/server/browserContext.ts @@ -336,10 +336,8 @@ export abstract class BrowserContext extends Sdk return; await this.addCookies(cookiesToExpire.map(cookie => ({ - name: cookie.name, + ...cookie, value: '', - domain: cookie.domain, - path: cookie.path, expires: 0, }))); } From 89379b9e81a3b9a9323f3c4dd54a43a6011c3683 Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Wed, 27 May 2026 16:34:26 +0200 Subject: [PATCH 5/6] test(clearCookies): address review feedback - Drop *-prefixed tests (__Secure-, __Host-) - Simplify partitioned-cookie test: inline newContext, add a preserved cookie - Simplify event log to plain strings for direct equality assertion --- .../browsercontext-clearcookies.spec.ts | 85 ++++++++----------- 1 file changed, 35 insertions(+), 50 deletions(-) diff --git a/tests/library/browsercontext-clearcookies.spec.ts b/tests/library/browsercontext-clearcookies.spec.ts index 773d76f5783ca..73b34cc77beca 100644 --- a/tests/library/browsercontext-clearcookies.spec.ts +++ b/tests/library/browsercontext-clearcookies.spec.ts @@ -184,68 +184,53 @@ it('should not transiently delete non-matching cookies when filtering', { await page.goto(server.PREFIX); const eventsHandle = await page.evaluateHandle(() => { - const events: { kind: string, name: string }[] = []; + const events: string[] = []; cookieStore.addEventListener('change', event => { for (const changed of event.changed) - events.push({ kind: 'changed', name: changed.name }); + events.push(`changed ${changed.name}`); for (const deleted of event.deleted) - events.push({ kind: 'deleted', name: deleted.name }); + events.push(`deleted ${deleted.name}`); }); return events; }); await context.clearCookies({ name: 'delete_me' }); - await expect.poll(() => eventsHandle.jsonValue()).toContainEqual({ kind: 'deleted', name: 'delete_me' }); - - expect(await eventsHandle.jsonValue()).not.toContainEqual({ kind: 'deleted', name: 'keep_me' }); + await expect.poll(() => eventsHandle.jsonValue()).toEqual(['deleted delete_me']); expect(await page.evaluate('document.cookie')).toBe('keep_me=1'); }); -it.describe('clearCookies with filter preserves cookie identity', () => { - it.use({ ignoreHTTPSErrors: true }); - - it('should remove __Secure- prefixed cookies by name', async ({ context, httpsServer }) => { - await context.addCookies([ - { name: '__Secure-delete_me', value: '1', domain: httpsServer.HOSTNAME, path: '/', secure: true, sameSite: 'None' }, - { name: 'keep_me', value: '2', domain: httpsServer.HOSTNAME, path: '/', secure: true, sameSite: 'None' }, - ]); - expect((await context.cookies()).map(c => c.name).sort()).toEqual(['__Secure-delete_me', 'keep_me']); - - await context.clearCookies({ name: '__Secure-delete_me' }); - expect(await context.cookies()).toEqual([expect.objectContaining({ name: 'keep_me' })]); - }); - - it('should remove __Host- prefixed cookies by name', async ({ context, httpsServer }) => { - await context.addCookies([ - { name: '__Host-delete_me', value: '1', url: httpsServer.PREFIX, secure: true, sameSite: 'None' }, - { name: 'keep_me', value: '2', url: httpsServer.PREFIX, secure: true, sameSite: 'None' }, - ]); - expect((await context.cookies()).map(c => c.name).sort()).toEqual(['__Host-delete_me', 'keep_me']); - - await context.clearCookies({ name: '__Host-delete_me' }); - expect(await context.cookies()).toEqual([expect.objectContaining({ name: 'keep_me' })]); - }); - - it('should remove partitioned cookies by name', async ({ context, httpsServer, browserName }) => { - it.skip(browserName !== 'chromium', 'Partitioned cookies (CHIPS) are Chromium-specific'); - await context.addCookies([ - { - name: 'delete_me', - value: '1', - domain: httpsServer.HOSTNAME, - path: '/', - secure: true, - sameSite: 'None', - partitionKey: `https://${httpsServer.HOSTNAME}`, - }, - ]); - const before = await context.cookies(); - expect(before).toEqual([expect.objectContaining({ +it('should remove partitioned cookies by name', async ({ browser, httpsServer, browserName }) => { + it.skip(browserName !== 'chromium', 'Partitioned cookies (CHIPS) are Chromium-specific'); + const context = await browser.newContext({ ignoreHTTPSErrors: true }); + await context.addCookies([ + { name: 'delete_me', + value: '1', + domain: httpsServer.HOSTNAME, + path: '/', + secure: true, + sameSite: 'None', + partitionKey: `https://${httpsServer.HOSTNAME}`, + }, + { + name: 'keep_me', + value: '2', + domain: httpsServer.HOSTNAME, + path: '/', + secure: true, + sameSite: 'None', partitionKey: `https://${httpsServer.HOSTNAME}`, - })]); + }, + ]); + const before = await context.cookies(); + expect(before).toEqual([ + expect.objectContaining({ name: 'delete_me', partitionKey: `https://${httpsServer.HOSTNAME}` }), + expect.objectContaining({ name: 'keep_me', partitionKey: `https://${httpsServer.HOSTNAME}` }), + ]); - await context.clearCookies({ name: 'delete_me' }); - expect(await context.cookies()).toHaveLength(0); - }); + await context.clearCookies({ name: 'delete_me' }); + expect(await context.cookies()).toEqual([ + expect.objectContaining({ name: 'keep_me', partitionKey: `https://${httpsServer.HOSTNAME}` }), + ]); + await context.close(); }); From dab6fb54923c76f55ad11f34e4631684f93ffdec Mon Sep 17 00:00:00 2001 From: Simon Knott Date: Wed, 27 May 2026 16:35:00 +0200 Subject: [PATCH 6/6] test(clearCookies): annotate partitioned-cookie test with issue link --- tests/library/browsercontext-clearcookies.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/library/browsercontext-clearcookies.spec.ts b/tests/library/browsercontext-clearcookies.spec.ts index 73b34cc77beca..ba94f1b3279de 100644 --- a/tests/library/browsercontext-clearcookies.spec.ts +++ b/tests/library/browsercontext-clearcookies.spec.ts @@ -199,7 +199,9 @@ it('should not transiently delete non-matching cookies when filtering', { expect(await page.evaluate('document.cookie')).toBe('keep_me=1'); }); -it('should remove partitioned cookies by name', async ({ browser, httpsServer, browserName }) => { +it('should remove partitioned cookies by name', { + annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/40953' }, +}, async ({ browser, httpsServer, browserName }) => { it.skip(browserName !== 'chromium', 'Partitioned cookies (CHIPS) are Chromium-specific'); const context = await browser.newContext({ ignoreHTTPSErrors: true }); await context.addCookies([