Skip to content

Commit fcc232a

Browse files
VIA-558 AS/DB Add custom Link component to prevent pending requests on slow connections
1 parent 811bfe3 commit fcc232a

19 files changed

Lines changed: 320 additions & 47 deletions

src/app/_components/context/ClientProviders.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"use client";
22

33
import { BrowserContextProvider } from "@src/app/_components/context/BrowserContext";
4+
import { NavigationProvider } from "@src/app/_components/context/NavigationContext";
45
import { InactivityDialog } from "@src/app/_components/inactivity/InactivityDialog";
56
import LinksInterceptor from "@src/app/_components/interceptor/LinksInterceptor";
67
import AppFooter from "@src/app/_components/nhs-frontend/AppFooter";
@@ -24,10 +25,12 @@ export function ClientProviders({ children }: Readonly<{ children: React.ReactNo
2425
<LinksInterceptor />
2526
<SkipLink />
2627
<SessionProvider refetchInterval={SESSION_REFETCH_SECONDS}>
27-
<AppHeader />
28-
<InactivityDialog />
29-
<div className="nhsuk-width-container">{children}</div>
30-
<AppFooter />
28+
<NavigationProvider>
29+
<AppHeader />
30+
<InactivityDialog />
31+
<div className="nhsuk-width-container">{children}</div>
32+
<AppFooter />
33+
</NavigationProvider>
3134
</SessionProvider>
3235
</BrowserContextProvider>
3336
);
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { NavigationProvider, useNavigationTransition } from "@src/app/_components/context/NavigationContext";
2+
import { fireEvent, render, screen } from "@testing-library/react";
3+
import Link from "next/link";
4+
import { useRouter } from "next/navigation";
5+
import React from "react";
6+
7+
jest.mock("next/navigation", () => ({
8+
useRouter: jest.fn(),
9+
}));
10+
11+
const mockPush = jest.fn();
12+
13+
const TestLink = () => {
14+
const { navigate } = useNavigationTransition();
15+
16+
return (
17+
<Link
18+
href="/test-route"
19+
onClick={(e) => {
20+
e.preventDefault();
21+
navigate("/test-route");
22+
}}
23+
>
24+
Go to test route
25+
</Link>
26+
);
27+
};
28+
29+
describe("NavigationProvider", () => {
30+
beforeEach(() => {
31+
jest.clearAllMocks();
32+
(useRouter as jest.Mock).mockReturnValue({
33+
push: mockPush,
34+
});
35+
});
36+
37+
it("navigates using router.push when link is clicked", () => {
38+
render(
39+
<NavigationProvider>
40+
<TestLink />
41+
</NavigationProvider>,
42+
);
43+
44+
fireEvent.click(screen.getByText("Go to test route"));
45+
46+
expect(mockPush).toHaveBeenCalledWith("/test-route");
47+
expect(mockPush).toHaveBeenCalledTimes(1);
48+
});
49+
50+
it("does not navigate when transition is pending", () => {
51+
jest.spyOn(React, "useTransition").mockReturnValue([true, jest.fn()]);
52+
53+
render(
54+
<NavigationProvider>
55+
<TestLink />
56+
</NavigationProvider>,
57+
);
58+
59+
fireEvent.click(screen.getByText("Go to test route"));
60+
61+
expect(mockPush).not.toHaveBeenCalled();
62+
});
63+
64+
it("throws when hook is used outside NavigationProvider", () => {
65+
expect(() => render(<TestLink />)).toThrow("useNavigationTransition must be used inside NavigationProvider");
66+
});
67+
});
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"use client";
2+
3+
import { useRouter } from "next/navigation";
4+
import { createContext, useContext, useTransition } from "react";
5+
6+
type NavigationContextType = {
7+
navigate: (href: string) => void;
8+
isPending: boolean;
9+
};
10+
11+
const NavigationContext = createContext<NavigationContextType | null>(null);
12+
13+
const NavigationProvider = ({ children }: { children: React.ReactNode }) => {
14+
const router = useRouter();
15+
const [isPending, startTransition] = useTransition();
16+
17+
const navigate = (href: string) => {
18+
if (isPending) return;
19+
20+
startTransition(() => {
21+
router.push(href);
22+
});
23+
};
24+
25+
return <NavigationContext.Provider value={{ navigate, isPending }}>{children}</NavigationContext.Provider>;
26+
};
27+
28+
const useNavigationTransition = () => {
29+
const context = useContext(NavigationContext);
30+
if (!context) throw new Error("useNavigationTransition must be used inside NavigationProvider");
31+
return context;
32+
};
33+
34+
export { useNavigationTransition, NavigationProvider };

src/app/_components/hub/AgeBasedHubCards.test.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,18 @@
1+
import { useNavigationTransition } from "@src/app/_components/context/NavigationContext";
12
import { AgeBasedHubCards } from "@src/app/_components/hub/AgeBasedHubCards";
23
import { AgeGroup } from "@src/models/ageBasedHub";
34
import { VaccineInfo, VaccineType } from "@src/models/vaccine";
45
import { render, screen } from "@testing-library/react";
56

7+
jest.mock("@src/app/_components/context/NavigationContext", () => ({
8+
useNavigationTransition: jest.fn(),
9+
}));
10+
11+
(useNavigationTransition as jest.Mock).mockReturnValue({
12+
navigate: jest.fn(),
13+
isPending: false,
14+
});
15+
616
const testData = [
717
{
818
ageGroup: AgeGroup.AGE_12_to_16,

src/app/_components/hub/PregnancyHubContent.test.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
1+
import { useNavigationTransition } from "@src/app/_components/context/NavigationContext";
12
import { PregnancyHubContent } from "@src/app/_components/hub/PregnancyHubContent";
23
import { render, screen } from "@testing-library/react";
34

5+
jest.mock("@src/app/_components/context/NavigationContext", () => ({
6+
useNavigationTransition: jest.fn(),
7+
}));
8+
9+
(useNavigationTransition as jest.Mock).mockReturnValue({
10+
navigate: jest.fn(),
11+
isPending: false,
12+
});
13+
414
describe("Pregnancy hub content", () => {
515
beforeEach(() => {
616
render(<PregnancyHubContent />);
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { useNavigationTransition } from "@src/app/_components/context/NavigationContext";
2+
import { TransitionLink } from "@src/app/_components/navigation/TransitionLink";
3+
import { fireEvent, render, screen } from "@testing-library/react";
4+
import React from "react";
5+
6+
jest.mock("@src/app/_components/context/NavigationContext", () => ({
7+
useNavigationTransition: jest.fn(),
8+
}));
9+
10+
const mockNavigate = jest.fn();
11+
12+
describe("TransitionLink", () => {
13+
beforeEach(() => {
14+
jest.clearAllMocks();
15+
});
16+
17+
it("calls navigate with href when clicked", () => {
18+
(useNavigationTransition as jest.Mock).mockReturnValue({
19+
navigate: mockNavigate,
20+
isPending: false,
21+
});
22+
23+
render(<TransitionLink href="/test-route">Go to test</TransitionLink>);
24+
25+
fireEvent.click(screen.getByText("Go to test"));
26+
27+
expect(mockNavigate).toHaveBeenCalledWith("/test-route");
28+
expect(mockNavigate).toHaveBeenCalledTimes(1);
29+
});
30+
31+
it("sets aria-disabled when navigation is pending", () => {
32+
(useNavigationTransition as jest.Mock).mockReturnValue({
33+
navigate: mockNavigate,
34+
isPending: true,
35+
});
36+
37+
render(<TransitionLink href="/test-route">Go to test</TransitionLink>);
38+
39+
const link = screen.getByRole("link");
40+
41+
expect(link).toHaveAttribute("aria-disabled", "true");
42+
});
43+
44+
it("passes className and target through to the link", () => {
45+
(useNavigationTransition as jest.Mock).mockReturnValue({
46+
navigate: mockNavigate,
47+
isPending: false,
48+
});
49+
50+
render(
51+
<TransitionLink href="/test-route" className="test-class" target="_blank">
52+
Go to test
53+
</TransitionLink>,
54+
);
55+
56+
const link = screen.getByRole("link");
57+
58+
expect(link).toHaveClass("test-class");
59+
expect(link).toHaveAttribute("target", "_blank");
60+
});
61+
});
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"use client";
2+
3+
import { useNavigationTransition } from "@src/app/_components/context/NavigationContext";
4+
import Link from "next/link";
5+
import { MouseEvent } from "react";
6+
7+
// This is a reusable Link component that can be used to prevent multiple clicks.
8+
export function TransitionLink({
9+
href,
10+
children,
11+
className,
12+
target,
13+
}: {
14+
href: string;
15+
children: React.ReactNode;
16+
className?: string;
17+
target?: string;
18+
}) {
19+
const { navigate, isPending } = useNavigationTransition();
20+
21+
const onClick = (e: MouseEvent<HTMLAnchorElement>) => {
22+
e.preventDefault();
23+
navigate(href);
24+
};
25+
26+
return (
27+
<Link
28+
href={href}
29+
onClick={onClick}
30+
aria-disabled={isPending}
31+
className={className}
32+
prefetch={false}
33+
target={target}
34+
>
35+
{children}
36+
</Link>
37+
);
38+
}

src/app/_components/nhs-app/CardLink.test.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
1+
import { useNavigationTransition } from "@src/app/_components/context/NavigationContext";
12
import CardLink from "@src/app/_components/nhs-app/CardLink";
23
import { render, screen } from "@testing-library/react";
34

5+
jest.mock("@src/app/_components/context/NavigationContext", () => ({
6+
useNavigationTransition: jest.fn(),
7+
}));
8+
9+
(useNavigationTransition as jest.Mock).mockReturnValue({
10+
navigate: jest.fn(),
11+
isPending: false,
12+
});
13+
414
describe("CardLink", () => {
515
it("should render component", () => {
616
render(<CardLink title="Flu" link="/vaccines/flu" />);

src/app/_components/nhs-app/CardLink.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import Link from "next/link";
1+
import { TransitionLink } from "@src/app/_components/navigation/TransitionLink";
22

33
interface CardProps {
44
title: string;
@@ -11,9 +11,9 @@ const CardLink = (cardProps: CardProps) => {
1111
<li className="nhsapp-card">
1212
<div className="nhsapp-card__container">
1313
<div className="nhsapp-card__content">
14-
<Link prefetch={false} href={cardProps.link} className="nhsapp-card__link nhsuk-link--no-visited-state">
14+
<TransitionLink href={cardProps.link} className="nhsapp-card__link nhsuk-link--no-visited-state">
1515
{cardProps.title}
16-
</Link>
16+
</TransitionLink>
1717
</div>
1818
<svg
1919
className="nhsapp-icon nhsapp-icon--chevron-right"

src/app/_components/nhs-app/CardLinkWithDescription.test.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
1+
import { useNavigationTransition } from "@src/app/_components/context/NavigationContext";
12
import CardLinkWithDescription from "@src/app/_components/nhs-app/CardLinkWithDescription";
23
import { render, screen } from "@testing-library/react";
34

5+
jest.mock("@src/app/_components/context/NavigationContext", () => ({
6+
useNavigationTransition: jest.fn(),
7+
}));
8+
9+
(useNavigationTransition as jest.Mock).mockReturnValue({
10+
navigate: jest.fn(),
11+
isPending: false,
12+
});
13+
414
describe("CardLinkWithDescription", () => {
515
it("should render component", () => {
616
render(<CardLinkWithDescription title="Flu" description="Around 12 weeks" link="/vaccines/flu" />);

0 commit comments

Comments
 (0)