From 1d304e358db3d3559d295d3bed6e61eaa1a96425 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 15 Apr 2026 11:25:29 +0200 Subject: [PATCH 1/8] test: add failing start-after-stop test --- .../start/start-after-stop.mocks.ts | 14 +++++++ .../start/start-after-stop.test.ts | 42 +++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 test/browser/msw-api/setup-worker/start/start-after-stop.mocks.ts create mode 100644 test/browser/msw-api/setup-worker/start/start-after-stop.test.ts diff --git a/test/browser/msw-api/setup-worker/start/start-after-stop.mocks.ts b/test/browser/msw-api/setup-worker/start/start-after-stop.mocks.ts new file mode 100644 index 000000000..aebc7164a --- /dev/null +++ b/test/browser/msw-api/setup-worker/start/start-after-stop.mocks.ts @@ -0,0 +1,14 @@ +import { http, HttpResponse } from 'msw' +import { setupWorker } from 'msw/browser' + +const worker = setupWorker( + http.get('/resource', () => { + return HttpResponse.text('hello world') + }), +) + +worker.start() + +window.msw = { + worker, +} diff --git a/test/browser/msw-api/setup-worker/start/start-after-stop.test.ts b/test/browser/msw-api/setup-worker/start/start-after-stop.test.ts new file mode 100644 index 000000000..c24ed50b7 --- /dev/null +++ b/test/browser/msw-api/setup-worker/start/start-after-stop.test.ts @@ -0,0 +1,42 @@ +/** + * @see https://github.com/mswjs/msw/issues/2714 + */ +import { test, expect } from '../../../playwright.extend' + +declare global { + interface Window { + msw: { + worker: import('msw/browser').SetupWorker + } + } +} + +test('handles requests after starting a stopped worker', async ({ + loadExample, + page, + fetch, + spyOnConsole, +}) => { + const consoleSpy = spyOnConsole() + await loadExample(new URL('./start-after-stop.mocks.ts', import.meta.url)) + + await page.evaluate(() => { + window.msw.worker.stop() + }) + + expect(consoleSpy.get('log')).toEqual( + expect.arrayContaining(['[MSW] Mocking disabled.']), + ) + + await page.evaluate(async () => { + await window.msw.worker.start() + }) + + expect(consoleSpy.get('startGroupCollapsed')).toEqual( + expect.arrayContaining(['[MSW] Mocking enabled.']), + ) + + const response = await fetch('/resource') + expect.soft(response.status()).toBe(200) + await expect.soft(response.text()).resolves.toBe('hello world') +}) From 7f2c9884e8f00b86675a99b35ae5f4028a292608 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 15 Apr 2026 11:26:54 +0200 Subject: [PATCH 2/8] fix(service-worker-source): open and close `WorkerChannel` properly --- src/browser/sources/service-worker-source.ts | 14 ++-- src/browser/utils/workerChannel.ts | 70 ++++++++++++++------ 2 files changed, 58 insertions(+), 26 deletions(-) diff --git a/src/browser/sources/service-worker-source.ts b/src/browser/sources/service-worker-source.ts index e6baacfb5..92a62b65d 100644 --- a/src/browser/sources/service-worker-source.ts +++ b/src/browser/sources/service-worker-source.ts @@ -66,9 +66,7 @@ export class ServiceWorkerSource extends NetworkSource worker), - }) + this.#channel = new WorkerChannel() } public async enable(): Promise { @@ -82,7 +80,10 @@ export class ServiceWorkerSource extends NetworkSource registration) } - this.#channel.removeAllListeners() + this.#channel.open({ + worker: this.workerPromise.then(([worker]) => worker), + }) + const [worker, registration] = await this.#startWorker() if (worker.state !== 'activated') { @@ -97,7 +98,9 @@ export class ServiceWorkerSource extends NetworkSource -} - export type WorkerChannelEventMap = { REQUEST: WorkerEvent RESPONSE: WorkerEvent @@ -121,25 +117,46 @@ type OutgoingWorkerEvents = | 'KEEPALIVE_REQUEST' | 'CLIENT_CLOSED' -export class WorkerChannel extends Emitter { - constructor(protected readonly options: WorkerChannelOptions) { - super() +export interface WorkerChannelOptions { + worker: Promise +} - if (!SUPPORTS_SERVICE_WORKER) { - return - } +export class WorkerChannel extends Emitter { + #controller?: AbortController + #worker?: Promise - navigator.serviceWorker.addEventListener('message', async (event) => { - const worker = await this.options.worker + public open(options: WorkerChannelOptions) { + invariant( + SUPPORTS_SERVICE_WORKER, + 'Failed to open a WorkerChannel: Service Worker is not supported in this environment.', + ) - if (event.source != null && event.source !== worker) { - return - } + invariant( + this.#worker == null, + 'Failed to open a WorkerChannel: the channel is already open. This is likely an issue in with MSW. Please report it on GitHub: https://github.com/mswjs/msw/issues', + ) - if (event.data && isObject(event.data) && 'type' in event.data) { - this.emit(new WorkerEvent(event)) - } - }) + const controller = new AbortController() + this.#controller = controller + this.#worker = options.worker + + navigator.serviceWorker.addEventListener( + 'message', + async (event) => { + const worker = await options.worker + + if (event.source != null && event.source !== worker) { + return + } + + if (event.data && isObject(event.data) && 'type' in event.data) { + this.emit(new WorkerEvent(event)) + } + }, + { + signal: this.#controller.signal, + }, + ) } /** @@ -149,11 +166,22 @@ export class WorkerChannel extends Emitter { public postMessage(type: OutgoingWorkerEvents): void { invariant( SUPPORTS_SERVICE_WORKER, - 'Failed to post message on a WorkerChannel: the Service Worker API is unavailable in this context. This is likely an issue with MSW. Please report it on GitHub: https://github.com/mswjs/msw/issues', + 'Failed to post message on a WorkerChannel: the Service Worker API is unavailable in this environment. This is likely an issue with MSW. Please report it on GitHub: https://github.com/mswjs/msw/issues', + ) + invariant( + this.#worker, + 'Failed to post message on a WorkerChannel: the channel is not open. Did you forget to call "channel.open()"?', ) - this.options.worker.then((worker) => { + this.#worker.then((worker) => { worker.postMessage(type) }) } + + public close(): void { + this.#controller?.abort() + this.#controller = undefined + this.#worker = undefined + this.removeAllListeners() + } } From 2bb6b78e4f00e2e1b0a8b1ab971b70ac6e3d4a63 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 15 Apr 2026 12:48:05 +0200 Subject: [PATCH 3/8] feat: implement proper `ServiceWorkerSource` singleton pattern --- src/browser/setup-worker.ts | 2 +- src/browser/sources/service-worker-source.ts | 131 +++++++++++++++--- src/browser/utils/workerChannel.ts | 38 +++-- .../start/start-after-stop.test.ts | 2 +- .../browser/msw-api/setup-worker/stop.test.ts | 6 +- 5 files changed, 133 insertions(+), 46 deletions(-) diff --git a/src/browser/setup-worker.ts b/src/browser/setup-worker.ts index 93ccc23a1..8ba0ca48e 100644 --- a/src/browser/setup-worker.ts +++ b/src/browser/setup-worker.ts @@ -65,7 +65,7 @@ export function setupWorker(...handlers: Array): SetupWorker { } const httpSource = supportsServiceWorker() - ? new ServiceWorkerSource({ + ? await ServiceWorkerSource.from({ serviceWorker: { url: options?.serviceWorker?.url?.toString() || DEFAULT_WORKER_URL, diff --git a/src/browser/sources/service-worker-source.ts b/src/browser/sources/service-worker-source.ts index 92a62b65d..117c20278 100644 --- a/src/browser/sources/service-worker-source.ts +++ b/src/browser/sources/service-worker-source.ts @@ -30,13 +30,25 @@ export interface ServiceWorkerSourceOptions { findWorker?: FindWorker } -type WorkerChannelRequestEvent = Emitter.EventType< +function shouldInvalidateWorker( + prev: ServiceWorkerSourceOptions, + next: ServiceWorkerSourceOptions, +): boolean { + return ( + prev.findWorker !== next.findWorker || + prev.serviceWorker.url !== next.serviceWorker.url || + JSON.stringify(prev.serviceWorker.options) !== + JSON.stringify(next.serviceWorker.options) + ) +} + +type WorkerChannelRequestEvent = Emitter.Event< WorkerChannel, 'REQUEST', WorkerChannelEventMap > -type WorkerChannelResponseEvent = Emitter.EventType< +type WorkerChannelResponseEvent = Emitter.Event< WorkerChannel, 'RESPONSE', WorkerChannelEventMap @@ -46,17 +58,39 @@ type WorkerChannelClient = WorkerChannelEventMap['MOCKING_ENABLED']['data']['client'] export class ServiceWorkerSource extends NetworkSource { + static #current?: ServiceWorkerSource + + /** + * Create a new Service Worker source or reuse an existing one. + * These sources act as a singleton and only get recreated if the options change. + */ + public static async from( + options: ServiceWorkerSourceOptions, + ): Promise { + if (ServiceWorkerSource.#current == null) { + ServiceWorkerSource.#current = new ServiceWorkerSource(options) + } else if ( + shouldInvalidateWorker(ServiceWorkerSource.#current.#options, options) + ) { + await ServiceWorkerSource.#current.terminate() + ServiceWorkerSource.#current = new ServiceWorkerSource(options) + } + + return ServiceWorkerSource.#current + } + #frames: Map #channel: WorkerChannel #clientPromise?: Promise #keepAliveInterval?: number #stoppedAt?: number + #options: ServiceWorkerSourceOptions public workerPromise: DeferredPromise< [ServiceWorker, ServiceWorkerRegistration] > - constructor(private readonly options: ServiceWorkerSourceOptions) { + constructor(options: ServiceWorkerSourceOptions) { super() invariant( @@ -64,15 +98,25 @@ export class ServiceWorkerSource extends NetworkSource this.workerPromise.then(([worker]) => worker), + }) } public async enable(): Promise { - this.#stoppedAt = undefined - - if (this.workerPromise.state !== 'pending') { + /** + * @note The source is considered already running if the worker has been + * resolved AND `stop()` has not been called since. `workerPromise` is NOT + * reset on `disable()` so that the channel's `getWorker()` can keep + * resolving to the registered SW for post-stop passthrough replies. + */ + if ( + this.workerPromise.state === 'fulfilled' && + typeof this.#stoppedAt == 'undefined' + ) { devUtils.warn( 'Found a redundant "worker.start()" call. Note that starting the worker while mocking is already enabled will have no effect. Consider removing this "worker.start()" call.', ) @@ -80,10 +124,8 @@ export class ServiceWorkerSource extends NetworkSource registration) } - this.#channel.open({ - worker: this.workerPromise.then(([worker]) => worker), - }) - + this.#stoppedAt = undefined + this.#channel.removeAllListeners() const [worker, registration] = await this.#startWorker() if (worker.state !== 'activated') { @@ -116,7 +158,7 @@ export class ServiceWorkerSource extends NetworkSource { + if (this.#keepAliveInterval != null) { + clearInterval(this.#keepAliveInterval) + this.#keepAliveInterval = undefined + } + + this.#frames.clear() + this.#channel.terminate() + + if (this.workerPromise.state === 'fulfilled') { + const [, registration] = await this.workerPromise + await registration.unregister() + } + + if (ServiceWorkerSource.#current === this) { + ServiceWorkerSource.#current = undefined + } + } + async #startWorker() { if (this.#keepAliveInterval) { clearInterval(this.#keepAliveInterval) } - const workerUrl = this.options.serviceWorker.url + const workerUrl = this.#options.serviceWorker.url const [worker, registration] = await getWorkerInstance( workerUrl, - this.options.serviceWorker.options, - this.options.findWorker || this.#defaultFindWorker, + this.#options.serviceWorker.options, + this.#options.findWorker || this.#defaultFindWorker, ) if (worker == null) { - const missingWorkerMessage = this.options?.findWorker + const missingWorkerMessage = this.#options?.findWorker ? devUtils.formatMessage( `Failed to locate the Service Worker registration using a custom "findWorker" predicate. @@ -185,7 +263,18 @@ Please consider using a custom "serviceWorker.url" option to point to the actual throw new Error(missingWorkerMessage) } - this.workerPromise.resolve([worker, registration]) + if (this.workerPromise.state === 'pending') { + this.workerPromise.resolve([worker, registration]) + } else { + /** + * @note Re-enable after `stop()`: the previous `workerPromise` is already + * fulfilled and cannot be resolved again. Swap in a pre-resolved one so + * `getWorker()` sees the new worker instance immediately. + */ + this.workerPromise = new DeferredPromise((resolve) => { + resolve([worker, registration]) + }) + } this.#channel.on('REQUEST', this.#handleRequest.bind(this)) this.#channel.on('RESPONSE', this.#handleResponse.bind(this)) @@ -211,7 +300,7 @@ Please consider using a custom "serviceWorker.url" option to point to the actual this.#channel.postMessage('KEEPALIVE_REQUEST') }, 5000) - if (!this.options.quiet) { + if (!this.#options.quiet) { validateWorkerScope(registration) } diff --git a/src/browser/utils/workerChannel.ts b/src/browser/utils/workerChannel.ts index fa37b0233..11cbdc23b 100644 --- a/src/browser/utils/workerChannel.ts +++ b/src/browser/utils/workerChannel.ts @@ -118,32 +118,28 @@ type OutgoingWorkerEvents = | 'CLIENT_CLOSED' export interface WorkerChannelOptions { - worker: Promise + getWorker: () => Promise } export class WorkerChannel extends Emitter { - #controller?: AbortController - #worker?: Promise + #getWorker: WorkerChannelOptions['getWorker'] + #controller: AbortController + + constructor(options: WorkerChannelOptions) { + super() - public open(options: WorkerChannelOptions) { invariant( SUPPORTS_SERVICE_WORKER, 'Failed to open a WorkerChannel: Service Worker is not supported in this environment.', ) - invariant( - this.#worker == null, - 'Failed to open a WorkerChannel: the channel is already open. This is likely an issue in with MSW. Please report it on GitHub: https://github.com/mswjs/msw/issues', - ) - - const controller = new AbortController() - this.#controller = controller - this.#worker = options.worker + this.#getWorker = options.getWorker + this.#controller = new AbortController() navigator.serviceWorker.addEventListener( 'message', async (event) => { - const worker = await options.worker + const worker = await this.#getWorker() if (event.source != null && event.source !== worker) { return @@ -168,20 +164,18 @@ export class WorkerChannel extends Emitter { SUPPORTS_SERVICE_WORKER, 'Failed to post message on a WorkerChannel: the Service Worker API is unavailable in this environment. This is likely an issue with MSW. Please report it on GitHub: https://github.com/mswjs/msw/issues', ) - invariant( - this.#worker, - 'Failed to post message on a WorkerChannel: the channel is not open. Did you forget to call "channel.open()"?', - ) - this.#worker.then((worker) => { + this.#getWorker().then((worker) => { worker.postMessage(type) }) } - public close(): void { - this.#controller?.abort() - this.#controller = undefined - this.#worker = undefined + /** + * Terminal teardown. Removes the `navigator.serviceWorker` message listener + * and all emitter subscriptions. The channel is not usable afterwards. + */ + public terminate(): void { + this.#controller.abort() this.removeAllListeners() } } diff --git a/test/browser/msw-api/setup-worker/start/start-after-stop.test.ts b/test/browser/msw-api/setup-worker/start/start-after-stop.test.ts index c24ed50b7..1fe8bb6a4 100644 --- a/test/browser/msw-api/setup-worker/start/start-after-stop.test.ts +++ b/test/browser/msw-api/setup-worker/start/start-after-stop.test.ts @@ -38,5 +38,5 @@ test('handles requests after starting a stopped worker', async ({ const response = await fetch('/resource') expect.soft(response.status()).toBe(200) - await expect.soft(response.text()).resolves.toBe('hello world') + await expect(response.text()).resolves.toBe('hello world') }) diff --git a/test/browser/msw-api/setup-worker/stop.test.ts b/test/browser/msw-api/setup-worker/stop.test.ts index 6e6180c91..b869ec031 100644 --- a/test/browser/msw-api/setup-worker/stop.test.ts +++ b/test/browser/msw-api/setup-worker/stop.test.ts @@ -62,7 +62,11 @@ test('disables the mocking when the worker is stopped', async ({ const response = await fetch(server.http.url('/resource')) - expect.soft(response.fromServiceWorker()).toBe(true) + /** + * @note Currently, "worker.stop()" sends "CLIENT_CLOSED". + * Since it's the only active client, the worker will self-destruct. + */ + expect.soft(response.fromServiceWorker()).toBe(false) await expect(response.json()).resolves.toEqual({ original: true }) }) From 0d208fbc72007a001859774f61144bb785004599 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 15 Apr 2026 12:51:36 +0200 Subject: [PATCH 4/8] chore: add unit tests for `shouldInvalidateWorker` --- src/browser/sources/service-worker-source.ts | 13 +- .../utils/should-invalidate-worker.test.ts | 122 ++++++++++++++++++ src/browser/utils/should-invalidate-worker.ts | 13 ++ 3 files changed, 136 insertions(+), 12 deletions(-) create mode 100644 src/browser/utils/should-invalidate-worker.test.ts create mode 100644 src/browser/utils/should-invalidate-worker.ts diff --git a/src/browser/sources/service-worker-source.ts b/src/browser/sources/service-worker-source.ts index 117c20278..f57a3edd4 100644 --- a/src/browser/sources/service-worker-source.ts +++ b/src/browser/sources/service-worker-source.ts @@ -20,6 +20,7 @@ import { WorkerChannel, WorkerChannelEventMap } from '../utils/workerChannel' import { FindWorker } from '../glossary' import { deserializeRequest } from '../utils/deserializeRequest' import { validateWorkerScope } from '../utils/validate-worker-scope' +import { shouldInvalidateWorker } from '../utils/should-invalidate-worker' export interface ServiceWorkerSourceOptions { quiet?: boolean @@ -30,18 +31,6 @@ export interface ServiceWorkerSourceOptions { findWorker?: FindWorker } -function shouldInvalidateWorker( - prev: ServiceWorkerSourceOptions, - next: ServiceWorkerSourceOptions, -): boolean { - return ( - prev.findWorker !== next.findWorker || - prev.serviceWorker.url !== next.serviceWorker.url || - JSON.stringify(prev.serviceWorker.options) !== - JSON.stringify(next.serviceWorker.options) - ) -} - type WorkerChannelRequestEvent = Emitter.Event< WorkerChannel, 'REQUEST', diff --git a/src/browser/utils/should-invalidate-worker.test.ts b/src/browser/utils/should-invalidate-worker.test.ts new file mode 100644 index 000000000..1edbb2d28 --- /dev/null +++ b/src/browser/utils/should-invalidate-worker.test.ts @@ -0,0 +1,122 @@ +import { type ServiceWorkerSourceOptions } from '../sources/service-worker-source' +import { shouldInvalidateWorker } from './should-invalidate-worker' + +function createOptions( + overrides: Partial = {}, +): ServiceWorkerSourceOptions { + return { + serviceWorker: { + url: '/mockServiceWorker.js', + options: { scope: '/' }, + }, + ...overrides, + } +} + +it('returns true when the worker url differs', () => { + expect( + shouldInvalidateWorker( + createOptions({ + serviceWorker: { url: '/a.js', options: { scope: '/' } }, + }), + createOptions({ + serviceWorker: { url: '/b.js', options: { scope: '/' } }, + }), + ), + ).toBe(true) +}) + +it('returns true when the registration options differ', () => { + expect( + shouldInvalidateWorker( + createOptions({ + serviceWorker: { url: '/sw.js', options: { scope: '/' } }, + }), + createOptions({ + serviceWorker: { url: '/sw.js', options: { scope: '/app' } }, + }), + ), + ).toBe(true) +}) + +it('returns true when only one side has registration options', () => { + expect( + shouldInvalidateWorker( + createOptions({ serviceWorker: { url: '/sw.js' } }), + createOptions({ + serviceWorker: { url: '/sw.js', options: { scope: '/' } }, + }), + ), + ).toBe(true) +}) + +it('returns true when findWorker differs by reference', () => { + expect( + shouldInvalidateWorker( + createOptions({ findWorker: () => true }), + createOptions({ findWorker: () => true }), + ), + ).toBe(true) +}) + +it('returns true when findWorker is added on one side', () => { + expect( + shouldInvalidateWorker( + createOptions(), + createOptions({ findWorker: () => true }), + ), + ).toBe(true) +}) + +it('returns false for the same options reference', () => { + const options = createOptions() + expect(shouldInvalidateWorker(options, options)).toBe(false) +}) + +it('returns false for deeply equal options', () => { + expect( + shouldInvalidateWorker( + createOptions({ + serviceWorker: { url: '/sw.js', options: { scope: '/' } }, + }), + createOptions({ + serviceWorker: { url: '/sw.js', options: { scope: '/' } }, + }), + ), + ).toBe(false) +}) + +it('returns false for the same worker url without options', () => { + expect( + shouldInvalidateWorker( + createOptions({ serviceWorker: { url: '/sw.js' } }), + createOptions({ serviceWorker: { url: '/sw.js' } }), + ), + ).toBe(false) +}) + +it('returns false when findWorker is the same reference', () => { + const findWorker = () => true + expect( + shouldInvalidateWorker( + createOptions({ findWorker }), + createOptions({ findWorker }), + ), + ).toBe(false) +}) + +it('returns false regardless of the "quiet" option', () => { + expect( + shouldInvalidateWorker( + createOptions({ quiet: true }), + createOptions({ quiet: true }), + ), + ).toBe(false) + + expect( + shouldInvalidateWorker( + createOptions({ quiet: false }), + createOptions({ quiet: true }), + ), + ).toBe(false) +}) diff --git a/src/browser/utils/should-invalidate-worker.ts b/src/browser/utils/should-invalidate-worker.ts new file mode 100644 index 000000000..c2f74443e --- /dev/null +++ b/src/browser/utils/should-invalidate-worker.ts @@ -0,0 +1,13 @@ +import { type ServiceWorkerSourceOptions } from '../sources/service-worker-source' + +export function shouldInvalidateWorker( + prevOptions: ServiceWorkerSourceOptions, + nextOptions: ServiceWorkerSourceOptions, +): boolean { + return ( + prevOptions.findWorker !== nextOptions.findWorker || + prevOptions.serviceWorker.url !== nextOptions.serviceWorker.url || + JSON.stringify(prevOptions.serviceWorker.options) !== + JSON.stringify(nextOptions.serviceWorker.options) + ) +} From 088239b829f8ef06fb0db43b75e7864373ab8bf7 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 15 Apr 2026 17:58:49 +0200 Subject: [PATCH 5/8] fix(service-worker-source): abort `beforeunload` listener in `.terminate()` --- src/browser/sources/service-worker-source.ts | 27 ++++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/src/browser/sources/service-worker-source.ts b/src/browser/sources/service-worker-source.ts index f57a3edd4..2239d51cd 100644 --- a/src/browser/sources/service-worker-source.ts +++ b/src/browser/sources/service-worker-source.ts @@ -68,12 +68,13 @@ export class ServiceWorkerSource extends NetworkSource #channel: WorkerChannel + #listenerController?: AbortController #clientPromise?: Promise #keepAliveInterval?: number #stoppedAt?: number - #options: ServiceWorkerSourceOptions public workerPromise: DeferredPromise< [ServiceWorker, ServiceWorkerRegistration] @@ -115,6 +116,8 @@ export class ServiceWorkerSource extends NetworkSource { - if (worker.state !== 'redundant') { - this.#channel.postMessage('CLIENT_CLOSED') - } + window.addEventListener( + 'beforeunload', + () => { + if (worker.state !== 'redundant') { + this.#channel.postMessage('CLIENT_CLOSED') + } - clearInterval(this.#keepAliveInterval) + clearInterval(this.#keepAliveInterval) - window.postMessage({ type: 'msw/worker:stop' }) - }) + window.postMessage({ type: 'msw/worker:stop' }) + }, + { + signal: this.#listenerController?.signal, + }, + ) await this.#checkWorkerIntegrity().catch((error) => { devUtils.error( From 3f3ebe5963e2875f89274ef3d9ba6ad44d0b4cc7 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Wed, 15 Apr 2026 18:15:32 +0200 Subject: [PATCH 6/8] fix(service-worker-source): clear listeners on `.disable()` too --- src/browser/sources/service-worker-source.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/browser/sources/service-worker-source.ts b/src/browser/sources/service-worker-source.ts index 2239d51cd..62e99879c 100644 --- a/src/browser/sources/service-worker-source.ts +++ b/src/browser/sources/service-worker-source.ts @@ -176,6 +176,9 @@ export class ServiceWorkerSource extends NetworkSource Date: Thu, 16 Apr 2026 14:30:02 +0200 Subject: [PATCH 7/8] fix: abort `frame` listeners on sources after disable --- src/core/experimental/define-network.ts | 30 ++++++++----- .../start/handlers-reset.mocks.ts | 17 +++++++ .../setup-worker/start/handlers-reset.test.ts | 45 +++++++++++++++++++ 3 files changed, 80 insertions(+), 12 deletions(-) create mode 100644 test/browser/msw-api/setup-worker/start/handlers-reset.mocks.ts create mode 100644 test/browser/msw-api/setup-worker/start/handlers-reset.test.ts diff --git a/src/core/experimental/define-network.ts b/src/core/experimental/define-network.ts index cdb4840c2..fc71f00fb 100644 --- a/src/core/experimental/define-network.ts +++ b/src/core/experimental/define-network.ts @@ -161,19 +161,25 @@ export function defineNetwork>>( readyState = NetworkReadyState.ENABLED const result = resolvedOptions.sources.map((source) => { - source.on('frame', async ({ frame }) => { - frame.events.on('*', (event) => events.emit(event), { + source.on( + 'frame', + async ({ frame }) => { + frame.events.on('*', (event) => events.emit(event), { + signal: listenersController.signal, + }) + + const handlers = frame.getHandlers(handlersController) + + await frame.resolve( + handlers, + resolvedOptions.onUnhandledFrame || 'warn', + resolvedOptions.context, + ) + }, + { signal: listenersController.signal, - }) - - const handlers = frame.getHandlers(handlersController) - - await frame.resolve( - handlers, - resolvedOptions.onUnhandledFrame || 'warn', - resolvedOptions.context, - ) - }) + }, + ) return source.enable() }) diff --git a/test/browser/msw-api/setup-worker/start/handlers-reset.mocks.ts b/test/browser/msw-api/setup-worker/start/handlers-reset.mocks.ts new file mode 100644 index 000000000..a3699fd98 --- /dev/null +++ b/test/browser/msw-api/setup-worker/start/handlers-reset.mocks.ts @@ -0,0 +1,17 @@ +import { http, HttpResponse } from 'msw' +import { setupWorker } from 'msw/browser' + +let callCount = 0 + +const worker = setupWorker( + http.get('/resource', () => { + callCount++ + return HttpResponse.json({ callCount }) + }), +) + +worker.start() + +window.msw = { + worker, +} diff --git a/test/browser/msw-api/setup-worker/start/handlers-reset.test.ts b/test/browser/msw-api/setup-worker/start/handlers-reset.test.ts new file mode 100644 index 000000000..830332220 --- /dev/null +++ b/test/browser/msw-api/setup-worker/start/handlers-reset.test.ts @@ -0,0 +1,45 @@ +/** + * @see https://github.com/mswjs/msw/issues/2714 + */ +import { test, expect } from '../../../playwright.extend' + +declare global { + interface Window { + msw: { + worker: import('msw/browser').SetupWorker + } + } +} + +test('does not accumulate request handlers across restarts', async ({ + loadExample, + page, + fetch, +}) => { + await loadExample(new URL('./handlers-reset.mocks.ts', import.meta.url)) + + // First request after the initial start. + const firstResponse = await fetch('/resource') + await expect(firstResponse.json()).resolves.toEqual({ callCount: 1 }) + + // Stop and restart the worker. + await page.evaluate(async () => { + window.msw.worker.stop() + await window.msw.worker.start({ quiet: true }) + }) + + // Second request after the first restart. + // The handler must be called exactly once per request. + const secondResponse = await fetch('/resource') + await expect(secondResponse.json()).resolves.toEqual({ callCount: 2 }) + + // Stop and restart the worker again. + await page.evaluate(async () => { + window.msw.worker.stop() + await window.msw.worker.start({ quiet: true }) + }) + + // Third request after the second restart. + const thirdResponse = await fetch('/resource') + await expect(thirdResponse.json()).resolves.toEqual({ callCount: 3 }) +}) From 2bf6c991b21b4b5b2e4eec0c6d84005d00ce9922 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Thu, 16 Apr 2026 15:33:35 +0200 Subject: [PATCH 8/8] fix: use `NetworkSource.prototype.disable` to prevent `frame` listeners leak --- src/browser/sources/service-worker-source.ts | 2 +- src/core/experimental/define-network.ts | 40 ++++++++++---------- 2 files changed, 22 insertions(+), 20 deletions(-) diff --git a/src/browser/sources/service-worker-source.ts b/src/browser/sources/service-worker-source.ts index 62e99879c..14ac8fc07 100644 --- a/src/browser/sources/service-worker-source.ts +++ b/src/browser/sources/service-worker-source.ts @@ -116,6 +116,7 @@ export class ServiceWorkerSource extends NetworkSource>>( readyState = NetworkReadyState.ENABLED const result = resolvedOptions.sources.map((source) => { - source.on( - 'frame', - async ({ frame }) => { - frame.events.on('*', (event) => events.emit(event), { - signal: listenersController.signal, - }) - - const handlers = frame.getHandlers(handlersController) - - await frame.resolve( - handlers, - resolvedOptions.onUnhandledFrame || 'warn', - resolvedOptions.context, - ) - }, - { + /** + * @note Preemptively disable the network source before enabling. + * This intentionally calls only the prototype method that clears the + * event listeners and nothing else. This prevents the "frame" listeners + * from accumulating across enable/disable in case the source is a singleton. + */ + NetworkSource.prototype.disable.call(source) + + source.on('frame', async ({ frame }) => { + frame.events.on('*', (event) => events.emit(event), { signal: listenersController.signal, - }, - ) + }) + + const handlers = frame.getHandlers(handlersController) + + await frame.resolve( + handlers, + resolvedOptions.onUnhandledFrame || 'warn', + resolvedOptions.context, + ) + }) return source.enable() })