From cfa5bc1f64dac5087e75a2b22fbaa08a811bd281 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 12 Mar 2025 15:24:04 +0100 Subject: [PATCH 1/3] Enhanced Link component to handle language switching and persistance properly --- .../language-switcher/LanguageSwitcher.tsx | 7 +- app/library/link/Link.browser.test.tsx | 164 ++++++++++++++++++ app/library/link/link.tsx | 19 +- app/library/link/useEnhancedTo.ts | 44 +++++ tests/setup.browser.tsx | 2 +- vitest.workspace.ts | 2 + 6 files changed, 232 insertions(+), 6 deletions(-) create mode 100644 app/library/link/Link.browser.test.tsx create mode 100644 app/library/link/useEnhancedTo.ts diff --git a/app/library/language-switcher/LanguageSwitcher.tsx b/app/library/language-switcher/LanguageSwitcher.tsx index 85294f7dd..3602fba9b 100644 --- a/app/library/language-switcher/LanguageSwitcher.tsx +++ b/app/library/language-switcher/LanguageSwitcher.tsx @@ -6,7 +6,6 @@ import { Link } from "../link" const LanguageSwitcher = () => { const { i18n } = useTranslation() const location = useLocation() - const to = location.pathname return (
@@ -14,7 +13,11 @@ const LanguageSwitcher = () => { i18n.changeLanguage(language)} > {language} diff --git a/app/library/link/Link.browser.test.tsx b/app/library/link/Link.browser.test.tsx new file mode 100644 index 000000000..626d12d33 --- /dev/null +++ b/app/library/link/Link.browser.test.tsx @@ -0,0 +1,164 @@ +import { waitFor } from "@testing-library/react" +import { userEvent } from "@vitest/browser/context" +import { useLocation } from "react-router" +import type { StubRouteEntry } from "tests/setup.browser" +import { Link, type LinkProps } from "./link" +const getEntries: (linkProps?: LinkProps) => StubRouteEntry[] = (linkProps) => [ + { + path: "/first", + Component: () => { + const url = useLocation() + return ( + <> +

+ {url.pathname} + {url.search} +

+ + go + + + ) + }, + }, + { + path: "/second", + Component: () => { + const url = useLocation() + return ( + <> +

+ {url.pathname} + {url.search} +

+ go + + ) + }, + }, +] +describe("Link", () => { + it("if the url is /first and you redirect to /second nothing is added to the url", async ({ renderStub }) => { + const { getByText } = await renderStub({ + entries: getEntries(), + props: { + initialEntries: ["/first"], + }, + }) + const link = getByText("go") + await userEvent.click(link) + const url = getByText("/second") + expect(url).toBeDefined() + await waitFor(() => { + expect(url.element()).toBeDefined() + expect(url.element()).toHaveTextContent("/second") + }) + }) + + it("if the url is /first?a=1 and you redirect to /second without keepSearchParams nothing is added to the url", async ({ + renderStub, + }) => { + const { getByText } = await renderStub({ + entries: getEntries(), + props: { + initialEntries: ["/first?a=1"], + }, + }) + const link = getByText("go") + await userEvent.click(link) + const url = getByText("/second") + await waitFor(() => { + expect(url.element()).toBeDefined() + expect(url.element()).toHaveTextContent("/second") + }) + }) + + it("if the url is /first?a=1 and you redirect to /second with keepSearchParams search params are kept", async ({ + renderStub, + }) => { + const { getByText } = await renderStub({ + entries: getEntries({ keepSearchParams: true, to: "/second" }), + props: { + initialEntries: ["/first?a=1"], + }, + }) + const link = getByText("go") + await userEvent.click(link) + const url = getByText("/second") + await waitFor(() => { + expect(url.element()).toBeDefined() + expect(url.element()).toHaveTextContent("/second?a=1") + }) + }) + + it("if the url is /first?a=1&lng=en and you redirect to /second with keepSearchParams search params and language are kept", async ({ + renderStub, + }) => { + const { getByText } = await renderStub({ + entries: getEntries({ keepSearchParams: true, to: "/second" }), + props: { + initialEntries: ["/first?a=1&lng=en"], + }, + }) + const link = getByText("go") + await userEvent.click(link) + const url = getByText("/second") + await waitFor(() => { + expect(url.element()).toBeDefined() + expect(url.element()).toHaveTextContent("/second?a=1&lng=en") + }) + }) + + it("if the url is /first?a=1&lng=en and you redirect to /second without keepSearchParams language is kept", async ({ + renderStub, + }) => { + const { getByText } = await renderStub({ + entries: getEntries({ to: "/second" }), + props: { + initialEntries: ["/first?lng=en"], + }, + }) + const link = getByText("go") + await userEvent.click(link) + const url = getByText("/second") + await waitFor(() => { + expect(url.element()).toBeDefined() + expect(url.element()).toHaveTextContent("/second?lng=en") + }) + }) + + 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 ({ + renderStub, + }) => { + const { getByText } = await renderStub({ + entries: getEntries({ to: "/second", language: "bs" }), + props: { + initialEntries: ["/first?lng=en"], + }, + }) + const link = getByText("go") + await userEvent.click(link) + const url = getByText("/second") + await waitFor(() => { + expect(url.element()).toBeDefined() + expect(url.element()).toHaveTextContent("/second?lng=bs") + }) + }) + + 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 ({ + renderStub, + }) => { + const { getByText } = await renderStub({ + entries: getEntries({ to: "/second", language: "bs", keepSearchParams: true }), + props: { + initialEntries: ["/first?a=a&lng=en"], + }, + }) + const link = getByText("go") + await userEvent.click(link) + const url = getByText("/second") + await waitFor(() => { + expect(url.element()).toBeDefined() + expect(url.element()).toHaveTextContent("/second?a=a&lng=bs") + }) + }) +}) diff --git a/app/library/link/link.tsx b/app/library/link/link.tsx index 3d7db669d..c3dd8a035 100644 --- a/app/library/link/link.tsx +++ b/app/library/link/link.tsx @@ -1,7 +1,20 @@ import { Link as ReactRouterLink, type LinkProps as ReactRouterLinkProps } from "react-router" +import type { Language } from "~/localization/resource" +import { useEnhancedTo } from "./useEnhancedTo" -interface LinkProps extends ReactRouterLinkProps {} +export interface LinkProps extends ReactRouterLinkProps { + keepSearchParams?: boolean + language?: Language +} -export const Link = ({ prefetch = "intent", viewTransition = true, ...props }: LinkProps) => { - return +export const Link = ({ + prefetch = "intent", + viewTransition = true, + keepSearchParams = false, + to, + language, + ...props +}: LinkProps) => { + const enhancedTo = useEnhancedTo({ language, to, keepSearchParams }) + return } diff --git a/app/library/link/useEnhancedTo.ts b/app/library/link/useEnhancedTo.ts new file mode 100644 index 000000000..adb2ad92d --- /dev/null +++ b/app/library/link/useEnhancedTo.ts @@ -0,0 +1,44 @@ +import { type To, useSearchParams } from "react-router" +import type { Language } from "~/localization/resource" + + +/** + * Enhances the default to prop by adding the language to the search params and conditionally keeping the search params + * @param language The language to use over the search param language + * @param to The new location to navigate to + * @param keepSearchParams Whether to keep the search params or not + * + * @example + * ```tsx + * // override the language + * function Component(){ + * const enhancedTo = useEnhancedTo({ language: "en", to: "/" }) + * return // Will navigate to /?lng=en even if the current url contains a different lanugage + * } + * + * function Component(){ + * const enhancedTo = useEnhancedTo({ to: "/" }) + * return // Will navigate to /?lng=X where X is the current language in the url search params, or just to / if no language is found + * } + * + * function Component(){ + * const enhancedTo = useEnhancedTo({ to: "/", keepSearchParams: true }) + * return // Will navigate to /?params=from_the_url_search_params&lng=en + * } + * ``` + */ +export const useEnhancedTo = ({ + language, + to, + keepSearchParams, +}: { language?: Language; to: To; keepSearchParams?: boolean }) => { + const [params] = useSearchParams() + const { lng, ...searchParams } = Object.fromEntries(params.entries()) + // allow language override for language switcher or manually setting the language in specific cases + const lang = language ?? params.get("lng") + const newSearchParams = new URLSearchParams(searchParams) + const searchString = newSearchParams.toString() + const hasSearchParams = searchString.length > 0 + const appendSearchParams = lang || hasSearchParams + return to + (appendSearchParams ? `?${keepSearchParams && hasSearchParams ? `${searchString}${lang ? "&" : ""}` : ""}${lang ? `lng=${lang}` : ""}` : "") +} diff --git a/tests/setup.browser.tsx b/tests/setup.browser.tsx index dae9d3ea5..2be6f943f 100644 --- a/tests/setup.browser.tsx +++ b/tests/setup.browser.tsx @@ -6,7 +6,7 @@ import { Outlet, type RoutesTestStubProps, createRoutesStub } from "react-router import { render } from "vitest-browser-react" import i18n from "~/localization/i18n" import { type Language, type Namespace, resources } from "~/localization/resource" -type StubRouteEntry = Parameters[0][0] +export type StubRouteEntry = Parameters[0][0] const renderStub = async (args?: { props?: RoutesTestStubProps diff --git a/vitest.workspace.ts b/vitest.workspace.ts index 29ce3a95a..39e33fb2a 100644 --- a/vitest.workspace.ts +++ b/vitest.workspace.ts @@ -26,9 +26,11 @@ export default defineWorkspace([ include: ["./**.test.{ts,tsx}", "./**.browser.test.{ts,tsx}", "!./**.server.test.{ts,tsx}"], setupFiles: ["./tests/setup.browser.tsx"], name: "browser tests", + browser: { enabled: true, instances: [{ browser: "chromium" }], + provider: "playwright", // https://playwright.dev //providerOptions: {}, From 6074195c02b6adb2a2ea75c0fb60884c52a77c5b Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 12 Mar 2025 15:46:47 +0100 Subject: [PATCH 2/3] fix --- app/library/link/useEnhancedTo.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/library/link/useEnhancedTo.ts b/app/library/link/useEnhancedTo.ts index adb2ad92d..27842f228 100644 --- a/app/library/link/useEnhancedTo.ts +++ b/app/library/link/useEnhancedTo.ts @@ -1,7 +1,6 @@ import { type To, useSearchParams } from "react-router" import type { Language } from "~/localization/resource" - /** * Enhances the default to prop by adding the language to the search params and conditionally keeping the search params * @param language The language to use over the search param language @@ -40,5 +39,10 @@ export const useEnhancedTo = ({ const searchString = newSearchParams.toString() const hasSearchParams = searchString.length > 0 const appendSearchParams = lang || hasSearchParams - return to + (appendSearchParams ? `?${keepSearchParams && hasSearchParams ? `${searchString}${lang ? "&" : ""}` : ""}${lang ? `lng=${lang}` : ""}` : "") + return ( + to + + (appendSearchParams + ? `?${keepSearchParams && hasSearchParams ? `${searchString}${lang ? "&" : ""}` : ""}${lang ? `lng=${lang}` : ""}` + : "") + ) } From de90f5bf14ede1c4b23aa5b187b20ffbffbb4e07 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Wed, 9 Apr 2025 11:10:41 +0200 Subject: [PATCH 3/3] update --- app/library/link/useEnhancedTo.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/app/library/link/useEnhancedTo.ts b/app/library/link/useEnhancedTo.ts index 27842f228..4f6afacb9 100644 --- a/app/library/link/useEnhancedTo.ts +++ b/app/library/link/useEnhancedTo.ts @@ -1,3 +1,4 @@ +import { useMemo } from "react" import { type To, useSearchParams } from "react-router" import type { Language } from "~/localization/resource" @@ -39,10 +40,13 @@ export const useEnhancedTo = ({ const searchString = newSearchParams.toString() const hasSearchParams = searchString.length > 0 const appendSearchParams = lang || hasSearchParams - return ( - to + - (appendSearchParams - ? `?${keepSearchParams && hasSearchParams ? `${searchString}${lang ? "&" : ""}` : ""}${lang ? `lng=${lang}` : ""}` - : "") + const newPath = useMemo( + () => + to + + (appendSearchParams + ? `?${keepSearchParams && hasSearchParams ? `${searchString}${lang ? "&" : ""}` : ""}${lang ? `lng=${lang}` : ""}` + : ""), + [to, appendSearchParams, keepSearchParams, hasSearchParams, searchString, lang] ) + return newPath }