Skip to content
Closed
Show file tree
Hide file tree
Changes from 5 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
2 changes: 2 additions & 0 deletions knip.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}!',
Expand Down
347 changes: 347 additions & 0 deletions static/app/utils/url/useQueryStateWithLocalStorage.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,347 @@
import {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('');
});
});
});
Loading
Loading