Compare commits

...

1 Commits

Author SHA1 Message Date
Alejandro Bailo
b7403912b6 fix(ui): use local transitions for filter navigation to prevent silent reverts (#10017)
(cherry picked from commit ea847d8824)
2026-02-11 13:42:03 +00:00
6 changed files with 91 additions and 77 deletions

View File

@@ -11,6 +11,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)
---

View File

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

View File

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

View File

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

View File

@@ -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 (
<FilterTransitionContext.Provider value={{ isPending, startTransition }}>
<FilterTransitionContext.Provider value={{ isPending, signalFilterChange }}>
{children}
</FilterTransitionContext.Provider>
);

View File

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