From ea847d88248dc798f50f410ccabc06a0f45ebd06 Mon Sep 17 00:00:00 2001 From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com> Date: Wed, 11 Feb 2026 14:41:03 +0100 Subject: [PATCH] fix(ui): use local transitions for filter navigation to prevent silent reverts (#10017) --- ui/CHANGELOG.md | 1 + .../_components/accounts-selector.tsx | 9 +- .../_components/provider-type-selector.tsx | 9 +- .../custom-checkbox-muted-findings.tsx | 9 +- ui/contexts/filter-transition-context.tsx | 31 ++++- ui/hooks/use-url-filters.ts | 109 +++++++++--------- 6 files changed, 91 insertions(+), 77 deletions(-) diff --git a/ui/CHANGELOG.md b/ui/CHANGELOG.md index fe4fe39565..9db5f265e3 100644 --- a/ui/CHANGELOG.md +++ b/ui/CHANGELOG.md @@ -19,6 +19,7 @@ All notable changes to the **Prowler UI** are documented in this file. - Filter navigations not coordinating with Suspense boundaries due to missing startTransition in ProviderTypeSelector, AccountsSelector, and muted findings checkbox [(#10013)](https://github.com/prowler-cloud/prowler/pull/10013) - Scans page pagination not updating table data because ScansTableWithPolling kept stale state from initial mount [(#10013)](https://github.com/prowler-cloud/prowler/pull/10013) - Duplicate `filter[search]` parameter in findings and scans API calls [(#10013)](https://github.com/prowler-cloud/prowler/pull/10013) +- Filter navigations silently reverting in production due to shared `useTransition()` context wrapping `router.push()` — each filter now uses a local transition while signaling the shared context for the DataTable loading indicator [(#10017)](https://github.com/prowler-cloud/prowler/pull/10017) --- diff --git a/ui/app/(prowler)/_overview/_components/accounts-selector.tsx b/ui/app/(prowler)/_overview/_components/accounts-selector.tsx index fed62b7441..c0b7946083 100644 --- a/ui/app/(prowler)/_overview/_components/accounts-selector.tsx +++ b/ui/app/(prowler)/_overview/_components/accounts-selector.tsx @@ -47,11 +47,9 @@ export function AccountsSelector({ providers }: AccountsSelectorProps) { const pathname = usePathname(); const searchParams = useSearchParams(); - // Use shared transition context if available, otherwise fall back to local - const sharedTransition = useFilterTransitionOptional(); - const [, localStartTransition] = useTransition(); - const startTransition = - sharedTransition?.startTransition ?? localStartTransition; + // Signal shared pending state for DataTable loading indicator + const filterTransition = useFilterTransitionOptional(); + const [, startTransition] = useTransition(); const filterKey = "filter[provider_id__in]"; const current = searchParams.get(filterKey) || ""; @@ -105,6 +103,7 @@ export function AccountsSelector({ providers }: AccountsSelectorProps) { params.set("page", "1"); } + filterTransition?.signalFilterChange(); startTransition(() => { router.push(`${pathname}?${params.toString()}`, { scroll: false }); }); diff --git a/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx b/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx index 1d10335334..ab6349fa69 100644 --- a/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx +++ b/ui/app/(prowler)/_overview/_components/provider-type-selector.tsx @@ -127,11 +127,9 @@ export const ProviderTypeSelector = ({ const pathname = usePathname(); const searchParams = useSearchParams(); - // Use shared transition context if available, otherwise fall back to local - const sharedTransition = useFilterTransitionOptional(); - const [, localStartTransition] = useTransition(); - const startTransition = - sharedTransition?.startTransition ?? localStartTransition; + // Signal shared pending state for DataTable loading indicator + const filterTransition = useFilterTransitionOptional(); + const [, startTransition] = useTransition(); const currentProviders = searchParams.get("filter[provider_type__in]") || ""; const selectedTypes = currentProviders @@ -157,6 +155,7 @@ export const ProviderTypeSelector = ({ params.set("page", "1"); } + filterTransition?.signalFilterChange(); startTransition(() => { router.push(`${pathname}?${params.toString()}`, { scroll: false }); }); diff --git a/ui/components/filters/custom-checkbox-muted-findings.tsx b/ui/components/filters/custom-checkbox-muted-findings.tsx index 74f94d96e5..4384bafe04 100644 --- a/ui/components/filters/custom-checkbox-muted-findings.tsx +++ b/ui/components/filters/custom-checkbox-muted-findings.tsx @@ -17,11 +17,9 @@ export const CustomCheckboxMutedFindings = () => { const pathname = usePathname(); const searchParams = useSearchParams(); - // Use shared transition context if available, otherwise fall back to local - const sharedTransition = useFilterTransitionOptional(); - const [, localStartTransition] = useTransition(); - const startTransition = - sharedTransition?.startTransition ?? localStartTransition; + // Signal shared pending state for DataTable loading indicator + const filterTransition = useFilterTransitionOptional(); + const [, startTransition] = useTransition(); // Get the current muted filter value from URL // Middleware ensures filter[muted] is always present when navigating to /findings @@ -49,6 +47,7 @@ export const CustomCheckboxMutedFindings = () => { params.set("page", "1"); } + filterTransition?.signalFilterChange(); startTransition(() => { router.push(`${pathname}?${params.toString()}`, { scroll: false }); }); diff --git a/ui/contexts/filter-transition-context.tsx b/ui/contexts/filter-transition-context.tsx index cd529e0a3c..b283829e3d 100644 --- a/ui/contexts/filter-transition-context.tsx +++ b/ui/contexts/filter-transition-context.tsx @@ -1,16 +1,17 @@ "use client"; +import { useSearchParams } from "next/navigation"; import { createContext, ReactNode, - TransitionStartFunction, useContext, - useTransition, + useEffect, + useState, } from "react"; interface FilterTransitionContextType { isPending: boolean; - startTransition: TransitionStartFunction; + signalFilterChange: () => void; } const FilterTransitionContext = createContext< @@ -39,13 +40,33 @@ interface FilterTransitionProviderProps { children: ReactNode; } +/** + * Provides a shared pending state for filter changes. + * + * Filter components signal the start of navigation via signalFilterChange(), + * and use their own local useTransition() for the actual router.push(). + * This avoids a known Next.js production bug where a shared useTransition() + * wrapping router.push() causes the navigation to be silently reverted. + * + * The pending state auto-resets when searchParams change (navigation completed). + */ export const FilterTransitionProvider = ({ children, }: FilterTransitionProviderProps) => { - const [isPending, startTransition] = useTransition(); + const searchParams = useSearchParams(); + const [isPending, setIsPending] = useState(false); + + // Auto-reset pending state when searchParams change (navigation completed) + useEffect(() => { + setIsPending(false); + }, [searchParams]); + + const signalFilterChange = () => { + setIsPending(true); + }; return ( - + {children} ); diff --git a/ui/hooks/use-url-filters.ts b/ui/hooks/use-url-filters.ts index 911aa61558..335facb7fc 100644 --- a/ui/hooks/use-url-filters.ts +++ b/ui/hooks/use-url-filters.ts @@ -1,7 +1,7 @@ "use client"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import { useCallback, useTransition } from "react"; +import { useTransition } from "react"; import { useFilterTransitionOptional } from "@/contexts"; @@ -20,70 +20,64 @@ export const useUrlFilters = () => { const searchParams = useSearchParams(); const pathname = usePathname(); - // Use shared context if available, otherwise fall back to local transition - const sharedTransition = useFilterTransitionOptional(); - const [localIsPending, localStartTransition] = useTransition(); + // Signal shared pending state for DataTable loading indicator + const filterTransition = useFilterTransitionOptional(); + const [localIsPending, startTransition] = useTransition(); - const isPending = sharedTransition?.isPending ?? localIsPending; - const startTransition = - sharedTransition?.startTransition ?? localStartTransition; + const isPending = filterTransition?.isPending ?? localIsPending; - const updateFilter = useCallback( - (key: string, value: string | string[] | null) => { - const params = new URLSearchParams(searchParams.toString()); + const updateFilter = (key: string, value: string | string[] | null) => { + const params = new URLSearchParams(searchParams.toString()); - const filterKey = key.startsWith("filter[") ? key : `filter[${key}]`; + const filterKey = key.startsWith("filter[") ? key : `filter[${key}]`; - const currentValue = params.get(filterKey); - const nextValue = Array.isArray(value) - ? value.length > 0 - ? value.join(",") - : null - : value === null - ? null - : value; + const currentValue = params.get(filterKey); + const nextValue = Array.isArray(value) + ? value.length > 0 + ? value.join(",") + : null + : value === null + ? null + : value; - // If effective value is unchanged, do nothing (avoids redundant fetches) - if (currentValue === nextValue) return; + // If effective value is unchanged, do nothing (avoids redundant fetches) + if (currentValue === nextValue) return; - // Only reset page to 1 if page parameter already exists - if (params.has("page")) { - params.set("page", "1"); - } - - if (nextValue === null) { - params.delete(filterKey); - } else { - params.set(filterKey, nextValue); - } - - startTransition(() => { - router.push(`${pathname}?${params.toString()}`, { scroll: false }); - }); - }, - [router, searchParams, pathname, startTransition], - ); - - const clearFilter = useCallback( - (key: string) => { - const params = new URLSearchParams(searchParams.toString()); - const filterKey = key.startsWith("filter[") ? key : `filter[${key}]`; + // Only reset page to 1 if page parameter already exists + if (params.has("page")) { + params.set("page", "1"); + } + if (nextValue === null) { params.delete(filterKey); + } else { + params.set(filterKey, nextValue); + } - // Only reset page to 1 if page parameter already exists - if (params.has("page")) { - params.set("page", "1"); - } + filterTransition?.signalFilterChange(); + startTransition(() => { + router.push(`${pathname}?${params.toString()}`, { scroll: false }); + }); + }; - startTransition(() => { - router.push(`${pathname}?${params.toString()}`, { scroll: false }); - }); - }, - [router, searchParams, pathname, startTransition], - ); + const clearFilter = (key: string) => { + const params = new URLSearchParams(searchParams.toString()); + const filterKey = key.startsWith("filter[") ? key : `filter[${key}]`; - const clearAllFilters = useCallback(() => { + params.delete(filterKey); + + // Only reset page to 1 if page parameter already exists + if (params.has("page")) { + params.set("page", "1"); + } + + filterTransition?.signalFilterChange(); + startTransition(() => { + router.push(`${pathname}?${params.toString()}`, { scroll: false }); + }); + }; + + const clearAllFilters = () => { const params = new URLSearchParams(searchParams.toString()); Array.from(params.keys()).forEach((key) => { if (key.startsWith("filter[") || key === "sort") { @@ -93,17 +87,18 @@ export const useUrlFilters = () => { params.delete("page"); + filterTransition?.signalFilterChange(); startTransition(() => { router.push(`${pathname}?${params.toString()}`, { scroll: false }); }); - }, [router, searchParams, pathname, startTransition]); + }; - const hasFilters = useCallback(() => { + const hasFilters = () => { const params = new URLSearchParams(searchParams.toString()); return Array.from(params.keys()).some( (key) => key.startsWith("filter[") || key === "sort", ); - }, [searchParams]); + }; return { updateFilter,