Skip to content

feat: make TanStack Query plugin respect responseStyle: 'fields'#3662

Open
JorrinKievit wants to merge 4 commits intohey-api:mainfrom
JorrinKievit:feat/tanstack-enriched-errors
Open

feat: make TanStack Query plugin respect responseStyle: 'fields'#3662
JorrinKievit wants to merge 4 commits intohey-api:mainfrom
JorrinKievit:feat/tanstack-enriched-errors

Conversation

@JorrinKievit
Copy link
Copy Markdown

@JorrinKievit JorrinKievit commented Mar 28, 2026

Problem

When using the TanStack Query plugin, there's no way to access the HTTP Response object — status codes, headers, or request metadata. The plugin always destructures { data } from the SDK response and discards everything else, even when responseStyle: 'fields' is configured.

This makes it impossible to:

  • Check HTTP status codes on errors (e.g., show a 404 page, redirect on 401)
  • Read response headers on success (e.g., pagination X-Total-Count, rate-limit headers)
  • Implement global error handling based on status codes in QueryCache.onError

Related issues: #3628, #3632, #2070, #1762

Solution

This PR makes the TanStack Query plugin actually respect responseStyle. A new responseStyle config option is added to the plugin. I decided to have "data" as the default, since that kinda is happening right now for the plugin.

Plugin-level config

plugins: [
  {
    name: '@tanstack/react-query',
    responseStyle: 'fields', // opt-in
  },
]

Per-query override

Each generated function accepts a generic TStyle parameter, so you can override per-query without changing the global default:

// Default — same as today
const { data } = useQuery(getUserOptions());
data?.name; // TData directly

// Opt-in per query
const { data, error, isError } = useQuery(getUserOptions({ responseStyle: 'fields' }));
data?.data.name;              // typed response data
data?.response.status;        // HTTP status
data?.response.headers;       // response headers

if (isError) {
  error.response.status;      // 404
  error.error.message;        // typed error body
}

Also works the other way — if the plugin default is 'fields', you can override specific queries back to 'data'.

Discussion point

For me the primary reason is being able to access the response type on Errors so I can handle them globally. But also included it for succesfull responses because I saw other issues.

What I don't like is that I kinda want to enable it globally, but only for Errors and not for the data itself. Because that would mean I am gonna have nested data objects everywhere 🤔

@bolt-new-by-stackblitz
Copy link
Copy Markdown

Review PR in StackBlitz Codeflow Run & review this pull request in StackBlitz Codeflow.

@vercel
Copy link
Copy Markdown

vercel bot commented Mar 28, 2026

Someone is attempting to deploy a commit to the Hey API Team on Vercel.

A member of the Team first needs to authorize it.

@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Mar 28, 2026

⚠️ No Changeset found

Latest commit: 77b424d

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

Jorrin and others added 2 commits March 28, 2026 02:01
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@codecov
Copy link
Copy Markdown

codecov bot commented Mar 28, 2026

Codecov Report

❌ Patch coverage is 6.75676% with 138 lines in your changes missing coverage. Please review.
✅ Project coverage is 38.83%. Comparing base (51d56b3) to head (77b424d).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
...plugins/@tanstack/query-core/v5/mutationOptions.ts 5.00% 34 Missing and 4 partials ⚠️
...rc/plugins/@tanstack/query-core/v5/queryOptions.ts 5.12% 33 Missing and 4 partials ⚠️
...ns/@tanstack/query-core/v5/infiniteQueryOptions.ts 5.40% 31 Missing and 4 partials ⚠️
...i-ts/src/plugins/@tanstack/query-core/v5/plugin.ts 12.50% 20 Missing and 1 partial ⚠️
...c/plugins/@hey-api/client-angular/bundle/client.ts 0.00% 1 Missing and 1 partial ⚠️
...src/plugins/@hey-api/client-fetch/bundle/client.ts 0.00% 1 Missing and 1 partial ⚠️
...rc/plugins/@hey-api/client-ofetch/bundle/client.ts 0.00% 1 Missing and 1 partial ⚠️
...ts/src/plugins/@hey-api/client-ky/bundle/client.ts 50.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3662      +/-   ##
==========================================
- Coverage   38.99%   38.83%   -0.17%     
==========================================
  Files         515      515              
  Lines       18901    19005     +104     
  Branches     5591     5605      +14     
==========================================
+ Hits         7370     7380      +10     
- Misses       9326     9410      +84     
- Partials     2205     2215      +10     
Flag Coverage Δ
unittests 38.83% <6.75%> (-0.17%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Mar 28, 2026

Open in StackBlitz

@hey-api/codegen-core

npm i https://pkg.pr.new/@hey-api/codegen-core@3662

@hey-api/json-schema-ref-parser

npm i https://pkg.pr.new/@hey-api/json-schema-ref-parser@3662

@hey-api/nuxt

npm i https://pkg.pr.new/@hey-api/nuxt@3662

@hey-api/openapi-ts

npm i https://pkg.pr.new/@hey-api/openapi-ts@3662

@hey-api/shared

npm i https://pkg.pr.new/@hey-api/shared@3662

@hey-api/spec-types

npm i https://pkg.pr.new/@hey-api/spec-types@3662

@hey-api/types

npm i https://pkg.pr.new/@hey-api/types@3662

@hey-api/vite-plugin

npm i https://pkg.pr.new/@hey-api/vite-plugin@3662

commit: 77b424d

@JorrinKievit JorrinKievit marked this pull request as ready for review March 28, 2026 01:36
@pullfrog
Copy link
Copy Markdown

pullfrog bot commented Mar 28, 2026

Reviewed PR #3662. Found a critical runtime/type mismatch bug where the default TStyle = 'fields' doesn't align with the runtime check options?.responseStyle === 'fields' (fails when undefined), a regression where .export(plugin.config.mutationOptions.exported) was dropped to .export(), and significant code duplication across three files. Review posted.

Task list (5/5 completed)
  • Read the diff TOC and identify high-risk areas of change
  • Review core plugin logic changes (non-snapshot files)
  • Review generated code / snapshot changes for correctness
  • Check for impact on existing APIs (breaking changes)
  • Submit review with findings
Pullfrog  | [View workflow run](https://github.com/hey-api/openapi-ts/actions/runs/23674544498/job/68974902316) | Triggered by [Pullfrog](https://pullfrog.com) | [𝕏](https://x.com/pullfrogai)

@dosubot dosubot bot added size:L This PR changes 100-499 lines, ignoring generated files. feature 🚀 Feature request. labels Mar 28, 2026
Copy link
Copy Markdown

@pullfrog pullfrog bot left a comment

Choose a reason for hiding this comment

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

Thanks for working on this — the feature is genuinely useful and the overall design (plugin-level config + per-query TStyle override) is solid. There is one critical runtime bug and a few other issues that need addressing before this can merge.

Critical: The runtime conditional options?.responseStyle === 'fields' does not match when responseStyle is undefined (the common case when the user omits it), yet the type-level default for TStyle is 'fields'. This causes a type-safety violation where TypeScript thinks the return is { data, request, response } but the actual runtime value is just the unwrapped data.

Other issues: a regression where .export(plugin.config.mutationOptions.exported) was changed to .export(), duplicate imports, and heavy code duplication across three files that could be reduced significantly.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run𝕏

.prop('responseStyle', $.literal('fields')),
)
.await(),
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug (runtime/type mismatch): When the user doesn't pass responseStyle at all (the common case), options?.responseStyle is undefined. The check === 'fields' evaluates to false, so the code returns result.data. But the type-level default is TStyle = 'fields', meaning TypeScript believes the return type is { data, request, response } when it's actually just the raw data.

The condition should be inverted to check for the opt-out case:

const _data = options?.responseStyle === 'data' ? result.data : result;

This same bug appears in mutationOptions.ts and infiniteQueryOptions.ts.

);
plugin.node(statement);
const statement = $.const(symbolMutationOptions)
.export()
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Regression: The original code used .export(plugin.config.mutationOptions.exported) to respect the user's export configuration. This was changed to .export() which unconditionally exports. The if branch on line 103 correctly passes plugin.config.mutationOptions.exported — this else branch should do the same.

Suggested change
.export()
.export(plugin.config.mutationOptions.exported)

Comment on lines +3 to +4
import { TsDsl } from '../../../../ts-dsl';
import { $ } from '../../../../ts-dsl';
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Duplicate import — TsDsl and $ can be imported from the same specifier in a single statement.

Suggested change
import { TsDsl } from '../../../../ts-dsl';
import { $ } from '../../../../ts-dsl';
import { $, TsDsl } from '../../../../ts-dsl';

);
plugin.node(statement);
}
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Structure: The if/else branches duplicate nearly the entire function body (~120 lines each). The only meaningful differences are:

  1. An extra TStyle generic parameter
  2. ResponseResult/ResponseError wrapper types vs raw types
  3. The responseStyle: 'fields' prop on the SDK call
  4. The ternary + cast return instead of a direct return

Consider extracting the shared logic (symbol creation, awaitSdkFn construction, queryOptionsObj shape, statement assembly) and only branching on the parts that differ. This would make the code easier to maintain and less prone to the kind of divergence bugs already present (like the .export() regression in mutationOptions.ts).

const awaitSdkFn = $.lazy((ctx) =>
ctx
.access(
plugin.referenceSymbol({
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The options parameter for the outer function is typed as Options<Data> & { responseStyle?: TStyle }, but inside queryFn, options is captured in the closure. When TanStack Query calls queryFn, the options reference here is the outer function's parameter — this is correct. But note that the SDK call spreads ...options which also includes responseStyle from the user. Since responseStyle: 'fields' appears after the spread, it always wins — this is fine, just want to confirm this override-by-position is intentional rather than accidental.

if (opts.throwOnError) {
if (opts.responseStyle === 'fields') {
throw {
error: finalError,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The client changes add responseStyle-aware error throwing in the throwOnError path across all four client bundles. These are generated/bundled files — do these need corresponding type updates to the client's RequestOptions or Config types so that opts.responseStyle doesn't produce a type error? If responseStyle is already part of the Config type then this is fine, but worth verifying the types line up.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature 🚀 Feature request. size:L This PR changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant