Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
104 changes: 104 additions & 0 deletions tests/js/sentry-test/nuqsTestingAdapter.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import {parseAsString, useQueryState} from 'nuqs';

import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';

describe('SentryNuqsTestingAdapter', () => {
it('reads search params from router location', async () => {
function TestComponent() {
const [search] = useQueryState('query', parseAsString);
return <div>Search: {search ?? 'empty'}</div>;
}

const {router} = render(<TestComponent />, {
initialRouterConfig: {
location: {
pathname: '/test',
query: {query: 'hello'},
},
},
});

expect(screen.getByText('Search: hello')).toBeInTheDocument();

// Navigate to a new location with different search params
router.navigate('/test?query=world');

expect(await screen.findByText('Search: world')).toBeInTheDocument();
});

it('updates router location when nuqs state changes', async () => {
function TestComponent() {
const [search, setSearch] = useQueryState('query', parseAsString);
return (
<div>
<div>Search: {search ?? 'empty'}</div>
<button onClick={() => setSearch('updated')}>Update</button>
</div>
);
}

const {router} = render(<TestComponent />, {
initialRouterConfig: {
location: {
pathname: '/test',
query: {query: 'initial'},
},
},
});

expect(screen.getByText('Search: initial')).toBeInTheDocument();

// Click button to update search param via nuqs
await userEvent.click(screen.getByRole('button', {name: 'Update'}));

// Wait for navigation to complete
await screen.findByText('Search: updated');

// Verify the router location was updated
await waitFor(() => {
expect(router.location.search).toContain('query=updated');
});
});

it('handles multiple query params', () => {
function TestComponent() {
const [foo] = useQueryState('foo', parseAsString);
const [bar] = useQueryState('bar', parseAsString);
return (
<div>
<div>Foo: {foo ?? 'empty'}</div>
<div>Bar: {bar ?? 'empty'}</div>
</div>
);
}

render(<TestComponent />, {
initialRouterConfig: {
location: {
pathname: '/test',
query: {foo: 'value1', bar: 'value2'},
},
},
});

expect(screen.getByText('Foo: value1')).toBeInTheDocument();
expect(screen.getByText('Bar: value2')).toBeInTheDocument();
});

it('handles missing query params', () => {
function TestComponent() {
const [search] = useQueryState('query', parseAsString);
return <div>Search: {search ?? 'empty'}</div>;
}

render(<TestComponent />, {
initialRouterConfig: {
location: {
pathname: '/test',
},
},
});

expect(screen.getByText('Search: empty')).toBeInTheDocument();
});
});
97 changes: 97 additions & 0 deletions tests/js/sentry-test/nuqsTestingAdapter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import {useCallback, useMemo, type ReactElement, type ReactNode} from 'react';
import {
unstable_createAdapterProvider as createAdapterProvider,
renderQueryString,
} from 'nuqs/adapters/custom';
import type {unstable_AdapterInterface as AdapterInterface} from 'nuqs/adapters/custom';
import type {OnUrlUpdateFunction} from 'nuqs/adapters/testing';

import {useLocation} from 'sentry/utils/useLocation';
import {useNavigate} from 'sentry/utils/useNavigate';

type SentryNuqsTestingAdapterProps = {
children: ReactNode;
/**
* Default options to pass to nuqs
*/
defaultOptions?: {
clearOnDefault?: boolean;
scroll?: boolean;
shallow?: boolean;
};
/**
* A function that will be called whenever the URL is updated.
* Connect that to a spy in your tests to assert the URL updates.
*/
onUrlUpdate?: OnUrlUpdateFunction;
};

/**
* Custom nuqs adapter component for Sentry that reads location from our
* useLocation hook instead of maintaining its own internal state.
*
* This ensures nuqs uses the same location source as the rest of the
* application during tests.
*/
export function SentryNuqsTestingAdapter({
children,
defaultOptions,
onUrlUpdate,
}: SentryNuqsTestingAdapterProps): ReactElement {
// Create a hook that nuqs will call to get the adapter interface
// This hook needs to be defined inside a component that has access to location/navigate
const useSentryAdapter = useCallback(
(_watchKeys: string[]): AdapterInterface => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const location = useLocation();
// eslint-disable-next-line react-hooks/rules-of-hooks
const navigate = useNavigate();
Comment on lines +45 to +48
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think this area was most surprising to me after looking at the result of what was vibe coded

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah I tried moving it out but it didn’t work :/

I think it’s good enough like this, I’d appreciate a ✅ so we can merge it, as we already have PRs blocked by this 🙏


// Get search params from the current location
const searchParams = new URLSearchParams(location.search || '');

const updateUrl: AdapterInterface['updateUrl'] = (search, options) => {
const newSearchParams = new URLSearchParams(search);
const queryString = renderQueryString(newSearchParams);

// Call the onUrlUpdate callback if provided
onUrlUpdate?.({
searchParams: new URLSearchParams(search), // make a copy
queryString,
options,
});

// Navigate to the new location using Sentry's navigate
// We need to construct the full path with the search string
const newPath = queryString
? `${location.pathname}${queryString}`
: location.pathname;

// The navigate function from TestRouter already wraps this in act()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is surprisingly true

navigate(newPath, {replace: options.history === 'replace'});
};

const getSearchParamsSnapshot = () => {
// Always read from the current location
return new URLSearchParams(location.search || '');
};

return {
searchParams,
updateUrl,
getSearchParamsSnapshot,
rateLimitFactor: 0, // No throttling in tests
autoResetQueueOnUpdate: true, // Reset update queue after each update
};
},
[onUrlUpdate]
);

// Create the adapter provider (memoized to prevent remounting)
const AdapterProvider = useMemo(
() => createAdapterProvider(useSentryAdapter),
[useSentryAdapter]
);

return <AdapterProvider defaultOptions={defaultOptions}>{children}</AdapterProvider>;
}
36 changes: 3 additions & 33 deletions tests/js/sentry-test/reactTestingLibrary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import {
import * as rtl from '@testing-library/react'; // eslint-disable-line no-restricted-imports
import userEvent from '@testing-library/user-event'; // eslint-disable-line no-restricted-imports

import {NuqsTestingAdapter} from 'nuqs/adapters/testing';
import * as qs from 'query-string';
import {LocationFixture} from 'sentry-fixture/locationFixture';
import {ThemeFixture} from 'sentry-fixture/theme';
Expand All @@ -37,14 +36,13 @@ import {
} from 'sentry/utils/browserHistory';
import {ProvideAriaRouter} from 'sentry/utils/provideAriaRouter';
import {QueryClientProvider} from 'sentry/utils/queryClient';
import {useLocation} from 'sentry/utils/useLocation';
import {useNavigate} from 'sentry/utils/useNavigate';
import {OrganizationContext} from 'sentry/views/organizationContext';
import {TestRouteContext} from 'sentry/views/routeContext';

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

import {initializeOrg} from './initializeOrg';
import {SentryNuqsTestingAdapter} from './nuqsTestingAdapter';

interface ProviderOptions {
/**
Expand All @@ -65,7 +63,6 @@ interface ProviderOptions {
* Sets the OrganizationContext. You may pass null to provide no organization
*/
organization?: Partial<Organization> | null;
query?: string;
/**
* Sets the RouterContext.
*/
Expand Down Expand Up @@ -176,31 +173,6 @@ function patchBrowserHistoryMocksEnabled(history: MemoryHistory, router: Injecte
});
}

function NuqsTestingAdapterWithNavigate({
children,
query,
}: {
children: React.ReactNode;
query: string;
}) {
const location = useLocation();
const navigate = useNavigate();
return (
<NuqsTestingAdapter
searchParams={new URLSearchParams(query)}
defaultOptions={{shallow: false}}
onUrlUpdate={({queryString, options: nuqsOptions}) => {
// Pass navigation events to the test router
const newParams = qs.parse(queryString);
const newLocation = {...location, query: newParams};
navigate(newLocation, {replace: nuqsOptions.history === 'replace'});
}}
>
{children}
</NuqsTestingAdapter>
);
}

function makeAllTheProviders(options: ProviderOptions) {
const enableRouterMocks = options.deprecatedRouterMocks ?? false;
const {organization, router} = initializeOrg({
Expand Down Expand Up @@ -245,11 +217,11 @@ function makeAllTheProviders(options: ProviderOptions) {
return (
<CacheProvider value={{...cache, compat: true}}>
<QueryClientProvider client={makeTestQueryClient()}>
<NuqsTestingAdapterWithNavigate query={options.query ?? ''}>
<SentryNuqsTestingAdapter defaultOptions={{shallow: false}}>
<CommandPaletteProvider>
<ThemeProvider theme={ThemeFixture()}>{wrappedContent}</ThemeProvider>
</CommandPaletteProvider>
</NuqsTestingAdapterWithNavigate>
</SentryNuqsTestingAdapter>
</QueryClientProvider>
</CacheProvider>
);
Expand Down Expand Up @@ -441,7 +413,6 @@ function render<T extends boolean = false>(
router: legacyRouterConfig,
deprecatedRouterMocks: options.deprecatedRouterMocks,
history,
query: parseQueryString(config?.location?.query),
});

const memoryRouter = makeRouter({
Expand Down Expand Up @@ -499,7 +470,6 @@ function renderHookWithProviders<Result = unknown, Props = unknown>(
router: legacyRouterConfig,
deprecatedRouterMocks: false,
history,
query: parseQueryString(config?.location?.query),
});

let memoryRouter: Router | null = null;
Expand Down
Loading