Skip to content

Commit ed0af59

Browse files
authored
ref: NuqsTestingAdapter (#102387)
Continued from - #101616 This PR creates a `SentryNuqsTestingAdapter` that uses our `useLocation` and `useNavigate` hooks to interface with nuqs in tests so that it uses our testing router as the source of truth
1 parent cb68fa8 commit ed0af59

File tree

3 files changed

+204
-33
lines changed

3 files changed

+204
-33
lines changed
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import {parseAsString, useQueryState} from 'nuqs';
2+
3+
import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
4+
5+
describe('SentryNuqsTestingAdapter', () => {
6+
it('reads search params from router location', async () => {
7+
function TestComponent() {
8+
const [search] = useQueryState('query', parseAsString);
9+
return <div>Search: {search ?? 'empty'}</div>;
10+
}
11+
12+
const {router} = render(<TestComponent />, {
13+
initialRouterConfig: {
14+
location: {
15+
pathname: '/test',
16+
query: {query: 'hello'},
17+
},
18+
},
19+
});
20+
21+
expect(screen.getByText('Search: hello')).toBeInTheDocument();
22+
23+
// Navigate to a new location with different search params
24+
router.navigate('/test?query=world');
25+
26+
expect(await screen.findByText('Search: world')).toBeInTheDocument();
27+
});
28+
29+
it('updates router location when nuqs state changes', async () => {
30+
function TestComponent() {
31+
const [search, setSearch] = useQueryState('query', parseAsString);
32+
return (
33+
<div>
34+
<div>Search: {search ?? 'empty'}</div>
35+
<button onClick={() => setSearch('updated')}>Update</button>
36+
</div>
37+
);
38+
}
39+
40+
const {router} = render(<TestComponent />, {
41+
initialRouterConfig: {
42+
location: {
43+
pathname: '/test',
44+
query: {query: 'initial'},
45+
},
46+
},
47+
});
48+
49+
expect(screen.getByText('Search: initial')).toBeInTheDocument();
50+
51+
// Click button to update search param via nuqs
52+
await userEvent.click(screen.getByRole('button', {name: 'Update'}));
53+
54+
// Wait for navigation to complete
55+
await screen.findByText('Search: updated');
56+
57+
// Verify the router location was updated
58+
await waitFor(() => {
59+
expect(router.location.search).toContain('query=updated');
60+
});
61+
});
62+
63+
it('handles multiple query params', () => {
64+
function TestComponent() {
65+
const [foo] = useQueryState('foo', parseAsString);
66+
const [bar] = useQueryState('bar', parseAsString);
67+
return (
68+
<div>
69+
<div>Foo: {foo ?? 'empty'}</div>
70+
<div>Bar: {bar ?? 'empty'}</div>
71+
</div>
72+
);
73+
}
74+
75+
render(<TestComponent />, {
76+
initialRouterConfig: {
77+
location: {
78+
pathname: '/test',
79+
query: {foo: 'value1', bar: 'value2'},
80+
},
81+
},
82+
});
83+
84+
expect(screen.getByText('Foo: value1')).toBeInTheDocument();
85+
expect(screen.getByText('Bar: value2')).toBeInTheDocument();
86+
});
87+
88+
it('handles missing query params', () => {
89+
function TestComponent() {
90+
const [search] = useQueryState('query', parseAsString);
91+
return <div>Search: {search ?? 'empty'}</div>;
92+
}
93+
94+
render(<TestComponent />, {
95+
initialRouterConfig: {
96+
location: {
97+
pathname: '/test',
98+
},
99+
},
100+
});
101+
102+
expect(screen.getByText('Search: empty')).toBeInTheDocument();
103+
});
104+
});
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import {useCallback, useMemo, type ReactElement, type ReactNode} from 'react';
2+
import {
3+
unstable_createAdapterProvider as createAdapterProvider,
4+
renderQueryString,
5+
} from 'nuqs/adapters/custom';
6+
import type {unstable_AdapterInterface as AdapterInterface} from 'nuqs/adapters/custom';
7+
import type {OnUrlUpdateFunction} from 'nuqs/adapters/testing';
8+
9+
import {useLocation} from 'sentry/utils/useLocation';
10+
import {useNavigate} from 'sentry/utils/useNavigate';
11+
12+
type SentryNuqsTestingAdapterProps = {
13+
children: ReactNode;
14+
/**
15+
* Default options to pass to nuqs
16+
*/
17+
defaultOptions?: {
18+
clearOnDefault?: boolean;
19+
scroll?: boolean;
20+
shallow?: boolean;
21+
};
22+
/**
23+
* A function that will be called whenever the URL is updated.
24+
* Connect that to a spy in your tests to assert the URL updates.
25+
*/
26+
onUrlUpdate?: OnUrlUpdateFunction;
27+
};
28+
29+
/**
30+
* Custom nuqs adapter component for Sentry that reads location from our
31+
* useLocation hook instead of maintaining its own internal state.
32+
*
33+
* This ensures nuqs uses the same location source as the rest of the
34+
* application during tests.
35+
*/
36+
export function SentryNuqsTestingAdapter({
37+
children,
38+
defaultOptions,
39+
onUrlUpdate,
40+
}: SentryNuqsTestingAdapterProps): ReactElement {
41+
// Create a hook that nuqs will call to get the adapter interface
42+
// This hook needs to be defined inside a component that has access to location/navigate
43+
const useSentryAdapter = useCallback(
44+
(_watchKeys: string[]): AdapterInterface => {
45+
// eslint-disable-next-line react-hooks/rules-of-hooks
46+
const location = useLocation();
47+
// eslint-disable-next-line react-hooks/rules-of-hooks
48+
const navigate = useNavigate();
49+
50+
// Get search params from the current location
51+
const searchParams = new URLSearchParams(location.search || '');
52+
53+
const updateUrl: AdapterInterface['updateUrl'] = (search, options) => {
54+
const newSearchParams = new URLSearchParams(search);
55+
const queryString = renderQueryString(newSearchParams);
56+
57+
// Call the onUrlUpdate callback if provided
58+
onUrlUpdate?.({
59+
searchParams: new URLSearchParams(search), // make a copy
60+
queryString,
61+
options,
62+
});
63+
64+
// Navigate to the new location using Sentry's navigate
65+
// We need to construct the full path with the search string
66+
const newPath = queryString
67+
? `${location.pathname}${queryString}`
68+
: location.pathname;
69+
70+
// The navigate function from TestRouter already wraps this in act()
71+
navigate(newPath, {replace: options.history === 'replace'});
72+
};
73+
74+
const getSearchParamsSnapshot = () => {
75+
// Always read from the current location
76+
return new URLSearchParams(location.search || '');
77+
};
78+
79+
return {
80+
searchParams,
81+
updateUrl,
82+
getSearchParamsSnapshot,
83+
rateLimitFactor: 0, // No throttling in tests
84+
autoResetQueueOnUpdate: true, // Reset update queue after each update
85+
};
86+
},
87+
[onUrlUpdate]
88+
);
89+
90+
// Create the adapter provider (memoized to prevent remounting)
91+
const AdapterProvider = useMemo(
92+
() => createAdapterProvider(useSentryAdapter),
93+
[useSentryAdapter]
94+
);
95+
96+
return <AdapterProvider defaultOptions={defaultOptions}>{children}</AdapterProvider>;
97+
}

tests/js/sentry-test/reactTestingLibrary.tsx

Lines changed: 3 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ import {
1919
import * as rtl from '@testing-library/react'; // eslint-disable-line no-restricted-imports
2020
import userEvent from '@testing-library/user-event'; // eslint-disable-line no-restricted-imports
2121

22-
import {NuqsTestingAdapter} from 'nuqs/adapters/testing';
2322
import * as qs from 'query-string';
2423
import {LocationFixture} from 'sentry-fixture/locationFixture';
2524
import {ThemeFixture} from 'sentry-fixture/theme';
@@ -37,14 +36,13 @@ import {
3736
} from 'sentry/utils/browserHistory';
3837
import {ProvideAriaRouter} from 'sentry/utils/provideAriaRouter';
3938
import {QueryClientProvider} from 'sentry/utils/queryClient';
40-
import {useLocation} from 'sentry/utils/useLocation';
41-
import {useNavigate} from 'sentry/utils/useNavigate';
4239
import {OrganizationContext} from 'sentry/views/organizationContext';
4340
import {TestRouteContext} from 'sentry/views/routeContext';
4441

4542
import {instrumentUserEvent} from '../instrumentedEnv/userEventIntegration';
4643

4744
import {initializeOrg} from './initializeOrg';
45+
import {SentryNuqsTestingAdapter} from './nuqsTestingAdapter';
4846

4947
interface ProviderOptions {
5048
/**
@@ -65,7 +63,6 @@ interface ProviderOptions {
6563
* Sets the OrganizationContext. You may pass null to provide no organization
6664
*/
6765
organization?: Partial<Organization> | null;
68-
query?: string;
6966
/**
7067
* Sets the RouterContext.
7168
*/
@@ -176,31 +173,6 @@ function patchBrowserHistoryMocksEnabled(history: MemoryHistory, router: Injecte
176173
});
177174
}
178175

179-
function NuqsTestingAdapterWithNavigate({
180-
children,
181-
query,
182-
}: {
183-
children: React.ReactNode;
184-
query: string;
185-
}) {
186-
const location = useLocation();
187-
const navigate = useNavigate();
188-
return (
189-
<NuqsTestingAdapter
190-
searchParams={new URLSearchParams(query)}
191-
defaultOptions={{shallow: false}}
192-
onUrlUpdate={({queryString, options: nuqsOptions}) => {
193-
// Pass navigation events to the test router
194-
const newParams = qs.parse(queryString);
195-
const newLocation = {...location, query: newParams};
196-
navigate(newLocation, {replace: nuqsOptions.history === 'replace'});
197-
}}
198-
>
199-
{children}
200-
</NuqsTestingAdapter>
201-
);
202-
}
203-
204176
function makeAllTheProviders(options: ProviderOptions) {
205177
const enableRouterMocks = options.deprecatedRouterMocks ?? false;
206178
const {organization, router} = initializeOrg({
@@ -245,11 +217,11 @@ function makeAllTheProviders(options: ProviderOptions) {
245217
return (
246218
<CacheProvider value={{...cache, compat: true}}>
247219
<QueryClientProvider client={makeTestQueryClient()}>
248-
<NuqsTestingAdapterWithNavigate query={options.query ?? ''}>
220+
<SentryNuqsTestingAdapter defaultOptions={{shallow: false}}>
249221
<CommandPaletteProvider>
250222
<ThemeProvider theme={ThemeFixture()}>{wrappedContent}</ThemeProvider>
251223
</CommandPaletteProvider>
252-
</NuqsTestingAdapterWithNavigate>
224+
</SentryNuqsTestingAdapter>
253225
</QueryClientProvider>
254226
</CacheProvider>
255227
);
@@ -441,7 +413,6 @@ function render<T extends boolean = false>(
441413
router: legacyRouterConfig,
442414
deprecatedRouterMocks: options.deprecatedRouterMocks,
443415
history,
444-
query: parseQueryString(config?.location?.query),
445416
});
446417

447418
const memoryRouter = makeRouter({
@@ -499,7 +470,6 @@ function renderHookWithProviders<Result = unknown, Props = unknown>(
499470
router: legacyRouterConfig,
500471
deprecatedRouterMocks: false,
501472
history,
502-
query: parseQueryString(config?.location?.query),
503473
});
504474

505475
let memoryRouter: Router | null = null;

0 commit comments

Comments
 (0)