fix: use nuqs prevent infinite re-render loop (#2868)

This commit is contained in:
Durgesh Shekhawat
2026-09-11 20:29:24 +10:00
committed by GitHub
parent 6a99b40cba
commit 20ae849a1a
10 changed files with 79 additions and 271 deletions
@@ -1,9 +1,7 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { LicenseClient } from '@documenso/lib/server-only/license/license-client';
import { Input } from '@documenso/ui/primitives/input';
import { useLingui } from '@lingui/react/macro';
import { useEffect, useState } from 'react';
import { useLocation, useSearchParams } from 'react-router';
import { debounce, parseAsString, useQueryState } from 'nuqs';
import { ClaimCreateDialog } from '~/components/dialogs/claim-create-dialog';
import { SettingsHeader } from '~/components/general/settings-header';
@@ -24,32 +22,10 @@ export default function Claims({ loaderData }: Route.ComponentProps) {
const { t } = useLingui();
const [searchParams, setSearchParams] = useSearchParams();
const { pathname } = useLocation();
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
/**
* Handle debouncing the search query.
*/
useEffect(() => {
const params = new URLSearchParams(searchParams?.toString());
params.set('query', debouncedSearchQuery);
if (debouncedSearchQuery === '') {
params.delete('query');
}
// If nothing to change then do nothing.
if (params.toString() === searchParams?.toString()) {
return;
}
setSearchParams(params);
}, [debouncedSearchQuery, pathname, searchParams]);
const [searchQuery, setSearchQuery] = useQueryState(
'query',
parseAsString.withDefault('').withOptions({ shallow: false, limitUrlUpdates: debounce(500) }),
);
return (
<div>
@@ -59,8 +35,8 @@ export default function Claims({ loaderData }: Route.ComponentProps) {
<div className="mt-4">
<Input
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
value={searchQuery}
onChange={(e) => void setSearchQuery(e.target.value || null)}
placeholder={t`Search by claim ID or name`}
className="mb-4"
/>
@@ -1,8 +1,6 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { Input } from '@documenso/ui/primitives/input';
import { useLingui } from '@lingui/react/macro';
import { useEffect, useState } from 'react';
import { useLocation, useSearchParams } from 'react-router';
import { debounce, parseAsString, useQueryState } from 'nuqs';
import { EmailTransportCreateDialog } from '~/components/dialogs/email-transport-create-dialog';
import { SettingsHeader } from '~/components/general/settings-header';
@@ -11,32 +9,10 @@ import { AdminEmailTransportsTable } from '~/components/tables/admin-email-trans
export default function AdminEmailTransportsPage() {
const { t } = useLingui();
const [searchParams, setSearchParams] = useSearchParams();
const { pathname } = useLocation();
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
/**
* Handle debouncing the search query.
*/
useEffect(() => {
const params = new URLSearchParams(searchParams?.toString());
params.set('query', debouncedSearchQuery);
if (debouncedSearchQuery === '') {
params.delete('query');
}
// If nothing to change then do nothing.
if (params.toString() === searchParams?.toString()) {
return;
}
setSearchParams(params);
}, [debouncedSearchQuery, pathname, searchParams]);
const [searchQuery, setSearchQuery] = useQueryState(
'query',
parseAsString.withDefault('').withOptions({ shallow: false, limitUrlUpdates: debounce(500) }),
);
return (
<div>
@@ -46,8 +22,8 @@ export default function AdminEmailTransportsPage() {
<div className="mt-4">
<Input
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
value={searchQuery}
onChange={(e) => void setSearchQuery(e.target.value || null)}
placeholder={t`Search by name or from address`}
className="mb-4"
/>
@@ -1,4 +1,3 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { currentMonthlyPeriod } from '@documenso/lib/universal/monthly-period';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription } from '@documenso/ui/primitives/alert';
@@ -6,8 +5,9 @@ import { Input } from '@documenso/ui/primitives/input';
import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@documenso/ui/primitives/select';
import { Trans, useLingui } from '@lingui/react/macro';
import { useEffect, useMemo, useState } from 'react';
import { useLocation, useSearchParams } from 'react-router';
import { debounce, parseAsInteger, parseAsString, useQueryStates } from 'nuqs';
import { useMemo, useState } from 'react';
import { useSearchParams } from 'react-router';
import { SettingsHeader } from '~/components/general/settings-header';
import {
@@ -52,14 +52,17 @@ export default function OrganisationStats() {
const { t } = useLingui();
const [searchParams, setSearchParams] = useSearchParams();
const { pathname } = useLocation();
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
const [{ query: searchQuery }, setSearchFilters] = useQueryStates(
{
query: parseAsString.withDefault(''),
page: parseAsInteger,
},
{ shallow: false, limitUrlUpdates: debounce(500) },
);
const [displayMode, setDisplayMode] = useState<OrganisationStatsDisplayMode>('usage');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
const periodOptions = useMemo(() => generatePeriodOptions(), []);
const selectedPeriod = searchParams?.get('period') ?? currentMonthlyPeriod();
@@ -71,29 +74,12 @@ export default function OrganisationStats() {
const claimOptions = claimsData?.data ?? [];
/**
* Handle debouncing the search query.
*/
useEffect(() => {
const params = new URLSearchParams(searchParams?.toString());
params.set('query', debouncedSearchQuery);
if (debouncedSearchQuery === '') {
params.delete('query');
}
if ((searchParams?.get('query') || '') !== debouncedSearchQuery) {
params.delete('page');
}
// If nothing to change then do nothing.
if (params.toString() === searchParams?.toString()) {
return;
}
setSearchParams(params);
}, [debouncedSearchQuery, pathname, searchParams]);
const onSearchQueryChange = (value: string) => {
void setSearchFilters({
query: value || null,
page: null,
});
};
const onPeriodChange = (value: string) => {
const params = new URLSearchParams(searchParams?.toString());
@@ -128,8 +114,8 @@ export default function OrganisationStats() {
<div className="mt-4 flex flex-col gap-4 sm:flex-row">
<Input
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
value={searchQuery}
onChange={(e) => onSearchQueryChange(e.target.value)}
placeholder={t`Search by organisation name, URL or ID`}
className="flex-1"
/>
@@ -1,8 +1,6 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { Input } from '@documenso/ui/primitives/input';
import { useLingui } from '@lingui/react/macro';
import { useEffect, useState } from 'react';
import { useLocation, useSearchParams } from 'react-router';
import { debounce, parseAsString, useQueryState } from 'nuqs';
import { SettingsHeader } from '~/components/general/settings-header';
import { AdminOrganisationsTable } from '~/components/tables/admin-organisations-table';
@@ -10,32 +8,10 @@ import { AdminOrganisationsTable } from '~/components/tables/admin-organisations
export default function Organisations() {
const { t } = useLingui();
const [searchParams, setSearchParams] = useSearchParams();
const { pathname } = useLocation();
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
/**
* Handle debouncing the search query.
*/
useEffect(() => {
const params = new URLSearchParams(searchParams?.toString());
params.set('query', debouncedSearchQuery);
if (debouncedSearchQuery === '') {
params.delete('query');
}
// If nothing to change then do nothing.
if (params.toString() === searchParams?.toString()) {
return;
}
setSearchParams(params);
}, [debouncedSearchQuery, pathname, searchParams]);
const [searchQuery, setSearchQuery] = useQueryState(
'query',
parseAsString.withDefault('').withOptions({ shallow: false, limitUrlUpdates: debounce(500) }),
);
return (
<div>
@@ -43,8 +19,8 @@ export default function Organisations() {
<div className="mt-4">
<Input
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
value={searchQuery}
onChange={(e) => void setSearchQuery(e.target.value || null)}
placeholder={t`Search by organisation ID, name, customer ID or owner email`}
className="mb-4"
/>
@@ -1,10 +1,9 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { Input } from '@documenso/ui/primitives/input';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Trans } from '@lingui/react/macro';
import { useEffect, useState } from 'react';
import { debounce, parseAsString, useQueryState } from 'nuqs';
import { Link, useLocation, useSearchParams } from 'react-router';
import { OrganisationMemberInviteDialog } from '~/components/dialogs/organisation-member-invite-dialog';
@@ -15,35 +14,16 @@ import { OrganisationMembersDataTable } from '~/components/tables/organisation-m
export default function TeamsSettingsMembersPage() {
const { _ } = useLingui();
const [searchParams, setSearchParams] = useSearchParams();
const [searchParams] = useSearchParams();
const { pathname } = useLocation();
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
const [searchQuery, setSearchQuery] = useQueryState(
'query',
parseAsString.withDefault('').withOptions({ shallow: false, limitUrlUpdates: debounce(500) }),
);
const currentTab = searchParams?.get('tab') === 'invites' ? 'invites' : 'members';
/**
* Handle debouncing the search query.
*/
useEffect(() => {
const params = new URLSearchParams(searchParams?.toString());
params.set('query', debouncedSearchQuery);
if (debouncedSearchQuery === '') {
params.delete('query');
}
// If nothing to change then do nothing.
if (params.toString() === searchParams?.toString()) {
return;
}
setSearchParams(params);
}, [debouncedSearchQuery, pathname, searchParams]);
return (
<div>
<SettingsHeader
@@ -57,8 +37,8 @@ export default function TeamsSettingsMembersPage() {
<div>
<div className="my-4 flex flex-row items-center justify-between space-x-4">
<Input
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
value={searchQuery}
onChange={(e) => void setSearchQuery(e.target.value || null)}
placeholder={_(msg`Search`)}
/>
@@ -1,8 +1,6 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { Input } from '@documenso/ui/primitives/input';
import { useLingui } from '@lingui/react/macro';
import { useEffect, useState } from 'react';
import { useLocation, useSearchParams } from 'react-router';
import { debounce, parseAsString, useQueryState } from 'nuqs';
import { TeamCreateDialog } from '~/components/dialogs/team-create-dialog';
import { SettingsHeader } from '~/components/general/settings-header';
@@ -11,27 +9,10 @@ import { OrganisationTeamsTable } from '~/components/tables/organisation-teams-t
export default function OrganisationSettingsTeamsPage() {
const { t } = useLingui();
const [searchParams, setSearchParams] = useSearchParams();
const { pathname } = useLocation();
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
/**
* Handle debouncing the search query.
*/
useEffect(() => {
const params = new URLSearchParams(searchParams?.toString());
params.set('query', debouncedSearchQuery);
if (debouncedSearchQuery === '') {
params.delete('query');
}
setSearchParams(params);
}, [debouncedSearchQuery, pathname, searchParams]);
const [searchQuery, setSearchQuery] = useQueryState(
'query',
parseAsString.withDefault('').withOptions({ shallow: false, limitUrlUpdates: debounce(500) }),
);
return (
<div>
@@ -40,8 +21,8 @@ export default function OrganisationSettingsTeamsPage() {
</SettingsHeader>
<Input
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
value={searchQuery}
onChange={(e) => void setSearchQuery(e.target.value || null)}
placeholder={t`Search`}
className="mb-4"
/>
@@ -1,11 +1,9 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { trpc } from '@documenso/trpc/react';
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
import { Input } from '@documenso/ui/primitives/input';
import { useLingui } from '@lingui/react/macro';
import { OrganisationGroupType, OrganisationMemberRole } from '@prisma/client';
import { useEffect, useState } from 'react';
import { useLocation, useSearchParams } from 'react-router';
import { debounce, parseAsString, useQueryState } from 'nuqs';
import { TeamGroupCreateDialog } from '~/components/dialogs/team-group-create-dialog';
import { SettingsHeader } from '~/components/general/settings-header';
@@ -16,34 +14,12 @@ import { useCurrentTeam } from '~/providers/team';
export default function TeamsSettingsGroupsPage() {
const { t } = useLingui();
const [searchParams, setSearchParams] = useSearchParams();
const { pathname } = useLocation();
const team = useCurrentTeam();
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
/**
* Handle debouncing the search query.
*/
useEffect(() => {
const params = new URLSearchParams(searchParams?.toString());
params.set('query', debouncedSearchQuery);
if (debouncedSearchQuery === '') {
params.delete('query');
}
// If nothing to change then do nothing.
if (params.toString() === searchParams?.toString()) {
return;
}
setSearchParams(params);
}, [debouncedSearchQuery, pathname, searchParams]);
const [searchQuery, setSearchQuery] = useQueryState(
'query',
parseAsString.withDefault('').withOptions({ shallow: false, limitUrlUpdates: debounce(500) }),
);
const everyoneGroupQuery = trpc.team.group.find.useQuery({
teamId: team.id,
@@ -61,8 +37,8 @@ export default function TeamsSettingsGroupsPage() {
</SettingsHeader>
<Input
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
value={searchQuery}
onChange={(e) => void setSearchQuery(e.target.value || null)}
placeholder={t`Search`}
className="mb-4"
/>
@@ -1,8 +1,6 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { Input } from '@documenso/ui/primitives/input';
import { useLingui } from '@lingui/react/macro';
import { useEffect, useState } from 'react';
import { useLocation, useSearchParams } from 'react-router';
import { debounce, parseAsString, useQueryState } from 'nuqs';
import { TeamMemberCreateDialog } from '~/components/dialogs/team-member-create-dialog';
import { SettingsHeader } from '~/components/general/settings-header';
@@ -11,32 +9,10 @@ import { TeamMembersTable } from '~/components/tables/team-members-table';
export default function TeamsSettingsMembersPage() {
const { t } = useLingui();
const [searchParams, setSearchParams] = useSearchParams();
const { pathname } = useLocation();
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
/**
* Handle debouncing the search query.
*/
useEffect(() => {
const params = new URLSearchParams(searchParams?.toString());
params.set('query', debouncedSearchQuery);
if (debouncedSearchQuery === '') {
params.delete('query');
}
// If nothing to change then do nothing.
if (params.toString() === searchParams?.toString()) {
return;
}
setSearchParams(params);
}, [debouncedSearchQuery, pathname, searchParams]);
const [searchQuery, setSearchQuery] = useQueryState(
'query',
parseAsString.withDefault('').withOptions({ shallow: false, limitUrlUpdates: debounce(500) }),
);
return (
<div>
@@ -45,8 +21,8 @@ export default function TeamsSettingsMembersPage() {
</SettingsHeader>
<Input
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
value={searchQuery}
onChange={(e) => void setSearchQuery(e.target.value || null)}
placeholder={t`Search`}
className="mb-4"
/>
@@ -1,4 +1,3 @@
import { useDebouncedValue } from '@documenso/lib/client-only/hooks/use-debounced-value';
import { useIsMounted } from '@documenso/lib/client-only/hooks/use-is-mounted';
import { useUpdateSearchParams } from '@documenso/lib/client-only/hooks/use-update-search-params';
import { ZUrlSearchParamsSchema } from '@documenso/lib/types/search-params';
@@ -20,7 +19,8 @@ import { msg } from '@lingui/core/macro';
import { Trans, useLingui } from '@lingui/react/macro';
import { WebhookCallStatus, WebhookTriggerEvents } from '@prisma/client';
import { CheckCircle2Icon, ChevronRightIcon, PencilIcon, TerminalIcon, XCircleIcon } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { debounce, parseAsString, useQueryState } from 'nuqs';
import { useMemo } from 'react';
import { Link, useLocation, useNavigate, useSearchParams } from 'react-router';
import { z } from 'zod';
@@ -51,13 +51,14 @@ export default function WebhookPage({ params }: Route.ComponentProps) {
const { toast } = useToast();
const { pathname } = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
const [searchParams] = useSearchParams();
const updateSearchParams = useUpdateSearchParams();
const team = useCurrentTeam();
const [searchQuery, setSearchQuery] = useState(() => searchParams?.get('query') ?? '');
const debouncedSearchQuery = useDebouncedValue(searchQuery, 500);
const [searchQuery, setSearchQuery] = useQueryState(
'query',
parseAsString.withDefault('').withOptions({ shallow: false, limitUrlUpdates: debounce(500) }),
);
const parsedSearchParams = WebhookSearchParamsSchema.parse(Object.fromEntries(searchParams ?? []));
@@ -81,26 +82,6 @@ export default function WebhookPage({ params }: Route.ComponentProps) {
query: parsedSearchParams.query,
});
/**
* Handle debouncing the search query.
*/
useEffect(() => {
const params = new URLSearchParams(searchParams?.toString());
params.set('query', debouncedSearchQuery);
if (debouncedSearchQuery === '') {
params.delete('query');
}
// If nothing to change then do nothing.
if (params.toString() === searchParams?.toString()) {
return;
}
setSearchParams(params);
}, [debouncedSearchQuery, pathname, searchParams]);
const onPaginationChange = (page: number, perPage: number) => {
updateSearchParams({
page,
@@ -252,8 +233,8 @@ export default function WebhookPage({ params }: Route.ComponentProps) {
<div className="mt-4">
<div className="mb-4 flex flex-row items-center justify-between gap-x-4">
<Input
defaultValue={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
value={searchQuery}
onChange={(e) => void setSearchQuery(e.target.value || null)}
placeholder={t`Search by ID`}
/>
@@ -8,7 +8,7 @@ export const findTeamsRoute = authenticatedProcedure
.input(ZFindTeamsRequestSchema)
.output(ZFindTeamsResponseSchema)
.query(async ({ ctx, input }) => {
const { organisationId } = input;
const { organisationId, query, page, perPage } = input;
const { user } = ctx;
ctx.logger.info({
@@ -17,5 +17,5 @@ export const findTeamsRoute = authenticatedProcedure
},
});
return findTeams({ userId: user.id, organisationId });
return findTeams({ userId: user.id, organisationId, query, page, perPage });
});