mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-11 13:46:10 -05:00
Optimize bundle (#3228)
This commit is contained in:
@@ -20,7 +20,13 @@ import {
|
||||
} from "react-aria-components";
|
||||
import { Flipped, Flipper } from "react-flip-toolkit";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, useFetcher, useLocation, useMatches } from "react-router";
|
||||
import {
|
||||
Link,
|
||||
useFetcher,
|
||||
useLocation,
|
||||
useMatches,
|
||||
useSearchParams,
|
||||
} from "react-router";
|
||||
import { Config } from "~/config";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { useChatContext } from "~/features/chat/useChatContext";
|
||||
@@ -50,7 +56,6 @@ import { NotificationDot } from "../NotificationDot";
|
||||
import { ListLink, SideNav, SideNavFooter, SideNavHeader } from "../SideNav";
|
||||
import sideNavStyles from "../SideNav.module.css";
|
||||
import { StreamListItems } from "../StreamListItems";
|
||||
import { AuthErrorDialog } from "./AuthErrorDialog";
|
||||
import { ChatSidebar } from "./ChatSidebar";
|
||||
import { Footer } from "./Footer";
|
||||
import styles from "./index.module.css";
|
||||
@@ -62,6 +67,14 @@ import { TopRightButtons } from "./TopRightButtons";
|
||||
|
||||
const MAX_DESKTOP_FRIENDS = 4;
|
||||
|
||||
// lazy loaded so the rarely needed auth error dialog stays out of the eager
|
||||
// bundle loaded on every page
|
||||
const AuthErrorDialog = React.lazy(() =>
|
||||
import("./AuthErrorDialog").then((module) => ({
|
||||
default: module.AuthErrorDialog,
|
||||
})),
|
||||
);
|
||||
|
||||
/** Id of the loading-bar track rendered inside the header. NProgress mounts its
|
||||
* bar into it; the track sits just below the header border, spans only the area
|
||||
* between the sidebars, and clips the bar so it never extends over a sidebar.
|
||||
@@ -240,6 +253,7 @@ export function Layout({
|
||||
const { formatRelativeDate } = useRelativeDayFormat();
|
||||
const isHydrated = useHydrated();
|
||||
const location = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const headerRef = React.useRef<HTMLElement>(null);
|
||||
const navOffset = useNavOffset(headerRef);
|
||||
|
||||
@@ -488,7 +502,11 @@ export function Layout({
|
||||
<ChatSidebar onClose={() => setChatSidebarOpen(false)} />
|
||||
</div>
|
||||
) : null}
|
||||
<AuthErrorDialog />
|
||||
{searchParams.has("authError") ? (
|
||||
<React.Suspense>
|
||||
<AuthErrorDialog />
|
||||
</React.Suspense>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { formatEnvErrors, requiredInProd } from "./config-helpers";
|
||||
import { formatEnvErrors, requiredInProd } from "./config-helpers.server";
|
||||
import { IS_E2E_TEST_RUN } from "./utils/e2e";
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { z } from "zod";
|
||||
import { formatEnvErrors, requiredInProd } from "./config-helpers";
|
||||
import { IS_E2E_TEST_RUN } from "./utils/e2e";
|
||||
|
||||
/**
|
||||
@@ -9,6 +7,9 @@ import { IS_E2E_TEST_RUN } from "./utils/e2e";
|
||||
* Values are validated once when this module is first imported, surfacing a
|
||||
* single clear error for any misconfigured variable. Variables required in
|
||||
* production fall back to development defaults outside of production.
|
||||
*
|
||||
* Note: this module ships in the critical client bundle so it must stay free of
|
||||
* heavy dependencies (e.g. zod, which the server config uses).
|
||||
*/
|
||||
|
||||
// `import.meta.env` is undefined when Playwright bundles test code, so guard the
|
||||
@@ -23,31 +24,35 @@ const isProd =
|
||||
import.meta.env.PROD === true &&
|
||||
!IS_E2E_TEST_RUN;
|
||||
|
||||
const schema = z.object({
|
||||
VITE_SITE_DOMAIN: requiredInProd(isProd, "http://localhost:5173"),
|
||||
const TRUTHY_STRINGS = ["true", "1", "yes", "on", "y", "enabled"];
|
||||
const FALSY_STRINGS = ["false", "0", "no", "off", "n", "disabled"];
|
||||
|
||||
const issues: Array<{ name: string; message: string }> = [];
|
||||
|
||||
const values = {
|
||||
VITE_SITE_DOMAIN: requiredInProd("VITE_SITE_DOMAIN", "http://localhost:5173"),
|
||||
VITE_TOURNAMENT_DEFAULT_LOGO: requiredInProd(
|
||||
isProd,
|
||||
"VITE_TOURNAMENT_DEFAULT_LOGO",
|
||||
"tournament-logo-default.avif",
|
||||
),
|
||||
VITE_STATIC_ASSETS_URL: z
|
||||
.string()
|
||||
.default("https://sendou-assets.nyc3.cdn.digitaloceanspaces.com"),
|
||||
VITE_PROD_MODE: z.stringbool().default(false),
|
||||
VITE_SHOW_LUTI_NAV_ITEM: z.stringbool().default(false),
|
||||
VITE_FUSE_ENABLED: z.stringbool().default(false),
|
||||
VITE_LEAGUE_GOOGLE_FORM_URL: z.string().optional(),
|
||||
VITE_SHOW_BANNER_FOR_SEASON: z.string().optional(),
|
||||
VITE_SENTRY_DSN: z.string().optional(),
|
||||
VITE_SENTRY_ENABLED: z.stringbool().default(false),
|
||||
VITE_SKALOP_WS_URL: z.string().optional(),
|
||||
VITE_VAPID_PUBLIC_KEY: z.string().optional(),
|
||||
});
|
||||
VITE_STATIC_ASSETS_URL: withDefault(
|
||||
"VITE_STATIC_ASSETS_URL",
|
||||
"https://sendou-assets.nyc3.cdn.digitaloceanspaces.com",
|
||||
),
|
||||
VITE_PROD_MODE: stringBool("VITE_PROD_MODE"),
|
||||
VITE_SHOW_LUTI_NAV_ITEM: stringBool("VITE_SHOW_LUTI_NAV_ITEM"),
|
||||
VITE_FUSE_ENABLED: stringBool("VITE_FUSE_ENABLED"),
|
||||
VITE_LEAGUE_GOOGLE_FORM_URL: env.VITE_LEAGUE_GOOGLE_FORM_URL,
|
||||
VITE_SHOW_BANNER_FOR_SEASON: env.VITE_SHOW_BANNER_FOR_SEASON,
|
||||
VITE_SENTRY_DSN: env.VITE_SENTRY_DSN,
|
||||
VITE_SENTRY_ENABLED: stringBool("VITE_SENTRY_ENABLED"),
|
||||
VITE_SKALOP_WS_URL: env.VITE_SKALOP_WS_URL,
|
||||
VITE_VAPID_PUBLIC_KEY: env.VITE_VAPID_PUBLIC_KEY,
|
||||
};
|
||||
|
||||
const parsed = schema.safeParse(env);
|
||||
if (!parsed.success) {
|
||||
throw formatEnvErrors("client", parsed.error);
|
||||
if (issues.length > 0) {
|
||||
throw envError(issues);
|
||||
}
|
||||
const values = parsed.data;
|
||||
|
||||
export const Config = {
|
||||
/** Base URL of the site, e.g. `https://sendou.ink`. */
|
||||
@@ -79,3 +84,51 @@ export const Config = {
|
||||
publicKey: values.VITE_VAPID_PUBLIC_KEY,
|
||||
},
|
||||
};
|
||||
|
||||
function requiredInProd(name: string, devFallback: string): string {
|
||||
const value = env[name];
|
||||
|
||||
if (!isProd) {
|
||||
return value ?? devFallback;
|
||||
}
|
||||
|
||||
if (value === undefined) {
|
||||
issues.push({ name, message: "required in production" });
|
||||
return "";
|
||||
}
|
||||
if (value.length === 0) {
|
||||
issues.push({ name, message: "required in production (cannot be empty)" });
|
||||
return "";
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function withDefault(name: string, defaultValue: string): string {
|
||||
return env[name] ?? defaultValue;
|
||||
}
|
||||
|
||||
function stringBool(name: string): boolean {
|
||||
const value = env[name];
|
||||
if (value === undefined) return false;
|
||||
|
||||
const normalized = value.toLowerCase();
|
||||
if (TRUTHY_STRINGS.includes(normalized)) return true;
|
||||
if (FALSY_STRINGS.includes(normalized)) return false;
|
||||
|
||||
issues.push({
|
||||
name,
|
||||
message: `must be a boolean-like string (e.g. "true" or "false"), got "${value}"`,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
function envError(issues: Array<{ name: string; message: string }>): Error {
|
||||
const lines = issues.map((issue) => ` - ${issue.name}: ${issue.message}`);
|
||||
|
||||
return new Error(
|
||||
`Invalid client environment configuration:\n${lines.join(
|
||||
"\n",
|
||||
)}\n\nSee .env.example for the full list of variables and how to set them.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,39 +1,14 @@
|
||||
import * as Sentry from "@sentry/react-router";
|
||||
import "@formatjs/intl-durationformat/polyfill.js";
|
||||
import i18next from "i18next";
|
||||
import { hydrateRoot } from "react-dom/client";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import { HydratedRouter } from "react-router/dom";
|
||||
import { Config } from "~/config";
|
||||
import type { LanguageCode } from "~/modules/i18n/config";
|
||||
import { i18nLoader } from "./modules/i18n/loader";
|
||||
import { loadDateFnsLocale } from "./utils/dates";
|
||||
import { logger } from "./utils/logger";
|
||||
import { getSessionId } from "./utils/session-id";
|
||||
|
||||
const SENTRY_ENABLED = Config.sentry.enabled;
|
||||
|
||||
const tracing = SENTRY_ENABLED
|
||||
? Sentry.reactRouterTracingIntegration({
|
||||
useInstrumentationAPI: true,
|
||||
})
|
||||
: null;
|
||||
|
||||
if (SENTRY_ENABLED) {
|
||||
Sentry.init({
|
||||
dsn: Config.sentry.dsn,
|
||||
sendDefaultPii: false,
|
||||
integrations: [
|
||||
tracing!,
|
||||
Sentry.thirdPartyErrorFilterIntegration({
|
||||
filterKeys: ["sendou-ink"],
|
||||
behaviour: "drop-error-if-contains-third-party-frames",
|
||||
}),
|
||||
],
|
||||
enableLogs: true,
|
||||
tracesSampleRate: 0.1,
|
||||
tracePropagationTargets: [/^\//],
|
||||
});
|
||||
}
|
||||
|
||||
/** Base delays in milliseconds before each retry attempt following the initial request. */
|
||||
const FETCH_RETRY_DELAYS_MS = [0, 5000, 15000];
|
||||
/** Random jitter added to each retry delay to avoid a thundering herd against a struggling server. */
|
||||
@@ -125,16 +100,36 @@ if ("serviceWorker" in navigator) {
|
||||
});
|
||||
}
|
||||
|
||||
i18nLoader()
|
||||
.then(() =>
|
||||
/**
|
||||
* Max time hydration waits for Sentry to initialize. If Sentry loads within
|
||||
* this window its instrumentation is passed to the router (enabling the
|
||||
* pageload trace); if not, hydration proceeds without it and errors are still
|
||||
* captured once Sentry finishes loading.
|
||||
*/
|
||||
const SENTRY_INIT_TIMEOUT_MS = 1000;
|
||||
|
||||
const sentryPromise = initSentry();
|
||||
|
||||
// the server rendered with the page language's date-fns locale, so it must be
|
||||
// cached before hydration to avoid a markup mismatch
|
||||
Promise.all([
|
||||
i18nLoader(),
|
||||
loadDateFnsLocale(document.documentElement.lang as LanguageCode),
|
||||
Promise.race([sentryPromise, wait(SENTRY_INIT_TIMEOUT_MS)]),
|
||||
])
|
||||
.then(([, , sentry]) =>
|
||||
hydrateRoot(
|
||||
document,
|
||||
<I18nextProvider i18n={i18next}>
|
||||
<HydratedRouter
|
||||
instrumentations={tracing ? [tracing.clientInstrumentation] : []}
|
||||
instrumentations={
|
||||
sentry ? [sentry.tracing.clientInstrumentation] : []
|
||||
}
|
||||
onError={(error) => {
|
||||
if (SENTRY_ENABLED && error && error instanceof Error) {
|
||||
Sentry.captureException(error);
|
||||
if (error instanceof Error) {
|
||||
void sentryPromise.then((sentry) =>
|
||||
sentry?.captureException(error),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -142,3 +137,38 @@ i18nLoader()
|
||||
),
|
||||
)
|
||||
.catch(logger.error);
|
||||
|
||||
// Sentry is dynamically imported so its code is not downloaded at all when
|
||||
// disabled. A load failure (e.g. an ad blocker) must not block hydration,
|
||||
// hence the catch.
|
||||
async function initSentry() {
|
||||
if (!Config.sentry.enabled) return null;
|
||||
|
||||
try {
|
||||
const Sentry = await import("@sentry/react-router");
|
||||
|
||||
const tracing = Sentry.reactRouterTracingIntegration({
|
||||
useInstrumentationAPI: true,
|
||||
});
|
||||
|
||||
Sentry.init({
|
||||
dsn: Config.sentry.dsn,
|
||||
sendDefaultPii: false,
|
||||
integrations: [
|
||||
tracing,
|
||||
Sentry.thirdPartyErrorFilterIntegration({
|
||||
filterKeys: ["sendou-ink"],
|
||||
behaviour: "drop-error-if-contains-third-party-frames",
|
||||
}),
|
||||
],
|
||||
enableLogs: true,
|
||||
tracesSampleRate: 0.1,
|
||||
tracePropagationTargets: [/^\//],
|
||||
});
|
||||
|
||||
return { tracing, captureException: Sentry.captureException };
|
||||
} catch (error) {
|
||||
logger.error("Failed to initialize Sentry", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
everyHourAt30,
|
||||
everyTwoMinutes,
|
||||
} from "./routines/list.server";
|
||||
import { loadAllDateFnsLocales } from "./utils/dates";
|
||||
import { logger } from "./utils/logger";
|
||||
|
||||
// Reject/cancel all pending promises after 5 seconds
|
||||
@@ -27,6 +28,8 @@ export const streamTimeout = 5000;
|
||||
|
||||
const SENTRY_ENABLED = Config.sentry.enabled;
|
||||
|
||||
const dateFnsLocalesLoaded = loadAllDateFnsLocales();
|
||||
|
||||
async function handleRequest(
|
||||
request: Request,
|
||||
responseStatusCode: number,
|
||||
@@ -34,6 +37,8 @@ async function handleRequest(
|
||||
reactRouterContext: EntryContext,
|
||||
loadContext: RouterContextProvider,
|
||||
) {
|
||||
await dateFnsLocalesLoaded;
|
||||
|
||||
const callbackName = isbot(request.headers.get("user-agent"))
|
||||
? "onAllReady"
|
||||
: "onShellReady";
|
||||
|
||||
@@ -10,12 +10,12 @@ import { isAbility } from "~/modules/in-game-lists/utils";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { MAX_LDE_INTENSITY } from "./analyzer-constants";
|
||||
import type { SpecialEffectType } from "./analyzer-types";
|
||||
import { serializeBuild } from "./core/serializer";
|
||||
import { applySpecialEffects, SPECIAL_EFFECTS } from "./core/specialEffects";
|
||||
import { buildStats } from "./core/stats";
|
||||
import {
|
||||
buildIsEmpty,
|
||||
buildToAbilityPoints,
|
||||
serializeBuild,
|
||||
validatedBuildFromSearchParams,
|
||||
validatedWeaponIdFromSearchParams,
|
||||
} from "./core/utils";
|
||||
|
||||
15
app/features/build-analyzer/core/serializer.ts
Normal file
15
app/features/build-analyzer/core/serializer.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { BuildAbilitiesTupleWithUnknown } from "~/modules/in-game-lists/types";
|
||||
import { UNKNOWN_SHORT } from "../analyzer-constants";
|
||||
|
||||
/**
|
||||
* Serializes a build to a comma separated string for use in URLs.
|
||||
*
|
||||
* Lives in its own module (instead of `core/utils.ts`) so that `~/utils/urls.ts`
|
||||
* can import it without pulling the weapon params data into the eager bundle.
|
||||
*/
|
||||
export function serializeBuild(build: BuildAbilitiesTupleWithUnknown) {
|
||||
return build
|
||||
.flat()
|
||||
.map((ability) => (ability === "UNKNOWN" ? UNKNOWN_SHORT : ability))
|
||||
.join(",");
|
||||
}
|
||||
@@ -322,13 +322,6 @@ export function validatedBuildFromSearchParams(
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeBuild(build: BuildAbilitiesTupleWithUnknown) {
|
||||
return build
|
||||
.flat()
|
||||
.map((ability) => (ability === "UNKNOWN" ? UNKNOWN_SHORT : ability))
|
||||
.join(",");
|
||||
}
|
||||
|
||||
export const hpDivided = (hp: number) => hp / 10;
|
||||
|
||||
export function possibleApValues() {
|
||||
|
||||
@@ -712,6 +712,10 @@ describe("SendouQ", () => {
|
||||
await createGroup([2]);
|
||||
await createGroup([3]);
|
||||
await createGroup([4]);
|
||||
await db
|
||||
.updateTable("Group")
|
||||
.set({ latestActionAt: databaseTimestampNow() })
|
||||
.execute();
|
||||
|
||||
await refreshSendouQInstance();
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import { sortableKeyboardCoordinates } from "@dnd-kit/sortable";
|
||||
import { snapdom } from "@zumer/snapdom";
|
||||
import clsx from "clsx";
|
||||
import { HardDriveDownload, Plus, RefreshCcw } from "lucide-react";
|
||||
import { useRef } from "react";
|
||||
@@ -138,6 +137,8 @@ function TierListMakerContent() {
|
||||
const handleDownload = async () => {
|
||||
if (!tierListRef.current) return;
|
||||
|
||||
const { snapdom } = await import("@zumer/snapdom");
|
||||
|
||||
flushSync(() => setScreenshotMode(true));
|
||||
|
||||
await snapdom.download(tierListRef.current, {
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { LanguageCode } from "~/modules/i18n/config";
|
||||
import { loadDateFnsLocale } from "~/utils/dates";
|
||||
|
||||
/**
|
||||
* Detect when the locale returned by the root route loader changes and call
|
||||
* `i18n.changeLanguage` with the new locale so translations load automatically.
|
||||
* The date-fns locale is loaded first so the re-render triggered by the
|
||||
* language change already has it available.
|
||||
*
|
||||
* Vendored from remix-i18next (removed in v8).
|
||||
*/
|
||||
export function useChangeLanguage(locale: string) {
|
||||
const { i18n } = useTranslation();
|
||||
React.useEffect(() => {
|
||||
if (i18n.language !== locale) i18n.changeLanguage(locale);
|
||||
if (i18n.language !== locale) {
|
||||
void loadDateFnsLocale(locale as LanguageCode).then(() =>
|
||||
i18n.changeLanguage(locale),
|
||||
);
|
||||
}
|
||||
}, [locale, i18n]);
|
||||
}
|
||||
|
||||
@@ -5,44 +5,67 @@ import {
|
||||
} from "@internationalized/date";
|
||||
import type { Locale } from "date-fns";
|
||||
import { formatDistanceToNow as dateFnsFormatDistanceToNow } from "date-fns";
|
||||
import { da } from "date-fns/locale/da";
|
||||
import { de } from "date-fns/locale/de";
|
||||
import { enUS } from "date-fns/locale/en-US";
|
||||
import { es } from "date-fns/locale/es";
|
||||
import { fr } from "date-fns/locale/fr";
|
||||
import { frCA } from "date-fns/locale/fr-CA";
|
||||
import { he } from "date-fns/locale/he";
|
||||
import { it } from "date-fns/locale/it";
|
||||
import { ja } from "date-fns/locale/ja";
|
||||
import { ko } from "date-fns/locale/ko";
|
||||
import { nl } from "date-fns/locale/nl";
|
||||
import { pl } from "date-fns/locale/pl";
|
||||
import { ptBR } from "date-fns/locale/pt-BR";
|
||||
import { ru } from "date-fns/locale/ru";
|
||||
import { zhCN } from "date-fns/locale/zh-CN";
|
||||
import type { MonthYear } from "~/features/plus-voting/core";
|
||||
import type { LanguageCode } from "~/modules/i18n/config";
|
||||
import { logger } from "./logger";
|
||||
import type { DayMonthYear } from "./zod";
|
||||
|
||||
const LOCALE_MAP: Record<LanguageCode, Locale> = {
|
||||
da,
|
||||
de,
|
||||
en: enUS,
|
||||
"es-ES": es,
|
||||
"es-US": es,
|
||||
"fr-CA": frCA,
|
||||
"fr-EU": fr,
|
||||
he,
|
||||
it,
|
||||
ja,
|
||||
ko,
|
||||
nl,
|
||||
pl,
|
||||
"pt-BR": ptBR,
|
||||
ru,
|
||||
zh: zhCN,
|
||||
// en-US ships with date-fns core as the default locale, so it costs no extra bytes
|
||||
const LOCALE_LOADERS: Record<LanguageCode, () => Promise<Locale>> = {
|
||||
da: () => import("date-fns/locale/da").then((module) => module.da),
|
||||
de: () => import("date-fns/locale/de").then((module) => module.de),
|
||||
en: () => Promise.resolve(enUS),
|
||||
"es-ES": () => import("date-fns/locale/es").then((module) => module.es),
|
||||
"es-US": () => import("date-fns/locale/es").then((module) => module.es),
|
||||
"fr-CA": () => import("date-fns/locale/fr-CA").then((module) => module.frCA),
|
||||
"fr-EU": () => import("date-fns/locale/fr").then((module) => module.fr),
|
||||
he: () => import("date-fns/locale/he").then((module) => module.he),
|
||||
it: () => import("date-fns/locale/it").then((module) => module.it),
|
||||
ja: () => import("date-fns/locale/ja").then((module) => module.ja),
|
||||
ko: () => import("date-fns/locale/ko").then((module) => module.ko),
|
||||
nl: () => import("date-fns/locale/nl").then((module) => module.nl),
|
||||
pl: () => import("date-fns/locale/pl").then((module) => module.pl),
|
||||
"pt-BR": () => import("date-fns/locale/pt-BR").then((module) => module.ptBR),
|
||||
ru: () => import("date-fns/locale/ru").then((module) => module.ru),
|
||||
zh: () => import("date-fns/locale/zh-CN").then((module) => module.zhCN),
|
||||
};
|
||||
|
||||
const loadedLocales = new Map<LanguageCode, Locale>();
|
||||
|
||||
/**
|
||||
* Loads the date-fns locale for the given language into the in-memory cache
|
||||
* used by {@link formatDistanceToNow}. Load failures are logged and result in
|
||||
* an English fallback instead of rejecting.
|
||||
*/
|
||||
export async function loadDateFnsLocale(language: LanguageCode) {
|
||||
if (loadedLocales.has(language)) return;
|
||||
|
||||
const loader = LOCALE_LOADERS[language];
|
||||
if (!loader) return;
|
||||
|
||||
try {
|
||||
loadedLocales.set(language, await loader());
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`Failed to load date-fns locale for language ${language}`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Loads every date-fns locale into the cache (meant for the server where bundle size does not matter). */
|
||||
export function loadAllDateFnsLocales() {
|
||||
return Promise.all(
|
||||
(Object.keys(LOCALE_LOADERS) as LanguageCode[]).map(loadDateFnsLocale),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats how long ago / until the given date in the given language. The
|
||||
* language's date-fns locale must be loaded first via
|
||||
* {@link loadDateFnsLocale}; otherwise falls back to English.
|
||||
*/
|
||||
export function formatDistanceToNow(
|
||||
date: Parameters<typeof dateFnsFormatDistanceToNow>[0],
|
||||
options: Omit<
|
||||
@@ -52,7 +75,7 @@ export function formatDistanceToNow(
|
||||
) {
|
||||
return dateFnsFormatDistanceToNow(date, {
|
||||
...options,
|
||||
locale: LOCALE_MAP[options.language],
|
||||
locale: loadedLocales.get(options.language) ?? enUS,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Config } from "~/config";
|
||||
import type { GearType, Preference, Tables } from "~/db/tables";
|
||||
import type { ArtSource } from "~/features/art/art-types";
|
||||
import type { AuthErrorCode } from "~/features/auth/core/errors";
|
||||
import { serializeBuild } from "~/features/build-analyzer/core/utils";
|
||||
import { serializeBuild } from "~/features/build-analyzer/core/serializer";
|
||||
import type { CalendarFilters } from "~/features/calendar/calendar-types";
|
||||
import type { MapPool } from "~/features/map-list-generator/core/map-pool";
|
||||
import type { StageBackgroundStyle } from "~/features/map-planner";
|
||||
|
||||
@@ -49,7 +49,6 @@
|
||||
"@dnd-kit/utilities": "3.2.2",
|
||||
"@epic-web/cachified": "5.6.3",
|
||||
"@faker-js/faker": "10.5.0",
|
||||
"@formatjs/intl-durationformat": "0.10.16",
|
||||
"@internationalized/date": "3.12.2",
|
||||
"@react-router/node": "8.2.0",
|
||||
"@react-router/serve": "8.1.0",
|
||||
|
||||
28
pnpm-lock.yaml
generated
28
pnpm-lock.yaml
generated
@@ -39,9 +39,6 @@ importers:
|
||||
'@faker-js/faker':
|
||||
specifier: 10.5.0
|
||||
version: 10.5.0
|
||||
'@formatjs/intl-durationformat':
|
||||
specifier: 0.10.16
|
||||
version: 0.10.16
|
||||
'@internationalized/date':
|
||||
specifier: 3.12.2
|
||||
version: 3.12.2
|
||||
@@ -638,18 +635,6 @@ packages:
|
||||
'@floating-ui/utils@0.2.11':
|
||||
resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
|
||||
|
||||
'@formatjs/bigdecimal@0.2.6':
|
||||
resolution: {integrity: sha512-aPzKsGQOkQRHUEbyO/ZtYfr4EqaBQnSs6U4tzTla1xBnIdEHgY2GqEqso28UMwWRkzKqqTj5+/6BmuOsRkfn2A==}
|
||||
|
||||
'@formatjs/fast-memoize@3.1.6':
|
||||
resolution: {integrity: sha512-H5aexk1Le7T9TPmscacZ+1pR6CTa2n1wq+HDVGXhH8TzUlQQpeXzZs91dRtmFHrbeNbjPFPfQujUqm7MHgVoXQ==}
|
||||
|
||||
'@formatjs/intl-durationformat@0.10.16':
|
||||
resolution: {integrity: sha512-Ponn8K7eQAA7qeZRRD85lrvsByfeRDxw6aEE9EXapQ47wknptM0aYgumImVsMaBdJikpWYryGAMRZIB7IYYfmQ==}
|
||||
|
||||
'@formatjs/intl-localematcher@0.8.11':
|
||||
resolution: {integrity: sha512-MzBbR9ifqgtzHwUl2PGEDtRVZGXF9nXPtrMyN6/iKbQaim1Ob7fnPJQMllVkHm3RK74o5VqnLrZIE9egWJhf5g==}
|
||||
|
||||
'@internationalized/date@3.12.2':
|
||||
resolution: {integrity: sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==}
|
||||
|
||||
@@ -5299,19 +5284,6 @@ snapshots:
|
||||
|
||||
'@floating-ui/utils@0.2.11': {}
|
||||
|
||||
'@formatjs/bigdecimal@0.2.6': {}
|
||||
|
||||
'@formatjs/fast-memoize@3.1.6': {}
|
||||
|
||||
'@formatjs/intl-durationformat@0.10.16':
|
||||
dependencies:
|
||||
'@formatjs/bigdecimal': 0.2.6
|
||||
'@formatjs/intl-localematcher': 0.8.11
|
||||
|
||||
'@formatjs/intl-localematcher@0.8.11':
|
||||
dependencies:
|
||||
'@formatjs/fast-memoize': 3.1.6
|
||||
|
||||
'@internationalized/date@3.12.2':
|
||||
dependencies:
|
||||
'@swc/helpers': 0.5.23
|
||||
|
||||
23
types/intl-duration-format.d.ts
vendored
23
types/intl-duration-format.d.ts
vendored
@@ -1,23 +0,0 @@
|
||||
declare namespace Intl {
|
||||
interface DurationFormatOptions {
|
||||
style?: "long" | "short" | "narrow" | "digital";
|
||||
}
|
||||
|
||||
interface DurationInput {
|
||||
years?: number;
|
||||
months?: number;
|
||||
weeks?: number;
|
||||
days?: number;
|
||||
hours?: number;
|
||||
minutes?: number;
|
||||
seconds?: number;
|
||||
milliseconds?: number;
|
||||
microseconds?: number;
|
||||
nanoseconds?: number;
|
||||
}
|
||||
|
||||
class DurationFormat {
|
||||
constructor(locales?: string | string[], options?: DurationFormatOptions);
|
||||
format(duration: DurationInput): string;
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,15 @@ export default defineConfig((config) => {
|
||||
telemetry: false,
|
||||
unstable_sentryVitePluginOptions: {
|
||||
applicationKey: "sendou-ink",
|
||||
// tree-shakes SDK features we don't use (Replay, debug logging)
|
||||
// out of the dynamically imported client bundle
|
||||
bundleSizeOptimizations: {
|
||||
excludeDebugStatements: true,
|
||||
excludeReplayCanvas: true,
|
||||
excludeReplayShadowDom: true,
|
||||
excludeReplayIframe: true,
|
||||
excludeReplayWorker: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
config,
|
||||
|
||||
Reference in New Issue
Block a user