Skip to content

fix: add finalize API for handler cleanup - #2738

Merged
kettanaito merged 7 commits into
mainfrom
feat/wait-until
May 7, 2026
Merged

kettanaito merged 7 commits into
mainfrom
feat/wait-until

Conversation

@kettanaito

Copy link
Copy Markdown
Member

@coderabbitai

coderabbitai Bot commented May 7, 2026 •

Copy link
Copy Markdown

Review Change Stack
No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 2adf44e1-c30d-4fea-9ef2-46d594cadfc0

📥 Commits

Reviewing files that changed from the base of the PR and between 418f3bc and 07aafb1.

📒 Files selected for processing (2)
  • src/core/handlers/RequestHandler.ts
  • test/node/msw-api/finalize.test.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • test/node/msw-api/finalize.test.ts
  • src/core/handlers/RequestHandler.ts

📝 Walkthrough

Walkthrough

RequestHandler adds a resolver-facing finalize(callback) API, schedules per-request or iterator-specific cleanup callbacks, wires abort listeners to trigger cleanup, and runs/awaits scheduled cleanups at resolver completion or iterator exhaustion; tests and typings were added to validate behavior and signature.

Changes

RequestHandler Finalize Cleanup Feature

Layer / File(s) Summary
Type Definitions & Imports
src/core/handlers/RequestHandler.ts
New ResponseResolverFinalizeFunction type, finalize added to ResponseResolverInfo, and devUtils imported.
Handler State & Teardown
src/core/handlers/RequestHandler.ts
resolverIteratorCleanups and scheduledCleanups storage added; teardown/reset clears cleanup state.
Cleanup Registration & Abort Trigger
src/core/handlers/RequestHandler.ts
Resolvers receive finalize() which enqueues callbacks for the request; an abort listener on request.signal triggers runScheduledCleanups for the request.
Resolver Wrapping & Generator Handling
src/core/handlers/RequestHandler.ts
Wrapper runs cleanups on resolver throw and for non-iterable returns, transfers request cleanups into iterator-specific storage for generator returns, and runs cleanups when the iterator reaches the done path.
Cleanup Scheduling & Execution
src/core/handlers/RequestHandler.ts
scheduleCleanup enqueues callbacks; exhaustCleanups awaits callbacks and aggregates Errors; runScheduledCleanups drains appropriate queues, clears state, and logs aggregated failures via devUtils.error.
Tests / Typings
test/node/msw-api/finalize.test.ts, test/typings/*
Node tests cover finalize across handler outcomes, async ordering, LIFO order, abort handling, and generator/iterator semantics. Typings tests assert the finalize signature for HTTP, GraphQL, and SSE handlers.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Resolver
  participant RequestHandler
  participant Signal
  participant Cleanup as runScheduledCleanups
  Client->>RequestHandler: send request
  RequestHandler->>Resolver: invoke resolver(info with finalize)
  Resolver->>RequestHandler: calls finalize(cleanup_fn)
  RequestHandler->>RequestHandler: scheduledCleanups.unshift(cleanup_fn)
  Signal->>RequestHandler: abort event
  RequestHandler->>Cleanup: runScheduledCleanups()
  Cleanup-->>RequestHandler: await callbacks (may throw)
  RequestHandler->>Client: respond / propagate error
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • mswjs/msw#2725: Modifies RequestHandler resolver lifecycle and generator iterator handling, complementary to finalize callback scheduling.

Poem

🐰 I tuck each cleanup in a queue,

A tiny hop when a request is through.
Abort or done, the callbacks run,
LIFO order, tidy and done.
Two requests, two hops — cleanup fun.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix: add finalize API for handler cleanup' directly and clearly summarizes the main change: introducing a finalize API for handler cleanup.
Description check ✅ Passed The description 'Closes #2630' is related to the changeset as it references the linked issue that defines the cleanup mechanism requirement.
Linked Issues check ✅ Passed The pull request implements the finalize API feature requested in issue #2630, enabling handlers to schedule cleanup callbacks that execute when resolvers complete, addressing all coding requirements.
Out of Scope Changes check ✅ Passed All changes directly support the finalize API feature: RequestHandler implementation, runtime tests, and TypeScript type tests for HTTP/GraphQL/SSE handlers remain within scope.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/wait-until

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@pkg-pr-new

pkg-pr-new Bot commented May 7, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 07aafb1

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/core/handlers/RequestHandler.ts (1)

197-205: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

reset() leaks pending scheduledCleanups, undermining the core SSE use case

When server.resetHandlers() is called (e.g. in afterEach), reset() clears resolverIterator but leaves scheduledCleanups untouched. For a generator handler that has already executed at least one .next() (registering cleanups via finalize), two bad outcomes follow:

  1. No further request arrives → cleanups never run → resource leak (e.g. setInterval keeps firing across subsequent tests).
  2. A new request arrives → !this.resolverIterator is true, wrapResolver re-runs setup, and the finally block drains stale cleanups from the previous request's lifecycle during the new request's initialization — semantically wrong.

This directly undercuts the primary motivation of this PR (SSE interval cleanup in tests).

🐛 Proposed fix — mirror the existing fire-and-forget pattern
 protected reset(): void {
   const iterator = this.resolverIterator
   this.resolverIterator = undefined
   this.resolverIteratorResult = undefined

   if (typeof iterator?.return === 'function') {
     void Promise.resolve(iterator.return())
   }
+
+  void this.runScheduledCleanups()
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/handlers/RequestHandler.ts` around lines 197 - 205, reset()
currently clears resolverIterator but leaves scheduledCleanups intact, causing
leaked/incorrect cleanup runs; update reset() to drain and clear
scheduledCleanups too: if this.scheduledCleanups exists, copy it, set
this.scheduledCleanups = undefined, then iterate the copy and invoke each
cleanup in a fire-and-forget manner (e.g. void Promise.resolve(cleanup())) so
callbacks run asynchronously and are not reused by future
wrapResolver/resolverIterator lifecycles; keep existing behavior for
resolverIterator and resolverIteratorResult.
🧹 Nitpick comments (1)
test/node/msw-api/finalize.test.ts (1)

1-181: ⚡ Quick win

Missing test for concurrent requests exposes the shared-scheduledCleanups bug

All tests exercise a single in-flight request at a time. The concurrency issue flagged in the source (two simultaneous requests sharing the handler's cleanup queue) would not be caught here. Consider adding a test like:

it('runs cleanups independently per request', async () => {
  const cleanupA = vi.fn()
  const cleanupB = vi.fn()

  server.use(
    http.get('http://localhost/resource', async ({ finalize, request }) => {
      const which = new URL(request.url).searchParams.get('id')
      finalize(which === 'a' ? cleanupA : cleanupB)
      await setTimeout(which === 'a' ? 300 : 150)
      return new Response(which ?? '')
    }),
  )

  // Issue both requests concurrently; B resolves first
  const [, responseB] = await Promise.all([
    fetch('http://localhost/resource?id=a'),
    fetch('http://localhost/resource?id=b'),
  ])

  // cleanupB should run when B finishes; cleanupA must NOT run yet
  expect(cleanupB).toHaveBeenCalledOnce()
  expect(cleanupA).not.toHaveBeenCalled()
})

This test will currently fail (cleanupA would be called prematurely when B finishes) and would serve as a regression guard once the per-request queue fix is applied.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/node/msw-api/finalize.test.ts` around lines 1 - 181, Add a
concurrent-request test that ensures finalize callbacks are tracked per request
rather than shared: add an it('runs cleanups independently per request', async
() => { ... }) test which registers a handler via server.use(http.get(..., async
({ finalize, request }) => { ... })) that finalizes either cleanupA or cleanupB
based on request URL param, awaits different setTimeout durations (e.g., 300 vs
150 ms) and returns a Response, then issues both fetches concurrently with
Promise.all and asserts cleanupB has been calledOnce while cleanupA has not been
called yet; this will reproduce and guard against the shared scheduledCleanups
bug.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/core/handlers/RequestHandler.ts`:
- Around line 463-467: The catch block in RequestHandler (where
errors.push(error) is executed) silently ignores non-Error throws; update the
catch to normalize any thrown value before pushing into the errors array: detect
if caught value is an instance of Error, otherwise wrap it in a new Error (e.g.,
include typeof and JSON.stringify/value as message) so strings, numbers, and
plain objects are converted into Error objects before calling errors.push(...);
keep the existing conditional branch for real Error instances and ensure the
normalization preserves useful context for debugging.
- Around line 350-363: scheduledCleanups is currently a handler-level array
shared across concurrent run() calls, so finalize callbacks from different
requests collide and a single abort drains all callbacks; change to a
per-request queue keyed by requestId: create a map (e.g.
requestScheduledCleanups) on the handler and update
runScheduledCleanups(requestId) and reset(requestId) to operate only on
requestScheduledCleanups.get(requestId); in run(), when adding the abort
listener call runScheduledCleanups(requestId) and when passing finalize into
executeResolver push callbacks into requestScheduledCleanups.get(requestId) (or
create the array if missing) instead of this.scheduledCleanups, and ensure
cleanup removes the map entry after draining.

---

Outside diff comments:
In `@src/core/handlers/RequestHandler.ts`:
- Around line 197-205: reset() currently clears resolverIterator but leaves
scheduledCleanups intact, causing leaked/incorrect cleanup runs; update reset()
to drain and clear scheduledCleanups too: if this.scheduledCleanups exists, copy
it, set this.scheduledCleanups = undefined, then iterate the copy and invoke
each cleanup in a fire-and-forget manner (e.g. void Promise.resolve(cleanup()))
so callbacks run asynchronously and are not reused by future
wrapResolver/resolverIterator lifecycles; keep existing behavior for
resolverIterator and resolverIteratorResult.

---

Nitpick comments:
In `@test/node/msw-api/finalize.test.ts`:
- Around line 1-181: Add a concurrent-request test that ensures finalize
callbacks are tracked per request rather than shared: add an it('runs cleanups
independently per request', async () => { ... }) test which registers a handler
via server.use(http.get(..., async ({ finalize, request }) => { ... })) that
finalizes either cleanupA or cleanupB based on request URL param, awaits
different setTimeout durations (e.g., 300 vs 150 ms) and returns a Response,
then issues both fetches concurrently with Promise.all and asserts cleanupB has
been calledOnce while cleanupA has not been called yet; this will reproduce and
guard against the shared scheduledCleanups bug.
🪄 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: 31fb30d7-b80b-4cc8-a4cd-d45d003c5187

📥 Commits

Reviewing files that changed from the base of the PR and between de18888 and c1fe94e.

📒 Files selected for processing (2)
  • src/core/handlers/RequestHandler.ts
  • test/node/msw-api/finalize.test.ts

Comment thread src/core/handlers/RequestHandler.ts
Comment thread src/core/handlers/RequestHandler.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/core/handlers/RequestHandler.ts`:
- Around line 466-484: runScheduledCleanups can run concurrently and iterate the
same this.scheduledCleanups array, causing duplicate executions; to fix,
atomically drain the queue by capturing the array into a local variable and
clearing this.scheduledCleanups (set to []) before entering the for/await loop
so each cleanup is executed exactly once; update the runScheduledCleanups method
to: if length is zero return, otherwise const toRun = this.scheduledCleanups;
this.scheduledCleanups = []; then iterate await on toRun, preserving the
existing error collection logic; this prevents the abort listener and
wrapResolver finally from double-running the same cleanup.
🪄 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: 9888c236-fcd5-4b8b-b56d-a300f5dd4883

📥 Commits

Reviewing files that changed from the base of the PR and between c1fe94e and 418f3bc.

📒 Files selected for processing (4)
  • src/core/handlers/RequestHandler.ts
  • test/typings/graphql.test-d.ts
  • test/typings/http.test-d.ts
  • test/typings/sse.test-d.ts

Comment thread src/core/handlers/RequestHandler.ts Outdated
@kettanaito
kettanaito merged commit a288f54 into main May 7, 2026
33 of 34 checks passed
@kettanaito
kettanaito deleted the feat/wait-until branch May 7, 2026 11:55
@kettanaito

Copy link
Copy Markdown
Member Author

Released: v2.14.4 🎉

This has been released in v2.14.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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support onCleanup callback to schedule side effect cleanup when resolvers are finished

1 participant