Skip to content

fix: infer selected data in the TanStack query integration - #665

Open
frovere wants to merge 1 commit into
LegendApp:mainfrom
frovere:fix/tanstack-query-select-types
Open

frovere wants to merge 1 commit into
LegendApp:mainfrom
frovere:fix/tanstack-query-select-types

Conversation

@frovere

@frovere frovere commented Sep 25, 2026

Copy link
Copy Markdown

Problem

syncedQuery and useObservableSyncedQuery reject valid queries when select returns a different type from queryFn. This affects both TanStack's queryOptions() helper and inferred plain-object options.

For example, this should produce an observable number:

const query = queryOptions({
    queryKey: ['todos'] as const,
    queryFn: async () => [{ id: 1, title: 'First' }],
    select: (todos) => todos.length,
});

const count$ = observable(syncedQuery({ queryClient, query }));

Before, TypeScript rejects the Todo[] => number selection because the integration expects Todo[] => Todo[]. After, the same call compiles and the observable getter infers number, without explicit Legend generics or casts.

Type correction

TanStack separates the raw query data from the selected observer result. ObservableQueryOptions incorrectly used selected TData in the raw-data slot. Internal observer declarations also supplied four generic arguments, placing TQueryKey in that slot.

- QueryObserverOptions<TQueryFnData, TError, TData, TData, TQueryKey>
+ QueryObserverOptions<TQueryFnData, TError, TData, TQueryFnData, TQueryKey>

All internal observer types now use the same five-argument ordering:

queryFn                 QueryObserver.select             observable
Todo[]  --------------------> length --------------------> number
raw query/cache data                                     selected result
TQueryFnData                                             TData

The emitted runtime JavaScript is unchanged.

Why replace the mock?

The deleted test, get returns refetched data after the initial run, mocked the entire @tanstack/query-core module. Its observer returned fixed "initial" and "fresh" strings and had a no-op subscription. That usefully checked the private get callback's return value, but could not demonstrate real selection or cache propagation.

The replacement uses a real QueryClient and observes the public observable / syncState APIs. Its query function returns local fixtures; no network calls are made.

Test step Before: mocked observer After: real QueryClient
Initial read Private get returns "initial" Fetch two todos; observable becomes 2
Refetch Second private get returns "fresh" Change source to one todo, call sync(), wait for completion; observable becomes 1
Cache update Not exercised Write three todos to the cache; observable becomes 3

An active subscription keeps the observable eligible for refetch. The test waits for public isGetting completion and uses finally to unsubscribe and clear the client even if an assertion fails.

The refetch regression remains covered: in a local mutation check, making refetch discard its returned data caused both the deleted test and its replacement to fail. The replacement checks the observable result rather than directly inspecting symbolLinked.get.

Regression coverage and validation

  • Compiler assertions cover queryOptions() through both core and React APIs, plain-object inference, raw callback data, and reactive tuple keys.
  • Reversing the raw-data type correction or restoring the original implementation makes the type fixtures fail. The new plain-object React case produces two compiler errors when reversed and none with the fix.
  • Real-observer tests cover selection after fetch, explicit refetch, and cache updates.
  • Local checks passed with TypeScript 5.7.2 and TanStack Query 5.40.0: typecheck, ESLint, Prettier, build, and all 1,084 tests in both Jest and Bun.

This PR does not address the separate tagged-key inference issue with any results or existing initialData selection behavior.

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.

1 participant