Skip to content

Commit 2960ef9

Browse files
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.
1 parent 0f9632b commit 2960ef9

2 files changed

Lines changed: 62 additions & 7 deletions

File tree

packages/playwright-core/src/server/browserContext.ts

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -309,8 +309,11 @@ export abstract class BrowserContext<EM extends EventMap = EventMap> extends Sdk
309309
}
310310

311311
async clearCookies(options: {name?: string | RegExp, domain?: string | RegExp, path?: string | RegExp}): Promise<void> {
312-
const currentCookies = await this._cookies();
313-
await this.doClearCookies();
312+
const hasFilter = options.name !== undefined || options.domain !== undefined || options.path !== undefined;
313+
if (!hasFilter) {
314+
await this.doClearCookies();
315+
return;
316+
}
314317

315318
const matches = (cookie: channels.NetworkCookie, prop: 'name' | 'domain' | 'path', value: string | RegExp | undefined) => {
316319
if (!value)
@@ -322,13 +325,23 @@ export abstract class BrowserContext<EM extends EventMap = EventMap> extends Sdk
322325
return cookie[prop] === value;
323326
};
324327

325-
const cookiesToReadd = currentCookies.filter(cookie => {
326-
return !matches(cookie, 'name', options.name)
327-
|| !matches(cookie, 'domain', options.domain)
328-
|| !matches(cookie, 'path', options.path);
328+
const currentCookies = await this._cookies();
329+
const cookiesToExpire = currentCookies.filter(cookie => {
330+
return matches(cookie, 'name', options.name)
331+
&& matches(cookie, 'domain', options.domain)
332+
&& matches(cookie, 'path', options.path);
329333
});
330334

331-
await this.addCookies(cookiesToReadd);
335+
if (!cookiesToExpire.length)
336+
return;
337+
338+
await this.addCookies(cookiesToExpire.map(cookie => ({
339+
name: cookie.name,
340+
value: '',
341+
domain: cookie.domain,
342+
path: cookie.path,
343+
expires: 0,
344+
})));
332345
}
333346

334347
setHTTPCredentials(progress: Progress, httpCredentials?: types.Credentials): Promise<void> {

tests/library/browsercontext-clearcookies.spec.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,3 +164,45 @@ it('should remove cookies by name and domain', async ({ context, page, server })
164164
await page.goto(server.CROSS_PROCESS_PREFIX);
165165
expect(await page.evaluate('document.cookie')).toBe('cookie1=1');
166166
});
167+
168+
it('should not transiently delete non-matching cookies when filtering', {
169+
annotation: { type: 'issue', description: 'https://github.com/microsoft/playwright/issues/40953' },
170+
}, async ({ context, page, server, browserName }) => {
171+
it.skip(browserName !== 'chromium', 'cookieStore API is only available in Chromium');
172+
173+
await context.addCookies([{
174+
name: 'keep_me',
175+
value: '1',
176+
domain: new URL(server.PREFIX).hostname,
177+
path: '/',
178+
},
179+
{
180+
name: 'delete_me',
181+
value: '2',
182+
domain: new URL(server.PREFIX).hostname,
183+
path: '/',
184+
}
185+
]);
186+
await page.goto(server.PREFIX);
187+
188+
await page.evaluate(() => {
189+
(window as any).__cookieEvents = [];
190+
(window as any).cookieStore.addEventListener('change', (event: any) => {
191+
for (const changed of event.changed)
192+
(window as any).__cookieEvents.push({ kind: 'changed', name: changed.name });
193+
for (const deleted of event.deleted)
194+
(window as any).__cookieEvents.push({ kind: 'deleted', name: deleted.name });
195+
});
196+
});
197+
198+
await context.clearCookies({ name: 'delete_me' });
199+
200+
// Flush microtasks so any change events fired during clearCookies are observed.
201+
await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 50)));
202+
203+
const events: { kind: string, name: string }[] = await page.evaluate(() => (window as any).__cookieEvents);
204+
205+
// The kept cookie must never appear in a deletion event.
206+
expect(events.filter(e => e.kind === 'deleted' && e.name === 'keep_me')).toEqual([]);
207+
expect(await page.evaluate('document.cookie')).toBe('keep_me=1');
208+
});

0 commit comments

Comments
 (0)