Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 34 additions & 32 deletions src/core/handlers/RequestHandler.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { invariant } from 'outvariant'
import { getCallFrame } from '../utils/internal/getCallFrame'
import { isIterable } from '../utils/internal/isIterable'
import type { ResponseResolutionContext } from '../utils/executeHandlers'
Expand Down Expand Up @@ -57,6 +56,11 @@ export type AsyncResponseResolverReturnType<
MaybeAsyncResponseResolverReturnType<ResponseBodyType>,
MaybeAsyncResponseResolverReturnType<ResponseBodyType>
>
| AsyncGenerator<
Copy link
Member Author

Choose a reason for hiding this comment

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

Older versions of TypeScript (4.7 - 5.2) have trouble digesting this type:

 FAIL  resolver-generator.test-d.ts > supports async generator function as response resolver
TypeCheckError: Argument of type '() => AsyncGenerator<StrictResponse<JsonBodyType>, StrictResponse<JsonBodyType>, any>' is not assignable to parameter of type 'HttpResponseResolver<never, never, { value string; }>'.
  Type 'AsyncGenerator<StrictResponse<JsonBodyType>, StrictResponse<JsonBodyType>, any>' is not assignable to type 'AsyncResponseResolverReturnType<{ value string; }>'.
    Type 'AsyncGenerator<StrictResponse<JsonBodyType>, StrictResponse<JsonBodyType>, any>' is not assignable to type 'AsyncGenerator<ResponseResolverReturnType<{ value string; }>, ResponseResolverReturnType<{ value string; }>, ResponseResolverReturnType<{ value string; }>>'.
      The types returned by 'next(...)' are incompatible between these types.
        Type 'Promise<IteratorResult<StrictResponse<JsonBodyType>, StrictResponse<JsonBodyType>>>' is not assignable to type 'Promise<IteratorResult<ResponseResolverReturnType<{ value string; }>, ResponseResolverReturnType<{ value string; }>>>'.
          Type 'IteratorResult<StrictResponse<JsonBodyType>, StrictResponse<JsonBodyType>>' is not assignable to type 'IteratorResult<ResponseResolverReturnType<{ value string; }>, ResponseResolverReturnType<{ value string; }>>'.
            Type 'IteratorYieldResult<StrictResponse<JsonBodyType>>' is not assignable to type 'IteratorResult<ResponseResolverReturnType<{ value string; }>, ResponseResolverReturnType<{ value string; }>>'.
              Type 'IteratorYieldResult<StrictResponse<JsonBodyType>>' is not assignable to type 'IteratorYieldResult<ResponseResolverReturnType<{ value string; }>>'.
                Type 'StrictResponse<JsonBodyType>' is not assignable to type 'ResponseResolverReturnType<{ value string; }>'.

Reproduction steps

  • TypeScript 4.7
  • pnpm test:ts

Copy link
Member Author

Choose a reason for hiding this comment

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

Hi, @Andarist! Sorry for troubling you once again. If you have a minute, could you please lend me a hand with this type issue?

Copy link
Contributor

Choose a reason for hiding this comment

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

I would need to see a slimmed-down repro. The one that I tried put together in a rush errors in the current TS version too: TS playground

Copy link
Member Author

Choose a reason for hiding this comment

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

Thanks for giving it a try, @Andarist. Here's a minimal repro:

pnpm install
pnpm build
pnpm test:ts test/typings/resolver-generator.test-d.ts

Copy link
Contributor

Choose a reason for hiding this comment

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

That is not exactly minimal ;p it still depends on everything in the MSW's codebase. I could really use a self-contained TS playground here.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yes, I can do that.

Copy link
Contributor

Choose a reason for hiding this comment

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

FWIW I did compile and test locally, and it all seemed to work, though I did not acutally test the full range of TS versions (but I can't imagine it wouldn't work, maybe I'm jinxing myself)

Copy link
Member Author

Choose a reason for hiding this comment

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

Happy to give this a try once it's here 👀 Installing different typescript dependency versions triggers the test:ts into using that version for testing. The CI will also cover all the ranges for us. So excited we may actually ship this!

Copy link
Contributor

Choose a reason for hiding this comment

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

Sent #2213.

FWIW if you're looking for multi-version testing and aren't too tied to vitest, https://github.com/tstyche/tstyche is one option I've heard of which automatically runs multiple versions of TS without having to install them explicitly.

Copy link
Member Author

Choose a reason for hiding this comment

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

That's nice! Something to consider in the setup.

MaybeAsyncResponseResolverReturnType<ResponseBodyType>,
MaybeAsyncResponseResolverReturnType<ResponseBodyType>,
MaybeAsyncResponseResolverReturnType<ResponseBodyType>
>
>

export type ResponseResolverInfo<
Expand Down Expand Up @@ -117,11 +121,17 @@ export abstract class RequestHandler<
public isUsed: boolean

protected resolver: ResponseResolver<ResolverExtras, any, any>
private resolverGenerator?: Generator<
MaybeAsyncResponseResolverReturnType<any>,
MaybeAsyncResponseResolverReturnType<any>,
MaybeAsyncResponseResolverReturnType<any>
>
private resolverGenerator?:
| Generator<
MaybeAsyncResponseResolverReturnType<any>,
MaybeAsyncResponseResolverReturnType<any>,
MaybeAsyncResponseResolverReturnType<any>
>
| AsyncGenerator<
MaybeAsyncResponseResolverReturnType<any>,
MaybeAsyncResponseResolverReturnType<any>,
MaybeAsyncResponseResolverReturnType<any>
>
private resolverGeneratorResult?: Response | StrictResponse<any>
private options?: HandlerOptions

Expand Down Expand Up @@ -256,6 +266,9 @@ export abstract class RequestHandler<
return null
}

// Preemptively mark the handler as used.
// Generators will undo this because only when the resolver reaches the
// "done" state of the generator that it considers the handler used.
this.isUsed = true
Copy link
Member Author

Choose a reason for hiding this comment

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

It's important to mark the handler as used immediately, even if using generators then opts-out from this behavior. This is what we promise right now, so let's keep that promise.


// Create a response extraction wrapper around the resolver
Expand Down Expand Up @@ -304,39 +317,28 @@ export abstract class RequestHandler<
const result = this.resolverGenerator || (await resolver(info))

if (isIterable<AsyncResponseResolverReturnType<any>>(result)) {
// Immediately mark this handler as unused.
// Only when the generator is done, the handler will be
// considered used.
// Opt-out from marking this handler as used.
this.isUsed = false

const { value, done } = result[Symbol.iterator]().next()
const nextResponse = await value

if (done) {
this.isUsed = true
}

// If the generator is done and there is no next value,
// return the previous generator's value.
if (!nextResponse && done) {
invariant(
this.resolverGeneratorResult,
'Failed to returned a previously stored generator response: the value is not a valid Response.',
)

// Clone the previously stored response from the generator
// so that it could be read again.
return this.resolverGeneratorResult.clone() as StrictResponse<any>
}

if (!this.resolverGenerator) {
this.resolverGenerator = result
}

const { done, value } = await this.resolverGenerator.next()
const nextResponse = await value

if (nextResponse) {
// Also clone the response before storing it
// so it could be read again.
this.resolverGeneratorResult = nextResponse?.clone()
this.resolverGeneratorResult = nextResponse.clone()
}

if (done) {
// A one-time generator resolver stops affecting the network
// only after it's been completely exhausted.
this.isUsed = true

// Clone the previously stored response so it can be read
// when receiving it repeatedly from the "done" generator.
return this.resolverGeneratorResult?.clone()
}

return nextResponse
Expand Down
8 changes: 6 additions & 2 deletions src/core/utils/internal/isIterable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,14 @@
*/
export function isIterable<IteratorType>(
fn: any,
): fn is Generator<IteratorType, IteratorType, IteratorType> {
): fn is
| Generator<IteratorType, IteratorType, IteratorType>
| AsyncGenerator<IteratorType, IteratorType, IteratorType> {
if (!fn) {
return false
}

return typeof (fn as Generator<unknown>)[Symbol.iterator] == 'function'
return (
Reflect.has(fn, Symbol.iterator) || Reflect.has(fn, Symbol.asyncIterator)
)
}
106 changes: 106 additions & 0 deletions test/node/rest-api/response/generator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* @vitest-environment node
*/
import { http, HttpResponse, delay } from 'msw'
import { setupServer } from 'msw/node'

const server = setupServer()
const toJson = (response: Response) => response.json()

beforeAll(() => {
server.listen()
})

afterEach(() => {
server.resetHandlers()
})

afterAll(() => {
server.close()
})

it('supports generator function as response resolver', async () => {
server.use(
http.get('https://example.com/weather', function* () {
let degree = 10

while (degree < 13) {
degree++
yield HttpResponse.json(degree)
}

degree++
return HttpResponse.json(degree)
}),
)

// Must respond with yielded responses.
expect(await fetch('https://example.com/weather').then(toJson)).toEqual(11)
expect(await fetch('https://example.com/weather').then(toJson)).toEqual(12)
expect(await fetch('https://example.com/weather').then(toJson)).toEqual(13)
// Must respond with the final "done" response.
expect(await fetch('https://example.com/weather').then(toJson)).toEqual(14)
// Must keep responding with the final "done" response.
expect(await fetch('https://example.com/weather').then(toJson)).toEqual(14)
})

it('supports async generator function as response resolver', async () => {
server.use(
http.get('https://example.com/weather', async function* () {
await delay(20)

let degree = 10

while (degree < 13) {
degree++
yield HttpResponse.json(degree)
}

degree++
return HttpResponse.json(degree)
}),
)

expect(await fetch('https://example.com/weather').then(toJson)).toEqual(11)
expect(await fetch('https://example.com/weather').then(toJson)).toEqual(12)
expect(await fetch('https://example.com/weather').then(toJson)).toEqual(13)
expect(await fetch('https://example.com/weather').then(toJson)).toEqual(14)
expect(await fetch('https://example.com/weather').then(toJson)).toEqual(14)
})

it('supports generator function as one-time response resolver', async () => {
server.use(
http.get(
'https://example.com/weather',
function* () {
let degree = 10

while (degree < 13) {
degree++
yield HttpResponse.json(degree)
}

degree++
return HttpResponse.json(degree)
},
{ once: true },
),
http.get('*', () => {
return HttpResponse.json('fallback')
}),
)

// Must respond with the yielded incrementing responses.
expect(await fetch('https://example.com/weather').then(toJson)).toEqual(11)
expect(await fetch('https://example.com/weather').then(toJson)).toEqual(12)
expect(await fetch('https://example.com/weather').then(toJson)).toEqual(13)
// Must respond with the "done" final response from the iterator.
expect(await fetch('https://example.com/weather').then(toJson)).toEqual(14)
// Must respond with the other handler since the generator one is used.
expect(await fetch('https://example.com/weather').then(toJson)).toEqual(
'fallback',
)
expect(await fetch('https://example.com/weather').then(toJson)).toEqual(
'fallback',
)
})
36 changes: 36 additions & 0 deletions test/typings/resolver-generator.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { it } from 'vitest'
import { http, HttpResponse } from 'msw'

it('supports generator function as response resolver', () => {
http.get<never, never, { value: number }>('/', function* () {
yield HttpResponse.json({ value: 1 })
yield HttpResponse.json({ value: 2 })
return HttpResponse.json({ value: 3 })
})

http.get<never, never, { value: string }>('/', function* () {
yield HttpResponse.json({ value: 'one' })
yield HttpResponse.json({
// @ts-expect-error Expected string, got number.
value: 2,
})
return HttpResponse.json({ value: 'three' })
})
})

it('supports async generator function as response resolver', () => {
http.get<never, never, { value: number }>('/', async function* () {
yield HttpResponse.json({ value: 1 })
yield HttpResponse.json({ value: 2 })
return HttpResponse.json({ value: 3 })
})

http.get<never, never, { value: string }>('/', async function* () {
yield HttpResponse.json({ value: 'one' })
yield HttpResponse.json({
// @ts-expect-error Expected string, got number.
value: 2,
})
return HttpResponse.json({ value: 'three' })
})
})