Use the history API directly in useEntityListProvider

Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
Fredrik Adelöw
2021-08-25 11:57:14 +02:00
parent 561d74a4d1
commit d39e7d1411
4 changed files with 70 additions and 44 deletions
+12
View File
@@ -0,0 +1,12 @@
---
'@backstage/plugin-catalog-react': patch
---
Use the history API directly in `useEntityListProvider`.
This replaces `useSearchParams`/`useNavigate`, since they cause at least one additional re-render compared to using this method.
Table re-render count is down additionally:
- Initial render of catalog page: 12 -> 9
- Full `CatalogPage` test: 57 -> 48
@@ -16,7 +16,6 @@
import React, { PropsWithChildren } from 'react';
import qs from 'qs';
import { MemoryRouter as Router } from 'react-router-dom';
import { act, renderHook } from '@testing-library/react-hooks';
import { MockStorageApi } from '@backstage/test-utils';
import { CatalogApi } from '@backstage/catalog-client';
@@ -86,26 +85,30 @@ const apis = ApiRegistry.from([
const wrapper = ({
userFilter,
queryParams,
children,
}: PropsWithChildren<{
userFilter?: UserListFilterKind;
queryParams?: string;
}>) => {
return (
<Router initialEntries={[`/?${queryParams ?? ''}`]}>
<ApiProvider apis={apis}>
<EntityListProvider>
<EntityKindPicker initialFilter="component" hidden />
<UserListPicker initialFilter={userFilter} />
{children}
</EntityListProvider>
</ApiProvider>
</Router>
<ApiProvider apis={apis}>
<EntityListProvider>
<EntityKindPicker initialFilter="component" hidden />
<UserListPicker initialFilter={userFilter} />
{children}
</EntityListProvider>
</ApiProvider>
);
};
describe('<EntityListProvider />', () => {
const origReplaceState = window.history.replaceState;
beforeEach(() => {
window.history.replaceState = jest.fn();
});
afterEach(() => {
window.history.replaceState = origReplaceState;
});
beforeEach(() => {
jest.clearAllMocks();
});
@@ -137,16 +140,13 @@ describe('<EntityListProvider />', () => {
});
it('resolves query param filter values', async () => {
const query = qs.stringify({
filters: { kind: 'component', type: 'service' },
});
delete (window as any).location;
(window as any).location = new URL(`http://localhost/catalog?${query}`);
const { result, waitFor } = renderHook(() => useEntityListProvider(), {
wrapper,
initialProps: {
queryParams: qs.stringify({
filters: {
kind: 'component',
type: 'service',
},
}),
},
});
await waitFor(() => !!result.current.queryParameters);
expect(result.current.queryParameters).toEqual({
@@ -25,7 +25,6 @@ import React, {
useMemo,
useState,
} from 'react';
import { useSearchParams } from 'react-router-dom';
import { useAsyncFn, useDebounce, useMountedState } from 'react-use';
import { catalogApiRef } from '../api';
import {
@@ -105,18 +104,25 @@ export const EntityListProvider = <EntityFilters extends DefaultEntityFilters>({
}: PropsWithChildren<{}>) => {
const isMounted = useMountedState();
const catalogApi = useApi(catalogApiRef);
const [searchParams, setSearchParams] = useSearchParams();
const allQueryParams = qs.parse(searchParams.toString());
const [requestedFilters, setRequestedFilters] = useState<EntityFilters>(
{} as EntityFilters,
);
const [outputState, setOutputState] = useState<OutputState<EntityFilters>>({
appliedFilters: {} as EntityFilters,
entities: [],
backendEntities: [],
queryParameters:
(allQueryParams.filters as Record<string, string | string[]>) ?? {},
});
const [outputState, setOutputState] = useState<OutputState<EntityFilters>>(
() => {
const query = qs.parse(window.location.search, {
ignoreQueryPrefix: true,
});
return {
appliedFilters: {} as EntityFilters,
entities: [],
backendEntities: [],
queryParameters: (query.filters ?? {}) as Record<
string,
string | string[]
>,
};
},
);
// The main async filter worker. Note that while it has a lot of dependencies
// in terms of its implementation, the triggering only happens (debounced)
@@ -167,12 +173,20 @@ export const EntityListProvider = <EntityFilters extends DefaultEntityFilters>({
}
if (isMounted()) {
setSearchParams(
qs.stringify({ ...allQueryParams, filters: queryParams }),
{
replace: true,
},
const oldParams = qs.parse(window.location.search, {
ignoreQueryPrefix: true,
});
const newParams = qs.stringify(
{ ...oldParams, filters: queryParams },
{ addQueryPrefix: true },
);
const newUrl = `${window.location.pathname}${newParams}`;
// We use direct history manipulation since useSearchParams and
// useNavigate in react-router-dom cause unnecessary extra rerenders.
// Also make sure to replace the state rather than pushing, since we
// don't want there to be back/forward slots for every single filter
// change.
window.history?.replaceState(null, document.title, newUrl);
}
},
[catalogApi, requestedFilters, outputState],
@@ -208,15 +222,7 @@ export const EntityListProvider = <EntityFilters extends DefaultEntityFilters>({
loading,
error,
}),
[
outputState.appliedFilters,
outputState.entities,
outputState.backendEntities,
updateFilters,
outputState.queryParameters,
loading,
error,
],
[outputState, updateFilters, loading, error],
);
return (
@@ -44,6 +44,14 @@ import {
} from '@backstage/core-plugin-api';
describe('CatalogPage', () => {
const origReplaceState = window.history.replaceState;
beforeEach(() => {
window.history.replaceState = jest.fn();
});
afterEach(() => {
window.history.replaceState = origReplaceState;
});
const catalogApi: Partial<CatalogApi> = {
getEntities: () =>
Promise.resolve({