fix(frontend): prevent state updates on unmounted OnboardingProvider (#11211)

<!-- Clearly explain the need for these changes: -->
Fixes
[BUILDER-48G](https://sentry.io/organizations/significant-gravitas/issues/6960009111/).
The issue was that: Asynchronous API update scheduled via
`setTimeout(0)` in `OnboardingProvider` creates a race condition,
causing React-DOM's portal cleanup (`removeChild`) to fail during
concurrent component unmounting.

### Changes 🏗️

<!-- Concisely describe all of the changes made in this pull request:
-->
- Prevents state updates and API calls after the `OnboardingProvider`
component has been unmounted.
- Introduces a `isMounted` ref to track the component's mount status.
- Uses a `pendingUpdatesRef` to manage and cancel pending API update
promises on unmount, preventing memory leaks and errors.
- Ensures that API update errors are only logged if the component is
still mounted.


This fix was generated by Seer in Sentry, triggered by Craig Swift. 👁️
Run ID: 2058387

Not quite right? [Click here to continue debugging with
Seer.](https://sentry.io/organizations/significant-gravitas/issues/6960009111/?seerDrawer=true)

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  - [x] Onboarding works and does not throw errors when unmounted

---------

Co-authored-by: seer-by-sentry[bot] <157164994+seer-by-sentry[bot]@users.noreply.github.com>
Co-authored-by: Lluis Agusti <hi@llu.lu>
Co-authored-by: Ubbe <hi@ubbe.dev>
This commit is contained in:
seer-by-sentry[bot]
2025-11-06 16:20:23 +07:00
committed by GitHub
parent cbe4086e79
commit df9850a141

View File

@@ -8,10 +8,11 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/__legacy__/ui/dialog";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { useOnboardingTimezoneDetection } from "@/hooks/useOnboardingTimezoneDetection";
import { OnboardingStep, UserOnboarding } from "@/lib/autogpt-server-api";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { useOnboardingTimezoneDetection } from "@/hooks/useOnboardingTimezoneDetection";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import {
@@ -83,6 +84,9 @@ export default function OnboardingProvider({
const [step, setStep] = useState(1);
const [npsDialogOpen, setNpsDialogOpen] = useState(false);
const hasInitialized = useRef(false);
const isMounted = useRef(true);
const pendingUpdatesRef = useRef<Set<Promise<void>>>(new Set());
const { toast } = useToast();
const api = useBackendAPI();
const pathname = usePathname();
@@ -91,6 +95,22 @@ export default function OnboardingProvider({
useOnboardingTimezoneDetection();
// Cleanup effect to track mount state and cancel pending operations
useEffect(() => {
isMounted.current = true;
return () => {
isMounted.current = false;
// Wait for pending updates to complete before unmounting
pendingUpdatesRef.current.forEach((promise) => {
promise.catch(() => {});
});
pendingUpdatesRef.current.clear();
};
}, []);
const isOnOnboardingRoute = pathname.startsWith("/onboarding");
useEffect(() => {
@@ -130,6 +150,12 @@ export default function OnboardingProvider({
}
} catch (error) {
console.error("Failed to initialize onboarding:", error);
toast({
title: "Failed to initialize onboarding",
variant: "destructive",
});
hasInitialized.current = false; // Allow retry on next render
}
}
@@ -139,6 +165,7 @@ export default function OnboardingProvider({
const updateState = useCallback(
(newState: Omit<Partial<UserOnboarding>, "rewardedFor">) => {
// Update local state immediately
setState((prev) => {
if (!prev) {
return createInitialOnboardingState(newState);
@@ -146,12 +173,28 @@ export default function OnboardingProvider({
return { ...prev, ...newState };
});
// Async API update without blocking render
setTimeout(() => {
api.updateUserOnboarding(newState).catch((error) => {
console.error("Failed to update user onboarding:", error);
});
}, 0);
const updatePromise = (async () => {
try {
if (!isMounted.current) return;
await api.updateUserOnboarding(newState);
} catch (error) {
if (isMounted.current) {
console.error("Failed to update user onboarding:", error);
}
toast({
title: "Failed to update user onboarding",
variant: "destructive",
});
}
})();
// Track this pending update
pendingUpdatesRef.current.add(updatePromise);
updatePromise.finally(() => {
pendingUpdatesRef.current.delete(updatePromise);
});
},
[api],
);