Skip to content

fix: reset generator state on .resetHandlers()/.restoreHandlers()#2725

Merged
kettanaito merged 4 commits into
mainfrom
fix/reset-generator-state
Apr 22, 2026
Merged

fix: reset generator state on .resetHandlers()/.restoreHandlers()#2725
kettanaito merged 4 commits into
mainfrom
fix/reset-generator-state

Conversation

@kettanaito
Copy link
Copy Markdown
Member

@kettanaito kettanaito commented Apr 20, 2026

Problem

Calling .resetHandlers()/.restoreHandlers() does not reset the generator state if the response resolver is a generator function. That renders exhausted generators effectively useless. User expectation is that either of those methods will transition generator resolvers into a "fresh" state (.resetHandlers() because it's a full reset; .restoreHandlers() because yield is a substitute for one-time responses).

Todos

  • .restoreHandlers() must ONLY reset the generator state if the handler is a one-time handler. Otherwise, it has no effect on the generator state.
    • Add two tests for this: restore + one-time, restore + regular handler.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 20, 2026

📝 Walkthrough

Walkthrough

Centralizes handler restoration: adds reset()/restore() to RequestHandler, removes requestHandlerUtils, moves restore logic into HandlersController.restore(), and updates SetupApi to delegate restoration; also a minor formatting tweak in NetworkApi's generic declaration.

Changes

Cohort / File(s) Summary
Handler Lifecycle Methods
src/core/handlers/RequestHandler.ts
Added protected reset() to clear iterator/resolver runtime state and protected restore() to reset exhausted once handlers and mark them unused.
Controller Restoration Logic
src/core/experimental/handlers-controller.ts
reset(nextHandlers) now calls each handler's reset() when present; added public restore() that iterates currentHandlers() and invokes handlers' restore().
API Integration
src/core/experimental/setup-api.ts, src/core/experimental/define-network.ts
SetupApi.restoreHandlers() now delegates to handlersController.restore(); NetworkApi generic declaration formatted with a trailing comma.
Utility Removal
src/core/utils/internal/requestHandlerUtils.ts
Removed module and exports: use(), restoreHandlers(), and resetHandlers().
Tests — Generator Behavior
test/node/rest-api/response/generator.test.ts
Switched test URLs to http://localhost, tightened assertions, added tests for generator/async-generator reset/restore and cleanup behavior, and updated test mock teardown.

Sequence Diagram(s)

sequenceDiagram
    participant SetupApi as SetupApi
    participant HandlersController as HandlersController
    participant RequestHandler as RequestHandler

    SetupApi->>HandlersController: restore()
    HandlersController->>HandlersController: currentHandlers() → iterate
    loop per handler with restore()
        HandlersController->>RequestHandler: restore()
        RequestHandler->>RequestHandler: reset() (clear iterator/resolver)
        RequestHandler->>RequestHandler: set isUsed = false (if options.once)
    end
    HandlersController-->>SetupApi: restore complete
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐇 I hopped through handlers, cleared their thread,

Reset the loops where generators tread,
Restored the once that once were done,
Cleaned up finally — the race was won,
A little hop, and back we run.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: fixing generator state reset behavior on .resetHandlers()/.restoreHandlers().
Description check ✅ Passed The description is directly related to the changeset, explaining the problem (generators not resetting), the solution (implementing reset/restore methods), and expected behavior for one-time vs regular handlers.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ 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 fix/reset-generator-state

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.

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.

Caution

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

⚠️ Outside diff range comments (1)
src/core/experimental/handlers-controller.ts (1)

93-115: ⚠️ Potential issue | 🟠 Major

resetHandlers() does not re-arm { once: true } handlers from the initial set.

The reset() method on handlers only clears the resolver iterator state and does not clear the isUsed flag. When resetHandlers() is called without arguments, it reassigns the handlers map to { ...initialHandlers } — preserving the original handler instances. If an initial handler with { once: true } has already fired (isUsed === true), it will remain exhausted after reset, because isUsed is never cleared.

For example:

  • Initial handler: http.get('/resource', () => {...}, { once: true })
  • First request: handler fires, isUsed set to true
  • Call resetHandlers(): handlers map reset to { ...initialHandlers }, but the same instance with isUsed === true is reused
  • Second request: handler short-circuits at this.isUsed && this.options?.once and never fires

To fix this, either:

  • Have reset() also clear isUsed (making it equivalent to restore() semantically), or
  • Call handler.restore() instead of handler.reset() in the reset flow

There is no existing test coverage for this scenario. The older restoreHandlers() utility (separate from resetHandlers()) explicitly cleared isUsed, but the reset flow has never tested or cleared it.

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

In `@src/core/experimental/handlers-controller.ts` around lines 93 - 115,
resetHandlers() currently calls handler.reset() which doesn't clear the
handler's isUsed flag, so once:true handlers remain exhausted after a reset;
change the reset loop to call handler.restore() (or ensure reset() clears
isUsed) so that each handler's isUsed is cleared when resetting: locate the loop
in handlers-controller.ts that iterates this.currentHandlers() and replace
handler['reset']() with handler['restore']() (or update the Handler.reset
implementation to also set this.isUsed = false) so initialHandlers and handlers
maps reuse freshly armed handlers.
🧹 Nitpick comments (2)
src/core/handlers/RequestHandler.ts (1)

174-193: Consider public (or a clearly marked internal method) instead of protected for lifecycle hooks invoked externally.

reset() and restore() are declared protected, yet HandlersController (a sibling class, not a subclass) invokes them via bracket notation (handler['reset'](), handler['restore']()) specifically to bypass TypeScript's access checks. This works but couples two files with an implicit escape hatch that will silently drift if the methods are ever renamed or the signatures change — the in narrowing + index-access pattern won't produce a type error.

Either make these public (they are part of the handler lifecycle contract with the controller), or expose them through an internal symbol-keyed method so the intent is explicit. protected + bracket access is the least discoverable of the three options.

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

In `@src/core/handlers/RequestHandler.ts` around lines 174 - 193, RequestHandler's
lifecycle hooks reset() and restore() are marked protected but are invoked by
HandlersController via bracket-access; make their contract explicit by changing
them to public (i.e., declare reset() and restore() as public methods on the
RequestHandler class) so HandlersController can call handler.reset() and
handler.restore() without using index-access, or alternatively define and export
a shared internal Symbol (e.g., HANDLER_RESET/ HANDLER_RESTORE) and implement
symbol-keyed methods on RequestHandler that HandlersController uses; update all
calls in HandlersController that currently use handler['reset']() /
handler['restore']() to the chosen public method names or symbol-keyed access to
remove the protected index-access escape hatch.
test/node/rest-api/response/generator.test.ts (1)

105-122: Consider broadening test coverage to lock in the intended semantics.

The new test covers the plain generator + resetHandlers() path, which is great. To prevent regressions in the adjacent paths that this PR also touches, consider adding two more cases:

  1. restoreHandlers() resets generator state for a { once: true } generator handler (the primary use case called out in the PR description).
  2. A { once: true } (non-generator) handler is re-armed by resetHandlers() — or, if that's explicitly not intended, a negative test asserting it stays inert. Today it stays inert because reset() doesn't clear isUsed (see the comment on handlers-controller.ts), and this deserves an explicit contract either way.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/node/rest-api/response/generator.test.ts` around lines 105 - 122, Add
two tests to broaden coverage: (1) ensure restoreHandlers() resets the state of
a generator handler created with { once: true } by registering a generator
http.get(..., { once: true }) handler, invoking it once, calling
restoreHandlers(), then asserting the generator yields the same sequence again;
(2) assert the intended behavior for non-generator handlers with { once: true }
after resetHandlers(): either add a positive test that resetHandlers() re-arms a
non-generator { once: true } handler (invoke, resetHandlers(), expect it to fire
again) or add a negative test that it remains inert (invoke, resetHandlers(),
expect no further responses), referencing restoreHandlers(), resetHandlers(),
and the handlers-controller.ts isUsed semantics when implementing the
assertions.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@src/core/experimental/handlers-controller.ts`:
- Around line 93-115: resetHandlers() currently calls handler.reset() which
doesn't clear the handler's isUsed flag, so once:true handlers remain exhausted
after a reset; change the reset loop to call handler.restore() (or ensure
reset() clears isUsed) so that each handler's isUsed is cleared when resetting:
locate the loop in handlers-controller.ts that iterates this.currentHandlers()
and replace handler['reset']() with handler['restore']() (or update the
Handler.reset implementation to also set this.isUsed = false) so initialHandlers
and handlers maps reuse freshly armed handlers.

---

Nitpick comments:
In `@src/core/handlers/RequestHandler.ts`:
- Around line 174-193: RequestHandler's lifecycle hooks reset() and restore()
are marked protected but are invoked by HandlersController via bracket-access;
make their contract explicit by changing them to public (i.e., declare reset()
and restore() as public methods on the RequestHandler class) so
HandlersController can call handler.reset() and handler.restore() without using
index-access, or alternatively define and export a shared internal Symbol (e.g.,
HANDLER_RESET/ HANDLER_RESTORE) and implement symbol-keyed methods on
RequestHandler that HandlersController uses; update all calls in
HandlersController that currently use handler['reset']() / handler['restore']()
to the chosen public method names or symbol-keyed access to remove the protected
index-access escape hatch.

In `@test/node/rest-api/response/generator.test.ts`:
- Around line 105-122: Add two tests to broaden coverage: (1) ensure
restoreHandlers() resets the state of a generator handler created with { once:
true } by registering a generator http.get(..., { once: true }) handler,
invoking it once, calling restoreHandlers(), then asserting the generator yields
the same sequence again; (2) assert the intended behavior for non-generator
handlers with { once: true } after resetHandlers(): either add a positive test
that resetHandlers() re-arms a non-generator { once: true } handler (invoke,
resetHandlers(), expect it to fire again) or add a negative test that it remains
inert (invoke, resetHandlers(), expect no further responses), referencing
restoreHandlers(), resetHandlers(), and the handlers-controller.ts isUsed
semantics when implementing the assertions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 47e2400c-0339-44e8-88fb-d3404e0bd70e

📥 Commits

Reviewing files that changed from the base of the PR and between edeb058 and 9700afa.

📒 Files selected for processing (6)
  • src/core/experimental/define-network.ts
  • src/core/experimental/handlers-controller.ts
  • src/core/experimental/setup-api.ts
  • src/core/handlers/RequestHandler.ts
  • src/core/utils/internal/requestHandlerUtils.ts
  • test/node/rest-api/response/generator.test.ts
💤 Files with no reviewable changes (1)
  • src/core/utils/internal/requestHandlerUtils.ts

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Apr 22, 2026

Open in StackBlitz

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

commit: a4023a7

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/core/handlers/RequestHandler.ts`:
- Around line 190-195: restore() currently clears the public isUsed flag for all
handlers, causing regular exhausted generator handlers to be mutated; change
restore() so it only mutates state for one-time handlers: if this.options?.once
is true then call this.reset() and set this.isUsed = false; otherwise make
restore() a no-op (do not touch isUsed). Locate the protected restore() method
and update its control flow to guard both reset() and isUsed assignment behind
the options.once check so restoreHandlers() becomes a true no-op for non-once
handlers.
- Around line 180-183: The reset() method currently drops resolverIterator
without invoking its cleanup; modify reset() in RequestHandler to check if
this.resolverIterator is defined and has a return method (typeof
this.resolverIterator.return === "function") and call it to allow generator
cleanup before clearing this.resolverIterator and this.resolverIteratorResult;
ensure you guard the call in a try/catch to swallow or log any errors from
return() so reset() remains safe.
🪄 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: d6fbd5bb-08c4-4660-978d-190fee097211

📥 Commits

Reviewing files that changed from the base of the PR and between 9700afa and cfd3c32.

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

Comment thread src/core/handlers/RequestHandler.ts
Comment thread src/core/handlers/RequestHandler.ts Outdated
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)
test/node/rest-api/response/generator.test.ts (1)

160-167: Cover regular-handler restore while the generator is still mid-flight.

This test currently calls restoreHandlers() only after the generator has reached the stable result. Add a restore between Yield and Stable to prove regular generators are not reset while active.

Proposed coverage tweak
   await server.boundary(async () => {
     await expect(fetchJson('http://localhost/resource')).resolves.toBe('Yield')
-    await expect(fetchJson('http://localhost/resource')).resolves.toBe('Stable')
 
     server.restoreHandlers()
 
+    await expect(fetchJson('http://localhost/resource')).resolves.toBe('Stable')
+
+    server.restoreHandlers()
+
     await expect(fetchJson('http://localhost/resource')).resolves.toBe('Stable')
     await expect(fetchJson('http://localhost/resource')).resolves.toBe('Stable')
   })()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/node/rest-api/response/generator.test.ts` around lines 160 - 167, Add a
call to server.restoreHandlers() between the first fetchJson assertion that
resolves to 'Yield' and the subsequent fetchJson that resolves to 'Stable' to
verify that regular generator handlers are not reset while the generator is
mid-flight; update the test inside the server.boundary block (which contains
fetchJson('http://localhost/resource') and server.restoreHandlers()) so that
server.restoreHandlers() is invoked immediately after the
expect(...).resolves.toBe('Yield') assertion and before the
expect(...).resolves.toBe('Stable') assertion, keeping the remaining assertions
the same.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/node/rest-api/response/generator.test.ts`:
- Around line 15-22: The test teardown currently calls vi.clearAllMocks() in
afterEach and vi.restoreAllMocks() only in afterAll, so spies (e.g., the
console.error spy created around line 220) remain restored only at the end of
the file and leak into later tests; move vi.restoreAllMocks() into afterEach
alongside vi.clearAllMocks() so each test run restores spy implementations
before the next test, leaving afterAll to only perform global cleanup like
server.close(), and keep server.resetHandlers() in afterEach as-is to reset
handlers between tests.

---

Nitpick comments:
In `@test/node/rest-api/response/generator.test.ts`:
- Around line 160-167: Add a call to server.restoreHandlers() between the first
fetchJson assertion that resolves to 'Yield' and the subsequent fetchJson that
resolves to 'Stable' to verify that regular generator handlers are not reset
while the generator is mid-flight; update the test inside the server.boundary
block (which contains fetchJson('http://localhost/resource') and
server.restoreHandlers()) so that server.restoreHandlers() is invoked
immediately after the expect(...).resolves.toBe('Yield') assertion and before
the expect(...).resolves.toBe('Stable') assertion, keeping the remaining
assertions the same.
🪄 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: 3f517c06-0cfe-431a-9a53-23316c7197c8

📥 Commits

Reviewing files that changed from the base of the PR and between cfd3c32 and f47742b.

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

Comment thread test/node/rest-api/response/generator.test.ts
@kettanaito kettanaito force-pushed the fix/reset-generator-state branch from f47742b to a4023a7 Compare April 22, 2026 09:51
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/core/handlers/RequestHandler.ts (1)

180-187: ⚠️ Potential issue | 🟠 Major

Handle cleanup failures from iterator.return().

Line 186 still lets synchronous generator cleanup throw out of resetHandlers(), and async-generator cleanup rejections become unhandled because the promise is discarded. Wrap the return() call and attach a rejection handler so reset/restore cleanup remains safe.

Proposed defensive cleanup handling
   protected reset(): void {
     const iterator = this.resolverIterator
     this.resolverIterator = undefined
     this.resolverIteratorResult = undefined
 
     if (typeof iterator?.return === 'function') {
-      void Promise.resolve(iterator.return())
+      try {
+        void Promise.resolve(iterator.return()).catch(() => undefined)
+      } catch {
+        // Ignore cleanup errors during reset.
+      }
     }
   }

Verification script:

#!/bin/bash
# Demonstrates why Promise.resolve(iterator.return()) does not make cleanup safe.

node - <<'NODE'
process.on('unhandledRejection', (reason) => {
  console.log('async cleanup rejection was unhandled:', reason.message)
})

function* syncCleanupThrows() {
  try {
    yield 'value'
  } finally {
    throw new Error('sync cleanup')
  }
}

const syncIterator = syncCleanupThrows()
syncIterator.next()

try {
  void Promise.resolve(syncIterator.return())
} catch (error) {
  console.log('sync cleanup threw synchronously:', error.message)
}

async function* asyncCleanupRejects() {
  try {
    yield 'value'
  } finally {
    throw new Error('async cleanup')
  }
}

void (async () => {
  const asyncIterator = asyncCleanupRejects()
  await asyncIterator.next()

  void Promise.resolve(asyncIterator.return())

  await new Promise((resolve) => setImmediate(resolve))
})()
NODE
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/core/handlers/RequestHandler.ts` around lines 180 - 187, In reset(),
guard the call to this.resolverIterator.return() so synchronous throws are
caught and async rejections are handled: wrap the call in a try/catch to swallow
or log synchronous exceptions from iterator.return(), and assign
Promise.resolve(iterator.return()).catch(...) to handle any async rejection (or
await it if appropriate); update the protected reset() method (the
resolverIterator/return invocation) to use this defensive pattern so
reset/restore cleanup cannot throw or produce unhandled rejections.
🤖 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/core/handlers/RequestHandler.ts`:
- Around line 180-187: In reset(), guard the call to
this.resolverIterator.return() so synchronous throws are caught and async
rejections are handled: wrap the call in a try/catch to swallow or log
synchronous exceptions from iterator.return(), and assign
Promise.resolve(iterator.return()).catch(...) to handle any async rejection (or
await it if appropriate); update the protected reset() method (the
resolverIterator/return invocation) to use this defensive pattern so
reset/restore cleanup cannot throw or produce unhandled rejections.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 4a230e45-4c9f-468d-a8a1-b878da2cb843

📥 Commits

Reviewing files that changed from the base of the PR and between f47742b and a4023a7.

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

@kettanaito kettanaito merged commit 8d16801 into main Apr 22, 2026
22 checks passed
@kettanaito kettanaito deleted the fix/reset-generator-state branch April 22, 2026 10:02
@kettanaito
Copy link
Copy Markdown
Member Author

Released: v2.13.5 🎉

This has been released in v2.13.5.

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.

1 participant