From 6cb7ee0d1e0773a116c63bc61394a1482b4baf62 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Tue, 7 Apr 2026 17:36:20 +0200 Subject: [PATCH 1/2] fix(apiOptions): prevent empty options from being included in queryKey --- static/app/utils/api/apiOptions.spec.tsx | 14 ++++++++++++++ static/app/utils/api/apiOptions.ts | 20 +++++++++++--------- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/static/app/utils/api/apiOptions.spec.tsx b/static/app/utils/api/apiOptions.spec.tsx index 0123732c5bba22..4e187493e410b7 100644 --- a/static/app/utils/api/apiOptions.spec.tsx +++ b/static/app/utils/api/apiOptions.spec.tsx @@ -40,6 +40,20 @@ describe('apiOptions', () => { ]); }); + it('should not include options in queryKey when all values are undefined', () => { + const options = apiOptions.as()('/api-tokens/$tokenId/', { + staleTime: 0, + path: {tokenId: '123'}, + query: undefined, + method: undefined, + }); + + expect(options.queryKey).toEqual([ + {infinite: false, version: 'v2'}, + '/api-tokens/123/', + ]); + }); + it('should stringify number path params', () => { const options = apiOptions.as()('/api-tokens/$tokenId/', { staleTime: 0, diff --git a/static/app/utils/api/apiOptions.ts b/static/app/utils/api/apiOptions.ts index 2271645782795f..f809a6b3a30456 100644 --- a/static/app/utils/api/apiOptions.ts +++ b/static/app/utils/api/apiOptions.ts @@ -16,13 +16,17 @@ import {parseLinkHeader} from 'sentry/utils/parseLinkHeader'; type KnownApiUrls = KnownGetsentryApiUrls | KnownSentryApiUrls; -type Options = QueryKeyEndpointOptions & {staleTime: number}; +type Options = QueryKeyEndpointOptions & {staleTime: number | 'static'}; type PathParamOptions = ExtractPathParams extends never ? {path?: never} : {path: Record, string | number> | SkipToken}; +function hasDefinedValues(obj: Record): boolean { + return Object.values(obj).some(v => v !== undefined); +} + const selectJson = (data: ApiResponse) => data.json; export const selectJsonWithHeaders = ( @@ -45,10 +49,9 @@ function _apiOptions< const url = getApiUrl(path, ...([{path: pathParams}] as OptionalPathParams)); return queryOptions({ - queryKey: - Object.keys(options).length > 0 - ? ([{infinite: false, version: 'v2'}, url, options] as ApiQueryKey) - : ([{infinite: false, version: 'v2'}, url] as ApiQueryKey), + queryKey: hasDefinedValues(options) + ? ([{infinite: false, version: 'v2'}, url, options] as ApiQueryKey) + : ([{infinite: false, version: 'v2'}, url] as ApiQueryKey), queryFn: pathParams === skipToken ? skipToken : apiFetch, enabled: pathParams !== skipToken, staleTime, @@ -79,10 +82,9 @@ function _apiOptionsInfinite< const url = getApiUrl(path, ...([{path: pathParams}] as OptionalPathParams)); return infiniteQueryOptions({ - queryKey: - Object.keys(options).length > 0 - ? ([{infinite: true, version: 'v2'}, url, options] as InfiniteApiQueryKey) - : ([{infinite: true, version: 'v2'}, url] as InfiniteApiQueryKey), + queryKey: hasDefinedValues(options) + ? ([{infinite: true, version: 'v2'}, url, options] as InfiniteApiQueryKey) + : ([{infinite: true, version: 'v2'}, url] as InfiniteApiQueryKey), queryFn: pathParams === skipToken ? skipToken : apiFetchInfinite, getPreviousPageParam: parsePageParam('previous'), getNextPageParam: parsePageParam('next'), From 1b47afad7fc312e2804b295c9879ac2ee9ec3495 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Tue, 7 Apr 2026 18:57:05 +0200 Subject: [PATCH 2/2] fix(apiOptions): strip undefined values from options in queryKey --- static/app/utils/api/apiOptions.spec.tsx | 15 +++++++++++++++ static/app/utils/api/apiOptions.ts | 20 ++++++++++++-------- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/static/app/utils/api/apiOptions.spec.tsx b/static/app/utils/api/apiOptions.spec.tsx index 4e187493e410b7..84060810722ab8 100644 --- a/static/app/utils/api/apiOptions.spec.tsx +++ b/static/app/utils/api/apiOptions.spec.tsx @@ -54,6 +54,21 @@ describe('apiOptions', () => { ]); }); + it('should strip undefined values from options in queryKey', () => { + const options = apiOptions.as()('/api-tokens/$tokenId/', { + staleTime: 0, + path: {tokenId: '123'}, + query: {cursor: 'abc'}, + method: undefined, + }); + + expect(options.queryKey).toEqual([ + {infinite: false, version: 'v2'}, + '/api-tokens/123/', + {query: {cursor: 'abc'}}, + ]); + }); + it('should stringify number path params', () => { const options = apiOptions.as()('/api-tokens/$tokenId/', { staleTime: 0, diff --git a/static/app/utils/api/apiOptions.ts b/static/app/utils/api/apiOptions.ts index f809a6b3a30456..2aa247aa406120 100644 --- a/static/app/utils/api/apiOptions.ts +++ b/static/app/utils/api/apiOptions.ts @@ -23,8 +23,8 @@ type PathParamOptions = ? {path?: never} : {path: Record, string | number> | SkipToken}; -function hasDefinedValues(obj: Record): boolean { - return Object.values(obj).some(v => v !== undefined); +function stripUndefinedValues(obj: Record): Record { + return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined)); } const selectJson = (data: ApiResponse) => data.json; @@ -47,11 +47,13 @@ function _apiOptions< : [Options & PathParamOptions] ) { const url = getApiUrl(path, ...([{path: pathParams}] as OptionalPathParams)); + const strippedOptions = stripUndefinedValues(options); return queryOptions({ - queryKey: hasDefinedValues(options) - ? ([{infinite: false, version: 'v2'}, url, options] as ApiQueryKey) - : ([{infinite: false, version: 'v2'}, url] as ApiQueryKey), + queryKey: + Object.keys(strippedOptions).length > 0 + ? ([{infinite: false, version: 'v2'}, url, strippedOptions] as ApiQueryKey) + : ([{infinite: false, version: 'v2'}, url] as ApiQueryKey), queryFn: pathParams === skipToken ? skipToken : apiFetch, enabled: pathParams !== skipToken, staleTime, @@ -80,11 +82,13 @@ function _apiOptionsInfinite< : [Options & PathParamOptions] ) { const url = getApiUrl(path, ...([{path: pathParams}] as OptionalPathParams)); + const strippedOptions = stripUndefinedValues(options); return infiniteQueryOptions({ - queryKey: hasDefinedValues(options) - ? ([{infinite: true, version: 'v2'}, url, options] as InfiniteApiQueryKey) - : ([{infinite: true, version: 'v2'}, url] as InfiniteApiQueryKey), + queryKey: + Object.keys(strippedOptions).length > 0 + ? ([{infinite: true, version: 'v2'}, url, strippedOptions] as InfiniteApiQueryKey) + : ([{infinite: true, version: 'v2'}, url] as InfiniteApiQueryKey), queryFn: pathParams === skipToken ? skipToken : apiFetchInfinite, getPreviousPageParam: parsePageParam('previous'), getNextPageParam: parsePageParam('next'),