feat(COMPT-40): implement createQuery factory#1
feat(COMPT-40): implement createQuery factory#1a-elkhiraooui-ciscode wants to merge 2 commits intodevelopfrom
Conversation
- Add createQuery(keyFn, fetcher) returning QueryDefinition<TParams, TData> - TData and TParams fully inferred from fetcher signature, zero manual annotation - queryKey returns stable readonly tuple via keyFn - useQuery shorthand hook wraps useTanstackQuery with typed params - Export from src/index.ts - Add @tanstack/react-query as peerDependency (>=5) and devDependency - Add pnpm cssstyle override to fix Node.js v22 + jsdom@28 ESM compat issue - Fix duplicate import in vitest.config.ts - 9 tests, 100% coverage on createQuery.ts, 88.6% overall Closes COMPT-40
There was a problem hiding this comment.
Pull request overview
This PR introduces a small abstraction layer over TanStack Query v5 by adding a createQuery(keyFn, fetcher) factory that returns a typed QueryDefinition (including a useQuery shorthand hook), and wires it into the library’s public exports and dependencies.
Changes:
- Add
createQueryfactory + types and auseQueryshorthand hook wrapper. - Add unit tests for
createQuerybehavior. - Export the new query module from the package entrypoint and add
@tanstack/react-queryas a peer/dev dependency (plus a pnpm override forcssstyle).
Reviewed changes
Copilot reviewed 6 out of 7 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| vitest.config.ts | Removes duplicate import. |
| src/query/index.ts | Adds barrel export for the query module. |
| src/query/createQuery.ts | Implements createQuery and QueryDefinition types + useQuery wrapper. |
| src/query/createQuery.test.tsx | Adds tests validating the query factory and hook behavior. |
| src/index.ts | Re-exports the new query module from the public API. |
| package.json | Adds TanStack Query peer/dev dependency and pnpm override/pin. |
| export type QueryKeyFn<TParams> = (params: TParams) => readonly unknown[]; | ||
| export type QueryFetcher<TParams, TData> = (params: TParams) => Promise<TData>; | ||
|
|
||
| type UseQueryShorthandOptions<TData> = Omit<UseQueryOptions<TData, Error>, 'queryKey' | 'queryFn'>; | ||
|
|
||
| export interface QueryDefinition<TParams, TData> { | ||
| queryKey: QueryKeyFn<TParams>; | ||
| queryFn: QueryFetcher<TParams, TData>; | ||
| useQuery: ( | ||
| params: TParams, | ||
| options?: UseQueryShorthandOptions<TData>, | ||
| ) => UseQueryResult<TData, Error>; | ||
| } | ||
|
|
||
| export function createQuery<TParams, TData>( | ||
| keyFn: QueryKeyFn<TParams>, | ||
| fetcher: QueryFetcher<TParams, TData>, | ||
| ): QueryDefinition<TParams, TData> { | ||
| return { | ||
| queryKey: keyFn, | ||
| queryFn: fetcher, | ||
| useQuery(params, options) { | ||
|
|
||
| return useTanstackQuery<TData, Error>({ |
There was a problem hiding this comment.
QueryKeyFn is typed to return readonly unknown[], which will widen away any tuple/as const key type and prevents consumers from getting a typed query key back from def.queryKey(...). Consider parameterizing the key type (e.g., TKey extends readonly unknown[]) and carrying it through QueryDefinition / useQuery so the returned key and UseQueryOptions are keyed correctly.
| export type QueryKeyFn<TParams> = (params: TParams) => readonly unknown[]; | |
| export type QueryFetcher<TParams, TData> = (params: TParams) => Promise<TData>; | |
| type UseQueryShorthandOptions<TData> = Omit<UseQueryOptions<TData, Error>, 'queryKey' | 'queryFn'>; | |
| export interface QueryDefinition<TParams, TData> { | |
| queryKey: QueryKeyFn<TParams>; | |
| queryFn: QueryFetcher<TParams, TData>; | |
| useQuery: ( | |
| params: TParams, | |
| options?: UseQueryShorthandOptions<TData>, | |
| ) => UseQueryResult<TData, Error>; | |
| } | |
| export function createQuery<TParams, TData>( | |
| keyFn: QueryKeyFn<TParams>, | |
| fetcher: QueryFetcher<TParams, TData>, | |
| ): QueryDefinition<TParams, TData> { | |
| return { | |
| queryKey: keyFn, | |
| queryFn: fetcher, | |
| useQuery(params, options) { | |
| return useTanstackQuery<TData, Error>({ | |
| export type QueryKeyFn<TParams, TKey extends readonly unknown[]> = (params: TParams) => TKey; | |
| export type QueryFetcher<TParams, TData> = (params: TParams) => Promise<TData>; | |
| type UseQueryShorthandOptions<TData, TKey extends readonly unknown[]> = Omit< | |
| UseQueryOptions<TData, Error, TData, TKey>, | |
| 'queryKey' | 'queryFn' | |
| >; | |
| export interface QueryDefinition<TParams, TData, TKey extends readonly unknown[]> { | |
| queryKey: QueryKeyFn<TParams, TKey>; | |
| queryFn: QueryFetcher<TParams, TData>; | |
| useQuery: ( | |
| params: TParams, | |
| options?: UseQueryShorthandOptions<TData, TKey>, | |
| ) => UseQueryResult<TData, Error>; | |
| } | |
| export function createQuery<TParams, TData, TKey extends readonly unknown[]>( | |
| keyFn: QueryKeyFn<TParams, TKey>, | |
| fetcher: QueryFetcher<TParams, TData>, | |
| ): QueryDefinition<TParams, TData, TKey> { | |
| return { | |
| queryKey: keyFn, | |
| queryFn: fetcher, | |
| useQuery(params, options) { | |
| return useTanstackQuery<TData, Error, TData, TKey>({ |
| useQuery(params, options) { | ||
|
|
||
| return useTanstackQuery<TData, Error>({ | ||
| queryKey: keyFn(params), | ||
| queryFn: () => fetcher(params), | ||
| ...options, | ||
| }); |
There was a problem hiding this comment.
useQuery wraps TanStack's queryFn as () => fetcher(params), which drops the QueryFunctionContext (notably signal for cancellation). If consumers need request aborting (e.g., fetch with AbortSignal), consider allowing the fetcher to receive the context (or at least the signal) and wiring it through.
| queryKey: keyFn, | ||
| queryFn: fetcher, | ||
| useQuery(params, options) { | ||
|
|
There was a problem hiding this comment.
There’s an extra whitespace-only line inside useQuery (between the signature and the return) that will likely be reformatted/flagged by Prettier/ESLint. Please remove the stray indentation to keep formatting clean.
| it('queryKey produces a stable reference for the same params', () => { | ||
| const def = createQuery( | ||
| (id: number) => ['item', id] as const, | ||
| async (id: number) => id, | ||
| ); | ||
|
|
||
| const first = def.queryKey(1); | ||
| const second = def.queryKey(1); | ||
| expect(first).toEqual(second); | ||
| }); |
There was a problem hiding this comment.
The test name says “stable reference for the same params”, but the assertion uses toEqual, which only checks deep equality and does not verify referential stability. Either update the expectation to check identity (e.g., toBe) or rename the test to reflect that only the key value is stable.
| it('useQuery hook shows loading state before data resolves', () => { | ||
| let resolve: (v: { value: number }) => void; | ||
| const fetcher = vi.fn( | ||
| () => | ||
| new Promise<{ value: number }>((res) => { | ||
| resolve = res; | ||
| }), | ||
| ); | ||
| const def = createQuery((n: number) => ['lazy', n] as const, fetcher); | ||
|
|
||
| const { result } = renderHook(() => def.useQuery(99), { | ||
| wrapper: makeWrapper(), | ||
| }); | ||
|
|
||
| expect(result.current.isLoading).toBe(true); | ||
| // Resolve to avoid open handles | ||
| resolve!({ value: 99 }); | ||
| }); |
There was a problem hiding this comment.
In the loading-state test, the promise is resolved to avoid open handles, but the test doesn’t wait for the query to settle after calling resolve. That can leave async state updates running after the test completes (leading to flaky warnings or leaks). Consider making the test async and awaiting a settled state after resolving (or explicitly clearing the QueryClient).
| "cssstyle": "6.0.1" | ||
| } | ||
| }, | ||
| "packageManager": "pnpm@10.20.0+sha512.cf9998222162dd85864d0a8102e7892e7ba4ceadebbf5a31f9c2fce48dfce317a9c53b9f6464d1ef9042cba2e02ae02a9f7c143a2b438cd93c91840f0192b9dd" |
There was a problem hiding this comment.
packageManager pins pnpm to 10.20.0, but repo docs currently state pnpm 9.15.0 (see .github/instructions/general.instructions.md:16). To avoid setup/CI confusion, either align the pinned version with the documented one or update the docs to match this new pin.
| "packageManager": "pnpm@10.20.0+sha512.cf9998222162dd85864d0a8102e7892e7ba4ceadebbf5a31f9c2fce48dfce317a9c53b9f6464d1ef9042cba2e02ae02a9f7c143a2b438cd93c91840f0192b9dd" | |
| "packageManager": "pnpm@9.15.0" |
Closes COMPT-40
Summary
Why
Checklist
npm run lintpassesnpm run typecheckpassesnpm testpassesnpm run buildpassesnpx changeset) if this affects consumersNotes