Fix subprocessor filter loop and scope refetch loading

Searching subprocessors flipped the list between filtered and unfiltered
results in an infinite loop: useSubprocessorFilters kept a local search
mirror plus a write-back effect, so every component calling it (page,
loader, toolbar, empty state) ran its own effect while only the toolbar
updated the mirror — the stale instances fought the real writer.

Make useSubprocessorFilters a pure URL-state hook and move the debounced
search input into a single-owner useSubprocessorSearch hook mounted only
by the toolbar, guarding the URL-to-input sync with a ref so its own
commits are not echoed back.

Refetch on filter change now runs inside a transition so the toolbar and
current results stay mounted instead of falling back to the whole-page
Suspense skeleton; only the results container dims while loading.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-07-08 09:38:04 -04:00
parent c9b74d6de0
commit b4b6599907
4 changed files with 76 additions and 39 deletions

View File

@@ -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) {
<SubprocessorsToolbar queryKey={root} />
</PageHeader>
<div className="flex w-full flex-col items-center px-8 py-8">
<div className="flex w-full max-w-5xl flex-col gap-8">
<div
aria-busy={isRefetching}
className={`flex w-full max-w-5xl flex-col gap-8 transition-opacity duration-150 ${isRefetching ? "opacity-60" : ""}`}
>
{groups.length === 0
? <SubprocessorsEmpty />
: groups.map(group => (

View File

@@ -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);

View File

@@ -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,

View File

@@ -0,0 +1,53 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// 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];
}