fix(ui): fix findings filter silent reverts by replacing useRelatedFilters effect with pure derivation (#10021)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alejandro Bailo
2026-02-11 17:57:38 +01:00
committed by GitHub
parent fce1e4f3d2
commit 86946f3a84
6 changed files with 122 additions and 254 deletions

View File

@@ -19,7 +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)
- All filters on `/findings` silently reverting on first click in production [(#10021)](https://github.com/prowler-cloud/prowler/pull/10021)
---

View File

@@ -1,7 +1,7 @@
"use client";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { ReactNode, useTransition } from "react";
import { useSearchParams } from "next/navigation";
import { ReactNode } from "react";
import {
AlibabaCloudProviderBadge,
@@ -22,7 +22,7 @@ import {
MultiSelectTrigger,
MultiSelectValue,
} from "@/components/shadcn/select/multiselect";
import { useFilterTransitionOptional } from "@/contexts";
import { useUrlFilters } from "@/hooks/use-url-filters";
import type { ProviderProps, ProviderType } from "@/types/providers";
const PROVIDER_ICON: Record<ProviderType, ReactNode> = {
@@ -43,13 +43,8 @@ interface AccountsSelectorProps {
}
export function AccountsSelector({ providers }: AccountsSelectorProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
// Signal shared pending state for DataTable loading indicator
const filterTransition = useFilterTransitionOptional();
const [, startTransition] = useTransition();
const { navigateWithParams } = useUrlFilters();
const filterKey = "filter[provider_id__in]";
const current = searchParams.get(filterKey) || "";
@@ -67,45 +62,36 @@ export function AccountsSelector({ providers }: AccountsSelectorProps) {
);
const handleMultiValueChange = (ids: string[]) => {
const params = new URLSearchParams(searchParams.toString());
params.delete(filterKey);
navigateWithParams((params) => {
params.delete(filterKey);
if (ids.length > 0) {
params.set(filterKey, ids.join(","));
}
// Auto-deselect provider types that no longer have any selected accounts
if (selectedTypesList.length > 0) {
// Get provider types of currently selected accounts
const selectedProviders = providers.filter((p) => ids.includes(p.id));
const selectedProviderTypes = new Set(
selectedProviders.map((p) => p.attributes.provider),
);
// Keep only provider types that still have selected accounts
const remainingProviderTypes = selectedTypesList.filter((type) =>
selectedProviderTypes.has(type as ProviderType),
);
// Update provider_type__in filter
if (remainingProviderTypes.length > 0) {
params.set(
"filter[provider_type__in]",
remainingProviderTypes.join(","),
);
} else {
params.delete("filter[provider_type__in]");
if (ids.length > 0) {
params.set(filterKey, ids.join(","));
}
}
// Reset to page 1 when changing filter
if (params.has("page")) {
params.set("page", "1");
}
// Auto-deselect provider types that no longer have any selected accounts
if (selectedTypesList.length > 0) {
// Get provider types of currently selected accounts
const selectedProviders = providers.filter((p) => ids.includes(p.id));
const selectedProviderTypes = new Set(
selectedProviders.map((p) => p.attributes.provider),
);
filterTransition?.signalFilterChange();
startTransition(() => {
router.push(`${pathname}?${params.toString()}`, { scroll: false });
// Keep only provider types that still have selected accounts
const remainingProviderTypes = selectedTypesList.filter((type) =>
selectedProviderTypes.has(type as ProviderType),
);
// Update provider_type__in filter
if (remainingProviderTypes.length > 0) {
params.set(
"filter[provider_type__in]",
remainingProviderTypes.join(","),
);
} else {
params.delete("filter[provider_type__in]");
}
}
});
};

View File

@@ -1,7 +1,7 @@
"use client";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { lazy, Suspense, useTransition } from "react";
import { useSearchParams } from "next/navigation";
import { lazy, Suspense } from "react";
import {
MultiSelect,
@@ -10,7 +10,7 @@ import {
MultiSelectTrigger,
MultiSelectValue,
} from "@/components/shadcn/select/multiselect";
import { useFilterTransitionOptional } from "@/contexts";
import { useUrlFilters } from "@/hooks/use-url-filters";
import { type ProviderProps, ProviderType } from "@/types/providers";
const AWSProviderBadge = lazy(() =>
@@ -123,13 +123,8 @@ type ProviderTypeSelectorProps = {
export const ProviderTypeSelector = ({
providers,
}: ProviderTypeSelectorProps) => {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
// Signal shared pending state for DataTable loading indicator
const filterTransition = useFilterTransitionOptional();
const [, startTransition] = useTransition();
const { navigateWithParams } = useUrlFilters();
const currentProviders = searchParams.get("filter[provider_type__in]") || "";
const selectedTypes = currentProviders
@@ -137,27 +132,17 @@ export const ProviderTypeSelector = ({
: [];
const handleMultiValueChange = (values: string[]) => {
const params = new URLSearchParams(searchParams.toString());
navigateWithParams((params) => {
// Update provider_type__in
if (values.length > 0) {
params.set("filter[provider_type__in]", values.join(","));
} else {
params.delete("filter[provider_type__in]");
}
// Update provider_type__in
if (values.length > 0) {
params.set("filter[provider_type__in]", values.join(","));
} else {
params.delete("filter[provider_type__in]");
}
// Clear account selection when changing provider types
// User should manually select accounts if they want to filter by specific accounts
params.delete("filter[provider_id__in]");
// Reset to page 1 when changing filter
if (params.has("page")) {
params.set("page", "1");
}
filterTransition?.signalFilterChange();
startTransition(() => {
router.push(`${pathname}?${params.toString()}`, { scroll: false });
// Clear account selection when changing provider types
// User should manually select accounts if they want to filter by specific accounts
params.delete("filter[provider_id__in]");
});
};

View File

@@ -1,10 +1,9 @@
"use client";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useTransition } from "react";
import { useSearchParams } from "next/navigation";
import { Checkbox } from "@/components/shadcn";
import { useFilterTransitionOptional } from "@/contexts";
import { useUrlFilters } from "@/hooks/use-url-filters";
// Constants for muted filter URL values
const MUTED_FILTER_VALUES = {
@@ -13,16 +12,10 @@ const MUTED_FILTER_VALUES = {
} as const;
export const CustomCheckboxMutedFindings = () => {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
// Signal shared pending state for DataTable loading indicator
const filterTransition = useFilterTransitionOptional();
const [, startTransition] = useTransition();
const { navigateWithParams } = useUrlFilters();
// Get the current muted filter value from URL
// Middleware ensures filter[muted] is always present when navigating to /findings
const mutedFilterValue = searchParams.get("filter[muted]");
// URL states:
@@ -32,24 +25,15 @@ export const CustomCheckboxMutedFindings = () => {
const handleMutedChange = (checked: boolean | "indeterminate") => {
const isChecked = checked === true;
const params = new URLSearchParams(searchParams.toString());
if (isChecked) {
// Include muted: set special value (API will ignore invalid value and show all)
params.set("filter[muted]", MUTED_FILTER_VALUES.INCLUDE);
} else {
// Exclude muted: apply filter to show only non-muted
params.set("filter[muted]", MUTED_FILTER_VALUES.EXCLUDE);
}
// Reset to page 1 when changing filter
if (params.has("page")) {
params.set("page", "1");
}
filterTransition?.signalFilterChange();
startTransition(() => {
router.push(`${pathname}?${params.toString()}`, { scroll: false });
navigateWithParams((params) => {
if (isChecked) {
// Include muted: set special value (API will ignore invalid value and show all)
params.set("filter[muted]", MUTED_FILTER_VALUES.INCLUDE);
} else {
// Exclude muted: apply filter to show only non-muted
params.set("filter[muted]", MUTED_FILTER_VALUES.EXCLUDE);
}
});
};

View File

@@ -1,7 +1,5 @@
import { useSearchParams } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { useUrlFilters } from "@/hooks/use-url-filters";
import { isScanEntity } from "@/lib/helper-filters";
import {
FilterEntity,
@@ -21,6 +19,19 @@ interface UseRelatedFiltersProps {
providerFilterType?: FilterType.PROVIDER | FilterType.PROVIDER_UID;
}
/**
* Derives available providers and scans based on the current URL filters.
*
* Pure computation — no effects, no state, no navigation. The returned
* lists update automatically when searchParams change because the component
* re-renders with new searchParams from Next.js.
*
* Cascading filter cleanup (e.g. auto-clearing a scan when its provider is
* deselected) is handled atomically by the filter components themselves
* (ProviderTypeSelector clears provider_id__in, AccountsSelector updates
* provider_type__in). This avoids the production bug where router.push()
* calls inside useEffect would silently abort pending navigations.
*/
export const useRelatedFilters = ({
providerIds = [],
providerUIDs = [],
@@ -31,33 +42,18 @@ export const useRelatedFilters = ({
providerFilterType = FilterType.PROVIDER,
}: UseRelatedFiltersProps) => {
const searchParams = useSearchParams();
const { updateFilter } = useUrlFilters();
const [availableScans, setAvailableScans] =
useState<string[]>(completedScanIds);
// Use providerIds if provided (for findings), otherwise use providerUIDs (for scans)
const providers = providerIds.length > 0 ? providerIds : providerUIDs;
const [availableProviders, setAvailableProviders] =
useState<string[]>(providers);
const previousProviders = useRef<string[]>([]);
const previousProviderTypes = useRef<ProviderType[]>([]);
const isManualDeselection = useRef(false);
const getScanProvider = (scanId: string) => {
if (!enableScanRelation) return null;
const scanDetail = scanDetails.find(
(detail) => Object.keys(detail)[0] === scanId,
);
return scanDetail ? scanDetail[scanId]?.providerInfo?.uid : null;
};
const providerParam = searchParams.get(`filter[${providerFilterType}]`);
const providerTypeParam = searchParams.get(
`filter[${FilterType.PROVIDER_TYPE}]`,
);
const getScanProviderType = (scanId: string): ProviderType | null => {
if (!enableScanRelation) return null;
const scanDetail = scanDetails.find(
(detail) => Object.keys(detail)[0] === scanId,
);
return scanDetail ? scanDetail[scanId]?.providerInfo?.provider : null;
};
const currentProviders = providerParam ? providerParam.split(",") : [];
const currentProviderTypes = providerTypeParam
? (providerTypeParam.split(",") as ProviderType[])
: [];
const getProviderType = (providerKey: string): ProviderType | null => {
const providerDetail = providerDetails.find(
@@ -72,128 +68,28 @@ export const useRelatedFilters = ({
return null;
};
useEffect(() => {
const scanParam = enableScanRelation
? searchParams.get(`filter[${FilterType.SCAN}]`)
: null;
const providerParam = searchParams.get(`filter[${providerFilterType}]`);
const providerTypeParam = searchParams.get(
`filter[${FilterType.PROVIDER_TYPE}]`,
);
// Derive available providers filtered by selected provider types
const availableProviders =
currentProviderTypes.length > 0
? providers.filter((key) => {
const providerType = getProviderType(key);
return providerType && currentProviderTypes.includes(providerType);
})
: providers;
const currentProviders = providerParam ? providerParam.split(",") : [];
const currentProviderTypes = providerTypeParam
? (providerTypeParam.split(",") as ProviderType[])
: [];
// Derive available scans filtered by selected providers and provider types
const availableScans = enableScanRelation
? currentProviders.length > 0 || currentProviderTypes.length > 0
? completedScanIds.filter((scanId) => {
const scanDetail = scanDetails.find(
(detail) => Object.keys(detail)[0] === scanId,
);
if (!scanDetail) return false;
// Detect deselected items
const deselectedProviders = previousProviders.current.filter(
(provider) => !currentProviders.includes(provider),
);
const deselectedProviderTypes = previousProviderTypes.current.filter(
(type) => !currentProviderTypes.includes(type),
);
// Check if it's a manual deselection
if (deselectedProviderTypes.length > 0) {
isManualDeselection.current = true;
} else if (
currentProviderTypes.length === 0 &&
previousProviderTypes.current.length === 0
) {
isManualDeselection.current = false;
}
// Update references
previousProviders.current = currentProviders;
previousProviderTypes.current = currentProviderTypes;
// Handle scan selection logic
if (enableScanRelation && scanParam) {
const scanProviderId = getScanProvider(scanParam);
const scanProviderType = getScanProviderType(scanParam);
const shouldDeselectScan =
(scanProviderId &&
(deselectedProviders.includes(scanProviderId) ||
(currentProviders.length > 0 &&
!currentProviders.includes(scanProviderId)))) ||
(scanProviderType &&
!isManualDeselection.current &&
(deselectedProviderTypes.includes(scanProviderType) ||
(currentProviderTypes.length > 0 &&
!currentProviderTypes.includes(scanProviderType))));
if (shouldDeselectScan) {
updateFilter(FilterType.SCAN, null);
// } else {
// // Add provider if not already selected
// if (scanProviderId && !currentProviders.includes(scanProviderId)) {
// updateFilter(FilterType.PROVIDER_UID, [
// ...currentProviders,
// scanProviderId,
// ]);
// }
// // Only add provider type if there are none selected
// if (
// scanProviderType &&
// currentProviderTypes.length === 0 &&
// !isManualDeselection.current
// ) {
// updateFilter(FilterType.PROVIDER_TYPE, [scanProviderType]);
// }
}
}
// // Handle provider selection logic
// if (
// currentProviders.length > 0 &&
// deselectedProviders.length === 0 &&
// !isManualDeselection.current
// ) {
// const providerTypes = currentProviders
// .map(getProviderType)
// .filter((type): type is ProviderType => type !== null);
// const selectedProviderTypes = Array.from(new Set(providerTypes));
// if (
// selectedProviderTypes.length > 0 &&
// currentProviderTypes.length === 0
// ) {
// updateFilter(FilterType.PROVIDER_TYPE, selectedProviderTypes);
// }
// }
// Update available providers
if (currentProviderTypes.length > 0) {
const filteredProviders = providers.filter((key) => {
const providerType = getProviderType(key);
return providerType && currentProviderTypes.includes(providerType);
});
setAvailableProviders(filteredProviders);
const validProviders = currentProviders.filter((key) => {
const providerType = getProviderType(key);
return providerType && currentProviderTypes.includes(providerType);
});
if (validProviders.length !== currentProviders.length) {
updateFilter(
providerFilterType,
validProviders.length > 0 ? validProviders : null,
);
}
} else {
setAvailableProviders(providers);
}
// Update available scans
if (enableScanRelation) {
if (currentProviders.length > 0 || currentProviderTypes.length > 0) {
const filteredScans = completedScanIds.filter((scanId) => {
const scanProviderId = getScanProvider(scanId);
const scanProviderType = getScanProviderType(scanId);
const scanProviderId = scanDetail[scanId]?.providerInfo?.uid ?? null;
const scanProviderType =
(scanDetail[scanId]?.providerInfo?.provider as ProviderType) ??
null;
return (
(currentProviders.length === 0 ||
@@ -202,14 +98,9 @@ export const useRelatedFilters = ({
(scanProviderType &&
currentProviderTypes.includes(scanProviderType)))
);
});
setAvailableScans(filteredScans);
} else {
setAvailableScans(completedScanIds);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchParams]);
})
: completedScanIds
: completedScanIds;
return {
availableProviderIds: providerIds.length > 0 ? availableProviders : [],

View File

@@ -100,11 +100,33 @@ export const useUrlFilters = () => {
);
};
/**
* Low-level navigation function for complex filter updates that need
* to modify multiple params atomically (e.g., setting provider_type
* while clearing provider_id). The modifier receives a mutable
* URLSearchParams; page is auto-reset if already present.
*/
const navigateWithParams = (modifier: (params: URLSearchParams) => void) => {
const params = new URLSearchParams(searchParams.toString());
modifier(params);
// 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 });
});
};
return {
updateFilter,
clearFilter,
clearAllFilters,
hasFilters,
isPending,
navigateWithParams,
};
};