Skip to content

fix: implement proper ServiceWorkerSource singleton pattern#2715

Merged
kettanaito merged 9 commits intomainfrom
fix/worker-enable-after-disable
Apr 16, 2026
Merged

fix: implement proper ServiceWorkerSource singleton pattern#2715
kettanaito merged 9 commits intomainfrom
fix/worker-enable-after-disable

Conversation

@kettanaito
Copy link
Copy Markdown
Member

@kettanaito kettanaito commented Apr 15, 2026

Root cause

The worker promise passed to the WorkerChannel in the constructor was stale after the worker source has been disabled. Reassigning #workerPromise did not provide a new value to the worker channel so it kept communicating with an outdated service worker reference.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 15, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Introduces a ServiceWorkerSource async singleton factory and a terminate lifecycle method, switches WorkerChannel to a lazy getWorker() pattern with its own terminate, preserves in-flight worker resolution across stop/start, and adds shouldInvalidateWorker with tests plus browser start/stop/restart tests.

Changes

Cohort / File(s) Summary
ServiceWorkerSource
src/browser/sources/service-worker-source.ts
Added static from(options) factory and terminate(); changed constructor to store mutable #options; reuse or recreate singleton based on shouldInvalidateWorker; updated enable/disable semantics and preserved in-flight workerPromise.
WorkerChannel
src/browser/utils/workerChannel.ts
Replaced options.worker with options.getWorker: () => Promise<ServiceWorker>; always attach navigator.serviceWorker message listener with AbortController signal; move worker resolution to getter; added terminate() to abort listeners and clear emitters; tightened SW support invariant.
Invalidate util & tests
src/browser/utils/should-invalidate-worker.ts, src/browser/utils/should-invalidate-worker.test.ts
Added shouldInvalidateWorker(prev,next) that compares findWorker, serviceWorker.url, and serialized serviceWorker.options; comprehensive unit tests covering equality and difference cases.
Setup integration
src/browser/setup-worker.ts
setupWorker().start() now awaits ServiceWorkerSource.from(...) when service worker support exists (async SW source initialization).
Tests / Mocks
test/browser/msw-api/setup-worker/start/start-after-stop.mocks.ts, test/browser/msw-api/setup-worker/start/start-after-stop.test.ts, test/browser/msw-api/setup-worker/stop.test.ts
Added mock module exposing window.msw.worker, new Playwright test verifying requests after stopping and restarting the worker, and adjusted existing test to expect response.fromServiceWorker() === false after worker.stop().

Sequence Diagram

sequenceDiagram
    participant Client
    participant SWS as ServiceWorkerSource
    participant WC as WorkerChannel
    participant SW as ServiceWorker
    participant Browser as navigator.serviceWorker

    Client->>SWS: setupWorker().start(options)
    SWS->>SWS: ServiceWorkerSource.from(options) (singleton, shouldInvalidateWorker)
    SWS->>WC: new WorkerChannel({ getWorker: () => this.workerPromise })
    WC->>Browser: register message listener (AbortController.signal)
    SWS->>SWS: `#startWorker`() -> create/resolve workerPromise
    Client->>SWS: enable()
    Client->>WC: postMessage(request)
    WC->>WC: `#getWorker`() -> await workerPromise
    WC->>SW: postMessage(request)
    SW-->>Client: intercepted response
    Client->>SWS: disable()
    SWS->>WC: postMessage(CLIENT_CLOSED)
    Client->>SWS: terminate()
    SWS->>Browser: unregister service worker, clear timers, clear singleton
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Suggested labels

release candidate

Poem

🐰
I twitched my whiskers, changed my tune,
A worker sleeps, then wakes at noon.
Channels hum and messages hop,
Singleton rests, then springs non-stop.
Hooray — mocks dance round the clock! 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the main change: implementing a proper singleton pattern for ServiceWorkerSource, which is the core architectural improvement that addresses the root cause of the linked issue.
Linked Issues check ✅ Passed The PR fully addresses issue #2714 by implementing singleton management with proper teardown, allowing the ServiceWorkerSource to be recreated when options change and ensuring the worker channel receives the updated worker reference after disable/restart cycles.
Out of Scope Changes check ✅ Passed All changes are directly related to fixing the ServiceWorkerSource singleton pattern and worker communication issue. Test files validate the start-after-stop scenario from the issue, and utility functions support the new singleton factory approach.
Description check ✅ Passed The pull request description directly references the root cause of the issues it fixes, explaining that the worker promise became stale after disabling and how this prevented proper re-initialization.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/worker-enable-after-disable

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/browser/sources/service-worker-source.ts (1)

193-201: Remove the beforeunload listener or prevent its accumulation.

Each call to #startWorker() adds a new beforeunload listener without removing prior ones. After multiple stop/start cycles (triggered by disable() resetting workerPromise), multiple listeners accumulate. While the operations performed are idempotent, accumulating listeners is poor practice and wastes memory. Either remove the listener on disable(), use an AbortSignal to clean up automatically, or ensure the listener is only added once.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/browser/sources/service-worker-source.ts` around lines 193 - 201, The
beforeunload listener added in `#startWorker`() accumulates across start/stop
cycles; modify the implementation so the listener is cleaned up on disable() (or
switched to an AbortSignal tied to the worker lifecycle) — e.g., capture the
listener function when adding it in `#startWorker`(), store it on the instance (or
register it with an AbortSignal), and then removeEventListener('beforeunload',
listener) (or abort the signal) inside disable() where workerPromise is reset
and `#keepAliveInterval` is cleared; ensure the code references the same listener
and also avoids adding the listener if it already exists to prevent duplicates.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/browser/utils/workerChannel.ts`:
- Around line 134-137: Typo in the invariant message inside WorkerChannel:
update the string passed to invariant (the call referencing this.#worker) to
replace "in with MSW" with "with MSW" so the message reads "...the channel is
already open. This is likely an issue with MSW. Please report it on GitHub:
https://github.com/mswjs/msw/issues"; keep the rest of the invariant call and
surrounding logic unchanged.

---

Nitpick comments:
In `@src/browser/sources/service-worker-source.ts`:
- Around line 193-201: The beforeunload listener added in `#startWorker`()
accumulates across start/stop cycles; modify the implementation so the listener
is cleaned up on disable() (or switched to an AbortSignal tied to the worker
lifecycle) — e.g., capture the listener function when adding it in
`#startWorker`(), store it on the instance (or register it with an AbortSignal),
and then removeEventListener('beforeunload', listener) (or abort the signal)
inside disable() where workerPromise is reset and `#keepAliveInterval` is cleared;
ensure the code references the same listener and also avoids adding the listener
if it already exists to prevent duplicates.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 73de015e-3038-4466-a383-db9e00f29991

📥 Commits

Reviewing files that changed from the base of the PR and between 41f725d and 7f2c988.

📒 Files selected for processing (4)
  • src/browser/sources/service-worker-source.ts
  • src/browser/utils/workerChannel.ts
  • test/browser/msw-api/setup-worker/start/start-after-stop.mocks.ts
  • test/browser/msw-api/setup-worker/start/start-after-stop.test.ts

Comment thread src/browser/utils/workerChannel.ts Outdated
@kettanaito kettanaito changed the title fix: properly start the worker after it was stopped feat: implement proper ServiceWorkerSource singleton pattern Apr 15, 2026
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/browser/utils/workerChannel.ts (1)

162-180: Enforce terminal state after terminate() to match documented contract.

terminate() says the channel is unusable afterward, but postMessage() can still send. Add a terminated flag/invariant to prevent accidental use-after-terminate.

♻️ Proposed contract guard
 export class WorkerChannel extends Emitter<WorkerChannelEventMap> {
   `#getWorker`: WorkerChannelOptions['getWorker']
   `#controller`: AbortController
+  `#terminated` = false
@@
   public postMessage(type: OutgoingWorkerEvents): void {
+    invariant(
+      !this.#terminated,
+      'Failed to post message on a WorkerChannel: the channel has been terminated.',
+    )
+
     invariant(
       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',
     )
@@
   public terminate(): void {
+    this.#terminated = true
     this.#controller.abort()
     this.removeAllListeners()
   }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/browser/utils/workerChannel.ts` around lines 162 - 180, Add a
terminal-state guard so postMessage cannot be called after terminate():
introduce a private boolean (e.g., this.#terminated) set to true in terminate()
immediately after this.#controller.abort() and before removeAllListeners(), and
update postMessage() to invariant(!this.#terminated, 'WorkerChannel has been
terminated') before checking SUPPORTS_SERVICE_WORKER and calling
this.#getWorker(). This prevents use-after-terminate in postMessage(), and
ensures the documented contract for terminate() is enforced; keep the existing
SUPPORTS_SERVICE_WORKER check and this.#getWorker() usage otherwise.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/browser/utils/workerChannel.ts`:
- Around line 162-180: Add a terminal-state guard so postMessage cannot be
called after terminate(): introduce a private boolean (e.g., this.#terminated)
set to true in terminate() immediately after this.#controller.abort() and before
removeAllListeners(), and update postMessage() to invariant(!this.#terminated,
'WorkerChannel has been terminated') before checking SUPPORTS_SERVICE_WORKER and
calling this.#getWorker(). This prevents use-after-terminate in postMessage(),
and ensures the documented contract for terminate() is enforced; keep the
existing SUPPORTS_SERVICE_WORKER check and this.#getWorker() usage otherwise.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: e4c1e98f-135b-423d-8cb1-f3e8c556572e

📥 Commits

Reviewing files that changed from the base of the PR and between 7f2c988 and 2bb6b78.

📒 Files selected for processing (5)
  • src/browser/setup-worker.ts
  • src/browser/sources/service-worker-source.ts
  • src/browser/utils/workerChannel.ts
  • test/browser/msw-api/setup-worker/start/start-after-stop.test.ts
  • test/browser/msw-api/setup-worker/stop.test.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • test/browser/msw-api/setup-worker/start/start-after-stop.test.ts
  • src/browser/sources/service-worker-source.ts

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 15, 2026

Open in StackBlitz

npm i https://pkg.pr.new/msw@2715

commit: 0041c12

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/browser/sources/service-worker-source.ts`:
- Around line 195-217: The terminate() method currently doesn't remove the
beforeunload listener installed by ServiceWorkerSource, so store the handler or
use an AbortController when you add the 'beforeunload' listener (e.g., save to
this.#unloadHandler or this.#unloadAbortController) and then remove/abort it
inside terminate(): if you chose a handler call
window.removeEventListener('beforeunload', this.#unloadHandler) (and clear the
field), or if you used an AbortController call
this.#unloadAbortController.abort() (and set it to undefined) to ensure the old
instance can't post CLIENT_CLOSED after teardown.
- Around line 98-118: The enable() path must be serialized so concurrent calls
share the same in-flight startup promise: introduce a private field (e.g.,
this.#startingPromise) and, at the start of enable(), if `#startingPromise` exists
return it; otherwise set `#startingPromise` to the async activation flow that
calls this.#startWorker(), clears this.#stoppedAt, removes listeners, awaits
start, and resolves to the ServiceWorkerRegistration; ensure `#startingPromise` is
cleared (set to undefined) on both fulfillment and rejection so future enable()
calls can retry; keep existing workerPromise and the early-return branch that
checks workerPromise.state and `#stoppedAt` intact.

In `@src/browser/utils/should-invalidate-worker.ts`:
- Around line 7-12: The current comparison uses
JSON.stringify(prevOptions.serviceWorker.options) !==
JSON.stringify(nextOptions.serviceWorker.options), which is order-sensitive;
replace that with an order-insensitive deep equality check so equivalent
RegistrationOptions don't trigger invalidation. Update the
should-invalidate-worker logic to use a stable/deep-equal comparison (e.g.,
import/use a utility like isEqual from lodash or implement a small deepEqual
that compares keys/values recursively or stringify with sorted keys) to compare
prevOptions.serviceWorker.options and nextOptions.serviceWorker.options, leaving
the prevOptions.findWorker and serviceWorker.url checks unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: b6b5e8fe-e493-45ab-af5a-ff9e86f5d4ce

📥 Commits

Reviewing files that changed from the base of the PR and between 2bb6b78 and 0d208fb.

📒 Files selected for processing (3)
  • src/browser/sources/service-worker-source.ts
  • src/browser/utils/should-invalidate-worker.test.ts
  • src/browser/utils/should-invalidate-worker.ts

Comment thread src/browser/sources/service-worker-source.ts
Comment thread src/browser/sources/service-worker-source.ts
Comment thread src/browser/utils/should-invalidate-worker.ts
@kettanaito kettanaito changed the title feat: implement proper ServiceWorkerSource singleton pattern fix: implement proper ServiceWorkerSource singleton pattern Apr 15, 2026
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/browser/sources/service-worker-source.ts`:
- Around line 120-121: The enable() flow currently creates a new AbortController
in this.#listenerController before each call to `#startWorker`(), causing multiple
beforeunload handlers to accumulate; instead reuse a single controller per
ServiceWorkerSource instance (create it once on construction) or explicitly call
this.#listenerController.abort() before replacing it so prior beforeunload
handlers are removed; update enable(), `#startWorker`(), and terminate() so the
beforeunload listener is registered only once (or previous controller aborted)
and the keepalive timer / service worker registration persist across
enable()/disable() cycles (refer to methods: enable(), `#startWorker`(),
terminate(), and the private field `#listenerController`).
- Around line 61-63: The current reuse path in ServiceWorkerSource.from() calls
shouldInvalidateWorker(ServiceWorkerSource.#current.#options, options) but
ignores changes to the quiet flag, causing stale logging/validateWorkerScope()
behavior; update shouldInvalidateWorker to include comparison of the quiet
property (or alternatively, when reusing the singleton in from(), refresh
ServiceWorkerSource.#current.#options with the new options) so that changes to
quiet take effect; reference shouldInvalidateWorker, ServiceWorkerSource.from(),
ServiceWorkerSource.#current, and the instance field `#options` to locate where to
add the comparison or the options refresh.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: d28b531b-41a2-4095-b463-04a7d03fcb3e

📥 Commits

Reviewing files that changed from the base of the PR and between 0d208fb and 088239b.

📒 Files selected for processing (1)
  • src/browser/sources/service-worker-source.ts

Comment thread src/browser/sources/service-worker-source.ts
Comment thread src/browser/sources/service-worker-source.ts
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
src/browser/sources/service-worker-source.ts (1)

61-66: ⚠️ Potential issue | 🟡 Minor

quiet option changes are silently ignored on reuse.

When shouldInvalidateWorker returns false (i.e., only quiet differs), the existing singleton is returned without updating #options. Subsequent enable() / disable() calls will use the stale quiet value for logging decisions.

Either include quiet in invalidation criteria, or refresh #options on the reuse path:

🔧 Option A: Refresh options on reuse
     } else if (
       shouldInvalidateWorker(ServiceWorkerSource.#current.#options, options)
     ) {
       await ServiceWorkerSource.#current.terminate()
       ServiceWorkerSource.#current = new ServiceWorkerSource(options)
+    } else {
+      // Refresh options that don't require invalidation (e.g., quiet)
+      ServiceWorkerSource.#current.#options = options
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/browser/sources/service-worker-source.ts` around lines 61 - 66, The
existing singleton ServiceWorkerSource.#current keeps stale `#options` when
shouldInvalidateWorker(...) returns false (i.e., only quiet changed), so
subsequent enable()/disable() use the old quiet; to fix, when reusing the
singleton (the else branch where shouldInvalidateWorker is false) update the
instance's options to the new options (e.g., assign
ServiceWorkerSource.#current.#options = options or call an instance method like
ServiceWorkerSource.#current.updateOptions(options)), or alternatively add quiet
to shouldInvalidateWorker's invalidation criteria; ensure enable()/disable()
read the refreshed `#options` from the reused ServiceWorkerSource.#current.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/browser/sources/service-worker-source.ts`:
- Around line 61-66: The existing singleton ServiceWorkerSource.#current keeps
stale `#options` when shouldInvalidateWorker(...) returns false (i.e., only quiet
changed), so subsequent enable()/disable() use the old quiet; to fix, when
reusing the singleton (the else branch where shouldInvalidateWorker is false)
update the instance's options to the new options (e.g., assign
ServiceWorkerSource.#current.#options = options or call an instance method like
ServiceWorkerSource.#current.updateOptions(options)), or alternatively add quiet
to shouldInvalidateWorker's invalidation criteria; ensure enable()/disable()
read the refreshed `#options` from the reused ServiceWorkerSource.#current.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 1a1ebd1e-1a81-4967-95c6-bdc9b29e1c9d

📥 Commits

Reviewing files that changed from the base of the PR and between 088239b and 3f3ebe5.

📒 Files selected for processing (1)
  • src/browser/sources/service-worker-source.ts

@kettanaito kettanaito merged commit 580256b into main Apr 16, 2026
22 checks passed
@kettanaito kettanaito deleted the fix/worker-enable-after-disable branch April 16, 2026 16:04
@kettanaito
Copy link
Copy Markdown
Member Author

Released: v2.13.4 🎉

This has been released in v2.13.4.

Get these changes by running the following command:

npm i msw@latest

Predictable release automation by Release.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

1 participant