From 471846d7844278a774f791f053cbc50e633d5aa5 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 3 Oct 2026 16:34:20 +0200 Subject: [PATCH] build(frontend): close the lint gaps the E32 audit measured eslint now also enforces: - the React Compiler diagnostics that `recommended` leaves out (todo, memo-dependencies and ten zero-hit tripwires); - a description on every suppression, and no blanket disables; - no focused or skipped tests (vitest and playwright); - the typed pair no-floating-promises and no-deprecated. Lint also covers e2e/ and the TypeScript build configs, which `eslint .` had been skipping in silence. Every hit is fixed or suppressed inline with a named reason: - The 31 try statements the compiler cannot lower yet carry a suppression until S3 rewrites them. Five other compiler skips were plain fixes: a module counter bumped inside a hook, a ref read in a default parameter, and a local named like the
it renders. - The real defects behind floating promises are fixed. ChatDock threw at nobody when the device key store could not be opened. VideoEditor's play() rejection went unhandled. loadYouTubeApi never rejected, so a blocked IFrame API left the player on an empty stage forever; it now fails on a script error or after 15 s, shows its own error with the way out, and lets the next open try again. - MutableRefObject, FormEvent and lucide's deprecated brand icon are replaced. The YouTube glyph now lives in lib/brandIcons. - The lint-probe harness lets its virtual file names into TypeScript's default project, so the contract tests still run the real config. --- frontend/eslint.config.js | 110 ++++++- frontend/package-lock.json | 299 ++++++++++++++++++ frontend/package.json | 3 + frontend/src/components/AddToPlaylist.tsx | 7 +- frontend/src/components/BootGate.dom.test.tsx | 15 +- .../ChannelPage.backfill.dom.test.tsx | 4 +- .../components/ChannelsProvider.dom.test.tsx | 10 +- frontend/src/components/ChatDock.tsx | 13 +- frontend/src/components/ChatThread.tsx | 3 +- frontend/src/components/DownloadDialog.tsx | 2 +- frontend/src/components/Feed.tsx | 8 +- frontend/src/components/Header.tsx | 4 +- frontend/src/components/IssueComposer.tsx | 6 +- frontend/src/components/KeyGate.tsx | 2 + frontend/src/components/Messages.tsx | 3 +- .../NavigationProvider.dom.test.tsx | 4 +- frontend/src/components/OnboardingWizard.tsx | 7 +- frontend/src/components/PlayerModal.tsx | 10 +- frontend/src/components/Playlists.tsx | 15 +- frontend/src/components/PlaylistsRail.tsx | 6 +- frontend/src/components/PlexBrowse.tsx | 4 +- .../src/components/PlexCollectionEditor.tsx | 4 + .../src/components/PlexCollectionView.tsx | 6 +- .../PlexPlayer.controls.dom.test.tsx | 44 ++- .../PlexPlayer.session.dom.test.tsx | 4 +- frontend/src/components/PlexPlayer.tsx | 10 +- frontend/src/components/PlexPlaylistAdd.tsx | 4 +- frontend/src/components/PlexPlaylistView.tsx | 2 +- frontend/src/components/PlexSidebar.tsx | 3 +- .../src/components/PrefsProvider.dom.test.tsx | 5 +- frontend/src/components/ProfileEditor.tsx | 2 +- .../SavedViewsProvider.dom.test.tsx | 4 +- .../src/components/SavedViewsProvider.tsx | 3 +- .../components/Scheduler.menu.dom.test.tsx | 4 +- frontend/src/components/SettingsPanel.tsx | 4 +- frontend/src/components/SetupWizard.tsx | 1 + frontend/src/components/ShareDialog.tsx | 2 +- frontend/src/components/VideoEditor.tsx | 5 +- frontend/src/components/WatchPage.tsx | 2 + frontend/src/components/Welcome.tsx | 6 +- .../src/components/accountPanel.dom.test.tsx | 4 +- frontend/src/components/legal/LegalLayout.tsx | 2 +- .../src/components/plexCards.dom.test.tsx | 12 +- frontend/src/components/plexViews.tsx | 10 +- .../src/components/ui/AnchoredPopover.tsx | 2 +- frontend/src/components/ui/Toaster.tsx | 2 +- frontend/src/components/ui/overlayOrder.ts | 4 +- frontend/src/components/ui/sortableA11y.ts | 2 +- frontend/src/components/useDebugRecording.ts | 1 + .../src/components/useDownloadActions.tsx | 1 + .../src/components/usePlaylistActions.tsx | 7 +- .../usePlexPlaylistEdits.dom.test.tsx | 6 +- .../src/components/usePlexPlaylistEdits.ts | 1 + frontend/src/design-lab/batchB.tsx | 4 +- frontend/src/i18n/locales/en/player.json | 1 + frontend/src/i18n/locales/hu/player.json | 1 + frontend/src/lib/brandIcons.ts | 16 + frontend/src/lib/clipboard.ts | 2 + frontend/src/lib/history.ts | 9 +- frontend/src/lib/lintProbe.testkit.ts | 26 +- frontend/src/lib/mediaLog.ts | 8 + frontend/src/lib/modules.ts | 4 +- .../lib/playerModalHooks.apiload.dom.test.tsx | 84 +++++ .../playerModalHooks.progress.dom.test.tsx | 4 +- frontend/src/lib/playerModalHooks.ts | 166 +++++----- .../lib/plexPlayerHooks.controls.dom.test.tsx | 39 ++- .../lib/plexPlayerHooks.subtitle.dom.test.tsx | 6 +- frontend/src/lib/plexPlayerHooks.ts | 51 +-- frontend/src/lib/useDebounced.dom.test.ts | 8 +- frontend/src/lib/usePref.dom.test.ts | 4 +- frontend/src/lib/usePref.ts | 4 +- frontend/tsconfig.node.json | 13 +- 72 files changed, 938 insertions(+), 216 deletions(-) create mode 100644 frontend/src/lib/brandIcons.ts create mode 100644 frontend/src/lib/playerModalHooks.apiload.dom.test.tsx diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 3d1b998a..cf5cc369 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -4,6 +4,9 @@ import reactHooks from "eslint-plugin-react-hooks"; import reactRefresh from "eslint-plugin-react-refresh"; import prettier from "eslint-config-prettier"; import globals from "globals"; +import eslintComments from "@eslint-community/eslint-plugin-eslint-comments"; +import vitest from "@vitest/eslint-plugin"; +import playwright from "eslint-plugin-playwright"; // Flat config (ESLint 9). Deliberately NARROW: the high-value rule here is // react-hooks/exhaustive-deps — 33 suppressions across the app exist for it, and until this @@ -14,6 +17,11 @@ import globals from "globals"; // // reportUnusedDisableDirectives: a `// eslint-disable` that no longer suppresses anything is itself // an error — that is what keeps inert disable comments from re-accumulating (the S1b mandate). +// +// E32 (gate integrity): every switched-off or narrowed rule here is listed, with its reason and its +// measured count, in docs/gates.md, and lib/gates.contract.test.ts fails when one is not. A rule +// with a single real hit is never switched off — its sites are fixed, or each suppressed inline with +// a `-- ` description (eslint-comments/require-description makes that mandatory). // E22 S4: every way out of the browser goes through the one module that LOGS it — a request line // with a request id (lib/http.ts), the upload XHR (lib/api/core.ts), the messages socket @@ -472,25 +480,83 @@ const SPLIT_CLASS = "/^([a-z][\\w-]*:[\\w\\[!-]|:[a-z\\[!])/"; const SPLIT_CLASS_MESSAGE = "a class split by an interpolation (`${x}hover:…`): Tailwind never sees it whole and emits no CSS; write the class literally (a shared guard is the `act:` variant)."; +// Applies to EVERY linted file: a suppression says which rule and why. A bare +// `eslint-disable-next-line rule` named nothing in four places the E32 audit found, against the +// CLAUDE.md rule that each one names its invariant; a blanket `eslint-disable` names no rule at all. +const suppressionRules = { + plugins: { "@eslint-community/eslint-comments": eslintComments }, + linterOptions: { reportUnusedDisableDirectives: "error" }, + rules: { + "@eslint-community/eslint-comments/require-description": ["error", { ignore: [] }], + "@eslint-community/eslint-comments/no-unlimited-disable": "error", + }, +}; + +// The two type-aware rules (E32 D3: a curated pair, not strictTypeChecked — ~2000 hits there, none +// real in the sample). A floating promise is a rejection nobody handles: the audit found two real +// ones (ChatDock's device key store, VideoEditor's play()). `void` marks a deliberate fire-and-forget +// whose callee reports its own failure. React Query's invalidate/refetch family never rejects +// unless asked to (`throwOnError`), so those calls are known-safe by name. +const typedRules = { + "@typescript-eslint/no-floating-promises": [ + "error", + { + allowForKnownSafeCalls: [ + { + from: "package", + package: "@tanstack/query-core", + name: [ + "invalidateQueries", + "refetchQueries", + "cancelQueries", + "resetQueries", + "prefetchQuery", + "prefetchInfiniteQuery", + ], + }, + ], + }, + ], + "@typescript-eslint/no-deprecated": "error", +}; + export default tseslint.config( { ignores: ["dist", "node_modules", "playwright-report", "test-results", "e2e/.auth"] }, + suppressionRules, { files: ["src/**/*.{ts,tsx}"], extends: [js.configs.recommended, ...tseslint.configs.recommended], languageOptions: { ecmaVersion: 2022, globals: { ...globals.browser }, + parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname }, }, plugins: { "react-hooks": reactHooks, "react-refresh": reactRefresh, local: localRules, }, - linterOptions: { - reportUnusedDisableDirectives: "error", - }, rules: { ...reactHooks.configs.recommended.rules, + ...typedRules, + // The React Compiler's own diagnostics that `recommended` leaves out (E32). Each is a function + // the compiler SKIPS, silently — `panicThreshold` is "none" and there is no logger, so a lint + // error is the only place the skip is said out loud. `todo` is the compiler's "cannot lower + // this shape yet" (31 try statements, each suppressed inline until E32 S3 rewrites them); the + // rest had 0 hits when switched on and are tripwires. The full per-function picture is + // components/compilerCoverage.contract.test.ts. + "react-hooks/todo": "error", + "react-hooks/memo-dependencies": "error", + "react-hooks/rule-suppression": "error", + "react-hooks/invariant": "error", + "react-hooks/syntax": "error", + "react-hooks/void-use-memo": "error", + "react-hooks/component-hook-factories": "error", + "react-hooks/capitalized-calls": "error", + "react-hooks/hooks": "error", + "react-hooks/memoized-effect-dependencies": "error", + "react-hooks/exhaustive-effect-dependencies": "error", + "react-hooks/no-deriving-state-in-effects": "error", // eslint-plugin-react-hooks 7's `recommended` is no longer two rules — it carries the whole // React Compiler rule set (E27 S1). On this codebase that landed 283 findings, and they were // triaged rather than bulk-suppressed, because the two answers are different: @@ -709,7 +775,12 @@ export default tseslint.config( // Tests stand doubles in for the network (`vi.stubGlobal("fetch", …)`) and may reach it directly. // …and render throwaway markup whose labels no user ever hears. files: ["src/**/*.test.{ts,tsx}"], + plugins: { vitest }, rules: { + // A focused test silently skips the rest of the suite; a skipped one is a test nobody runs + // (E32 — vitest's allowOnly is on outside CI, so nothing else catches a stray `.only` locally). + "vitest/no-focused-tests": "error", + "vitest/no-disabled-tests": "error", "no-restricted-globals": "off", "no-restricted-properties": "off", "local/literal-a11y-text": "off", @@ -720,5 +791,38 @@ export default tseslint.config( "local/glyph-link": "off", }, }, + { + // Outside src (E32): the e2e suite and the TypeScript build-tool configs went unlinted — the + // flat config named only src/**, so `eslint .` skipped them without a word. The same base and + // typed rules as the app; Node globals, since none of it runs in the browser. + files: ["e2e/**/*.ts", "*.config.ts", "reactPlugin.ts", "devOnlyGuard.ts"], + extends: [js.configs.recommended, ...tseslint.configs.recommended], + languageOptions: { + ecmaVersion: 2022, + globals: { ...globals.node }, + parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname }, + }, + rules: { + ...typedRules, + // tsc owns these here too (both tsconfig.node.json and e2e/tsconfig.json are strict + + // noUnusedLocals/Parameters). + "@typescript-eslint/no-unused-vars": "off", + "no-undef": "off", + }, + }, + { + files: ["e2e/**/*.ts"], + plugins: { playwright }, + rules: { + "playwright/no-focused-test": "error", + "playwright/no-skipped-test": "error", + }, + }, + { + // The two plain-JS configs: this file and postcss's. + files: ["*.config.js"], + extends: [js.configs.recommended], + languageOptions: { ecmaVersion: 2022, sourceType: "module", globals: { ...globals.node } }, + }, prettier, ); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 78063d2e..a049ed8d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -24,6 +24,7 @@ }, "devDependencies": { "@babel/core": "7.29.7", + "@eslint-community/eslint-plugin-eslint-comments": "4.8.1", "@eslint/js": "9.39.1", "@playwright/test": "^1.61.1", "@tailwindcss/postcss": "^4.3.3", @@ -32,9 +33,11 @@ "@types/react": "19.3.0", "@types/react-dom": "19.3.0", "@vitejs/plugin-react": "^4.3.1", + "@vitest/eslint-plugin": "1.6.27", "babel-plugin-react-compiler": "1.0.0", "eslint": "9.39.1", "eslint-config-prettier": "10.1.8", + "eslint-plugin-playwright": "2.12.0", "eslint-plugin-react-hooks": "7.1.1", "eslint-plugin-react-refresh": "0.4.24", "globals": "16.5.0", @@ -970,6 +973,36 @@ "node": ">=12" } }, + "node_modules/@eslint-community/eslint-plugin-eslint-comments": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-plugin-eslint-comments/-/eslint-plugin-eslint-comments-4.8.1.tgz", + "integrity": "sha512-vafrdi15JHBFh1qWOAucyJwlEEuhdoMof28jOdKdgnwZ7bXwd6v4wzHYSmAVLwnx0yc7bqAtfJ0EZQV1WAzV/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^4.0.0", + "ignore": "^7.0.5" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/@eslint-community/eslint-plugin-eslint-comments/node_modules/ignore": { + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", + "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -3236,6 +3269,243 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@vitest/eslint-plugin": { + "version": "1.6.27", + "resolved": "https://registry.npmjs.org/@vitest/eslint-plugin/-/eslint-plugin-1.6.27.tgz", + "integrity": "sha512-X1RCAfwbatG4GFbJ/1PIHP9MSZMt0JJThqbYID+vS4L783k6QGlLPe421wQE8dHXuGCcenVLsydiM4qzsYWxhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "^8.58.0", + "@typescript-eslint/utils": "^8.58.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@typescript-eslint/eslint-plugin": "*", + "eslint": ">=8.57.0", + "typescript": ">=5.0.0", + "vitest": "*" + }, + "peerDependenciesMeta": { + "@typescript-eslint/eslint-plugin": { + "optional": true + }, + "typescript": { + "optional": true + }, + "vitest": { + "optional": true + } + } + }, + "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/project-service": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.0.tgz", + "integrity": "sha512-aABjw5rjBacYONVPaPiWOCjJu0vEF4a25iQuodlmQYL1trtLZ0X/y+2Vzl3BKI1odM4LnwLE1oUDXYp1wzx1TQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.71.0", + "@typescript-eslint/types": "^8.71.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/scope-manager": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.0.tgz", + "integrity": "sha512-gWF0BhUcnjZxSpLE8ngS/59n2SB0J3YqRxvX1+2aoRJk9hNtHSLOV+TcarFiOr5ipXm3yc1QrI4c9YZc8zyCxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.0.tgz", + "integrity": "sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/types": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.0.tgz", + "integrity": "sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/typescript-estree": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.0.tgz", + "integrity": "sha512-PEEF4G5sLLWAS5BpPrUvms4ySZkiBQQZM4z+3ReI46axK5Vqr/vXBQatJQIZZOYdGyPUAKTtsrWzpqKuU+3DEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.71.0", + "@typescript-eslint/tsconfig-utils": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/visitor-keys": "8.71.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/utils": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.0.tgz", + "integrity": "sha512-pKR/tEMVrXZG23UFKUn5BQf3zfmfk7KQceI2cGzywZ5nxM5Eu3hEJU1utjWzydtzBbcJAQhHN8iPCxobHpPcZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.71.0", + "@typescript-eslint/types": "8.71.0", + "@typescript-eslint/typescript-estree": "8.71.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@vitest/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": { + "version": "8.71.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.0.tgz", + "integrity": "sha512-8eQ9R218XORK+KLosnf4bu/QsUXvUyVwTbArg7/0NMB1Pu87OJKvj4nhFblkYE8gQV73mW1dx1ptlPCkwRGa7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitest/eslint-plugin/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@vitest/eslint-plugin/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@vitest/eslint-plugin/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitest/eslint-plugin/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@vitest/eslint-plugin/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@vitest/expect": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", @@ -4087,6 +4357,35 @@ "eslint": ">=7.0.0" } }, + "node_modules/eslint-plugin-playwright": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-playwright/-/eslint-plugin-playwright-2.12.0.tgz", + "integrity": "sha512-7HpSKR/D2oCH4RKRauFskcrnDrHvLQKopW7Nen8lqrFZtx/SB+FgqVaH+zyQd32noZSZy9Lrsa+DsYrVUpn6hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "globals": "^17.3.0" + }, + "engines": { + "node": ">=16.9.0" + }, + "peerDependencies": { + "eslint": ">=8.40.0" + } + }, + "node_modules/eslint-plugin-playwright/node_modules/globals": { + "version": "17.13.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.13.0.tgz", + "integrity": "sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/eslint-plugin-react-hooks": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index e9bd1f67..64620f6a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -40,6 +40,7 @@ }, "devDependencies": { "@babel/core": "7.29.7", + "@eslint-community/eslint-plugin-eslint-comments": "4.8.1", "@eslint/js": "9.39.1", "@playwright/test": "^1.61.1", "@tailwindcss/postcss": "^4.3.3", @@ -48,9 +49,11 @@ "@types/react": "19.3.0", "@types/react-dom": "19.3.0", "@vitejs/plugin-react": "^4.3.1", + "@vitest/eslint-plugin": "1.6.27", "babel-plugin-react-compiler": "1.0.0", "eslint": "9.39.1", "eslint-config-prettier": "10.1.8", + "eslint-plugin-playwright": "2.12.0", "eslint-plugin-react-hooks": "7.1.1", "eslint-plugin-react-refresh": "0.4.24", "globals": "16.5.0", diff --git a/frontend/src/components/AddToPlaylist.tsx b/frontend/src/components/AddToPlaylist.tsx index 6ece7882..d4179213 100644 --- a/frontend/src/components/AddToPlaylist.tsx +++ b/frontend/src/components/AddToPlaylist.tsx @@ -2,7 +2,8 @@ import { useLayoutEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; -import { ListPlus, Plus, Youtube } from "lucide-react"; +import { ListPlus, Plus } from "lucide-react"; +import { YouTubeIcon } from "../lib/brandIcons"; import { MenuCheck } from "./ui/MenuCheck"; import { api, type Playlist } from "../lib/api"; import { notifyActionError } from "../lib/actionErrors"; @@ -117,6 +118,7 @@ function MembershipPanel({ async function toggle(pl: Playlist) { if (busy) return; setBusy(true); + // eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3 try { if (pl.has_video) await api.removeFromPlaylist(pl.id, videoId); else await api.addToPlaylist(pl.id, videoId); @@ -132,6 +134,7 @@ function MembershipPanel({ const name = newName.trim(); if (!name || busy) return; setBusy(true); + // eslint-disable-next-line react-hooks/todo -- the React Compiler cannot lower try/finally yet, so the enclosing component or hook stays uncompiled; pinned in compilerCoverage, burned down in E32 S3 try { const pl = await api.createPlaylist(name); await api.addToPlaylist(pl.id, videoId); @@ -183,7 +186,7 @@ function MembershipPanel({ {playlistName(pl, t)} {(pl.source === "youtube" || pl.yt_playlist_id) && ( - + )} ))} diff --git a/frontend/src/components/BootGate.dom.test.tsx b/frontend/src/components/BootGate.dom.test.tsx index a81f4c74..e436e12c 100644 --- a/frontend/src/components/BootGate.dom.test.tsx +++ b/frontend/src/components/BootGate.dom.test.tsx @@ -23,13 +23,14 @@ import i18n from "../i18n"; const renders: { page: string; lang: string }[] = []; let mounts = 0; +const countMount = () => { + mounts++; // outside the component: the compiler cannot lower `++` on a module variable in one +}; function Probe() { const { page } = useNavigation(); const { i18n: inst } = useTranslation(); renders.push({ page, lang: inst.language }); - useEffect(() => { - mounts++; - }, []); + useEffect(countMount, []); return
{page}
; } @@ -226,7 +227,9 @@ describe("BootGate", () => { const qc = mount(); await screen.findByTestId("app"); // Anything that re-renders the gate must leave it alone: a pref save, a background refetch. - act(() => qc.setQueryData(qk.me(), account({ preferences: { startup: "last" } }))); + act(() => { + qc.setQueryData(qk.me(), account({ preferences: { startup: "last" } })); + }); await act(() => new Promise((r) => setTimeout(r, 20))); expect(reload).not.toHaveBeenCalled(); @@ -241,7 +244,9 @@ describe("BootGate", () => { vi.stubGlobal("location", { ...window.location, reload }); // What every pref save does to the cache (usePref's mergePrefsCache): a new `me` for the same account. - act(() => qc.setQueryData(qk.me(), account({ preferences: { language: "hu" } }))); + act(() => { + qc.setQueryData(qk.me(), account({ preferences: { language: "hu" } })); + }); await act(() => new Promise((r) => setTimeout(r, 20))); expect(reload).not.toHaveBeenCalled(); diff --git a/frontend/src/components/ChannelPage.backfill.dom.test.tsx b/frontend/src/components/ChannelPage.backfill.dom.test.tsx index 673a714d..8f060b47 100644 --- a/frontend/src/components/ChannelPage.backfill.dom.test.tsx +++ b/frontend/src/components/ChannelPage.backfill.dom.test.tsx @@ -82,7 +82,9 @@ describe("ChannelPage — the grid follows a running backfill", () => { expect(qc.getQueryState(feedKey)?.isInvalidated).toBe(false); // The next poll tick: the background job has stored 60 more. - act(() => qc.setQueryData(qk.channel(CH), reading(79))); + act(() => { + qc.setQueryData(qk.channel(CH), reading(79)); + }); await waitFor(() => expect(qc.getQueryState(feedKey)?.isInvalidated).toBe(true)); }); diff --git a/frontend/src/components/ChannelsProvider.dom.test.tsx b/frontend/src/components/ChannelsProvider.dom.test.tsx index c5b86cc9..cdd36b56 100644 --- a/frontend/src/components/ChannelsProvider.dom.test.tsx +++ b/frontend/src/components/ChannelsProvider.dom.test.tsx @@ -60,9 +60,9 @@ describe("ChannelsProvider tab history", () => { expect(result.current.view).toBe("blocked"); // Browser Back to an entry stamped with a different tab. - act(() => - window.dispatchEvent(new PopStateEvent("popstate", { state: { _chTab: "discovery" } })), - ); + act(() => { + window.dispatchEvent(new PopStateEvent("popstate", { state: { _chTab: "discovery" } })); + }); expect(result.current.view).toBe("discovery"); expect(localStorage.getItem(VIEW_KEY)).toBe("discovery"); @@ -71,7 +71,9 @@ describe("ChannelsProvider tab history", () => { it("ignores a popstate for an entry outside the manager (no _chTab)", () => { const { result } = render(); act(() => result.current.setView("hidden")); - act(() => window.dispatchEvent(new PopStateEvent("popstate", { state: { sfPage: "feed" } }))); + act(() => { + window.dispatchEvent(new PopStateEvent("popstate", { state: { sfPage: "feed" } })); + }); expect(result.current.view).toBe("hidden"); // unchanged }); }); diff --git a/frontend/src/components/ChatDock.tsx b/frontend/src/components/ChatDock.tsx index 957ffb9e..db984d3d 100644 --- a/frontend/src/components/ChatDock.tsx +++ b/frontend/src/components/ChatDock.tsx @@ -13,6 +13,7 @@ import { } from "../lib/chatDock"; import { onMessage, onUnread } from "../lib/messagesSocket"; import * as e2ee from "../lib/e2ee"; +import { errorAttrs, log } from "../lib/log"; import Avatar from "./ui/Avatar"; import ChatThread from "./ChatThread"; import Overlay from "./ui/Overlay"; @@ -30,7 +31,17 @@ export default function ChatDock({ meId }: { meId: number }) { // Restore the per-device unlocked key and the persisted dock windows once, app-wide. useEffect(() => { initDock(meId); - e2ee.loadFromDevice(meId); + // The device key store can be unopenable (storage disabled, some private windows): messages then + // stay locked behind the passphrase, as on a new device — said in the log, not thrown at nobody. + e2ee + .loadFromDevice(meId) + .catch((err: unknown) => + log.warn( + "ui.e2ee_device_key_unreadable", + "the device key store could not be read", + errorAttrs(err), + ), + ); }, [meId]); // Live delivery: refresh the conversation list, unread badge, and the affected thread; and diff --git a/frontend/src/components/ChatThread.tsx b/frontend/src/components/ChatThread.tsx index f6249c5e..6cdd4ee5 100644 --- a/frontend/src/components/ChatThread.tsx +++ b/frontend/src/components/ChatThread.tsx @@ -54,7 +54,8 @@ export default function ChatThread({ useEffect(() => { if (isSystem || !ready) return; let cancelled = false; - (async () => { + // Every await inside is caught per message (a failure reads "can't decrypt"), so this never rejects. + void (async () => { const out: Record = {}; // Fetch the partner key lazily inside the try (only when there's a ciphertext message) and // once — so an empty thread never fetches, and a fetch failure (e.g. a partner who reset and diff --git a/frontend/src/components/DownloadDialog.tsx b/frontend/src/components/DownloadDialog.tsx index 8594101e..eab36ba5 100644 --- a/frontend/src/components/DownloadDialog.tsx +++ b/frontend/src/components/DownloadDialog.tsx @@ -104,7 +104,7 @@ export default function DownloadDialog({ { setManage(false); - profilesQ.refetch(); + void profilesQ.refetch(); }} /> diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index bd10d7e1..aee20101 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -19,9 +19,9 @@ import { RefreshCw, Rows3, Trash2, - Youtube, type LucideIcon, } from "lucide-react"; +import { YouTubeIcon } from "../lib/brandIcons"; import { api, HttpError, type FeedFilters, type Video, type VideoStatus } from "../lib/api"; import i18n from "../i18n"; import { notifyActionError } from "../lib/actionErrors"; @@ -352,7 +352,7 @@ export default function Feed({ // The toast's Undo action re-enters onState, which is a forward reference to the binding still // being defined — the same shape PlexPlayer's session reclaim has, and answered the same way. const onStateRef = useRef(onState); - // eslint-disable-next-line react-hooks/immutability + // eslint-disable-next-line react-hooks/immutability -- a latest-value ref for a forward reference: the Undo re-enters onState before its binding exists onStateRef.current = onState; const onResetState = useCallback( @@ -473,7 +473,7 @@ export default function Feed({ {t("feed.yt.back")}