Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 74 additions & 57 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,14 @@
"access": "public"
},
"peerDependencies": {
"@tanstack/react-query": ">=5",
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

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

The peer dependency range for @tanstack/react-query is >=5, which will also allow future major versions (e.g. v6) that may include breaking changes. Since this package targets TanStack Query v5, consider tightening the range (e.g. ^5 or >=5 <6) to avoid accidental incompatibilities for consumers.

Suggested change
"@tanstack/react-query": ">=5",
"@tanstack/react-query": "^5",

Copilot uses AI. Check for mistakes.
"react": ">=18",
"react-dom": ">=18"
},
"devDependencies": {
"@changesets/cli": "^2.27.8",
"@eslint/js": "^9.39.2",
"@tanstack/react-query": "^5.96.2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
Expand Down Expand Up @@ -84,5 +86,8 @@
},
"engines": {
"node": ">=20"
},
"overrides": {
"cssstyle": "6.0.1"
}
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './components';
export * from './hooks';
export * from './utils';
export * from './query';
128 changes: 128 additions & 0 deletions src/query/createQuery.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { describe, it, expect, vi } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import React from 'react';
import { createQuery } from './createQuery';

function makeWrapper() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}

describe('createQuery', () => {
it('returns a QueryDefinition with queryKey, queryFn, and useQuery', () => {
const def = createQuery(
(id: string) => ['user', id] as const,
async (id: string) => ({ id, name: 'Alice' }),
);

expect(typeof def.queryKey).toBe('function');
expect(typeof def.queryFn).toBe('function');
expect(typeof def.useQuery).toBe('function');
});

it('queryKey returns the correct readonly tuple given params', () => {
const def = createQuery(
(id: string) => ['user', id] as const,
async (id: string) => ({ id }),
);

expect(def.queryKey('123')).toEqual(['user', '123']);
});

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);
});
Comment on lines +37 to +46
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

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

This test description says the key function returns a “stable reference”, but the assertion only uses toEqual (deep equality) and does not validate referential stability. Either change the test name/expectation to reflect deep equality, or change the assertion to check reference identity and update the implementation accordingly.

Copilot uses AI. Check for mistakes.

it('queryFn calls the fetcher with the given params', async () => {
const fetcher = vi.fn(async (id: string) => ({ id, name: 'Bob' }));
const def = createQuery((id: string) => ['user', id] as const, fetcher);

const result = await def.queryFn('42');

expect(fetcher).toHaveBeenCalledWith('42');
expect(result).toEqual({ id: '42', name: 'Bob' });
});

it('queryFn return type is inferred from the fetcher (TData inference)', async () => {
const def = createQuery(
(n: number) => ['count', n] as const,
async (n: number) => ({ value: n * 2 }),
);

const result = await def.queryFn(5);
// TypeScript infers result as { value: number }
expect(result.value).toBe(10);
});

it('useQuery hook resolves data from the fetcher', async () => {
const fetcher = vi.fn(async (id: string) => ({ id, name: 'Carol' }));
const def = createQuery((id: string) => ['user', id] as const, fetcher);

const { result } = renderHook(() => def.useQuery('1'), {
wrapper: makeWrapper(),
});

await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual({ id: '1', name: 'Carol' });
expect(fetcher).toHaveBeenCalledWith('1');
});

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 });
});
Comment on lines +82 to +99
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

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

In the loading-state test, the promise is resolved but the test does not wait for React Query to process the resolution. This can lead to async updates happening after the test finishes (and potential act/open-handle warnings). After calling resolve, wait for the hook to reach a settled state (e.g. isSuccess) or explicitly unmount/clear the QueryClient.

Copilot uses AI. Check for mistakes.

it('useQuery respects enabled: false and does not call fetcher', () => {
const fetcher = vi.fn(async (id: string) => ({ id }));
const def = createQuery((id: string) => ['item', id] as const, fetcher);

const { result } = renderHook(() => def.useQuery('x', { enabled: false }), {
wrapper: makeWrapper(),
});

expect(fetcher).not.toHaveBeenCalled();
expect(result.current.fetchStatus).toBe('idle');
});

it('useQuery uses the key from keyFn — different params produce different keys', async () => {
const fetcher = vi.fn(async (id: string) => ({ id }));
const def = createQuery((id: string) => ['entity', id] as const, fetcher);

const wrapper = makeWrapper();

const { result: r1 } = renderHook(() => def.useQuery('a'), { wrapper });
const { result: r2 } = renderHook(() => def.useQuery('b'), { wrapper });

await waitFor(() => expect(r1.current.isSuccess).toBe(true));
await waitFor(() => expect(r2.current.isSuccess).toBe(true));

expect(r1.current.data).toEqual({ id: 'a' });
expect(r2.current.data).toEqual({ id: 'b' });
});
});
34 changes: 34 additions & 0 deletions src/query/createQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { useQuery as useTanstackQuery } from '@tanstack/react-query';
import type { UseQueryOptions, UseQueryResult } from '@tanstack/react-query';

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'>;

Comment on lines +7 to +8
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

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

UseQueryShorthandOptions binds the query function data type and the returned data type to the same TData (because UseQueryOptions<TData, Error> sets both TQueryFnData and TData). This prevents consumers from using options like select to transform the fetched data into a different output type. Consider introducing separate generics (e.g. TQueryFnData vs TData) or defining the options type as UseQueryOptions<TQueryFnData, Error, TData> so select can change the result type safely.

Copilot uses AI. Check for mistakes.
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>({
queryKey: keyFn(params),
queryFn: () => fetcher(params),
...options,
});
},
};
}
1 change: 1 addition & 0 deletions src/query/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './createQuery';
1 change: 0 additions & 1 deletion vitest.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { defineConfig } from 'vitest/config';
import { defineConfig } from 'vitest/config';

export default defineConfig({
test: {
Expand Down
Loading