diff --git a/app/entry.server.tsx b/app/entry.server.tsx index e571f19d6..e8dc1f9eb 100644 --- a/app/entry.server.tsx +++ b/app/entry.server.tsx @@ -1,21 +1,19 @@ import { PassThrough } from "node:stream"; import { createReadableStreamFromReadable } from "@react-router/node"; import * as Sentry from "@sentry/react-router"; -import { createInstance } from "i18next"; import { isbot } from "isbot"; import cron from "node-cron"; import { renderToPipeableStream } from "react-dom/server"; -import { I18nextProvider, initReactI18next } from "react-i18next"; +import { I18nextProvider } from "react-i18next"; import { type EntryContext, type HandleErrorFunction, + type RouterContextProvider, ServerRouter, } from "react-router"; import { Config } from "~/config"; import { ServerConfig } from "~/config.server"; -import { config } from "~/modules/i18n/config"; // your i18n configuration file -import { i18next } from "~/modules/i18n/i18next.server"; -import { resources } from "./modules/i18n/resources.server"; +import { getI18nInstance } from "~/modules/i18n/i18next.server"; import { daily, everyHourAt00, @@ -34,23 +32,13 @@ async function handleRequest( responseStatusCode: number, responseHeaders: Headers, reactRouterContext: EntryContext, + loadContext: RouterContextProvider, ) { const callbackName = isbot(request.headers.get("user-agent")) ? "onAllReady" : "onShellReady"; - const instance = createInstance(); - const lng = await i18next.getLocale(request); - const ns = i18next.getRouteNamespaces(reactRouterContext); - - await instance - .use(initReactI18next) // Tell our instance to use react-i18next - .init({ - ...config, // spread the configuration - lng, // The locale we detected above - ns, // The namespaces the routes about to render wants to use - resources, - }); + const instance = getI18nInstance(loadContext); return new Promise((resolve, reject) => { let didError = false; diff --git a/app/features/api-public/routes/sendouq.match.$matchId.ts b/app/features/api-public/routes/sendouq.match.$matchId.ts index 0f4ac2a2f..cebda392e 100644 --- a/app/features/api-public/routes/sendouq.match.$matchId.ts +++ b/app/features/api-public/routes/sendouq.match.$matchId.ts @@ -1,7 +1,7 @@ import type { LoaderFunctionArgs } from "react-router"; import { z } from "zod"; import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server"; -import { i18next } from "~/modules/i18n/i18next.server"; +import { getFixedTForLanguage } from "~/modules/i18n/i18next.server"; import { notFoundIfFalsy, parseParams } from "~/utils/remix.server"; import { id } from "~/utils/zod"; import type { GetSendouqMatchResponse, MapListMap } from "../schema"; @@ -18,7 +18,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { const match = notFoundIfFalsy(await SQMatchRepository.findById(matchId)); - const t = await i18next.getFixedT("en", ["game-misc"]); + const t = await getFixedTForLanguage("en", ["game-misc"]); const userIdToRank = (userId: number) => { const memento = match.memento; diff --git a/app/features/api-public/routes/tournament-match.$id.ts b/app/features/api-public/routes/tournament-match.$id.ts index 91d504aae..354fa3606 100644 --- a/app/features/api-public/routes/tournament-match.$id.ts +++ b/app/features/api-public/routes/tournament-match.$id.ts @@ -6,7 +6,7 @@ import * as TournamentRepository from "~/features/tournament/TournamentRepositor import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server"; import { tournamentFromDBCached } from "~/features/tournament-bracket/core/Tournament.server"; import { resolveMapList } from "~/features/tournament-match/core/mapList.server"; -import { i18next } from "~/modules/i18n/i18next.server"; +import { getFixedTForLanguage } from "~/modules/i18n/i18next.server"; import { logger } from "~/utils/logger"; import { notFoundIfFalsy, parseParams } from "~/utils/remix.server"; import { id } from "~/utils/zod"; @@ -17,7 +17,7 @@ const paramsSchema = z.object({ }); export const loader = async ({ params }: LoaderFunctionArgs) => { - const t = await i18next.getFixedT("en", ["game-misc"]); + const t = await getFixedTForLanguage("en", ["game-misc"]); const { id } = parseParams({ params, schema: paramsSchema, diff --git a/app/features/api-public/routes/tournament.$id.teams.ts b/app/features/api-public/routes/tournament.$id.teams.ts index 8018dd439..cdfe5f2e2 100644 --- a/app/features/api-public/routes/tournament.$id.teams.ts +++ b/app/features/api-public/routes/tournament.$id.teams.ts @@ -5,7 +5,7 @@ import { z } from "zod"; import { db } from "~/db/sql"; import { ordinalToSp } from "~/features/mmr/mmr-utils"; import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; -import { i18next } from "~/modules/i18n/i18next.server"; +import { getFixedTForLanguage } from "~/modules/i18n/i18next.server"; import { nullifyingAvg } from "~/utils/arrays"; import { databaseTimestampToDate } from "~/utils/dates"; import { concatUserSubmittedImagePrefix } from "~/utils/kysely.server"; @@ -18,7 +18,7 @@ const paramsSchema = z.object({ }); export const loader = async ({ params }: LoaderFunctionArgs) => { - const t = await i18next.getFixedT("en", ["game-misc"]); + const t = await getFixedTForLanguage("en", ["game-misc"]); const { id } = parseParams({ params, schema: paramsSchema, diff --git a/app/features/api-public/routes/user.$identifier.ts b/app/features/api-public/routes/user.$identifier.ts index e57b6e3e4..e51cd5c3b 100644 --- a/app/features/api-public/routes/user.$identifier.ts +++ b/app/features/api-public/routes/user.$identifier.ts @@ -6,7 +6,7 @@ import * as Seasons from "~/features/mmr/core/Seasons"; import { userSkills as _userSkills } from "~/features/mmr/tiered.server"; import { peakXpOverallSql } from "~/features/top-search/XRankPlacementRepository.server"; import * as UserRepository from "~/features/user-page/UserRepository.server"; -import { i18next } from "~/modules/i18n/i18next.server"; +import { getFixedTForLanguage } from "~/modules/i18n/i18next.server"; import { safeNumberParse } from "~/utils/number"; import { notFoundIfFalsy, parseParams } from "~/utils/remix.server"; import { badgeUrl } from "~/utils/urls"; @@ -17,7 +17,7 @@ const paramsSchema = z.object({ }); export const loader = async ({ params }: LoaderFunctionArgs) => { - const t = await i18next.getFixedT("en", ["weapons"]); + const t = await getFixedTForLanguage("en", ["weapons"]); const { identifier } = parseParams({ params, schema: paramsSchema }); const user = notFoundIfFalsy( diff --git a/app/features/build-stats/loaders/builds.$slug.popular.server.ts b/app/features/build-stats/loaders/builds.$slug.popular.server.ts index 85a559f06..7b0d81c12 100644 --- a/app/features/build-stats/loaders/builds.$slug.popular.server.ts +++ b/app/features/build-stats/loaders/builds.$slug.popular.server.ts @@ -1,15 +1,15 @@ import { cachified } from "@epic-web/cachified"; import type { LoaderFunctionArgs } from "react-router"; import * as BuildRepository from "~/features/builds/BuildRepository.server"; -import { i18next } from "~/modules/i18n/i18next.server"; +import { getServerTFunction } from "~/modules/i18n/i18next.server"; import { weaponIdToType } from "~/modules/in-game-lists/weapon-ids"; import { cache, IN_MILLISECONDS, ttl } from "~/utils/cache.server"; import { notFoundIfNullLike } from "~/utils/remix.server"; import { weaponNameSlugToId } from "~/utils/unslugify.server"; import { popularBuilds } from "../build-stats-utils"; -export const loader = async ({ params, request }: LoaderFunctionArgs) => { - const t = await i18next.getFixedT(request, ["builds", "weapons"]); +export const loader = async ({ params }: LoaderFunctionArgs) => { + const t = getServerTFunction(["builds", "weapons"]); const slug = params.slug; const weaponId = notFoundIfNullLike(weaponNameSlugToId(slug)); diff --git a/app/features/build-stats/loaders/builds.$slug.stats.server.ts b/app/features/build-stats/loaders/builds.$slug.stats.server.ts index 5ad74d43b..1296a68cd 100644 --- a/app/features/build-stats/loaders/builds.$slug.stats.server.ts +++ b/app/features/build-stats/loaders/builds.$slug.stats.server.ts @@ -1,15 +1,15 @@ import { cachified } from "@epic-web/cachified"; import type { LoaderFunctionArgs } from "react-router"; import * as BuildRepository from "~/features/builds/BuildRepository.server"; -import { i18next } from "~/modules/i18n/i18next.server"; +import { getServerTFunction } from "~/modules/i18n/i18next.server"; import { weaponIdToType } from "~/modules/in-game-lists/weapon-ids"; import { cache } from "~/utils/cache.server"; import { notFoundIfNullLike } from "~/utils/remix.server"; import { weaponNameSlugToId } from "~/utils/unslugify.server"; import { abilityPointCountsToAverages } from "../build-stats-utils"; -export const loader = async ({ params, request }: LoaderFunctionArgs) => { - const t = await i18next.getFixedT(request, ["builds", "weapons"]); +export const loader = async ({ params }: LoaderFunctionArgs) => { + const t = getServerTFunction(["builds", "weapons"]); const weaponId = notFoundIfNullLike(weaponNameSlugToId(params.slug)); if (weaponIdToType(weaponId) === "ALT_SKIN") { diff --git a/app/features/builds/loaders/builds.$slug.server.ts b/app/features/builds/loaders/builds.$slug.server.ts index db2c680bf..65c98fb36 100644 --- a/app/features/builds/loaders/builds.$slug.server.ts +++ b/app/features/builds/loaders/builds.$slug.server.ts @@ -1,6 +1,6 @@ import type { LoaderFunctionArgs } from "react-router"; import { getUser } from "~/features/auth/core/user.server"; -import { i18next } from "~/modules/i18n/i18next.server"; +import { getFixedTForLanguage } from "~/modules/i18n/i18next.server"; import { weaponIdToType } from "~/modules/in-game-lists/weapon-ids"; import { weaponNameSlugToId } from "~/utils/unslugify.server"; import { mySlugify } from "~/utils/urls"; @@ -15,11 +15,9 @@ import { } from "../builds-schemas"; import { filterBuilds } from "../core/filter.server"; -export const loader = async ({ request, params, url }: LoaderFunctionArgs) => { +export const loader = async ({ params, url }: LoaderFunctionArgs) => { const user = getUser(); - const t = await i18next.getFixedT(request, ["weapons", "common"], { - lng: "en", - }); + const t = await getFixedTForLanguage("en", ["weapons", "common"]); const weaponId = weaponNameSlugToId(params.slug); if (typeof weaponId !== "number" || weaponIdToType(weaponId) === "ALT_SKIN") { diff --git a/app/features/notifications/core/notify.server.ts b/app/features/notifications/core/notify.server.ts index aa9020fa4..4d044db96 100644 --- a/app/features/notifications/core/notify.server.ts +++ b/app/features/notifications/core/notify.server.ts @@ -4,7 +4,7 @@ import { type Urgency, WebPushError } from "web-push"; import { IS_E2E_TEST_RUN } from "~/utils/e2e"; import { APP_ICON_URL } from "~/utils/urls"; import type { NotificationSubscription } from "../../../db/tables"; -import { i18next } from "../../../modules/i18n/i18next.server"; +import { getFixedTForLanguage } from "../../../modules/i18n/i18next.server"; import { logger } from "../../../utils/logger"; import * as NotificationRepository from "../NotificationRepository.server"; import type { Notification } from "../notifications-types"; @@ -76,7 +76,7 @@ export async function notify({ dededuplicatedUserIds, ); if (subscriptions.length > 0) { - const t = await i18next.getFixedT("en-US", ["common"]); + const t = await getFixedTForLanguage("en-US", ["common"]); const limit = pLimit(50); diff --git a/app/features/params/loaders/params.$slug.server.ts b/app/features/params/loaders/params.$slug.server.ts index a1a8b7d8a..520dcdaa4 100644 --- a/app/features/params/loaders/params.$slug.server.ts +++ b/app/features/params/loaders/params.$slug.server.ts @@ -1,6 +1,6 @@ import type { LoaderFunctionArgs } from "react-router"; import { mainWeaponParams } from "~/features/build-analyzer/core/utils"; -import { i18next } from "~/modules/i18n/i18next.server"; +import { getFixedTForLanguage } from "~/modules/i18n/i18next.server"; import type { MainWeaponId, SpecialWeaponId, @@ -35,10 +35,8 @@ const subParamsData = subWeaponParamsData as WeaponParams.AllVersionParams; const specialParamsData = specialWeaponParamsData as WeaponParams.AllVersionParams; -export const loader = async ({ request, params }: LoaderFunctionArgs) => { - const t = await i18next.getFixedT(request, ["weapons"], { - lng: "en", - }); +export const loader = async ({ params }: LoaderFunctionArgs) => { + const t = await getFixedTForLanguage("en", ["weapons"]); const mainWeaponId = weaponNameSlugToId(params.slug); if (typeof mainWeaponId === "number") { diff --git a/app/features/tournament-organization/actions/org.$slug.edit.server.ts b/app/features/tournament-organization/actions/org.$slug.edit.server.ts index 630751b30..212e5ac30 100644 --- a/app/features/tournament-organization/actions/org.$slug.edit.server.ts +++ b/app/features/tournament-organization/actions/org.$slug.edit.server.ts @@ -3,7 +3,7 @@ import { requireUser } from "~/features/auth/core/user.server"; import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server"; import { clearTournamentDataCache } from "~/features/tournament-bracket/core/Tournament.server"; import { parseFormDataWithImages } from "~/form/parse.server"; -import { i18next } from "~/modules/i18n/i18next.server"; +import { getServerTFunction } from "~/modules/i18n/i18next.server"; import { requirePermission } from "~/modules/permissions/guards.server"; import { actionError } from "~/utils/remix.server"; import { tournamentOrganizationPage } from "~/utils/urls"; @@ -24,7 +24,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { const data = result.data; - const t = await i18next.getFixedT(request, ["org"]); + const t = getServerTFunction(["org"]); const organization = await organizationFromParams(params); diff --git a/app/modules/i18n/i18next.server.ts b/app/modules/i18n/i18next.server.ts index 69c13e3c1..2af4c413c 100644 --- a/app/modules/i18n/i18next.server.ts +++ b/app/modules/i18n/i18next.server.ts @@ -1,5 +1,20 @@ -import { createCookie } from "react-router"; -import { RemixI18Next } from "remix-i18next/server"; +import { AsyncLocalStorage } from "node:async_hooks"; +import { + createInstance, + type DefaultNamespace, + type FlatNamespace, + type i18n, + type Namespace, + type TFunction, +} from "i18next"; +import { type FallbackNs, initReactI18next } from "react-i18next"; +import { + createCookie, + type MiddlewareFunction, + type RouterContextProvider, +} from "react-router"; +import { createI18nextMiddleware } from "remix-i18next"; +import invariant from "~/utils/invariant"; import { config } from "./config"; import { resources } from "./resources.server"; @@ -11,14 +26,95 @@ export const i18nCookie = createCookie("i18n", { maxAge: TEN_YEARS_IN_SECONDS, }); -export const i18next = new RemixI18Next({ - detection: { - cookie: i18nCookie, - supportedLanguages: config.supportedLngs, - fallbackLanguage: config.fallbackLng, - }, - i18next: { - ...config, - resources: resources, - }, -}); +const [remixI18nextMiddleware, getLocaleFromContext, getInstanceFromContext] = + createI18nextMiddleware({ + detection: { + cookie: i18nCookie, + supportedLanguages: config.supportedLngs, + fallbackLanguage: config.fallbackLng, + }, + i18next: { + ...config, + resources, + }, + plugins: [initReactI18next], + }); + +interface I18nStore { + locale: string; + instance: i18n; +} + +const i18nAsyncLocalStorage = new AsyncLocalStorage(); + +/** + * Detects the request locale, initializes a request-scoped i18next instance and + * makes both available to server helpers ({@link getLocale}, + * {@link getServerTFunction}) for the duration of the request. + */ +export const i18nMiddleware: MiddlewareFunction = (args, next) => + remixI18nextMiddleware(args, () => + i18nAsyncLocalStorage.run( + { + locale: getLocaleFromContext(args.context), + instance: getInstanceFromContext(args.context), + }, + () => next(), + ), + ); + +/** The locale detected for the current request. */ +export function getLocale(): string { + return currentStore().locale; +} + +/** + * A `TFunction` bound to the current request's locale and the given namespaces. + * Namespaces are already available in-memory so no async loading is needed. + */ +export function getServerTFunction< + N extends + | FlatNamespace + | readonly [FlatNamespace, ...FlatNamespace[]] = DefaultNamespace, +>(namespaces?: N): TFunction> { + const { instance, locale } = currentStore(); + return instance.getFixedT( + locale, + namespaces as Namespace, + ) as unknown as TFunction>; +} + +/** + * The request-scoped i18next instance, used to provide translations while + * server-side rendering. Reads from the React Router context so it can be called + * from `entry.server` where the request context is passed explicitly. + */ +export function getI18nInstance( + context: Readonly, +): i18n { + return getInstanceFromContext(context); +} + +/** + * A `TFunction` bound to a fixed language, independent of the current request. + * Use this when translating outside of a request (e.g. notifications) or when a + * specific language is required regardless of the user's locale. + */ +export async function getFixedTForLanguage< + N extends + | FlatNamespace + | readonly [FlatNamespace, ...FlatNamespace[]] = DefaultNamespace, +>(language: string, namespaces?: N): Promise>> { + const instance = createInstance(); + await instance.init({ ...config, resources, lng: language }); + return instance.getFixedT( + language, + namespaces as Namespace, + ) as unknown as TFunction>; +} + +function currentStore(): I18nStore { + const store = i18nAsyncLocalStorage.getStore(); + invariant(store, "i18n store not found, is i18nMiddleware registered?"); + return store; +} diff --git a/app/modules/i18n/loader.ts b/app/modules/i18n/loader.ts index ce39bc62e..3646caa0c 100644 --- a/app/modules/i18n/loader.ts +++ b/app/modules/i18n/loader.ts @@ -2,7 +2,6 @@ import i18next from "i18next"; import LanguageDetector from "i18next-browser-languagedetector"; import Backend from "i18next-http-backend"; import { initReactI18next } from "react-i18next"; -import { getInitialNamespaces } from "remix-i18next/client"; import { config } from "./config"; export function i18nLoader() { @@ -30,3 +29,32 @@ export function i18nLoader() { initAsync: false, }); } + +declare global { + interface Window { + __reactRouterRouteModules: Record; + } +} + +/** + * Get the list of namespaces used by the application server-side so they can be + * set on i18next init options and preloaded before hydration. + * + * Vendored from remix-i18next (removed in v8). + */ +function getInitialNamespaces(): string[] { + return Object.values(window.__reactRouterRouteModules).flatMap((route) => { + const handle = route?.handle; + if (typeof handle !== "object" || handle === null) return []; + if (!("i18n" in handle)) return []; + const namespaces = (handle as { i18n: unknown }).i18n; + if (typeof namespaces === "string") return [namespaces]; + if ( + Array.isArray(namespaces) && + namespaces.every((value) => typeof value === "string") + ) { + return namespaces as string[]; + } + return []; + }); +} diff --git a/app/modules/i18n/useChangeLanguage.ts b/app/modules/i18n/useChangeLanguage.ts new file mode 100644 index 000000000..1249b2078 --- /dev/null +++ b/app/modules/i18n/useChangeLanguage.ts @@ -0,0 +1,15 @@ +import * as React from "react"; +import { useTranslation } from "react-i18next"; + +/** + * Detect when the locale returned by the root route loader changes and call + * `i18n.changeLanguage` with the new locale so translations load automatically. + * + * Vendored from remix-i18next (removed in v8). + */ +export function useChangeLanguage(locale: string) { + const { i18n } = useTranslation(); + React.useEffect(() => { + if (i18n.language !== locale) i18n.changeLanguage(locale); + }, [locale, i18n]); +} diff --git a/app/root.tsx b/app/root.tsx index 04a66da5b..32eda7ef8 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -27,7 +27,6 @@ import { useRevalidator, useSearchParams, } from "react-router"; -import { useChangeLanguage } from "remix-i18next/react"; import { Config } from "~/config"; import type { CustomTheme } from "~/db/tables"; import * as NotificationRepository from "~/features/notifications/NotificationRepository.server"; @@ -56,7 +55,12 @@ import { getThemeSession } from "./features/theme/core/theme-session.server"; import { useUserIntlPreference } from "./hooks/intl/useUserIntlPreference"; import { useHydrated } from "./hooks/useHydrated"; import { DEFAULT_LANGUAGE } from "./modules/i18n/config"; -import { i18nCookie, i18next } from "./modules/i18n/i18next.server"; +import { + getLocale, + i18nCookie, + i18nMiddleware, +} from "./modules/i18n/i18next.server"; +import { useChangeLanguage } from "./modules/i18n/useChangeLanguage"; import { isSupporter } from "./modules/permissions/utils"; import { IS_E2E_TEST_RUN } from "./utils/e2e"; import { allI18nNamespaces } from "./utils/i18n"; @@ -68,6 +72,7 @@ export const middleware: Route.MiddlewareFunction[] = [ requestContextMiddleware, sessionIdMiddleware, userMiddleware, + i18nMiddleware, ]; import "~/styles/vars.css"; @@ -110,7 +115,7 @@ export type LoggedInUser = NonNullable; export const loader = async ({ request }: LoaderFunctionArgs) => { const user = getUser(); - const locale = await i18next.getLocale(request); + const locale = getLocale(); const themeSession = await getThemeSession(request); const sidenavSession = await getSidenavSession(request); diff --git a/package.json b/package.json index 05de431e0..7ae560609 100644 --- a/package.json +++ b/package.json @@ -90,7 +90,7 @@ "remeda": "2.39.0", "remix-auth": "4.2.0", "remix-auth-oauth2": "3.4.1", - "remix-i18next": "7.5.0", + "remix-i18next": "8.0.0", "slugify": "1.6.9", "swr": "2.4.2", "web-haptics": "0.0.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e71cd6175..e2876b9ab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -172,8 +172,8 @@ importers: specifier: 3.4.1 version: 3.4.1(remix-auth@4.2.0) remix-i18next: - specifier: 7.5.0 - version: 7.5.0(i18next@26.3.4(typescript@6.0.3))(react-i18next@17.0.8(i18next@26.3.4(typescript@6.0.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3))(react-router@8.1.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7) + specifier: 8.0.0 + version: 8.0.0(i18next@26.3.4(typescript@6.0.3))(react-router@8.1.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)) slugify: specifier: 1.6.9 version: 1.6.9 @@ -4089,14 +4089,12 @@ packages: resolution: {integrity: sha512-3LSfWEvSgG2CgbG/p4ge5hbV8tTXWNnnYIGbTr9oSSiHz9dD7wh6S0MEyo3pwh7MlKezB2WIlevGeyqUZykk7g==} engines: {node: '>=20.0.0'} - remix-i18next@7.5.0: - resolution: {integrity: sha512-7vAwnNkAXzr/YQwFkV1FHrY+3utgGrDwn7oP/aM2gFHrwbGT0+uVtuUx3USpTrJvhzAN+2YU/mnmgyBi9EhYyw==} - engines: {node: '>=20.0.0'} + remix-i18next@8.0.0: + resolution: {integrity: sha512-+QbCBo3VP6JH2zCbPoRPhgYErY89vDnr4Sy66tlzANwyRnEIHEA2o6HlEuz0iCoDn9f8I88cknNrHGsc4n77zg==} + engines: {node: '>=22.22.0'} peerDependencies: i18next: ^24.0.0 || ^25.0.0 || ^26.0.0 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-i18next: ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 - react-router: ^7.0.0 + react-router: ^8.0.0 require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} @@ -8700,11 +8698,9 @@ snapshots: remix-auth@4.2.0: {} - remix-i18next@7.5.0(i18next@26.3.4(typescript@6.0.3))(react-i18next@17.0.8(i18next@26.3.4(typescript@6.0.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3))(react-router@8.1.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7): + remix-i18next@8.0.0(i18next@26.3.4(typescript@6.0.3))(react-router@8.1.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)): dependencies: i18next: 26.3.4(typescript@6.0.3) - react: 19.2.7 - react-i18next: 17.0.8(i18next@26.3.4(typescript@6.0.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3) react-router: 8.1.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) require-directory@2.1.1: {}