From 738788afbfab719209dfa40bf86bfd5fa950e2fd Mon Sep 17 00:00:00 2001
From: Kalle <38327916+Sendouc@users.noreply.github.com>
Date: Wed, 15 Jul 2026 21:24:38 +0300
Subject: [PATCH] More
---
app/components/MobileNav.tsx | 4 +-
app/components/layout/LazyChatSidebar.tsx | 16 +++
app/components/layout/index.tsx | 30 ++++--
...ig-helpers.ts => config-helpers.server.ts} | 0
app/config.server.ts | 2 +-
app/config.ts | 97 ++++++++++++++-----
app/entry.client.tsx | 73 ++++++++------
app/features/build-analyzer/analyzer-hooks.ts | 2 +-
.../build-analyzer/core/serializer.ts | 15 +++
app/features/build-analyzer/core/utils.ts | 7 --
.../routes/tier-list-maker.tsx | 3 +-
app/utils/urls.ts | 2 +-
package.json | 1 -
pnpm-lock.yaml | 28 ------
react-router.config.ts | 5 +-
types/intl-duration-format.d.ts | 23 -----
16 files changed, 180 insertions(+), 128 deletions(-)
create mode 100644 app/components/layout/LazyChatSidebar.tsx
rename app/{config-helpers.ts => config-helpers.server.ts} (100%)
create mode 100644 app/features/build-analyzer/core/serializer.ts
delete mode 100644 types/intl-duration-format.d.ts
diff --git a/app/components/MobileNav.tsx b/app/components/MobileNav.tsx
index 207a0a1e5..b0b316ae7 100644
--- a/app/components/MobileNav.tsx
+++ b/app/components/MobileNav.tsx
@@ -35,7 +35,7 @@ import { Avatar } from "./Avatar";
import { EventsList } from "./EventsList";
import { LinkButton } from "./elements/Button";
import { Image } from "./Image";
-import { ChatSidebar } from "./layout/ChatSidebar";
+import { LazyChatSidebar } from "./layout/LazyChatSidebar";
import { LogInButtonContainer } from "./layout/LogInButtonContainer";
import {
NotificationContent,
@@ -608,7 +608,7 @@ function ChatPanel({
)}
>
diff --git a/app/components/layout/LazyChatSidebar.tsx b/app/components/layout/LazyChatSidebar.tsx
new file mode 100644
index 000000000..55c5b1e5b
--- /dev/null
+++ b/app/components/layout/LazyChatSidebar.tsx
@@ -0,0 +1,16 @@
+import * as React from "react";
+
+// lazy loaded so the chat code and its dependencies (e.g. qrcode.react) stay
+// out of the eager bundle loaded on every page
+const ChatSidebarImpl = React.lazy(() =>
+ import("./ChatSidebar").then((module) => ({ default: module.ChatSidebar })),
+);
+
+// xxx: better loading state?
+export function LazyChatSidebar({ onClose }: { onClose?: () => void }) {
+ return (
+
+
+
+ );
+}
diff --git a/app/components/layout/index.tsx b/app/components/layout/index.tsx
index b8dee259b..bc0ae2c10 100644
--- a/app/components/layout/index.tsx
+++ b/app/components/layout/index.tsx
@@ -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,10 +56,9 @@ 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";
+import { LazyChatSidebar } from "./LazyChatSidebar";
import { LogInButtonContainer } from "./LogInButtonContainer";
import { NotificationContent, useNotifications } from "./NotificationPopover";
import notificationPopoverStyles from "./NotificationPopover.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(null);
const navOffset = useNavOffset(headerRef);
@@ -436,7 +450,7 @@ export function Layout({
className={styles.chatSidebarModalDialog}
aria-label={t("common:chat.sidebar.title")}
>
-
+
@@ -485,10 +499,14 @@ export function Layout({
showLeaderboard && styles.sidebarFuseSpace,
)}
>
- setChatSidebarOpen(false)} />
+ setChatSidebarOpen(false)} />
) : null}
-
+ {searchParams.has("authError") ? (
+
+
+
+ ) : null}
>
);
}
diff --git a/app/config-helpers.ts b/app/config-helpers.server.ts
similarity index 100%
rename from app/config-helpers.ts
rename to app/config-helpers.server.ts
diff --git a/app/config.server.ts b/app/config.server.ts
index 022a1aef4..cf0772842 100644
--- a/app/config.server.ts
+++ b/app/config.server.ts
@@ -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";
/**
diff --git a/app/config.ts b/app/config.ts
index c66ac6fb2..bae212fac 100644
--- a/app/config.ts
+++ b/app/config.ts
@@ -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.`,
+ );
+}
diff --git a/app/entry.client.tsx b/app/entry.client.tsx
index 1df748e61..2edce725a 100644
--- a/app/entry.client.tsx
+++ b/app/entry.client.tsx
@@ -1,5 +1,3 @@
-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";
@@ -11,31 +9,6 @@ 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. */
@@ -132,16 +105,19 @@ if ("serviceWorker" in navigator) {
Promise.all([
i18nLoader(),
loadDateFnsLocale(document.documentElement.lang as LanguageCode),
+ initSentry(),
])
- .then(() =>
+ .then(([, , sentry]) =>
hydrateRoot(
document,
{
- if (SENTRY_ENABLED && error && error instanceof Error) {
- Sentry.captureException(error);
+ if (sentry && error instanceof Error) {
+ sentry.captureException(error);
}
}}
/>
@@ -149,3 +125,38 @@ Promise.all([
),
)
.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;
+ }
+}
diff --git a/app/features/build-analyzer/analyzer-hooks.ts b/app/features/build-analyzer/analyzer-hooks.ts
index 580577388..ff96be030 100644
--- a/app/features/build-analyzer/analyzer-hooks.ts
+++ b/app/features/build-analyzer/analyzer-hooks.ts
@@ -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";
diff --git a/app/features/build-analyzer/core/serializer.ts b/app/features/build-analyzer/core/serializer.ts
new file mode 100644
index 000000000..777b1f651
--- /dev/null
+++ b/app/features/build-analyzer/core/serializer.ts
@@ -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(",");
+}
diff --git a/app/features/build-analyzer/core/utils.ts b/app/features/build-analyzer/core/utils.ts
index 18312a3f0..73626f0ee 100644
--- a/app/features/build-analyzer/core/utils.ts
+++ b/app/features/build-analyzer/core/utils.ts
@@ -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() {
diff --git a/app/features/tier-list-maker/routes/tier-list-maker.tsx b/app/features/tier-list-maker/routes/tier-list-maker.tsx
index 47665f5cb..54f5150da 100644
--- a/app/features/tier-list-maker/routes/tier-list-maker.tsx
+++ b/app/features/tier-list-maker/routes/tier-list-maker.tsx
@@ -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, {
diff --git a/app/utils/urls.ts b/app/utils/urls.ts
index 68366ab81..fff7581e2 100644
--- a/app/utils/urls.ts
+++ b/app/utils/urls.ts
@@ -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";
diff --git a/package.json b/package.json
index 97e9f69aa..a05013003 100644
--- a/package.json
+++ b/package.json
@@ -48,7 +48,6 @@
"@dnd-kit/utilities": "3.2.2",
"@epic-web/cachified": "5.6.3",
"@faker-js/faker": "10.5.0",
- "@formatjs/intl-durationformat": "0.10.15",
"@internationalized/date": "3.12.2",
"@react-router/node": "8.1.0",
"@react-router/serve": "8.1.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f09edbefe..2d843a611 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -39,9 +39,6 @@ importers:
'@faker-js/faker':
specifier: 10.5.0
version: 10.5.0
- '@formatjs/intl-durationformat':
- specifier: 0.10.15
- version: 0.10.15
'@internationalized/date':
specifier: 3.12.2
version: 3.12.2
@@ -634,18 +631,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.15':
- resolution: {integrity: sha512-3W636wOl2pOVKR2+aFC+2BMOHA/FYsHZfKTr/cT1OG0YIXI2hhGrBH2+EFmqEzzClRi5BlPvwE6LlLN2dikdDQ==}
-
- '@formatjs/intl-localematcher@0.8.10':
- resolution: {integrity: sha512-P/IC3qws3jH+1fEs+o0RIFgXKRaQlFehjS5W0FPAqdo6hgzawLl+eD0q0JjheQ3XtoOe5n8WSYfX06KQZI/QJA==}
-
'@internationalized/date@3.12.2':
resolution: {integrity: sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==}
@@ -5176,19 +5161,6 @@ snapshots:
'@floating-ui/utils@0.2.11': {}
- '@formatjs/bigdecimal@0.2.6': {}
-
- '@formatjs/fast-memoize@3.1.6': {}
-
- '@formatjs/intl-durationformat@0.10.15':
- dependencies:
- '@formatjs/bigdecimal': 0.2.6
- '@formatjs/intl-localematcher': 0.8.10
-
- '@formatjs/intl-localematcher@0.8.10':
- dependencies:
- '@formatjs/fast-memoize': 3.1.6
-
'@internationalized/date@3.12.2':
dependencies:
'@swc/helpers': 0.5.23
diff --git a/react-router.config.ts b/react-router.config.ts
index 052881ddc..3fd705afe 100644
--- a/react-router.config.ts
+++ b/react-router.config.ts
@@ -1,11 +1,8 @@
import type { Config } from "@react-router/dev/config";
import { sentryOnBuildEnd } from "@sentry/react-router";
+// xxx: if we remove routeDiscovery, need to add caching in cloudflare
export default {
- // Upfront cost vs. lazy loading trade-off
- // also lazy loading causes more load on the server
- // this matches old Remix v2 behavior
- routeDiscovery: { mode: "initial" },
splitRouteModules: true,
buildEnd: async ({ viteConfig, reactRouterConfig, buildManifest }) => {
await sentryOnBuildEnd({
diff --git a/types/intl-duration-format.d.ts b/types/intl-duration-format.d.ts
deleted file mode 100644
index c69b12a88..000000000
--- a/types/intl-duration-format.d.ts
+++ /dev/null
@@ -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;
- }
-}