Skip to content

Commit 149c13e

Browse files
authored
fix(client): stop client proxies from resolving primitive coercion over the network (#1815)
Backports `RECURSIVE_CLIENT_UNWRAP_KEYS` from v2 to the 1.x line. Client proxies no longer treat `bind`, `valueOf`, `toString`, and `toJSON` as procedure paths, so `String(client)`, template literals, and `JSON.stringify(client)` resolve normally instead of throwing or issuing spurious RPCs — including the React 19.2 dev-build freeze when a client is passed through props. Resolves #1809 ## Fixes - `createORPCClient` and `createSafeClient` no longer fire network requests or throw on primitive coercion and serialization. - `createRouterUtils` in all query integrations (tanstack-query, react-query, vue-query, solid-query, svelte-query, vue-colada, react-swr) gained the same unwrap keys plus v2's `isTypescriptObject` guard: `JSON.stringify` of utils built over a server-side client no longer crashes, and `utils.call` on server-side clients now returns the underlying client as documented. - Unlike v2, `then` is intentionally not in the unwrap set: v1 keeps supporting procedures named `then` via `preventNativeAwait`, so no existing behavior breaks in a patch release. ## Testing - New coercion/serialization tests for the client, safe client, and every integration's router utils; changed files are at 100% statement/branch/function/line coverage. - All 713 unit tests across the touched packages pass; e2e failures observed locally are identical on a clean checkout (environment timing, unrelated).
1 parent ee68137 commit 149c13e

20 files changed

Lines changed: 264 additions & 22 deletions

File tree

apps/content/docs/router.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ const router = {
2424
}
2525
```
2626

27+
::: warning
28+
For compatibility, do not use these router keys: `then`, `bind`, `valueOf`, `toString`, `toJSON`.
29+
:::
30+
2731
## Extending Router
2832

2933
Routers can be modified to include additional features. For example, to require authentication on all procedures:

packages/client/src/client-safe.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,14 @@ describe('createSafeClient', () => {
5454
expect(safeClient[Symbol('test')]).toEqual(undefined)
5555
expect(safeClient.nested[Symbol('test')]).toEqual(undefined)
5656
})
57+
58+
it('not proxy on unwrap keys', () => {
59+
expect(() => String(safeClient)).not.toThrow()
60+
expect(() => `${safeClient}`).not.toThrow()
61+
expect(JSON.stringify({ safeClient })).toEqual('{}')
62+
expect(typeof safeClient.bind).toEqual('function')
63+
64+
expect(client.ping).not.toBeCalled()
65+
expect(client.nested.pong).not.toBeCalled()
66+
})
5767
})

packages/client/src/client-safe.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Client, ClientRest, NestedClient } from './types'
22
import type { SafeResult } from './utils'
33
import { isTypescriptObject } from '@orpc/shared'
4+
import { RECURSIVE_CLIENT_UNWRAP_KEYS } from './consts'
45
import { safe } from './utils'
56

67
export type SafeClient<T extends NestedClient<any>>
@@ -23,13 +24,13 @@ export type SafeClient<T extends NestedClient<any>>
2324
*/
2425
export function createSafeClient<T extends NestedClient<any>>(client: T): SafeClient<T> {
2526
const proxy = new Proxy((...args: any[]) => safe((client as any)(...args)), {
26-
get(_, prop, receiver) {
27-
const value = Reflect.get(client, prop, receiver)
28-
29-
if (typeof prop !== 'string') {
30-
return value
27+
get(target, prop, receiver) {
28+
if (typeof prop !== 'string' || RECURSIVE_CLIENT_UNWRAP_KEYS.has(prop)) {
29+
return Reflect.get(target, prop)
3130
}
3231

32+
const value = Reflect.get(client, prop, receiver)
33+
3334
if (!isTypescriptObject(value)) {
3435
return value
3536
}

packages/client/src/client.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,20 @@ describe('createORPCClient', () => {
4646
expect(client[Symbol('test')]).toBeUndefined()
4747
})
4848

49+
it('not recursive on unwrap keys', async () => {
50+
const client = createORPCClient(mockedLink) as any
51+
52+
expect(() => String(client)).not.toThrow()
53+
expect(() => `${client}`).not.toThrow()
54+
expect(() => `${client.nested}`).not.toThrow()
55+
expect(JSON.stringify(client)).toBeUndefined()
56+
expect(JSON.stringify({ client })).toEqual('{}')
57+
expect(typeof client.bind).toEqual('function')
58+
expect(typeof client.nested.bind).toEqual('function')
59+
60+
expect(mockedLink.call).not.toBeCalled()
61+
})
62+
4963
it('prevent native await', async () => {
5064
const client = createORPCClient(mockedLink) as any
5165

packages/client/src/client.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Client, ClientLink, FriendlyClientOptions, InferClientContext, NestedClient } from './types'
22
import { preventNativeAwait } from '@orpc/shared'
3+
import { RECURSIVE_CLIENT_UNWRAP_KEYS } from './consts'
34
import { resolveFriendlyClientOptions } from './utils'
45

56
export interface createORPCClientOptions {
@@ -28,7 +29,7 @@ export function createORPCClient<T extends NestedClient<any>>(
2829

2930
const recursive = new Proxy(procedureClient, {
3031
get(target, key) {
31-
if (typeof key !== 'string') {
32+
if (typeof key !== 'string' || RECURSIVE_CLIENT_UNWRAP_KEYS.has(key)) {
3233
return Reflect.get(target, key)
3334
}
3435

packages/client/src/consts.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,34 @@
11
export const ORPC_CLIENT_PACKAGE_NAME = '__ORPC_CLIENT_PACKAGE_NAME_PLACEHOLDER__'
22
export const ORPC_CLIENT_PACKAGE_VERSION = '__ORPC_CLIENT_PACKAGE_VERSION_PLACEHOLDER__'
3+
4+
/**
5+
* Property names that should resolve to the underlying value instead of
6+
* continuing recursive proxy traversal.
7+
*
8+
* These properties are commonly accessed automatically by JavaScript runtimes,
9+
* language features, or third-party libraries. Returning another recursive
10+
* proxy for them can cause unexpected behavior, compatibility issues, or
11+
* infinite proxy chains.
12+
*
13+
* Unlike v2, `then` is not included here because v1 supports procedures
14+
* named `then` and relies on `preventNativeAwait` to keep `await client` safe.
15+
*/
16+
export const RECURSIVE_CLIENT_UNWRAP_KEYS = new Set([
17+
/**
18+
* Commonly used by libraries to bind functions to a specific `this`
19+
* context.
20+
*/
21+
'bind',
22+
/**
23+
* Commonly accessed during primitive conversion, inspection, and logging.
24+
*/
25+
'valueOf',
26+
/**
27+
* Commonly accessed during string conversion, inspection, and logging.
28+
*/
29+
'toString',
30+
/**
31+
* Commonly accessed by serializers such as `JSON.stringify`.
32+
*/
33+
'toJSON',
34+
])

packages/react-query/src/router-utils.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,4 +60,23 @@ describe('createRouterUtils', () => {
6060

6161
expect(utils[Symbol.for('a')]).toBe(undefined)
6262
})
63+
64+
it('safe on primitive coercion and serialization', () => {
65+
const utils = createRouterUtils(client) as any
66+
67+
expect(() => String(utils)).not.toThrow()
68+
expect(() => `${utils}`).not.toThrow()
69+
expect(() => `${utils.key}`).not.toThrow()
70+
expect(() => JSON.stringify({ utils })).not.toThrow()
71+
72+
expect(client).not.toBeCalled()
73+
expect(client.key).not.toBeCalled()
74+
})
75+
76+
it('not recursive on non-object clients', () => {
77+
const utils = createRouterUtils(undefined as any) as any
78+
79+
expect(utils.nonExists).toBeUndefined()
80+
expect(utils.key()).toEqual(generalUtilsSpy.mock.results[0]!.value.key())
81+
})
6382
})

packages/react-query/src/router-utils.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import type { Client, NestedClient } from '@orpc/client'
22
import type { GeneralUtils } from './general-utils'
33
import type { ProcedureUtils } from './procedure-utils'
4+
import { RECURSIVE_CLIENT_UNWRAP_KEYS } from '@orpc/client'
5+
import { isTypescriptObject } from '@orpc/shared'
46
import { createGeneralUtils } from './general-utils'
57
import { createProcedureUtils } from './procedure-utils'
68

@@ -36,8 +38,9 @@ export function createRouterUtils<T extends NestedClient<any>>(
3638
}, {
3739
get(target, prop) {
3840
const value = Reflect.get(target, prop)
41+
const nextClient = isTypescriptObject(client) ? Reflect.get(client, prop) : undefined
3942

40-
if (typeof prop !== 'string') {
43+
if (typeof prop !== 'string' || RECURSIVE_CLIENT_UNWRAP_KEYS.has(prop) || !isTypescriptObject(nextClient)) {
4144
return value
4245
}
4346

@@ -48,7 +51,11 @@ export function createRouterUtils<T extends NestedClient<any>>(
4851
}
4952

5053
return new Proxy(value, {
51-
get(_, prop) {
54+
get(target, prop) {
55+
if (typeof prop !== 'string' || RECURSIVE_CLIENT_UNWRAP_KEYS.has(prop)) {
56+
return Reflect.get(target, prop)
57+
}
58+
5259
return Reflect.get(nextUtils, prop)
5360
},
5461
})

packages/react-swr/src/router-utils.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,4 +63,23 @@ describe('createRouterUtils', () => {
6363
const utils = createRouterUtils(client) as any
6464
expect(utils[Symbol.for('a')]).toBe(undefined)
6565
})
66+
67+
it('safe on primitive coercion and serialization', () => {
68+
const utils = createRouterUtils(client) as any
69+
70+
expect(() => String(utils)).not.toThrow()
71+
expect(() => `${utils}`).not.toThrow()
72+
expect(() => `${utils.key}`).not.toThrow()
73+
expect(() => JSON.stringify({ utils })).not.toThrow()
74+
75+
expect(client).not.toBeCalled()
76+
expect(client.key).not.toBeCalled()
77+
})
78+
79+
it('not recursive on non-object clients', () => {
80+
const utils = createRouterUtils(undefined as any) as any
81+
82+
expect(utils.nonExists).toBeUndefined()
83+
expect(utils.key()).toEqual(procedureUtilsSpy.mock.results[0]!.value.key())
84+
})
6685
})

packages/react-swr/src/router-utils.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import type { Client, NestedClient } from '@orpc/client'
22
import type { GeneralUtils } from './general-utils'
33
import type { ProcedureUtils } from './procedure-utils'
4-
import { toArray } from '@orpc/shared'
4+
import { RECURSIVE_CLIENT_UNWRAP_KEYS } from '@orpc/client'
5+
import { isTypescriptObject, toArray } from '@orpc/shared'
56
import { createGeneralUtils } from './general-utils'
67
import { createProcedureUtils } from './procedure-utils'
78

@@ -37,8 +38,9 @@ export function createRouterUtils<T extends NestedClient<any>>(
3738
}, {
3839
get(target, prop) {
3940
const value = Reflect.get(target, prop)
41+
const nextClient = isTypescriptObject(client) ? Reflect.get(client, prop) : undefined
4042

41-
if (typeof prop !== 'string') {
43+
if (typeof prop !== 'string' || RECURSIVE_CLIENT_UNWRAP_KEYS.has(prop) || !isTypescriptObject(nextClient)) {
4244
return value
4345
}
4446

@@ -49,7 +51,11 @@ export function createRouterUtils<T extends NestedClient<any>>(
4951
}
5052

5153
return new Proxy(value, {
52-
get(_, prop) {
54+
get(target, prop) {
55+
if (typeof prop !== 'string' || RECURSIVE_CLIENT_UNWRAP_KEYS.has(prop)) {
56+
return Reflect.get(target, prop)
57+
}
58+
5359
return Reflect.get(nextUtils, prop)
5460
},
5561
})

0 commit comments

Comments
 (0)