+
{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];
+}