fix(frontend): clear ?subscription=success URL param after showing toast

Replace toastShownRef guard with router.replace(pathname) so the success
toast is not re-shown on page refresh and correctly re-fires on a second
checkout in the same SPA session. Adds test coverage for the behaviour.
This commit is contained in:
majdyz
2026-04-13 21:47:15 +07:00
parent 929c8a316c
commit 62a6175d2a
2 changed files with 32 additions and 19 deletions

View File

@@ -10,12 +10,13 @@ import { SubscriptionTierSection } from "../SubscriptionTierSection";
// Mock next/navigation
const mockSearchParams = new URLSearchParams();
const mockRouterReplace = vi.fn();
vi.mock("next/navigation", async (importOriginal) => {
const actual = await importOriginal<typeof import("next/navigation")>();
return {
...actual,
useSearchParams: () => mockSearchParams,
useRouter: () => ({ push: vi.fn() }),
useRouter: () => ({ push: vi.fn(), replace: mockRouterReplace }),
usePathname: () => "/profile/credits",
};
});
@@ -103,6 +104,7 @@ afterEach(() => {
mockUseGetSubscriptionStatus.mockReset();
mockUseUpdateSubscriptionTier.mockReset();
mockToast.mockReset();
mockRouterReplace.mockReset();
// Reset search params
mockSearchParams.delete("subscription");
});
@@ -289,4 +291,18 @@ describe("SubscriptionTierSection", () => {
expect(screen.queryByText("Pro")).toBeNull();
expect(screen.queryByText("Business")).toBeNull();
});
it("shows success toast and clears URL param when ?subscription=success is present", async () => {
mockSearchParams.set("subscription", "success");
setupMocks();
render(<SubscriptionTierSection />);
await waitFor(() => {
expect(mockToast).toHaveBeenCalledWith(
expect.objectContaining({ title: "Subscription upgraded" }),
);
});
// URL param must be stripped so a page refresh doesn't re-trigger the toast
expect(mockRouterReplace).toHaveBeenCalledWith("/profile/credits");
});
});

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import {
useGetSubscriptionStatus,
useUpdateSubscriptionTier,
@@ -13,8 +13,9 @@ export type SubscriptionStatus = SubscriptionStatusResponse;
export function useSubscriptionTierSection() {
const searchParams = useSearchParams();
const subscriptionStatus = searchParams.get("subscription");
const router = useRouter();
const pathname = usePathname();
const { toast } = useToast();
const toastShownRef = useRef(false);
const [tierError, setTierError] = useState<string | null>(null);
const {
@@ -36,22 +37,18 @@ export function useSubscriptionTierSection() {
useEffect(() => {
if (subscriptionStatus === "success") {
if (!toastShownRef.current) {
toastShownRef.current = true;
refetch();
toast({
title: "Subscription upgraded",
description:
"Your plan has been updated. It may take a moment to reflect.",
});
}
} else {
// Reset so the toast fires again if the user completes another checkout
// during the same mount (e.g. SPA navigation away and back with a new
// ?subscription=success param).
toastShownRef.current = false;
refetch();
toast({
title: "Subscription upgraded",
description:
"Your plan has been updated. It may take a moment to reflect.",
});
// Strip ?subscription=success from the URL so a page refresh does not
// re-trigger the toast, and so a second checkout in the same session
// correctly fires the toast again.
router.replace(pathname);
}
}, [subscriptionStatus, refetch, toast]);
}, [subscriptionStatus, refetch, toast, router, pathname]);
async function changeTier(tier: string) {
setTierError(null);