Skip to content

Commit aff6c24

Browse files
authored
fix(client): resolve default fetch lazily so interception tools work regardless of link construction order (#1901)
FetchLinkTransport captured `globalThis.fetch` at construction time (`options.fetch ?? globalThis.fetch.bind(globalThis)`), so any link created before a tool patches the global fetch — msw's `setupServer`, undici's `MockAgent`, and similar interceptors — permanently bypassed the patched implementation, surfacing as confusing ECONNREFUSED failures in tests. The default is now a late-bound wrapper, `(url, init) => globalThis.fetch(url, init)`, resolved on every request. ## Fixes - Links constructed before fetch interception is installed are now intercepted correctly; construction order no longer matters. - The default now forwards only `(url, init)` to the global fetch, matching the native signature; an explicit `fetch` option still receives `(url, init, options, path)` as before. ## Testing - New regression test constructs the link first, patches `globalThis.fetch` afterward, and verifies the patched fetch is used. - Existing default-fetch test updated to the native two-argument call shape; all fetch adapter tests pass, `pnpm type:check` clean across packages. This was the only eager-binding site — the websocket and message-port transports take explicit instances.
1 parent 2a80b57 commit aff6c24

2 files changed

Lines changed: 35 additions & 4 deletions

File tree

packages/client/src/adapters/fetch/rpc-link.test.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,10 +150,39 @@ describe('rpcLink', () => {
150150
method: 'POST',
151151
redirect: 'manual',
152152
}),
153+
)
154+
})
155+
156+
it('uses the current global fetch even when it is patched after link construction', async ({ onTestFinished }) => {
157+
const orpc = createORPCClient(new RPCLink({
158+
origin: 'http://api.example.com/',
159+
})) as any
160+
161+
const fetchSpy = vi.fn(async () => {
162+
return new Response(JSON.stringify({ json: 'pong' }), {
163+
status: 200,
164+
headers: {
165+
'content-type': 'application/json',
166+
},
167+
})
168+
})
169+
170+
const originalFetch = globalThis.fetch
171+
;(globalThis as any).fetch = fetchSpy
172+
173+
onTestFinished(() => {
174+
globalThis.fetch = originalFetch
175+
})
176+
177+
await expect(orpc.ping('input')).resolves.toEqual('pong')
178+
179+
expect(fetchSpy).toHaveBeenCalledOnce()
180+
expect(fetchSpy).toHaveBeenCalledWith(
181+
'http://api.example.com/ping',
153182
expect.objectContaining({
154-
context: {},
183+
method: 'POST',
184+
redirect: 'manual',
155185
}),
156-
['ping'],
157186
)
158187
})
159188

packages/client/src/adapters/fetch/transport.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ export interface FetchLinkTransportOptions<T extends ClientContext> {
5656
/**
5757
* Override the default fetch implementation.
5858
*
59-
* @default globalThis.fetch.bind(globalThis)
59+
* @default (url, init) => globalThis.fetch(url, init)
6060
*/
6161
fetch?(url: string, init: RequestInit, options: ClientOptions<T>, path: string[]): Promise<Response>
6262

@@ -78,7 +78,9 @@ export class FetchLinkTransport<T extends ClientContext> implements StandardLink
7878
options = new CompositeFetchLinkTransportPlugin(options.plugins).initFetchLinkTransportOptions(options)
7979

8080
this.origin = options.origin
81-
this.fetch = options.fetch ?? globalThis.fetch.bind(globalThis)
81+
// Resolve `globalThis.fetch` lazily so interception tools (msw, undici MockAgent, etc.)
82+
// that patch it after this transport is constructed still take effect.
83+
this.fetch = options.fetch ?? ((url, init) => (globalThis.fetch)(url, init))
8284
this.toFetchRequestOptions = options.toFetchRequest
8385
this.fetchInterceptors = options.fetchInterceptors
8486
}

0 commit comments

Comments
 (0)