Skip to content

fix: allow router utils and error constructor maps to be converted to primitive values - #2306

Merged
dinwwwh merged 8 commits into
mainfrom
claude/serene-bardeen-aw8mr0
Oct 23, 2026
Merged

dinwwwh merged 8 commits into
mainfrom
claude/serene-bardeen-aw8mr0

Conversation

@dinwwwh

@dinwwwh dinwwwh commented Oct 22, 2026 •

Copy link
Copy Markdown
Member

Problem

Two proxies could not be converted to a string. String(x), template literals and x + '' threw TypeError: Cannot convert object to primitive value.

1. Router utils (@orpc/tanstack-query, @orpc/swr, @orpc/pinia-colada)

The router-utils proxies wrap the result of bindMethods(), which is a null-prototype object. For RECURSIVE_CLIENT_UNWRAP_KEYS the get trap returned Reflect.get(target, prop), which is undefined for valueOf and toString:

const utils = createTanstackQueryUtils(client)
String(utils.planet)   // TypeError
`${utils.planet}`      // TypeError

React 19.2 trigger. In React 19.2.0–19.2.9 dev builds, logComponentRender diffs props and runs value.name + "() {}" when a prop changes identity between renders. If that prop is a utils proxy, the code reads .name (which returns another utils proxy) and concatenates it, so it throws. I reproduced this with real react-dom@19.2.9. React 19.3 guards against it upstream, but direct coercion still throws there.

This is the same class of bug as tRPC's trpc/trpc#7336.

2. Error constructor map (@orpc/contract)

createORPCErrorConstructorMap (the errors object in handlers, middleware and interceptors) returned an error constructor for every key except then. So errors.toString() and errors.valueOf() returned ORPCError objects instead of primitives:

String(errors)          // TypeError
JSON.stringify(errors)  // '{"name":"ORPCError","defined":false,"code":"toJSON"}', a fake error

Core clients (createORPCClient, createRouterClient) are not affected.

Fix

  • Router utils: in each get trap, unwrap keys are handled first and return value ?? Reflect.get(Object.prototype, prop). valueOf and toString fall back to Object.prototype; then, toJSON, bind and apply stay undefined; the utils' own call is kept. The null prototype from bindMethods is left as is. It is deliberate: it lets procedures named toString, constructor or __proto__ resolve to nested utils.
  • Error constructor map: it now resolves every RECURSIVE_CLIENT_UNWRAP_KEYS name to the error map value, like the other oRPC proxies, instead of only then. Error maps are plain objects, so toString and valueOf are the normal built-ins.
  • @orpc/client: RECURSIVE_CLIENT_UNWRAP_KEYS is now typed Set<PropertyKey> instead of Set<string>, so proxy traps can call .has(prop) without narrowing prop first. Runtime values are unchanged. This is a public export; code that reads values from the set as string would need a cast.
  • Docs: error-handling.mdx now lists the reserved names as error codes to avoid, matching the existing warning on the router pages.

Behavior change

errors.then, errors.bind, errors.call, errors.apply, errors.valueOf, errors.toString and errors.toJSON no longer return error constructors. An error code with one of these names can no longer be created through the errors map. Before this PR only then was reserved.

The existing contract test "does not resolve error codes through Object.prototype" used toString as its example of an Object.prototype name. It now uses constructor, so it still checks that the getOwn config lookup ignores the prototype.

Known limitation, left alone on purpose

utils.x.queryOptions.name (and fetcher.name in SWR) is still a utils proxy, not a string. The method proxy forwards non-unwrap keys to the nested utils so a nested procedure called name keeps working. Making .name return the function name would break that. It can now be coerced to a string without throwing, which is enough for the React 19.2 code path.

Tests

  • Router utils: a new can be converted to primitive values test in each package checks String(utils) on the existing client fixture.
  • Error map: the existing unwrap-keys test (renamed to not proxy on symbol and RECURSIVE_CLIENT_UNWRAP_KEYS) now also checks that toJSON is undefined and that String(map) works.
  • Without the source changes, all four tests fail.

Verification

  • pnpm vitest run on client, contract, server, effect, tanstack-query, swr and pinia-colada: passes
  • pnpm type:check: passes
  • pnpm lint: passes
  • pnpm docs:validate: passes

🤖 Generated with Claude Code

https://claude.ai/code/session_015iL1oczPjpwbYpfCYwSFdo

…ted to primitive values

Router utils proxies wrap a null-prototype object from bindMethods, so
valueOf/toString resolved to undefined and String(utils), template
literals and React 19.2 dev props-diff logging threw "Cannot convert
object to primitive value". Unwrap keys now fall back to
Object.prototype; then/toJSON stay undefined.

Similar to the tRPC fix in trpc/trpc#7336.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDG8EYcjqwAUxqRdkQiEbA
macroscopeapp[bot]
macroscopeapp Bot previously approved these changes Oct 22, 2026
@macroscopeapp

macroscopeapp Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Approvability

Verdict: Approved at 3d8beee

Macroscope's review found this PR approvable — This is a narrowly scoped compatibility fix for primitive conversion of router utilities and error-constructor maps, with focused regression tests across all affected integrations. Normal routing and error-construction paths remain unchanged.

Notes:

  • All code in this push has already been reviewed. Approvability was decided on eligibility alone.

You can add or adjust custom eligibility rules. Learn more.

@codecov

codecov Bot commented Oct 22, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@pkg-pr-new

pkg-pr-new Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
More templates

@orpc/ai-sdk

npm i https://pkg.pr.new/@orpc/ai-sdk@2306

@orpc/arktype

npm i https://pkg.pr.new/@orpc/arktype@2306

@orpc/bun

npm i https://pkg.pr.new/@orpc/bun@2306

@orpc/client

npm i https://pkg.pr.new/@orpc/client@2306

@orpc/cloudflare

npm i https://pkg.pr.new/@orpc/cloudflare@2306

@orpc/contract

npm i https://pkg.pr.new/@orpc/contract@2306

@orpc/experimental-effect

npm i https://pkg.pr.new/@orpc/experimental-effect@2306

@orpc/evlog

npm i https://pkg.pr.new/@orpc/evlog@2306

@orpc/hibernation

npm i https://pkg.pr.new/@orpc/hibernation@2306

@orpc/json-schema

npm i https://pkg.pr.new/@orpc/json-schema@2306

@orpc/experimental-lock

npm i https://pkg.pr.new/@orpc/experimental-lock@2306

@orpc/mcp

npm i https://pkg.pr.new/@orpc/mcp@2306

@orpc/msw

npm i https://pkg.pr.new/@orpc/msw@2306

@orpc/nest

npm i https://pkg.pr.new/@orpc/nest@2306

@orpc/next

npm i https://pkg.pr.new/@orpc/next@2306

@orpc/node

npm i https://pkg.pr.new/@orpc/node@2306

@orpc/openapi

npm i https://pkg.pr.new/@orpc/openapi@2306

@orpc/opentelemetry

npm i https://pkg.pr.new/@orpc/opentelemetry@2306

@orpc/pinia-colada

npm i https://pkg.pr.new/@orpc/pinia-colada@2306

@orpc/pino

npm i https://pkg.pr.new/@orpc/pino@2306

@orpc/publisher

npm i https://pkg.pr.new/@orpc/publisher@2306

@orpc/ratelimit

npm i https://pkg.pr.new/@orpc/ratelimit@2306

@orpc/server

npm i https://pkg.pr.new/@orpc/server@2306

@orpc/shared

npm i https://pkg.pr.new/@orpc/shared@2306

@orpc/swr

npm i https://pkg.pr.new/@orpc/swr@2306

@orpc/tanstack-query

npm i https://pkg.pr.new/@orpc/tanstack-query@2306

@orpc/trpc

npm i https://pkg.pr.new/@orpc/trpc@2306

@orpc/valibot

npm i https://pkg.pr.new/@orpc/valibot@2306

@orpc/zod

npm i https://pkg.pr.new/@orpc/zod@2306

commit: e149af7

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
🔵 In progress
View logs
orpc 3d8beee Oct 23 2026, 07:08 AM

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ No new issues found.

Reviewed changes

  • Primitive-conversion fix in all three router-utils proxies — packages/tanstack-query/src/router-utils.ts, packages/swr/src/router-utils.ts, and packages/pinia-colada/src/router-utils.ts now handle RECURSIVE_CLIENT_UNWRAP_KEYS first in the recursive proxy get trap, returning value ?? Reflect.get(Object.prototype, prop) so valueOf/toString resolve to Object.prototype and coercion no longer throws.
  • Recursion behavior preserved — then, toJSON, bind, call, and apply don't exist on Object.prototype, so they stay undefined; the redundant RECURSIVE_CLIENT_UNWRAP_KEYS.has(prop) check was removed from the later condition.
  • Regression tests added — one can be converted to primitive values test per package covering String(...), template-literal coercion, and then/toJSON staying undefined.

I verified the fix and the surrounding proxy chains independently. The nested method proxy (the one built when a key is both a util method and a nested client) was left unchanged, and that's correct: its target is always a function, so toString/valueOf already resolve through Function.prototype/Object.prototype. The old condition already short-circuited unwrap keys, so no nested router/procedure key becomes newly unreachable — valueOf/toString were already shadowed before this change. pnpm vitest run packages/tanstack-query packages/swr packages/pinia-colada passes (30 files, 315 tests).

Pullfrog  | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

@codspeed

codspeed Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

1 warning

⚠️ 5 benchmarks are dominated by system calls

System calls cannot be simulated consistently and are left out of the measure. The reported value understates the real cost. Switch to the Walltime instrument to measure them.

Learn more about system calls

See affected benchmarks

✅ 28 untouched benchmarks
⏩ 2 skipped benchmarks1


Comparing claude/serene-bardeen-aw8mr0 (3d8beee) with main (3ff2342)

Open in CodSpeed

Footnotes

  1. 2 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

…-aw8mr0

# Conflicts:
#	packages/tanstack-query/src/router-utils.test.ts
@macroscopeapp
macroscopeapp Bot dismissed their stale review October 23, 2026 06:12

Dismissing prior approval to re-evaluate 5f67d3f

… primitive conversion tests

The regression tests built a separate createORPCClient just to reach
nested utils; the existing fixture already exercises the same proxy path
(root utils and utils reached through a conflicting method proxy). Drop
`then` assertions where the RECURSIVE_CLIENT_UNWRAP_KEYS test already
covers them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015iL1oczPjpwbYpfCYwSFdo
macroscopeapp[bot]
macroscopeapp Bot previously approved these changes Oct 23, 2026

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ No new issues found.

Reviewed changes

The delta since the prior pullfrog review is test-only — the source fix in all three packages is unchanged and its behavior is identical to what was already approved. pnpm vitest run packages/tanstack-query packages/swr packages/pinia-colada passes (31 files, 357 tests).

  • Switched the new can be converted to primitive values tests to the existing client fixture instead of constructing an ad-hoc createORPCClient({ call: vi.fn() }), and dropped the now-unused imports.
  • Retargeted the assertions to String(utils) on the root proxy, `${utils.key.pong}` through the conflicting method proxy, and then/toJSON staying undefined. Both coercion assertions still exercise the fixed get trap (the root proxy and the nested pong recursive proxy are null-prototype), so they still fail without the source change; the then check moved to the root proxy while the existing RECURSIVE_CLIENT_UNWRAP_KEYS test covers the nested case.

Pullfrog  | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

Lets proxy traps check `prop` directly without first narrowing it to a
string, so the router-utils unwrap check drops its `typeof` guard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015iL1oczPjpwbYpfCYwSFdo
…ve values

The error constructor map returned a constructor for every key except
`then`, so `toString`/`valueOf` returned ORPCError objects and
`String(errors)` threw "Cannot convert object to primitive value", while
`JSON.stringify(errors)` produced a fake error with code "toJSON". It now
resolves all RECURSIVE_CLIENT_UNWRAP_KEYS to the error map value, like
the client proxies. Docs list these names as reserved error codes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015iL1oczPjpwbYpfCYwSFdo
@macroscopeapp
macroscopeapp Bot dismissed their stale review October 23, 2026 06:45

Dismissing prior approval to re-evaluate 6765f26

@dinwwwh dinwwwh changed the title fix(tanstack-query,swr,pinia-colada): allow router utils to be converted to primitive values fix: allow router utils and error constructor maps to be converted to primitive values Oct 23, 2026
macroscopeapp[bot]
macroscopeapp Bot previously approved these changes Oct 23, 2026

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ No new issues found.

Reviewed changes

The delta since the prior pullfrog review (b045a10) adds the @orpc/contract half of the fix plus a @orpc/client export type change; the previously reviewed router-utils fix is untouched and all prior review points remain addressed.

  • Error-map coercion (@orpc/contract) — createORPCErrorConstructorMap's get trap now resolves every RECURSIVE_CLIENT_UNWRAP_KEYS name via Reflect.get(target, code) instead of only then, so String(errors)/errors.valueOf use Object.prototype builtins and errors.toJSON stays undefined rather than returning a fake ORPCError.
  • RECURSIVE_CLIENT_UNWRAP_KEYS typed Set<PropertyKey> — the public export type was widened so traps can call .has(prop) on string | symbol keys without narrowing first; runtime values are unchanged, and the now-redundant typeof prop === 'string' guard was dropped from the three router-utils early-return branches (behaviour for symbol keys is identical, since none are in the set).
  • Docs — error-handling.mdx documents the reserved error-code names (then, bind, call, apply, valueOf, toString, toJSON), matching the router pages.
  • Tests — a can be converted to primitive values case for the error map, and the Object.prototype lookup test switched from toString to constructor now that toString is an unwrap key.

The reserved-name behaviour change (errors.{then,bind,call,apply,valueOf,toString,toJSON} no longer create error constructors) is intentional and called out in the PR description. Verified locally at 6765f26: vitest run over client, contract, server, effect, tanstack-query, swr and pinia-colada → 142 files / 1771 tests pass; tsc --noEmit clean; eslint on the changed files clean; node scripts/verify-docs-jsdoc.ts reports 0 errors.

Pullfrog  | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

…y tests

Router-utils tests keep only `String(utils)`; the other assertions passed
without the fix or exercised the same trap. Fold the error map check into
the existing unwrap-keys test. Remove the inline and JSDoc comments that
restate what these tests cover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015iL1oczPjpwbYpfCYwSFdo
@macroscopeapp
macroscopeapp Bot dismissed their stale review October 23, 2026 06:53

Dismissing prior approval to re-evaluate 1b7f5a5

macroscopeapp[bot]
macroscopeapp Bot previously approved these changes Oct 23, 2026
Only undefined RECURSIVE_CLIENT_UNWRAP_KEYS codes resolve to the error
map value, so codes such as `toJSON` or `call` declared in the error map
still get constructors, matching ORPCErrorConstructorMap's types. `then`
stays reserved as before. Drop the docs warning that reserved all seven
names as error codes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015iL1oczPjpwbYpfCYwSFdo
@macroscopeapp
macroscopeapp Bot dismissed their stale review October 23, 2026 07:02

Dismissing prior approval to re-evaluate e149af7

macroscopeapp[bot]
macroscopeapp Bot previously approved these changes Oct 23, 2026
…rap keys"

This reverts commit e149af7. The error constructor map skips every
RECURSIVE_CLIENT_UNWRAP_KEYS name again, and the docs list them as
reserved error codes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015iL1oczPjpwbYpfCYwSFdo

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ℹ️ No critical issues — one optional documentation note below.

Reviewed changes

The delta since the prior pullfrog review (6765f2) narrows the error-map unwrap rule, plus the 1b7f5a5 test/comment trim.

  • Declared unwrap-key error codes are constructible again — createORPCErrorConstructorMap's get trap (packages/contract/src/error-factory.ts:159) now short-circuits only unwrap-key names that are not own properties of the error map, so a code such as toJSON declared via .errors({...}) resolves to a constructor again, while undeclared names keep resolving to the plain object's Object.prototype builtins / undefined. then stays reserved unconditionally. This corrects a regression from the previous round, where a declared toJSON code returned the raw map value despite being typed as callable.
  • Test — packages/contract/src/error-factory.test.ts:235 asserts a declared toJSON code still builds a defined error and that the map stays non-thenable.
  • Docs — the error-handling.mdx warning listing all seven reserved names was removed (net-zero in the full PR diff, so the file is not in the TOC).

ℹ️ Declared reserved-name error codes still shadow primitive conversion, now undocumented

Removing the warning is correct for the six names that are usable again, but it also drops the only documentation that these names have consequences when declared. then remains non-callable, and a declared toString/valueOf re-breaks String(errors) — the very failure this PR targets.

Technical details
# Reserved-name error codes after the warning was dropped

## Affected sites
- `apps/content/docs/error-handling.mdx` — the warning listing `then, bind, call, apply, valueOf, toString, toJSON` was deleted by `e149af788`.
- `packages/contract/src/error-factory.ts:159-163` — declared unwrap-key codes now take the constructor branch; `then` never does.

## Why it matters
- Declared `then`: `errors.then` is the raw map value (not callable), though `ORPCErrorConstructorMap` types it as callable.
- Declared `toString`/`valueOf`: `String(errors)` throws `Cannot convert object to primitive value` (verified at `e149af788`).
- Declared `toJSON`: `JSON.stringify(errors)` emits a fake `ORPCError`.

## Required outcome
- Nothing blocking. If these names should stay discouraged, a short note (at minimum for `then`) would preserve the guidance the deleted warning carried.

## Open questions for the human
- Intentional that the six declared unwrap-key names stay fully usable as error codes with no docs caution?

Pullfrog  | Fix it ➔ | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

@macroscopeapp
macroscopeapp Bot dismissed their stale review October 23, 2026 07:08

Dismissing prior approval to re-evaluate 3d8beee

@dinwwwh
dinwwwh merged commit 83f2fc1 into main Oct 23, 2026
6 of 11 checks passed

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

✅ No new issues found.

Reviewed changes

The delta since the prior pullfrog review (e149af788) is a clean revert of that commit plus the restored documentation warning; it resolves the previous round's open concern by choosing the reserve-and-document approach.

  • Reverted the declared-unwrap-key special case — createORPCErrorConstructorMap's get trap (packages/contract/src/error-factory.ts:159) is back to typeof code !== 'string' || RECURSIVE_CLIENT_UNWRAP_KEYS.has(code), so all seven reserved names resolve to the plain map value unconditionally and are uniformly reserved (declared or not).
  • Dropped the now-inapplicable test — the still constructs defined RECURSIVE_CLIENT_UNWRAP_KEYS codes except then case was removed along with the behavior it covered.
  • Restored the reserved-name warning — apps/content/docs/error-handling.mdx:82 again lists then, bind, call, apply, valueOf, toString, toJSON as error codes to avoid, matching router.mdx / contract/router.mdx and making the seven names consistently reserved across clients, router utils, and error maps.

Verified locally at 3d8beee: vitest run over packages/contract/src/error-factory.test.ts and the three router-utils.test.ts suites passes (4 files, 54 tests).

Pullfrog  | View workflow run | Using deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏

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.

2 participants