diff --git a/knip.config.ts b/knip.config.ts index de578287056e92..7222eda184039a 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -59,6 +59,8 @@ const config: KnipConfig = { '!static/**/{fixtures,__fixtures__}/**!', // helper files for tests - it's fine that they are only used in tests '!static/**/*{t,T}estUtils*.{js,mjs,ts,tsx}!', + // utility hook only used in tests (intentionally) + '!static/app/utils/url/useQueryStateWithLocalStorage.tsx!', // helper files for stories - it's fine that they are only used in tests '!static/app/**/__stories__/*.{js,mjs,ts,tsx}!', '!static/app/stories/**/*.{js,mjs,ts,tsx}!', diff --git a/static/app/utils/url/useQueryStateWithLocalStorage.spec.tsx b/static/app/utils/url/useQueryStateWithLocalStorage.spec.tsx new file mode 100644 index 00000000000000..b64808215ee0c0 --- /dev/null +++ b/static/app/utils/url/useQueryStateWithLocalStorage.spec.tsx @@ -0,0 +1,542 @@ +import { + createMultiParser, + parseAsArrayOf, + parseAsBoolean, + parseAsInteger, + parseAsString, +} from 'nuqs'; +import {withNuqsTestingAdapter} from 'nuqs/adapters/testing'; + +import {act, renderHook, waitFor} from 'sentry-test/reactTestingLibrary'; + +import localStorageWrapper from 'sentry/utils/localStorage'; +import {useQueryStateWithLocalStorage} from 'sentry/utils/url/useQueryStateWithLocalStorage'; + +describe('useQueryStateWithLocalStorage', () => { + beforeEach(() => { + localStorageWrapper.clear(); + }); + + it('returns default value when neither URL nor localStorage has value', () => { + const {result} = renderHook( + () => + useQueryStateWithLocalStorage( + 'testParam', + 'testNamespace:testParam', + parseAsString, + 'fallback' + ), + { + wrapper: withNuqsTestingAdapter(), + } + ); + + expect(result.current[0]).toBe('fallback'); + }); + + it('returns localStorage value when URL is empty', () => { + localStorageWrapper.setItem('testNamespace:testParam', 'fromStorage'); + + const {result} = renderHook( + () => + useQueryStateWithLocalStorage( + 'testParam', + 'testNamespace:testParam', + parseAsString, + 'notUsed' + ), + { + wrapper: withNuqsTestingAdapter(), + } + ); + + expect(result.current[0]).toBe('fromStorage'); + }); + + it('returns URL value when URL has value (URL takes precedence)', () => { + localStorageWrapper.setItem('testNamespace:testParam', 'fromStorage'); + + const {result} = renderHook( + () => + useQueryStateWithLocalStorage( + 'testParam', + 'testNamespace:testParam', + parseAsString, + 'unused' + ), + { + wrapper: withNuqsTestingAdapter({ + searchParams: {testParam: 'fromURL'}, + }), + } + ); + + expect(result.current[0]).toBe('fromURL'); + }); + + it('syncs localStorage when URL changes', async () => { + renderHook( + () => + useQueryStateWithLocalStorage( + 'testParam', + 'testNamespace:testParam', + parseAsString, + 'initial' + ), + { + wrapper: withNuqsTestingAdapter({ + searchParams: {testParam: 'newURLValue'}, + }), + } + ); + + await waitFor(() => { + const storedValue = localStorageWrapper.getItem('testNamespace:testParam'); + expect(storedValue).toBe('newURLValue'); + }); + }); + + it('setValue updates both URL and localStorage', async () => { + const onUrlUpdate = jest.fn(); + + const {result} = renderHook( + () => + useQueryStateWithLocalStorage( + 'testParam', + 'testNamespace:testParam', + parseAsString, + 'starting' + ), + { + wrapper: withNuqsTestingAdapter({onUrlUpdate}), + } + ); + + act(() => { + result.current[1]('newValue'); + }); + + await waitFor(() => { + expect(onUrlUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + queryString: expect.stringContaining('testParam=newValue'), + }) + ); + }); + + await waitFor(() => { + const storedValue = localStorageWrapper.getItem('testNamespace:testParam'); + expect(storedValue).toBe('newValue'); + }); + }); + + it('works with enum-like string types', async () => { + localStorageWrapper.setItem('myNamespace:sort', 'name'); + + const onUrlUpdate = jest.fn(); + + const {result} = renderHook( + () => + useQueryStateWithLocalStorage('sort', 'myNamespace:sort', parseAsString, 'date'), + { + wrapper: withNuqsTestingAdapter({onUrlUpdate}), + } + ); + + expect(result.current[0]).toBe('name'); + + act(() => { + result.current[1]('size'); + }); + + await waitFor(() => { + expect(onUrlUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + queryString: expect.stringContaining('sort=size'), + }) + ); + }); + + await waitFor(() => { + const storedValue = localStorageWrapper.getItem('myNamespace:sort'); + expect(storedValue).toBe('size'); + }); + }); + + it('does not sync localStorage if URL value matches localStorage', () => { + localStorageWrapper.setItem('testNamespace:testParam', 'sameValue'); + + const {result} = renderHook( + () => + useQueryStateWithLocalStorage( + 'testParam', + 'testNamespace:testParam', + parseAsString, + 'baseline' + ), + { + wrapper: withNuqsTestingAdapter({ + searchParams: {testParam: 'sameValue'}, + }), + } + ); + + expect(result.current[0]).toBe('sameValue'); + + const storedValue = localStorageWrapper.getItem('testNamespace:testParam'); + expect(storedValue).toBe('sameValue'); + }); + + it('works with integer values using parseAsInteger', async () => { + localStorageWrapper.setItem('testNamespace:count', '42'); + + const onUrlUpdate = jest.fn(); + + const {result} = renderHook( + () => + useQueryStateWithLocalStorage( + 'count', + 'testNamespace:count', + parseAsInteger, + 999 + ), + { + wrapper: withNuqsTestingAdapter({onUrlUpdate}), + } + ); + + expect(result.current[0]).toBe(42); + expect(typeof result.current[0]).toBe('number'); + + act(() => { + result.current[1](100); + }); + + await waitFor(() => { + expect(onUrlUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + queryString: expect.stringContaining('count=100'), + }) + ); + }); + + await waitFor(() => { + const storedValue = localStorageWrapper.getItem('testNamespace:count'); + expect(storedValue).toBe('100'); + }); + }); + + it('works with boolean values using parseAsBoolean', async () => { + localStorageWrapper.setItem('testNamespace:enabled', 'true'); + + const onUrlUpdate = jest.fn(); + + const {result} = renderHook( + () => + useQueryStateWithLocalStorage( + 'enabled', + 'testNamespace:enabled', + parseAsBoolean, + false + ), + { + wrapper: withNuqsTestingAdapter({onUrlUpdate}), + } + ); + + expect(result.current[0]).toBe(true); + expect(typeof result.current[0]).toBe('boolean'); + + act(() => { + result.current[1](false); + }); + + await waitFor(() => { + expect(onUrlUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + queryString: expect.stringContaining('enabled=false'), + }) + ); + }); + + await waitFor(() => { + const storedValue = localStorageWrapper.getItem('testNamespace:enabled'); + expect(storedValue).toBe('false'); + }); + }); + + it('URL integer value overrides localStorage', () => { + localStorageWrapper.setItem('testNamespace:pageSize', '25'); + + const {result} = renderHook( + () => + useQueryStateWithLocalStorage( + 'pageSize', + 'testNamespace:pageSize', + parseAsInteger, + 5 + ), + { + wrapper: withNuqsTestingAdapter({ + searchParams: {pageSize: '50'}, + }), + } + ); + + expect(result.current[0]).toBe(50); + }); + + it('throws error when parser has .withDefault() configured', () => { + expect(() => { + renderHook( + () => + useQueryStateWithLocalStorage( + 'testParam', + 'testNamespace:testParam', + parseAsString.withDefault('shouldThrow'), + 'ignored' + ), + { + wrapper: withNuqsTestingAdapter(), + } + ); + }).toThrow( + 'useQueryStateWithLocalStorage: parser should not have .withDefault() configured' + ); + }); + + it('handles empty string values correctly', () => { + // Set empty string in localStorage + localStorageWrapper.setItem('testNamespace:testParam', ''); + + const {result} = renderHook( + () => + useQueryStateWithLocalStorage( + 'testParam', + 'testNamespace:testParam', + parseAsString, + 'defaultValue' + ), + { + wrapper: withNuqsTestingAdapter(), + } + ); + + // Empty string from localStorage should be returned, not the default + expect(result.current[0]).toBe(''); + }); + + it('syncs empty string URL values to localStorage', async () => { + const {result} = renderHook( + () => + useQueryStateWithLocalStorage( + 'testParam', + 'testNamespace:testParam', + parseAsString, + 'defaultValue' + ), + { + wrapper: withNuqsTestingAdapter({ + searchParams: {testParam: ''}, + }), + } + ); + + expect(result.current[0]).toBe(''); + + // Empty string from URL should be synced to localStorage + await waitFor(() => { + const storedValue = localStorageWrapper.getItem('testNamespace:testParam'); + expect(storedValue).toBe(''); + }); + }); + + it('works with array values using parseAsArrayOf (SingleParser)', async () => { + localStorageWrapper.setItem('testNamespace:tags', 'foo,bar,baz'); + + const onUrlUpdate = jest.fn(); + + const {result} = renderHook( + () => + useQueryStateWithLocalStorage( + 'tags', + 'testNamespace:tags', + parseAsArrayOf(parseAsString), + [] + ), + { + wrapper: withNuqsTestingAdapter({onUrlUpdate}), + } + ); + + // Should populate URL from localStorage on mount + await waitFor(() => { + expect(onUrlUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + queryString: expect.stringContaining('tags=foo,bar,baz'), + }) + ); + }); + + // Value should be parsed as array + expect(result.current[0]).toEqual(['foo', 'bar', 'baz']); + + act(() => { + result.current[1](['new', 'tags']); + }); + + await waitFor(() => { + expect(onUrlUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + queryString: expect.stringContaining('tags=new,tags'), + }) + ); + }); + + await waitFor(() => { + const storedValue = localStorageWrapper.getItem('testNamespace:tags'); + expect(storedValue).toBe('new,tags'); + }); + }); + + it('works with native array values using MultiParser', async () => { + // Create a custom MultiParser without .withDefault() + // This mimics parseAsNativeArrayOf but without the automatic default + const parseAsStringArray = createMultiParser({ + parse: values => { + const filtered = values.filter(v => v !== null); + return filtered.length > 0 ? filtered : null; + }, + serialize: values => values, + }); + + // Multi parser stores as JSON array in localStorage + localStorageWrapper.setItem('testNamespace:tags', '["foo","bar","baz"]'); + + const onUrlUpdate = jest.fn(); + + const {result} = renderHook( + () => + useQueryStateWithLocalStorage( + 'tags', + 'testNamespace:tags', + parseAsStringArray, + [] + ), + { + wrapper: withNuqsTestingAdapter({onUrlUpdate}), + } + ); + + // Should populate URL from localStorage on mount + await waitFor(() => { + // Native array format uses repeated keys: ?tags=foo&tags=bar&tags=baz + expect(onUrlUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + queryString: expect.stringMatching(/tags=foo.*tags=bar.*tags=baz/), + }) + ); + }); + + // Value should be parsed as array + expect(result.current[0]).toEqual(['foo', 'bar', 'baz']); + + act(() => { + result.current[1](['new', 'tags']); + }); + + await waitFor(() => { + // Native array format: ?tags=new&tags=tags + expect(onUrlUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + queryString: expect.stringMatching(/tags=new.*tags=tags/), + }) + ); + }); + + await waitFor(() => { + const storedValue = localStorageWrapper.getItem('testNamespace:tags'); + // Should be stored as JSON array + expect(JSON.parse(storedValue!)).toEqual(['new', 'tags']); + }); + }); + + it('setting value to null clears both URL and localStorage', async () => { + localStorageWrapper.setItem('testNamespace:param', 'fromStorage'); + + const onUrlUpdate = jest.fn(); + + const {result} = renderHook( + () => + useQueryStateWithLocalStorage( + 'param', + 'testNamespace:param', + parseAsString, + 'defaultValue' + ), + { + wrapper: withNuqsTestingAdapter({ + searchParams: {param: 'fromURL'}, + onUrlUpdate, + }), + } + ); + + expect(result.current[0]).toBe('fromURL'); + + // Set to null + act(() => { + result.current[1](null); + }); + + await waitFor(() => { + // Should fall back to default + expect(result.current[0]).toBe('defaultValue'); + }); + + // localStorage should be cleared + expect(localStorageWrapper.getItem('testNamespace:param')).toBeNull(); + + // URL should be cleared + await waitFor(() => { + expect(onUrlUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + queryString: '', + }) + ); + }); + }); + + it('populates URL from localStorage on mount when URL is empty', async () => { + localStorageWrapper.setItem('testNamespace:param', 'fromStorage'); + + const onUrlUpdate = jest.fn(); + + const {result} = renderHook( + () => + useQueryStateWithLocalStorage( + 'param', + 'testNamespace:param', + parseAsString, + 'defaultValue' + ), + { + wrapper: withNuqsTestingAdapter({onUrlUpdate}), + } + ); + + // Initially shows localStorage value (as it gets written to URL) + await waitFor(() => { + expect(result.current[0]).toBe('fromStorage'); + }); + + // URL should be populated + await waitFor(() => { + expect(onUrlUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + queryString: expect.stringContaining('param=fromStorage'), + }) + ); + }); + }); +}); diff --git a/static/app/utils/url/useQueryStateWithLocalStorage.tsx b/static/app/utils/url/useQueryStateWithLocalStorage.tsx new file mode 100644 index 00000000000000..23bc529de58246 --- /dev/null +++ b/static/app/utils/url/useQueryStateWithLocalStorage.tsx @@ -0,0 +1,181 @@ +import {useCallback, useEffect, useState} from 'react'; +import {useQueryState, type GenericParserBuilder, type MultiParserBuilder} from 'nuqs'; + +import {defined} from 'sentry/utils'; +import localStorageWrapper from 'sentry/utils/localStorage'; + +/** + * Type guard to detect if a parser is a MultiParserBuilder. + * Multi parsers require special handling for localStorage. + */ +function isMultiParser( + parser: GenericParserBuilder +): parser is MultiParserBuilder { + return (parser as MultiParserBuilder).type === 'multi'; +} + +/** + * Parse a value from localStorage based on parser type. + * - Single parsers: parse string directly + * - Multi parsers: parse JSON array first, then pass to parser + */ +function parseFromLocalStorage( + stored: string, + parser: GenericParserBuilder +): T | null { + if (isMultiParser(parser)) { + try { + const array = JSON.parse(stored); + if (Array.isArray(array)) { + return parser.parse(array); + } + return null; + } catch { + return null; + } + } + return parser.parse(stored); +} + +/** + * Serialize a value to localStorage based on parser type. + * - Single parsers: serialize returns string directly + * - Multi parsers: serialize returns Array, store as JSON + */ +function serializeToLocalStorage(value: T, parser: GenericParserBuilder): string { + if (isMultiParser(parser)) { + const serialized = parser.serialize(value); + return JSON.stringify(serialized); + } + return parser.serialize(value); +} + +/** + * Hook that syncs state between URL query parameters and localStorage. + * After initial mount, URL becomes the single source of truth. On mount, if URL + * is empty and localStorage has a value, the URL is populated with that value. + * + * Supports both SingleParserBuilder (parseAsString, parseAsInteger, parseAsArrayOf) + * and MultiParserBuilder for maximum flexibility. + * + * **Important**: Pass the parser WITHOUT `.withDefault()` - use the separate + * `defaultValue` parameter instead. This is enforced at runtime. + * + * @param queryKey - The URL query parameter name + * @param localStorageKey - The localStorage key + * @param parser - Nuqs parser WITHOUT .withDefault() + * @param defaultValue - The default value when URL has no value + * @returns Tuple of [value, setValue] where setValue accepts value or null to clear + * + * @example + * // String values + * const [sortBy, setSortBy] = useQueryStateWithLocalStorage( + * 'sortBy', + * 'dashboards:sortBy', + * parseAsString, + * 'date' + * ); + * setSortBy('name'); // Set to 'name' + * setSortBy(null); // Clear and return to default 'date' + * + * @example + * // Array with parseAsArrayOf (SingleParser) + * const [tags, setTags] = useQueryStateWithLocalStorage( + * 'tags', + * 'filters:tags', + * parseAsArrayOf(parseAsString), + * [] + * ); + */ +export function useQueryStateWithLocalStorage( + queryKey: string, + localStorageKey: string, + parser: GenericParserBuilder, + defaultValue: T +): [T, (value: T | null) => void] { + // Detect if parser has a default value configured (runtime check) + // Parsers with .withDefault() have a 'defaultValue' property + // + // If the parser has `.withDefault()`, it will _always_ return that default + // instead of `null` when there's no URL param. This breaks our priority + // because we need the parser to return null when there's no URL value. + // + // Note: MultiParsers may have internal defaultValue handling that's different, + // so we skip this check for them. + if ( + !isMultiParser(parser) && + 'defaultValue' in parser && + defined(parser.defaultValue) + ) { + throw new Error( + `useQueryStateWithLocalStorage: parser should not have .withDefault() configured. ` + + `Pass the base parser and use the separate defaultValue parameter instead.` + ); + } + + // The authoritative state is from the URL parameter + const [urlValue, setUrlValue] = useQueryState(queryKey, parser); + + // Track whether we've initialized from localStorage + const [initialized, setInitialized] = useState(false); + + // On mount, read localStorage ONCE and populate URL if empty + // This ensures localStorage is only read on mount, not on every render + useEffect(() => { + if (!initialized && !defined(urlValue)) { + const stored = localStorageWrapper.getItem(localStorageKey); + if (defined(stored)) { + const parsed = parseFromLocalStorage(stored, parser); + if (defined(parsed)) { + // Cast to satisfy Nuqs's type signature (T & {} excludes null/undefined) + setUrlValue(parsed as T & {}); + } + } + setInitialized(true); + } + }, [initialized, urlValue, localStorageKey, parser, setUrlValue]); + + // After initialization, URL is the single source of truth + const effectiveValue = urlValue ?? defaultValue; + + // Synchronize URL changes to localStorage + // This happens when URL is updated by external means (e.g., browser back/forward) + useEffect(() => { + if (defined(urlValue)) { + const storedRaw = localStorageWrapper.getItem(localStorageKey); + const storedValue = defined(storedRaw) + ? parseFromLocalStorage(storedRaw, parser) + : null; + + // Use parser's equality check if available (for arrays, objects, etc.) + const areEqual = + defined(storedValue) && 'eq' in parser && typeof parser.eq === 'function' + ? parser.eq(urlValue, storedValue) + : urlValue === storedValue; + + if (!areEqual) { + const serializedUrlValue = serializeToLocalStorage(urlValue, parser); + localStorageWrapper.setItem(localStorageKey, serializedUrlValue); + } + } + }, [parser, urlValue, localStorageKey]); + + const setValue = useCallback( + (value: T | null) => { + if (value === null) { + // Clear both URL and localStorage + setUrlValue(null); + localStorageWrapper.removeItem(localStorageKey); + } else { + // Set value in both URL and localStorage + // Cast to satisfy Nuqs's type signature (T & {} excludes null/undefined) + setUrlValue(value as T & {}); + const serializedValue = serializeToLocalStorage(value, parser); + localStorageWrapper.setItem(localStorageKey, serializedValue); + } + }, + [setUrlValue, parser, localStorageKey] + ); + + return [effectiveValue, setValue]; +}