Skip to content

Commit cfa5bc1

Browse files
committed
Enhanced Link component to handle language switching and persistance properly
1 parent 161bcf2 commit cfa5bc1

File tree

6 files changed

+232
-6
lines changed

6 files changed

+232
-6
lines changed

app/library/language-switcher/LanguageSwitcher.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,18 @@ import { Link } from "../link"
66
const LanguageSwitcher = () => {
77
const { i18n } = useTranslation()
88
const location = useLocation()
9-
const to = location.pathname
109

1110
return (
1211
<div className="flex gap-2 p-2 fixed top-0 right-0 w-min z-10">
1312
{supportedLanguages.map((language) => (
1413
<Link
1514
className="text-blue-500 dark:text-white hover:underline transition-all"
1615
key={language}
17-
to={`${to}?lng=${language}`}
16+
to={`${location.pathname}`}
17+
// We override the default appending of the language to the search params via our language
18+
language={language}
19+
// We keep the search params if any on language change
20+
keepSearchParams
1821
onClick={() => i18n.changeLanguage(language)}
1922
>
2023
{language}
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { waitFor } from "@testing-library/react"
2+
import { userEvent } from "@vitest/browser/context"
3+
import { useLocation } from "react-router"
4+
import type { StubRouteEntry } from "tests/setup.browser"
5+
import { Link, type LinkProps } from "./link"
6+
const getEntries: (linkProps?: LinkProps) => StubRouteEntry[] = (linkProps) => [
7+
{
8+
path: "/first",
9+
Component: () => {
10+
const url = useLocation()
11+
return (
12+
<>
13+
<p>
14+
{url.pathname} + {url.search}
15+
</p>
16+
<Link {...linkProps} to="/second">
17+
go
18+
</Link>
19+
</>
20+
)
21+
},
22+
},
23+
{
24+
path: "/second",
25+
Component: () => {
26+
const url = useLocation()
27+
return (
28+
<>
29+
<p>
30+
{url.pathname}
31+
{url.search}
32+
</p>
33+
<Link to="/first">go</Link>
34+
</>
35+
)
36+
},
37+
},
38+
]
39+
describe("Link", () => {
40+
it("if the url is /first and you redirect to /second nothing is added to the url", async ({ renderStub }) => {
41+
const { getByText } = await renderStub({
42+
entries: getEntries(),
43+
props: {
44+
initialEntries: ["/first"],
45+
},
46+
})
47+
const link = getByText("go")
48+
await userEvent.click(link)
49+
const url = getByText("/second")
50+
expect(url).toBeDefined()
51+
await waitFor(() => {
52+
expect(url.element()).toBeDefined()
53+
expect(url.element()).toHaveTextContent("/second")
54+
})
55+
})
56+
57+
it("if the url is /first?a=1 and you redirect to /second without keepSearchParams nothing is added to the url", async ({
58+
renderStub,
59+
}) => {
60+
const { getByText } = await renderStub({
61+
entries: getEntries(),
62+
props: {
63+
initialEntries: ["/first?a=1"],
64+
},
65+
})
66+
const link = getByText("go")
67+
await userEvent.click(link)
68+
const url = getByText("/second")
69+
await waitFor(() => {
70+
expect(url.element()).toBeDefined()
71+
expect(url.element()).toHaveTextContent("/second")
72+
})
73+
})
74+
75+
it("if the url is /first?a=1 and you redirect to /second with keepSearchParams search params are kept", async ({
76+
renderStub,
77+
}) => {
78+
const { getByText } = await renderStub({
79+
entries: getEntries({ keepSearchParams: true, to: "/second" }),
80+
props: {
81+
initialEntries: ["/first?a=1"],
82+
},
83+
})
84+
const link = getByText("go")
85+
await userEvent.click(link)
86+
const url = getByText("/second")
87+
await waitFor(() => {
88+
expect(url.element()).toBeDefined()
89+
expect(url.element()).toHaveTextContent("/second?a=1")
90+
})
91+
})
92+
93+
it("if the url is /first?a=1&lng=en and you redirect to /second with keepSearchParams search params and language are kept", async ({
94+
renderStub,
95+
}) => {
96+
const { getByText } = await renderStub({
97+
entries: getEntries({ keepSearchParams: true, to: "/second" }),
98+
props: {
99+
initialEntries: ["/first?a=1&lng=en"],
100+
},
101+
})
102+
const link = getByText("go")
103+
await userEvent.click(link)
104+
const url = getByText("/second")
105+
await waitFor(() => {
106+
expect(url.element()).toBeDefined()
107+
expect(url.element()).toHaveTextContent("/second?a=1&lng=en")
108+
})
109+
})
110+
111+
it("if the url is /first?a=1&lng=en and you redirect to /second without keepSearchParams language is kept", async ({
112+
renderStub,
113+
}) => {
114+
const { getByText } = await renderStub({
115+
entries: getEntries({ to: "/second" }),
116+
props: {
117+
initialEntries: ["/first?lng=en"],
118+
},
119+
})
120+
const link = getByText("go")
121+
await userEvent.click(link)
122+
const url = getByText("/second")
123+
await waitFor(() => {
124+
expect(url.element()).toBeDefined()
125+
expect(url.element()).toHaveTextContent("/second?lng=en")
126+
})
127+
})
128+
129+
it("if the url is /first?a=1&lng=en and you redirect to /second with a language override it is changed and search params are removed", async ({
130+
renderStub,
131+
}) => {
132+
const { getByText } = await renderStub({
133+
entries: getEntries({ to: "/second", language: "bs" }),
134+
props: {
135+
initialEntries: ["/first?lng=en"],
136+
},
137+
})
138+
const link = getByText("go")
139+
await userEvent.click(link)
140+
const url = getByText("/second")
141+
await waitFor(() => {
142+
expect(url.element()).toBeDefined()
143+
expect(url.element()).toHaveTextContent("/second?lng=bs")
144+
})
145+
})
146+
147+
it("if the url is /first?a=1&lng=en and you redirect to /second with a language override it is changed and search params are kept with keepSearchParams", async ({
148+
renderStub,
149+
}) => {
150+
const { getByText } = await renderStub({
151+
entries: getEntries({ to: "/second", language: "bs", keepSearchParams: true }),
152+
props: {
153+
initialEntries: ["/first?a=a&lng=en"],
154+
},
155+
})
156+
const link = getByText("go")
157+
await userEvent.click(link)
158+
const url = getByText("/second")
159+
await waitFor(() => {
160+
expect(url.element()).toBeDefined()
161+
expect(url.element()).toHaveTextContent("/second?a=a&lng=bs")
162+
})
163+
})
164+
})

app/library/link/link.tsx

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,20 @@
11
import { Link as ReactRouterLink, type LinkProps as ReactRouterLinkProps } from "react-router"
2+
import type { Language } from "~/localization/resource"
3+
import { useEnhancedTo } from "./useEnhancedTo"
24

3-
interface LinkProps extends ReactRouterLinkProps {}
5+
export interface LinkProps extends ReactRouterLinkProps {
6+
keepSearchParams?: boolean
7+
language?: Language
8+
}
49

5-
export const Link = ({ prefetch = "intent", viewTransition = true, ...props }: LinkProps) => {
6-
return <ReactRouterLink prefetch={prefetch} viewTransition={viewTransition} {...props} />
10+
export const Link = ({
11+
prefetch = "intent",
12+
viewTransition = true,
13+
keepSearchParams = false,
14+
to,
15+
language,
16+
...props
17+
}: LinkProps) => {
18+
const enhancedTo = useEnhancedTo({ language, to, keepSearchParams })
19+
return <ReactRouterLink prefetch={prefetch} viewTransition={viewTransition} to={enhancedTo} {...props} />
720
}

app/library/link/useEnhancedTo.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { type To, useSearchParams } from "react-router"
2+
import type { Language } from "~/localization/resource"
3+
4+
5+
/**
6+
* Enhances the default to prop by adding the language to the search params and conditionally keeping the search params
7+
* @param language The language to use over the search param language
8+
* @param to The new location to navigate to
9+
* @param keepSearchParams Whether to keep the search params or not
10+
*
11+
* @example
12+
* ```tsx
13+
* // override the language
14+
* function Component(){
15+
* const enhancedTo = useEnhancedTo({ language: "en", to: "/" })
16+
* return <Link to={enhancedTo} /> // Will navigate to /?lng=en even if the current url contains a different lanugage
17+
* }
18+
*
19+
* function Component(){
20+
* const enhancedTo = useEnhancedTo({ to: "/" })
21+
* return <Link to={enhancedTo} /> // Will navigate to /?lng=X where X is the current language in the url search params, or just to / if no language is found
22+
* }
23+
*
24+
* function Component(){
25+
* const enhancedTo = useEnhancedTo({ to: "/", keepSearchParams: true })
26+
* return <Link to={enhancedTo} /> // Will navigate to /?params=from_the_url_search_params&lng=en
27+
* }
28+
* ```
29+
*/
30+
export const useEnhancedTo = ({
31+
language,
32+
to,
33+
keepSearchParams,
34+
}: { language?: Language; to: To; keepSearchParams?: boolean }) => {
35+
const [params] = useSearchParams()
36+
const { lng, ...searchParams } = Object.fromEntries(params.entries())
37+
// allow language override for language switcher or manually setting the language in specific cases
38+
const lang = language ?? params.get("lng")
39+
const newSearchParams = new URLSearchParams(searchParams)
40+
const searchString = newSearchParams.toString()
41+
const hasSearchParams = searchString.length > 0
42+
const appendSearchParams = lang || hasSearchParams
43+
return to + (appendSearchParams ? `?${keepSearchParams && hasSearchParams ? `${searchString}${lang ? "&" : ""}` : ""}${lang ? `lng=${lang}` : ""}` : "")
44+
}

tests/setup.browser.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { Outlet, type RoutesTestStubProps, createRoutesStub } from "react-router
66
import { render } from "vitest-browser-react"
77
import i18n from "~/localization/i18n"
88
import { type Language, type Namespace, resources } from "~/localization/resource"
9-
type StubRouteEntry = Parameters<typeof createRoutesStub>[0][0]
9+
export type StubRouteEntry = Parameters<typeof createRoutesStub>[0][0]
1010

1111
const renderStub = async (args?: {
1212
props?: RoutesTestStubProps

vitest.workspace.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,11 @@ export default defineWorkspace([
2626
include: ["./**.test.{ts,tsx}", "./**.browser.test.{ts,tsx}", "!./**.server.test.{ts,tsx}"],
2727
setupFiles: ["./tests/setup.browser.tsx"],
2828
name: "browser tests",
29+
2930
browser: {
3031
enabled: true,
3132
instances: [{ browser: "chromium" }],
33+
3234
provider: "playwright",
3335
// https://playwright.dev
3436
//providerOptions: {},

0 commit comments

Comments
 (0)