From a9fbd9a3c922ad90cfc2faf18c3930088d832d64 Mon Sep 17 00:00:00 2001 From: jettapat Date: Tue, 16 Sep 2025 16:03:38 +0700 Subject: [PATCH 1/4] feat: return full query key from useCollectionListQuery hook --- packages/react/src/react/views/collections/list/context.tsx | 5 ++--- .../views/collections/list/hooks/use-collection-list.ts | 5 +++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/react/src/react/views/collections/list/context.tsx b/packages/react/src/react/views/collections/list/context.tsx index affea1ea..8ba26584 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 } } From 057e3dbeedd1289916aeee7d93bf14182ce9cbd6 Mon Sep 17 00:00:00 2001 From: jettapat Date: Wed, 17 Sep 2025 11:28:53 +0700 Subject: [PATCH 2/4] feat: filter empty object and sort key in queryKey --- packages/react-query/src/index.ts | 49 +++++++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/packages/react-query/src/index.ts b/packages/react-query/src/index.ts index a75e1c31..0bc9908c 100644 --- a/packages/react-query/src/index.ts +++ b/packages/react-query/src/index.ts @@ -209,13 +209,52 @@ export type QueryClient = } : never +function isEmptyObject(obj: any): boolean { + return ( + obj != null && typeof obj === 'object' && !Array.isArray(obj) && Object.keys(obj).length === 0 + ) +} + +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) +} + +function normalizePayload(payload?: any): any { + if (!payload) return undefined + + const normalized = { + pathParams: payload.pathParams, + query: payload.query, + headers: payload.headers, + } + + const filtered = Object.entries(normalized).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 +} + export function queryKey(method: string, path: string | number | symbol, payload?: any) { - const payloadKey = { - pathParams: payload?.pathParams ?? {}, - query: payload?.query ?? {}, - headers: payload?.headers ?? {}, + const normalizedPayload = normalizePayload(payload) + + if (normalizedPayload) { + return [method, path, normalizedPayload] as const } - return [method, path, payloadKey] as const + + return [method, path] as const } export function createQueryClient( From 49850e8bed845a4bfb8f7fb47ab74f7cc2254d00 Mon Sep 17 00:00:00 2001 From: jettapat Date: Wed, 17 Sep 2025 15:41:01 +0700 Subject: [PATCH 3/4] refactor: split utils from main logic and write unit test --- packages/react-query/package.json | 3 +- packages/react-query/src/index.ts | 46 ++------ packages/react-query/src/utils.spec.ts | 154 +++++++++++++++++++++++++ packages/react-query/src/utils.ts | 31 +++++ 4 files changed, 197 insertions(+), 37 deletions(-) create mode 100644 packages/react-query/src/utils.spec.ts create mode 100644 packages/react-query/src/utils.ts diff --git a/packages/react-query/package.json b/packages/react-query/package.json index e8162c50..1f49dc07 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 0bc9908c..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 @@ -209,47 +211,19 @@ export type QueryClient = } : never -function isEmptyObject(obj: any): boolean { - return ( - obj != null && typeof obj === 'object' && !Array.isArray(obj) && Object.keys(obj).length === 0 - ) -} +export function queryKey(method: string, path: string | number | symbol, payload?: any) { + const { header, ...rest } = payload + const normalizedPayload = normalizeObject(rest) + const normalizedHeaders = header && !isEmptyObject(header) ? sortObjectDeep(header) : undefined -function sortObjectDeep(obj: any): any { - if (obj == null || typeof obj !== 'object' || Array.isArray(obj)) { - return obj + if (normalizedHeaders && normalizedPayload) { + return [method, path, normalizedPayload, normalizedHeaders] as const } - return Object.keys(obj) - .sort() - .reduce((result, key) => { - result[key] = sortObjectDeep(obj[key]) - return result - }, {} as any) -} - -function normalizePayload(payload?: any): any { - if (!payload) return undefined - - const normalized = { - pathParams: payload.pathParams, - query: payload.query, - headers: payload.headers, + if (normalizedHeaders) { + return [method, path, normalizedHeaders] as const } - const filtered = Object.entries(normalized).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 -} - -export function queryKey(method: string, path: string | number | symbol, payload?: any) { - const normalizedPayload = normalizePayload(payload) - if (normalizedPayload) { return [method, path, normalizedPayload] as const } 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 +} From 28387bfadbb87537c21f4fbcf3ec9af53693efb3 Mon Sep 17 00:00:00 2001 From: Saenyakorn Siangsanoh <33742791+saenyakorn@users.noreply.github.com> Date: Fri, 19 Sep 2025 10:44:55 +0700 Subject: [PATCH 4/4] Fix query key in useInvalidateQueries --- .changeset/fluffy-papayas-accept.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/fluffy-papayas-accept.md 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