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:
@@ -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 => (
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
Reference in New Issue
Block a user