From 2cc335c0145da0a9989ac96502d3467fa3e93c4c Mon Sep 17 00:00:00 2001 From: Christopher Serr Date: Thu, 23 Jul 2026 12:12:14 +0200 Subject: [PATCH 1/2] Strengthen TypeScript Checks Enable exact optional properties, unchecked indexed access, explicit index-signature access, isolated modules, verbatim module syntax, and the remaining low-risk compiler checks across the frontend and Node configurations. Migrate imports and unsafe array or optional-property access to satisfy the stricter contracts. Validate RGBA tuples and generated binding data at their boundaries instead of hiding possible undefined values with assertions. Add a reusable typecheck script and enforce it in web CI. Keep library declaration checking disabled for the Node configuration because the combined DOM, WebWorker, and Workbox declarations conflict, and leave frontend noImplicitOverride disabled until generated livesplit-core classes emit override modifiers. Validation: npm ci, ESLint, both TypeScript configurations, web production build, and Tauri frontend build. --- .github/workflows/ci.yml | 4 + eslint.config.mjs | 5 + package.json | 1 + src/api/GameList.ts | 15 +- src/api/LiveSplitServer.ts | 4 +- src/api/SpeedrunCom.ts | 2 +- src/index.tsx | 10 +- src/localization/chinese-traditional.ts | 2 +- src/platform/CORS.ts | 5 +- src/platform/Hotkeys.ts | 2 +- src/storage/index.ts | 12 +- src/ui/Embed.tsx | 12 +- src/ui/LiveSplit.tsx | 30 ++-- src/ui/components/ColorPicker.tsx | 66 ++++--- src/ui/components/ContextMenu.tsx | 4 +- src/ui/components/Layout.tsx | 17 +- src/ui/components/Leaderboard.tsx | 52 +++--- src/ui/components/Markdown.tsx | 32 ++-- src/ui/components/Settings/Accuracy.tsx | 4 +- src/ui/components/Settings/Alignment.tsx | 2 +- src/ui/components/Settings/Color.tsx | 2 +- src/ui/components/Settings/Column.tsx | 2 +- src/ui/components/Settings/DigitsFormat.tsx | 4 +- src/ui/components/Settings/Font.tsx | 2 +- src/ui/components/Settings/Gradient.tsx | 169 +++++++++--------- src/ui/components/Settings/HotkeyButton.tsx | 28 +-- .../components/Settings/LayoutBackground.tsx | 61 ++++--- .../components/Settings/LayoutDirection.tsx | 2 +- .../Settings/ServerConnectionButton.tsx | 6 +- src/ui/components/Settings/String.tsx | 4 +- .../Settings/SubsplitDisplayMode.tsx | 6 +- src/ui/components/Settings/TimingMethod.tsx | 4 +- src/ui/components/Settings/index.tsx | 12 +- src/ui/views/About.tsx | 14 +- src/ui/views/LayoutEditor.tsx | 20 ++- src/ui/views/LayoutView.tsx | 22 +-- src/ui/views/MainSettings.tsx | 23 ++- src/ui/views/RunEditor.tsx | 44 +++-- src/ui/views/RunEditor/SegmentTableCells.tsx | 4 +- src/ui/views/RunEditor/SegmentsTable.tsx | 10 +- src/ui/views/SplitsSelection.tsx | 18 +- src/ui/views/TimerView.tsx | 14 +- src/util/AutoRefresh.tsx | 2 +- src/util/Color.ts | 21 +++ src/util/FileUtil.ts | 2 +- src/util/FontList.ts | 8 +- src/util/LSOCommandSink.ts | 26 +-- src/util/OptionUtil.tsx | 2 +- src/util/TimeUtil.ts | 2 +- src/util/UrlCache.ts | 2 +- tsconfig.json | 12 ++ tsconfig.node.json | 17 ++ vite.config.ts | 8 +- 53 files changed, 507 insertions(+), 347 deletions(-) create mode 100644 src/util/Color.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ceada5c7a..f07955303 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -146,6 +146,10 @@ jobs: if: matrix.platform == 'web' run: npm run lint + - name: Run TypeScript type checking (Web) + if: matrix.platform == 'web' + run: npm run typecheck + - name: Build Frontend (Web) if: matrix.platform == 'web' env: diff --git a/eslint.config.mjs b/eslint.config.mjs index 9342f6a33..82f91f18d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -4,6 +4,11 @@ export default tseslint.config({ ignores: ["src/livesplit-core/**", "src/type-definitions/**"], extends: tseslint.configs.recommendedTypeChecked, rules: { + "@typescript-eslint/consistent-type-exports": "error", + "@typescript-eslint/consistent-type-imports": [ + "error", + { fixStyle: "inline-type-imports" }, + ], "@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-floating-promises": "off", "@typescript-eslint/no-misused-promises": "off", diff --git a/package.json b/package.json index 67c0cb2ee..7a4fe4530 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "build:core:release": "node buildCore.js --release", "build:core:deploy": "node buildCore.js --max-opt --nightly", "lint": "eslint src", + "typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.node.json", "publish": "vite build", "serve": "vite", "tauri:icons": "tauri icon src/assets/icon.png", diff --git a/src/api/GameList.ts b/src/api/GameList.ts index 8813930a4..92b7e44a0 100644 --- a/src/api/GameList.ts +++ b/src/api/GameList.ts @@ -1,16 +1,16 @@ import { getGameHeaders, getCategories as apiGetCategories, - Category, + type Category, getRuns as apiGetRuns, getPlatforms as apiGetPlatforms, getRegions as apiGetRegions, getGame as apiGetGame, - Game, - Run, - PlayersEmbedded, + type Game, + type Run, + type PlayersEmbedded, } from "./SpeedrunCom"; -import { Option } from "../util/OptionUtil"; +import { type Option } from "../util/OptionUtil"; import { FuzzyList } from "../livesplit-core"; const gameList: string[] = []; @@ -197,11 +197,10 @@ export async function downloadLeaderboard( if (categories === undefined) { return; } - const index = categories.map((c) => c.name).indexOf(categoryName); - if (index < 0) { + const category = categories.find((c) => c.name === categoryName); + if (category === undefined) { return; } - const category = categories[index]; const runPages = await apiGetRuns( true, category.id, diff --git a/src/api/LiveSplitServer.ts b/src/api/LiveSplitServer.ts index 96231786e..a786b23cb 100644 --- a/src/api/LiveSplitServer.ts +++ b/src/api/LiveSplitServer.ts @@ -1,7 +1,7 @@ import { toast } from "react-toastify"; -import { LSOCommandSink } from "../util/LSOCommandSink"; +import { type LSOCommandSink } from "../util/LSOCommandSink"; import { ServerProtocol } from "../livesplit-core/livesplit_core"; -import { Event } from "../livesplit-core"; +import { type Event } from "../livesplit-core"; export class LiveSplitServer { private connection: WebSocket; diff --git a/src/api/SpeedrunCom.ts b/src/api/SpeedrunCom.ts index d66c2a420..d6f62d149 100644 --- a/src/api/SpeedrunCom.ts +++ b/src/api/SpeedrunCom.ts @@ -1,4 +1,4 @@ -import { Option, map } from "../util/OptionUtil"; +import { type Option, map } from "../util/OptionUtil"; const BASE_URI = "https://www.speedrun.com/api/v1/"; diff --git a/src/index.tsx b/src/index.tsx index 52ecb191e..87ebd34fb 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -58,12 +58,12 @@ try { const root = createRoot(container!); root.render( { let response: Response | undefined; + const options = signal === undefined ? {} : { signal }; if (window.__TAURI__ != null) { - response = await window.__TAURI__.http.fetch(url, { signal }); + response = await window.__TAURI__.http.fetch(url, options); } else { - response = await fetch(url, { signal }); + response = await fetch(url, options); } return response.arrayBuffer(); } diff --git a/src/platform/Hotkeys.ts b/src/platform/Hotkeys.ts index b8a3351b4..8bb5c43b6 100644 --- a/src/platform/Hotkeys.ts +++ b/src/platform/Hotkeys.ts @@ -1,4 +1,4 @@ -import { CommandSinkRef, HotkeyConfig, HotkeySystem, Language } from "../livesplit-core"; +import { type CommandSinkRef, HotkeyConfig, HotkeySystem, type Language } from "../livesplit-core"; import { expect } from "../util/OptionUtil"; export interface HotkeyImplementation { diff --git a/src/storage/index.ts b/src/storage/index.ts index 670ec95ea..f3ed38a82 100644 --- a/src/storage/index.ts +++ b/src/storage/index.ts @@ -1,8 +1,8 @@ -import { openDB, IDBPDatabase } from "idb"; -import { Option, assert } from "../util/OptionUtil"; -import { RunRef, Run, TimingMethod, Language } from "../livesplit-core"; +import { openDB, type IDBPDatabase } from "idb"; +import { type Option, assert } from "../util/OptionUtil"; +import { type RunRef, Run, TimingMethod, type Language } from "../livesplit-core"; import { - GeneralSettings, + type GeneralSettings, MANUAL_GAME_TIME_SETTINGS_DEFAULT, THEME_MODE_AUTOMATIC, } from "../ui/views/MainSettings"; @@ -35,8 +35,8 @@ function getSplitsInfo(run: RunRef): SplitsInfo { return { game: run.gameName(), category: run.extendedCategoryName(true, true, true), - realTime, - gameTime, + ...(realTime === undefined ? {} : { realTime }), + ...(gameTime === undefined ? {} : { gameTime }), }; } diff --git a/src/ui/Embed.tsx b/src/ui/Embed.tsx index a436c05b3..6c4f5b969 100644 --- a/src/ui/Embed.tsx +++ b/src/ui/Embed.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { Option } from "../util/OptionUtil"; +import { type Option } from "../util/OptionUtil"; import runEditorClasses from "../css/RunEditor.module.css"; @@ -29,7 +29,10 @@ function tryYoutubeFromUri(uri: string): Option { result = youtubeBase2.exec(uri); } if (result !== null) { - return resolveYoutube(result[1]); + const videoId = result[1]; + if (videoId !== undefined) { + return resolveYoutube(videoId); + } } return null; } @@ -42,7 +45,10 @@ function tryTwitchFromUri(uri: string): Option { result = twitchBase2.exec(uri); } if (result !== null) { - return resolveTwitch(result[3]); + const videoId = result[3]; + if (videoId !== undefined) { + return resolveTwitch(videoId); + } } return null; } diff --git a/src/ui/LiveSplit.tsx b/src/ui/LiveSplit.tsx index eb87b8e42..b655932b7 100644 --- a/src/ui/LiveSplit.tsx +++ b/src/ui/LiveSplit.tsx @@ -7,14 +7,14 @@ import { RunEditor, Segment, Timer, - HotkeyConfig, + type HotkeyConfig, LayoutState, - LayoutStateJson, - TimingMethod, - TimerPhase, + type LayoutStateJson, + type TimingMethod, + type TimerPhase, Event, - LayoutRefMut, - Language, + type LayoutRefMut, + type Language, } from "../livesplit-core"; import { Layout as ShowLayout } from "./components/Layout"; import { @@ -25,7 +25,7 @@ import { openFileAsString, } from "../util/FileUtil"; import { - Option, + type Option, assertNull, expect, maybeDisposeAndThen, @@ -34,14 +34,14 @@ import { import { LayoutEditor as LayoutEditorComponent } from "./views/LayoutEditor"; import { RunEditor as RunEditorComponent } from "./views/RunEditor"; import { - GeneralSettings, + type GeneralSettings, MainSettings as SettingsEditorComponent, THEME_MODE_AUTOMATIC, THEME_MODE_DARK, } from "./views/MainSettings"; import { TimerView } from "./views/TimerView"; import { About } from "./views/About"; -import { SplitsSelection, EditingInfo } from "./views/SplitsSelection"; +import { SplitsSelection, type EditingInfo } from "./views/SplitsSelection"; import { LayoutView } from "./views/LayoutView"; import { ToastContainer, toast } from "react-toastify"; import * as Storage from "../storage"; @@ -52,10 +52,10 @@ import { TheRunClient, WebRenderer, } from "../livesplit-core/livesplit_core"; -import { LiveSplitServer } from "../api/LiveSplitServer"; +import { type LiveSplitServer } from "../api/LiveSplitServer"; import { LSOCommandSink } from "../util/LSOCommandSink"; import { DialogContainer } from "./components/Dialog"; -import { createHotkeys, HotkeyImplementation } from "../platform/Hotkeys"; +import { createHotkeys, type HotkeyImplementation } from "../platform/Hotkeys"; import { Menu } from "lucide-react"; import { createRoot } from "react-dom/client"; @@ -118,7 +118,11 @@ function isMenuLocked(menuKind: MenuKind) { type Menu = | { kind: MenuKind.Timer } | { kind: MenuKind.Splits } - | { kind: MenuKind.RunEditor; editor: RunEditor; splitsKey?: number } + | { + kind: MenuKind.RunEditor; + editor: RunEditor; + splitsKey: number | undefined; + } | { kind: MenuKind.Layout } | { kind: MenuKind.LayoutEditor; editor: LayoutEditor } | { kind: MenuKind.MainSettings; config: HotkeyConfig } @@ -147,7 +151,7 @@ export interface State { layoutWidth: number; layoutHeight: number; menu: Menu; - openedSplitsKey?: number; + openedSplitsKey: number | undefined; sidebarOpen: boolean; sidebarTransitionsEnabled: boolean; storedLayoutWidth: number; diff --git a/src/ui/components/ColorPicker.tsx b/src/ui/components/ColorPicker.tsx index 07c879c12..e551d0857 100644 --- a/src/ui/components/ColorPicker.tsx +++ b/src/ui/components/ColorPicker.tsx @@ -1,6 +1,7 @@ -import React, { ChangeEvent, MouseEvent } from "react"; +import React, { type ChangeEvent, type MouseEvent } from "react"; import { useEffect, useState } from "react"; -import { Color } from "../../livesplit-core"; +import { type Color } from "../../livesplit-core"; +import { type RgbaColor, toRgbaColor } from "../../util/Color"; import { Pipette } from "lucide-react"; import classes from "../../css/ColorPicker.module.css"; @@ -8,7 +9,7 @@ import classes from "../../css/ColorPicker.module.css"; const EyeDropper = (window as any).EyeDropper; const hasEyeDropper = !!EyeDropper; -function colorToCss(color: Color): string { +function colorToCss(color: RgbaColor): string { const r = Math.round(color[0] * 255); const g = Math.round(color[1] * 255); const b = Math.round(color[2] * 255); @@ -22,20 +23,21 @@ export function ColorPicker({ setColor, }: { color: Color; - setColor: (color: Color) => void; + setColor: (color: RgbaColor) => void; }) { + const rgbaColor = toRgbaColor(color); const [isShowing, setIsShowing] = useState(false); return (
setIsShowing(true)} />
{isShowing && ( setIsShowing(false)} /> @@ -50,8 +52,8 @@ function ColorPickerDialog({ setColor, close, }: { - color: Color; - setColor: (color: Color) => void; + color: RgbaColor; + setColor: (color: RgbaColor) => void; close: () => void; }) { return ( @@ -76,8 +78,8 @@ function GradientSelector({ color, setColor, }: { - color: Color; - setColor: (color: Color) => void; + color: RgbaColor; + setColor: (color: RgbaColor) => void; }) { const [r, g, b, a] = color; const [h, s, v] = rgbToHsv(r, g, b); @@ -150,8 +152,8 @@ function ControlPanel({ color, setColor, }: { - color: Color; - setColor: (color: Color) => void; + color: RgbaColor; + setColor: (color: RgbaColor) => void; }) { const [r, g, b, a] = color; const [h, s, v] = rgbToHsv(r, g, b); @@ -246,8 +248,8 @@ function ColorPreview({ color, setColor, }: { - color: Color; - setColor: (color: Color) => void; + color: RgbaColor; + setColor: (color: RgbaColor) => void; }) { const [r, g, b, a] = color; return ( @@ -292,8 +294,8 @@ function Hsva({ color, setColor, }: { - color: Color; - setColor: (color: Color) => void; + color: RgbaColor; + setColor: (color: RgbaColor) => void; }) { const [r, g, b, a] = color; const [h, s, v] = rgbToHsv(r, g, b); @@ -347,8 +349,8 @@ function Rgba({ color, setColor, }: { - color: Color; - setColor: (color: Color) => void; + color: RgbaColor; + setColor: (color: RgbaColor) => void; }) { const [r, g, b, a] = color; @@ -398,8 +400,8 @@ function Hex({ color, setColor, }: { - color: Color; - setColor: (color: Color) => void; + color: RgbaColor; + setColor: (color: RgbaColor) => void; }) { const [r, g, b, a] = color; @@ -417,10 +419,10 @@ function Hex({ .toString(16) .padStart(2, "0")}`.toUpperCase() } - parse={(value) => { + parse={(value): RgbaColor | undefined => { const parsed = parseHex(value); if (parsed) { - return [...parsed, a]; + return [parsed[0], parsed[1], parsed[2], a]; } return undefined; }} @@ -541,8 +543,14 @@ function ColorComponent({ ); } -function PredefinedColors({ setColor }: { setColor: (color: Color) => void }) { - const predefinedColors = [ +function PredefinedColors({ + setColor, +}: { + setColor: (color: RgbaColor) => void; +}) { + const predefinedColors: ReadonlyArray< + ReadonlyArray + > = [ [ ["Red", 244, 67, 54], ["Pink", 233, 30, 99], @@ -570,7 +578,7 @@ function PredefinedColors({ setColor }: { setColor: (color: Color) => void }) { ["Black", 0, 0, 0], ["White", 255, 255, 255], ], - ] as unknown as [[[string, number, number, number]]]; + ]; return (
@@ -593,8 +601,8 @@ function PredefinedColor({ color: [title, r, g, b], setColor, }: { - color: [string, number, number, number]; - setColor: (color: Color) => void; + color: readonly [string, number, number, number]; + setColor: (color: RgbaColor) => void; }) { return (