mirror of
https://github.com/documenso/documenso.git
synced 2026-07-24 17:04:12 +10:00
perf: reduce rerenders and async waterfalls in document tables
Parallelize user + team lookups in findDocuments via Promise.all, dedupe redundant getTeamById call in the internal TRPC route, stabilize useUpdateSearchParams callback with useCallback + refs, memoize parsed search params in toolbar components, fix stale columns memo deps in template documents table, and use replace:true for search/filter URL updates to avoid history spam.
This commit is contained in:
@@ -56,8 +56,11 @@ export const TemplatePageViewDocumentsTable = ({
|
||||
|
||||
const team = useCurrentTeam();
|
||||
|
||||
const parsedSearchParams = ZDocumentSearchParamsSchema.parse(
|
||||
Object.fromEntries(searchParams ?? []),
|
||||
const searchParamsString = searchParams.toString();
|
||||
|
||||
const parsedSearchParams = useMemo(
|
||||
() => ZDocumentSearchParamsSchema.parse(Object.fromEntries(searchParams)),
|
||||
[searchParamsString],
|
||||
);
|
||||
|
||||
const { data, isLoading, isLoadingError } = trpc.document.findDocumentsInternal.useQuery(
|
||||
@@ -174,7 +177,7 @@ export const TemplatePageViewDocumentsTable = ({
|
||||
),
|
||||
},
|
||||
] satisfies DataTableColumnDef<(typeof results)['data'][number]>[];
|
||||
}, []);
|
||||
}, [_, i18n, team?.url]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -36,9 +36,11 @@ export const DocumentsTableToolbar = ({
|
||||
|
||||
const query = searchParams.get('query') ?? '';
|
||||
const period = searchParams.get('period') ?? '';
|
||||
const statusParam = searchParams.get('status');
|
||||
const senderIdsParam = searchParams.get('senderIds');
|
||||
|
||||
const selectedStatusValues = parseToStringArray(searchParams.get('status'));
|
||||
const selectedSenderValues = parseToStringArray(searchParams.get('senderIds'));
|
||||
const selectedStatusValues = useMemo(() => parseToStringArray(statusParam), [statusParam]);
|
||||
const selectedSenderValues = useMemo(() => parseToStringArray(senderIdsParam), [senderIdsParam]);
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState(query);
|
||||
const debouncedSearchTerm = useDebouncedValue(searchTerm, 500);
|
||||
@@ -56,8 +58,11 @@ export const DocumentsTableToolbar = ({
|
||||
return;
|
||||
}
|
||||
|
||||
updateSearchParams({ query: debouncedSearchTerm || undefined, page: undefined });
|
||||
}, [debouncedSearchTerm, query, searchTerm]);
|
||||
updateSearchParams(
|
||||
{ query: debouncedSearchTerm || undefined, page: undefined },
|
||||
{ replace: true },
|
||||
);
|
||||
}, [debouncedSearchTerm, query, searchTerm, updateSearchParams]);
|
||||
|
||||
const { data: members } = trpc.team.member.getMany.useQuery(
|
||||
{
|
||||
@@ -118,7 +123,7 @@ export const DocumentsTableToolbar = ({
|
||||
className="absolute inset-y-0 end-0 flex w-9 items-center justify-center text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
setSearchTerm('');
|
||||
updateSearchParams({ query: undefined, page: undefined });
|
||||
updateSearchParams({ query: undefined, page: undefined }, { replace: true });
|
||||
}}
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
@@ -133,10 +138,13 @@ export const DocumentsTableToolbar = ({
|
||||
counts={statusCounts}
|
||||
showSearch={false}
|
||||
onSelectedValuesChange={(values) => {
|
||||
updateSearchParams({
|
||||
status: toCommaSeparatedSearchParam(values),
|
||||
page: undefined,
|
||||
});
|
||||
updateSearchParams(
|
||||
{
|
||||
status: toCommaSeparatedSearchParam(values),
|
||||
page: undefined,
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -147,10 +155,13 @@ export const DocumentsTableToolbar = ({
|
||||
selectedValues={selectedSenderValues}
|
||||
showSearch
|
||||
onSelectedValuesChange={(values) => {
|
||||
updateSearchParams({
|
||||
senderIds: toCommaSeparatedSearchParam(values),
|
||||
page: undefined,
|
||||
});
|
||||
updateSearchParams(
|
||||
{
|
||||
senderIds: toCommaSeparatedSearchParam(values),
|
||||
page: undefined,
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -164,10 +175,13 @@ export const DocumentsTableToolbar = ({
|
||||
onSelectedValuesChange={(values) => {
|
||||
const nextPeriod = values[0];
|
||||
|
||||
updateSearchParams({
|
||||
period: nextPeriod ?? undefined,
|
||||
page: undefined,
|
||||
});
|
||||
updateSearchParams(
|
||||
{
|
||||
period: nextPeriod ?? undefined,
|
||||
page: undefined,
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -25,9 +25,11 @@ export const TemplateDocumentsTableToolbar = () => {
|
||||
|
||||
const query = searchParams.get('query') ?? '';
|
||||
const period = searchParams.get('period') ?? '';
|
||||
const statusParam = searchParams.get('status');
|
||||
const sourceParam = searchParams.get('source');
|
||||
|
||||
const selectedStatusValues = parseToStringArray(searchParams.get('status'));
|
||||
const selectedSourceValues = parseToStringArray(searchParams.get('source'));
|
||||
const selectedStatusValues = useMemo(() => parseToStringArray(statusParam), [statusParam]);
|
||||
const selectedSourceValues = useMemo(() => parseToStringArray(sourceParam), [sourceParam]);
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState(query);
|
||||
const debouncedSearchTerm = useDebouncedValue(searchTerm, 500);
|
||||
@@ -45,8 +47,11 @@ export const TemplateDocumentsTableToolbar = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
updateSearchParams({ query: debouncedSearchTerm || undefined, page: undefined });
|
||||
}, [debouncedSearchTerm, query, searchTerm]);
|
||||
updateSearchParams(
|
||||
{ query: debouncedSearchTerm || undefined, page: undefined },
|
||||
{ replace: true },
|
||||
);
|
||||
}, [debouncedSearchTerm, query, searchTerm, updateSearchParams]);
|
||||
|
||||
const statusOptions = useMemo<DataTableFacetedFilterOption[]>(
|
||||
() => [
|
||||
@@ -131,7 +136,7 @@ export const TemplateDocumentsTableToolbar = () => {
|
||||
className="absolute inset-y-0 end-0 flex w-9 items-center justify-center text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
setSearchTerm('');
|
||||
updateSearchParams({ query: undefined, page: undefined });
|
||||
updateSearchParams({ query: undefined, page: undefined }, { replace: true });
|
||||
}}
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
@@ -145,10 +150,13 @@ export const TemplateDocumentsTableToolbar = () => {
|
||||
selectedValues={selectedStatusValues}
|
||||
showSearch={false}
|
||||
onSelectedValuesChange={(values) => {
|
||||
updateSearchParams({
|
||||
status: toCommaSeparatedSearchParam(values),
|
||||
page: undefined,
|
||||
});
|
||||
updateSearchParams(
|
||||
{
|
||||
status: toCommaSeparatedSearchParam(values),
|
||||
page: undefined,
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -158,10 +166,13 @@ export const TemplateDocumentsTableToolbar = () => {
|
||||
selectedValues={selectedSourceValues}
|
||||
showSearch={false}
|
||||
onSelectedValuesChange={(values) => {
|
||||
updateSearchParams({
|
||||
source: toCommaSeparatedSearchParam(values),
|
||||
page: undefined,
|
||||
});
|
||||
updateSearchParams(
|
||||
{
|
||||
source: toCommaSeparatedSearchParam(values),
|
||||
page: undefined,
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -174,10 +185,13 @@ export const TemplateDocumentsTableToolbar = () => {
|
||||
onSelectedValuesChange={(values) => {
|
||||
const nextPeriod = values[0];
|
||||
|
||||
updateSearchParams({
|
||||
period: nextPeriod ?? undefined,
|
||||
page: undefined,
|
||||
});
|
||||
updateSearchParams(
|
||||
{
|
||||
period: nextPeriod ?? undefined,
|
||||
page: undefined,
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -22,7 +22,9 @@ export const TemplatesTableToolbar = () => {
|
||||
const updateSearchParams = useUpdateSearchParams();
|
||||
|
||||
const query = searchParams.get('query') ?? '';
|
||||
const selectedTypeValues = parseToStringArray(searchParams.get('type'));
|
||||
const typeParam = searchParams.get('type');
|
||||
|
||||
const selectedTypeValues = useMemo(() => parseToStringArray(typeParam), [typeParam]);
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState(query);
|
||||
const debouncedSearchTerm = useDebouncedValue(searchTerm, 500);
|
||||
@@ -40,8 +42,11 @@ export const TemplatesTableToolbar = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
updateSearchParams({ query: debouncedSearchTerm || undefined, page: undefined });
|
||||
}, [debouncedSearchTerm, query, searchTerm]);
|
||||
updateSearchParams(
|
||||
{ query: debouncedSearchTerm || undefined, page: undefined },
|
||||
{ replace: true },
|
||||
);
|
||||
}, [debouncedSearchTerm, query, searchTerm, updateSearchParams]);
|
||||
|
||||
const typeOptions = useMemo<DataTableFacetedFilterOption[]>(
|
||||
() => [
|
||||
@@ -91,7 +96,7 @@ export const TemplatesTableToolbar = () => {
|
||||
className="absolute inset-y-0 end-0 flex w-9 items-center justify-center text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
setSearchTerm('');
|
||||
updateSearchParams({ query: undefined, page: undefined });
|
||||
updateSearchParams({ query: undefined, page: undefined }, { replace: true });
|
||||
}}
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
@@ -105,10 +110,13 @@ export const TemplatesTableToolbar = () => {
|
||||
selectedValues={selectedTypeValues}
|
||||
showSearch={false}
|
||||
onSelectedValuesChange={(values) => {
|
||||
updateSearchParams({
|
||||
type: toCommaSeparatedSearchParam(values),
|
||||
page: undefined,
|
||||
});
|
||||
updateSearchParams(
|
||||
{
|
||||
type: toCommaSeparatedSearchParam(values),
|
||||
page: undefined,
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
|
||||
import type { NavigateOptions } from 'react-router';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
@@ -7,20 +9,29 @@ type UpdateSearchParamsOptions = Pick<NavigateOptions, 'preventScrollReset' | 'r
|
||||
export const useUpdateSearchParams = (defaultOptions: UpdateSearchParamsOptions = {}) => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
return (params: SearchParamValues, options?: UpdateSearchParamsOptions) => {
|
||||
const nextSearchParams = new URLSearchParams(searchParams?.toString() ?? '');
|
||||
const searchParamsRef = useRef(searchParams);
|
||||
searchParamsRef.current = searchParams;
|
||||
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null) {
|
||||
nextSearchParams.delete(key);
|
||||
} else {
|
||||
nextSearchParams.set(key, String(value));
|
||||
}
|
||||
});
|
||||
const defaultOptionsRef = useRef(defaultOptions);
|
||||
defaultOptionsRef.current = defaultOptions;
|
||||
|
||||
setSearchParams(nextSearchParams, {
|
||||
...defaultOptions,
|
||||
...options,
|
||||
});
|
||||
};
|
||||
return useCallback(
|
||||
(params: SearchParamValues, options?: UpdateSearchParamsOptions) => {
|
||||
const nextSearchParams = new URLSearchParams(searchParamsRef.current?.toString() ?? '');
|
||||
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null) {
|
||||
nextSearchParams.delete(key);
|
||||
} else {
|
||||
nextSearchParams.set(key, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
setSearchParams(nextSearchParams, {
|
||||
...defaultOptionsRef.current,
|
||||
...options,
|
||||
});
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ export type PeriodSelectorValue = '' | 'all' | '7d' | '14d' | '30d';
|
||||
export type FindDocumentsOptions = {
|
||||
userId: number;
|
||||
teamId?: number;
|
||||
team?: Awaited<ReturnType<typeof getTeamById>>;
|
||||
templateId?: number;
|
||||
source?: DocumentSource | DocumentSource[];
|
||||
status?: ExtendedDocumentStatus | ExtendedDocumentStatus[];
|
||||
@@ -34,6 +35,7 @@ export type FindDocumentsOptions = {
|
||||
export const findDocuments = async ({
|
||||
userId,
|
||||
teamId,
|
||||
team: preloadedTeam,
|
||||
templateId,
|
||||
source,
|
||||
status,
|
||||
@@ -45,25 +47,19 @@ export const findDocuments = async ({
|
||||
query = '',
|
||||
folderId,
|
||||
}: FindDocumentsOptions) => {
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
|
||||
let team = null;
|
||||
|
||||
if (teamId !== undefined) {
|
||||
team = await getTeamById({
|
||||
userId,
|
||||
teamId,
|
||||
});
|
||||
}
|
||||
const [user, team] = await Promise.all([
|
||||
prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
}),
|
||||
preloadedTeam ?? (teamId !== undefined ? getTeamById({ userId, teamId }) : null),
|
||||
]);
|
||||
|
||||
const orderByColumn = orderBy?.column ?? 'createdAt';
|
||||
const orderByDirection = orderBy?.direction ?? 'desc';
|
||||
|
||||
@@ -37,8 +37,10 @@ export const findDocumentsInternalRoute = authenticatedProcedure
|
||||
folderId,
|
||||
};
|
||||
|
||||
let team: Awaited<ReturnType<typeof getTeamById>> | undefined;
|
||||
|
||||
if (teamId) {
|
||||
const team = await getTeamById({ userId: user.id, teamId });
|
||||
team = await getTeamById({ userId: user.id, teamId });
|
||||
|
||||
getStatOptions.team = {
|
||||
teamId: team.id,
|
||||
@@ -55,6 +57,7 @@ export const findDocumentsInternalRoute = authenticatedProcedure
|
||||
findDocuments({
|
||||
userId: user.id,
|
||||
teamId,
|
||||
team,
|
||||
query,
|
||||
templateId,
|
||||
page,
|
||||
|
||||
Reference in New Issue
Block a user