diff --git a/apps/compliance-portal/src/pages/subprocessors/SubprocessorsPage.tsx b/apps/compliance-portal/src/pages/subprocessors/SubprocessorsPage.tsx index 6bfaec46f..c599e12a9 100644 --- a/apps/compliance-portal/src/pages/subprocessors/SubprocessorsPage.tsx +++ b/apps/compliance-portal/src/pages/subprocessors/SubprocessorsPage.tsx @@ -12,7 +12,7 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useTransition } from "react"; import { useTranslation } from "react-i18next"; import type { PreloadedQuery } from "react-relay"; import { graphql, usePreloadedQuery, useRefetchableFragment } from "react-relay"; @@ -74,16 +74,21 @@ export function SubprocessorsPage({ queryRef }: SubprocessorsPageProps) { const filters = useSubprocessorFilters(); const { query, category, country } = filters; + const [isRefetching, startTransition] = useTransition(); // The initial query already loaded with the URL's filter values; only refetch - // on subsequent filter changes. + // on subsequent filter changes. Refetch inside a transition so the toolbar and + // current results stay mounted (no whole-page Suspense fallback) while the + // filtered results load — the results are just dimmed via `isRefetching`. const isFirstRender = useRef(true); useEffect(() => { if (isFirstRender.current) { isFirstRender.current = false; return; } - refetch(toQueryVariables({ query, category, country }), { fetchPolicy: "store-or-network" }); + startTransition(() => { + refetch(toQueryVariables({ query, category, country }), { fetchPolicy: "store-or-network" }); + }); }, [refetch, query, category, country]); const { subprocessors } = data.currentTrustCenter; @@ -96,7 +101,10 @@ export function SubprocessorsPage({ queryRef }: SubprocessorsPageProps) {
-
+
{groups.length === 0 ? : groups.map(group => ( diff --git a/apps/compliance-portal/src/pages/subprocessors/_components/SubprocessorsToolbar.tsx b/apps/compliance-portal/src/pages/subprocessors/_components/SubprocessorsToolbar.tsx index d5e50fd47..0c8ff9d23 100644 --- a/apps/compliance-portal/src/pages/subprocessors/_components/SubprocessorsToolbar.tsx +++ b/apps/compliance-portal/src/pages/subprocessors/_components/SubprocessorsToolbar.tsx @@ -23,6 +23,7 @@ import { graphql, useFragment } from "react-relay"; import { useCountryLabel } from "../_lib/useCountryLabel"; import { useSubprocessorFilters } from "../_lib/useSubprocessorFilters"; +import { useSubprocessorSearch } from "../_lib/useSubprocessorSearch"; import type { SubprocessorsToolbar_query$key } from "./__generated__/SubprocessorsToolbar_query.graphql"; @@ -55,7 +56,8 @@ export function SubprocessorsToolbar({ queryKey }: SubprocessorsToolbarProps) { const { t } = useTranslation("subprocessors"); const data = useFragment(subprocessorsToolbarFragment, queryKey); const countryLabel = useCountryLabel(); - const { queryInput, category, country, setQueryInput, setCategory, setCountry } = useSubprocessorFilters(); + const { category, country, setCategory, setCountry } = useSubprocessorFilters(); + const [queryInput, setQueryInput] = useSubprocessorSearch(); const nodes = data.currentTrustCenter.allSubprocessors.edges.map(edge => edge.node); diff --git a/apps/compliance-portal/src/pages/subprocessors/_lib/useSubprocessorFilters.ts b/apps/compliance-portal/src/pages/subprocessors/_lib/useSubprocessorFilters.ts index 91a7a7edf..eca6b6bb2 100644 --- a/apps/compliance-portal/src/pages/subprocessors/_lib/useSubprocessorFilters.ts +++ b/apps/compliance-portal/src/pages/subprocessors/_lib/useSubprocessorFilters.ts @@ -12,28 +12,25 @@ // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. -import { useCallback, useEffect, useState } from "react"; +import { useCallback } from "react"; import { useSearchParams } from "react-router"; -const SEARCH_DEBOUNCE_MS = 300; - export interface SubprocessorFilters { - // Debounced search term written to the URL (drives the query variables). query: string; category: string; country: string; - // Immediate search input value (updates on every keystroke). - queryInput: string; hasActiveFilters: boolean; - setQueryInput: (value: string) => void; + setQuery: (value: string) => void; setCategory: (value: string) => void; setCountry: (value: string) => void; clear: () => void; } // Subprocessor filter state, persisted in the URL so it is shareable and -// survives reloads. The search term is debounced before it is committed to the -// URL (and therefore before it triggers a refetch). +// survives reloads. This hook is pure URL state (no local component state or +// effects), so it can be read from any number of components without them +// fighting over the search params. The debounced search *input* lives in a +// single-owner hook (`useSubprocessorSearch`) to avoid write-back loops. export function useSubprocessorFilters(): SubprocessorFilters { const [searchParams, setSearchParams] = useSearchParams(); @@ -41,28 +38,6 @@ export function useSubprocessorFilters(): SubprocessorFilters { const country = searchParams.get("country") ?? ""; const query = searchParams.get("q") ?? ""; - const [queryInput, setQueryInput] = useState(query); - - useEffect(() => { - if (queryInput === query) { - return; - } - - const handle = setTimeout(() => { - setSearchParams((previous) => { - const next = new URLSearchParams(previous); - if (queryInput) { - next.set("q", queryInput); - } else { - next.delete("q"); - } - return next; - }, { replace: true }); - }, SEARCH_DEBOUNCE_MS); - - return () => clearTimeout(handle); - }, [queryInput, query, setSearchParams]); - const setParam = useCallback((key: string, value: string) => { setSearchParams((previous) => { const next = new URLSearchParams(previous); @@ -75,11 +50,11 @@ export function useSubprocessorFilters(): SubprocessorFilters { }, { replace: true }); }, [setSearchParams]); + const setQuery = useCallback((value: string) => setParam("q", value), [setParam]); const setCategory = useCallback((value: string) => setParam("category", value), [setParam]); const setCountry = useCallback((value: string) => setParam("country", value), [setParam]); const clear = useCallback(() => { - setQueryInput(""); setSearchParams({}, { replace: true }); }, [setSearchParams]); @@ -87,9 +62,8 @@ export function useSubprocessorFilters(): SubprocessorFilters { query, category, country, - queryInput, hasActiveFilters: query !== "" || category !== "" || country !== "", - setQueryInput, + setQuery, setCategory, setCountry, clear, diff --git a/apps/compliance-portal/src/pages/subprocessors/_lib/useSubprocessorSearch.ts b/apps/compliance-portal/src/pages/subprocessors/_lib/useSubprocessorSearch.ts new file mode 100644 index 000000000..b96d73a9b --- /dev/null +++ b/apps/compliance-portal/src/pages/subprocessors/_lib/useSubprocessorSearch.ts @@ -0,0 +1,53 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import { useEffect, useRef, useState } from "react"; + +import { useSubprocessorFilters } from "./useSubprocessorFilters"; + +const SEARCH_DEBOUNCE_MS = 300; + +// Owns the debounced search input for the toolbar. Mount this in exactly ONE +// component (the toolbar) — it is the single writer of the `q` URL param. It +// keeps an immediate local value for the input and, after a debounce, commits it +// to the URL. A ref tracks our own writes so the URL→input sync only reacts to +// *external* changes (clear button, back/forward), never echoing our own commit +// back onto the input (which would drop in-flight keystrokes). +export function useSubprocessorSearch(): [string, (value: string) => void] { + const { query, setQuery } = useSubprocessorFilters(); + const [input, setInput] = useState(query); + const lastCommittedRef = useRef(query); + + useEffect(() => { + if (input === query) { + return; + } + + const handle = setTimeout(() => { + lastCommittedRef.current = input; + setQuery(input); + }, SEARCH_DEBOUNCE_MS); + + return () => clearTimeout(handle); + }, [input, query, setQuery]); + + useEffect(() => { + if (query !== lastCommittedRef.current) { + lastCommittedRef.current = query; + setInput(query); + } + }, [query]); + + return [input, setInput]; +}