diff --git a/.changeset/fluffy-papayas-accept.md b/.changeset/fluffy-papayas-accept.md new file mode 100644 index 00000000..8acaf727 --- /dev/null +++ b/.changeset/fluffy-papayas-accept.md @@ -0,0 +1,6 @@ +--- +"@genseki/react-query": patch +"@genseki/react": patch +--- + +Fix query key in useInvalidateQueries diff --git a/packages/react-query/package.json b/packages/react-query/package.json index 0abf71c4..9f531a38 100644 --- a/packages/react-query/package.json +++ b/packages/react-query/package.json @@ -27,7 +27,8 @@ "format": "prettier --write .", "format:check": "prettier --check .", "typecheck": "tsc", - "bundle": "rm -rf dist && tsc -p tsconfig.bundle.json" + "bundle": "rm -rf dist && tsc -p tsconfig.bundle.json", + "test": "vitest run" }, "keywords": [], "author": "", diff --git a/packages/react-query/src/index.ts b/packages/react-query/src/index.ts index a75e1c31..ed40f39d 100644 --- a/packages/react-query/src/index.ts +++ b/packages/react-query/src/index.ts @@ -24,6 +24,8 @@ import type { } from '@genseki/react' import { createRestClient, type CreateRestClientConfig } from '@genseki/rest' +import { isEmptyObject, normalizeObject, sortObjectDeep } from './utils' + type QueryMethod = 'GET' type MutationMethod = 'POST' | 'PATCH' | 'PUT' | 'DELETE' type Method = QueryMethod | MutationMethod @@ -210,12 +212,23 @@ export type QueryClient = : never export function queryKey(method: string, path: string | number | symbol, payload?: any) { - const payloadKey = { - pathParams: payload?.pathParams ?? {}, - query: payload?.query ?? {}, - headers: payload?.headers ?? {}, + const { header, ...rest } = payload + const normalizedPayload = normalizeObject(rest) + const normalizedHeaders = header && !isEmptyObject(header) ? sortObjectDeep(header) : undefined + + if (normalizedHeaders && normalizedPayload) { + return [method, path, normalizedPayload, normalizedHeaders] as const + } + + if (normalizedHeaders) { + return [method, path, normalizedHeaders] as const } - return [method, path, payloadKey] as const + + if (normalizedPayload) { + return [method, path, normalizedPayload] as const + } + + return [method, path] as const } export function createQueryClient( diff --git a/packages/react-query/src/utils.spec.ts b/packages/react-query/src/utils.spec.ts new file mode 100644 index 00000000..98720abe --- /dev/null +++ b/packages/react-query/src/utils.spec.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from 'vitest' + +import { isEmptyObject, normalizeObject, sortObjectDeep } from './utils' + +describe('isEmptyObject', () => { + it('should return true for empty object', () => { + expect(isEmptyObject({})).toBe(true) + }) + + it('should return false for object with properties', () => { + expect(isEmptyObject({ id: 1 })).toBe(false) + }) + + it('should return false for null', () => { + expect(isEmptyObject(null)).toBe(false) + }) + + it('should return false for undefined', () => { + expect(isEmptyObject(undefined)).toBe(false) + }) + + it('should return false for arrays', () => { + expect(isEmptyObject([])).toBe(false) + expect(isEmptyObject([1, 2, 3])).toBe(false) + }) + + it('should return false for primitives', () => { + expect(isEmptyObject('string')).toBe(false) + expect(isEmptyObject(42)).toBe(false) + expect(isEmptyObject(true)).toBe(false) + }) +}) + +describe('sortObjectDeep', () => { + it('should sort object keys alphabetically', () => { + const input = { z: 1, a: 2, m: 3 } + const expected = { a: 2, m: 3, z: 1 } + expect(sortObjectDeep(input)).toEqual(expected) + }) + + it('should sort nested objects recursively', () => { + const input = { + z: { y: 1, x: 2 }, + a: { c: 3, b: 4 }, + } + const expected = { + a: { b: 4, c: 3 }, + z: { x: 2, y: 1 }, + } + expect(sortObjectDeep(input)).toEqual(expected) + }) + + it('should handle arrays without sorting', () => { + const input = { items: [3, 1, 2] } + const expected = { items: [3, 1, 2] } + expect(sortObjectDeep(input)).toEqual(expected) + }) + + it('should handle primitives', () => { + expect(sortObjectDeep(null)).toBe(null) + expect(sortObjectDeep(undefined)).toBe(undefined) + expect(sortObjectDeep(42)).toBe(42) + expect(sortObjectDeep('string')).toBe('string') + }) + + it('should handle deeply nested mixed structures', () => { + const input = { + z: { nested: { b: 1, a: 2 } }, + a: [1, 2, 3], + m: 'string', + } + const expected = { + a: [1, 2, 3], + m: 'string', + z: { nested: { a: 2, b: 1 } }, + } + expect(sortObjectDeep(input)).toEqual(expected) + }) + + it('should maintain object references for primitives', () => { + const obj = { value: 42 } + expect(sortObjectDeep(obj)).toEqual({ value: 42 }) + }) +}) + +describe('normalizeObject', () => { + it('should return undefined for falsy inputs', () => { + expect(normalizeObject()).toBe(undefined) + expect(normalizeObject(null)).toBe(undefined) + expect(normalizeObject(undefined)).toBe(undefined) + expect(normalizeObject(false)).toBe(undefined) + }) + + it('should return undefined when all fields are empty', () => { + const payload = { + query: {}, + pathParams: {}, + } + expect(normalizeObject(payload)).toBe(undefined) + }) + + it('should filter and sort non-empty objects', () => { + const payload = { + query: { z: 1, a: 2 }, + pathParams: { id: '123' }, + } + const expected = { + pathParams: { id: '123' }, + query: { a: 2, z: 1 }, + } + expect(normalizeObject(payload)).toEqual(expected) + }) + + it('should exclude empty objects while keeping non-empty ones', () => { + const payload = { + query: { name: 'John' }, + pathParams: {}, + } + const expected = { + query: { name: 'John' }, + } + expect(normalizeObject(payload)).toEqual(expected) + }) + + it('should handle nested objects in query and pathParams', () => { + const payload = { + query: { + filter: { z: 'last', a: 'first' }, + sort: 'name', + }, + pathParams: { + nested: { b: 2, a: 1 }, + }, + } + const expected = { + pathParams: { + nested: { a: 1, b: 2 }, + }, + query: { + filter: { a: 'first', z: 'last' }, + sort: 'name', + }, + } + expect(normalizeObject(payload)).toEqual(expected) + }) + + it('should handle null and undefined values in fields', () => { + const payload = { + query: null, + pathParams: undefined, + } + expect(normalizeObject(payload)).toBe(undefined) + }) +}) diff --git a/packages/react-query/src/utils.ts b/packages/react-query/src/utils.ts new file mode 100644 index 00000000..9a9c7ef0 --- /dev/null +++ b/packages/react-query/src/utils.ts @@ -0,0 +1,31 @@ +export function isEmptyObject(obj: any): boolean { + return ( + obj != null && typeof obj === 'object' && !Array.isArray(obj) && Object.keys(obj).length === 0 + ) +} + +export function sortObjectDeep(obj: any): any { + if (obj == null || typeof obj !== 'object' || Array.isArray(obj)) { + return obj + } + + return Object.keys(obj) + .sort() + .reduce((result, key) => { + result[key] = sortObjectDeep(obj[key]) + return result + }, {} as any) +} + +export function normalizeObject(payload?: any): any { + if (!payload) return undefined + + const filtered = Object.entries(payload).reduce((acc, [key, value]) => { + if (value != null && !isEmptyObject(value)) { + acc[key] = sortObjectDeep(value) + } + return acc + }, {} as any) + + return Object.keys(filtered).length > 0 ? filtered : undefined +} diff --git a/packages/react/src/react/views/collections/list/context.tsx b/packages/react/src/react/views/collections/list/context.tsx index 70ba2073..0236d5ac 100644 --- a/packages/react/src/react/views/collections/list/context.tsx +++ b/packages/react/src/react/views/collections/list/context.tsx @@ -81,10 +81,9 @@ function _CollectionListProvider(props: CollectionListProvid const queryClient = useQueryClient() const query = useCollectionListQuery({ slug: context.slug }) - const invalidateList = async (page?: number) => { - const additionalKeys = page ? [{ query: { page } }] : [] + const invalidateList = async () => { await queryClient.invalidateQueries({ - queryKey: ['GET', `/${context.slug}`, ...additionalKeys], + queryKey: query.queryKey, exact: false, }) } diff --git a/packages/react/src/react/views/collections/list/hooks/use-collection-list.ts b/packages/react/src/react/views/collections/list/hooks/use-collection-list.ts index 7bb30756..8a6d60d2 100644 --- a/packages/react/src/react/views/collections/list/hooks/use-collection-list.ts +++ b/packages/react/src/react/views/collections/list/hooks/use-collection-list.ts @@ -20,9 +20,10 @@ export function useCollectionListQuery( search: args.search ?? search, sort: sort, } + const fullQueryKey = ['GET', `/${args.slug}`, { query: queryKey }] as const const query: UseQueryResult = useQuery({ - queryKey: ['GET', `/${args.slug}`, { query: queryKey }] as const, + queryKey: fullQueryKey, queryFn: async (context) => { const [, , payload] = context.queryKey const params = new URLSearchParams([ @@ -50,5 +51,5 @@ export function useCollectionListQuery( placeholderData: keepPreviousData, }) - return query + return { ...query, queryKey: fullQueryKey } }