build(frontend): React 18 -> 19 with the React Compiler (E27 S1)

The compiler auto-memoises, which is the point: it infers what the app's
112 `useCallback` and 48 `useMemo` call sites were hand-written for and
applies the equivalent itself. It is on repo-wide with no opt-out list —
it bails on anything it cannot prove safe, and says so through the lint
rules. Verified in the built bundle rather than assumed: 369
`react.memo_cache_sentinel` markers across 35 of the 76 chunks.

ONE transform, shared by vite.config.ts and vitest.config.ts through
reactPlugin.ts. Not two `react()` calls: if the compiler ran only in the
production build, every vitest run would exercise code the user never
receives and a compiler-induced change would ship green — the same
gate-vs-image split 0.93.2 was released to fix. The plugin is imported
rather than named by the usual string, because a string is invisible to
knip and, worse, a typo in it is a silent no-op that ships an uncompiled
bundle, while a bad import is a tsc error.

eslint-plugin-react-hooks 5 -> 7 brings the whole compiler rule set: 283
findings, triaged rather than bulk-suppressed, because the two halves
needed different answers.

  react-hooks/refs (217) is OFF. 153 of those 217 are this app's popover
  architecture, not its history: 79 are @floating-ui/react's documented
  `pop.refs.setReference` / `getReferenceProps` callback-ref setters, and
  74 more are passing the popover handle down as a prop, which is what
  components/ui's popover engine is for. Suppressing them would not help
  the next popover anyone writes. The remaining ~64 are genuine
  render-phase `ref.current = x` writes and are real, but the rule cannot
  tell them apart here; they are E27 S2's work.

  The other 66 go into eslint-suppressions.json as a ratchet — new
  violations fail the gate, and `--prune-suppressions` makes a fixed one
  fail too until it is removed. 45 of them are `set-state-in-effect`,
  which IS E27 S2's subject, so that sprint now has a measured worklist.

react-i18next deliberately stays on 14. It never declared a React-19
peer — every version from 14 to 17 asks only for `react >= 16.8.0` — and
what the newer lines actually require is i18next 25/26 against our 23.
That is an i18n runtime migration wearing a React hat, and it buys this
sprint nothing.

Two React 19 type changes: `useRef<T>(null)` is now `RefObject<T | null>`
(the honest shape — the element is null until it mounts), so
PlexPlayerSettings' prop follows; and reactPlugin.ts joins
tsconfig.node.json's include.

The compiler skips memoising VirtualGrid and reports it: TanStack
Virtual's `useVirtualizer()` returns functions that cannot be memoised
without going stale. That is the right trade for a component whose cost
is its row window, and it is the one such site in the app, so it carries
a targeted disable with the reason — a second incompatible library
anywhere else must still stop the gate.

Nine lanes green, vitest 1558. Browser UAT on the dev server against the
still-running React 18 build as a control: boot, feed (virtualized grid,
popover menu, a filter change that moved the list 195,187 -> 64 items
with the URL mirrored, so no stale memoised UI), command palette, player
modal — no console errors on any of it.
This commit is contained in:
2026-09-20 23:21:08 +02:00
parent cc9fc3b544
commit 7d682c90d5
10 changed files with 409 additions and 67 deletions
+253
View File
@@ -0,0 +1,253 @@
{
"src/components/BootGate.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/ChannelPage.tsx": {
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/components/ChannelTags.tsx": {
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/components/ChannelsProvider.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/ConfigPanel.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/DateFilterSelect.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/DebugRecorder.dom.test.tsx": {
"react-hooks/globals": {
"count": 1
}
},
"src/components/DebugRecorder.tsx": {
"react-hooks/purity": {
"count": 1
}
},
"src/components/DiagnosticsSettings.tsx": {
"react-hooks/purity": {
"count": 1
}
},
"src/components/DownloadCenter.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/Feed.tsx": {
"react-hooks/immutability": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/FeedFiltersProvider.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/GlassTuner.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
},
"react-hooks/use-memo": {
"count": 1
}
},
"src/components/Home.tsx": {
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/components/IssueComposer.tsx": {
"react-hooks/immutability": {
"count": 2
}
},
"src/components/MyLogViewer.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/NavigationProvider.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/PersonalViewsModal.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/PlayerModal.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/Playlists.tsx": {
"react-hooks/immutability": {
"count": 2
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/PlexBrowse.tsx": {
"react-hooks/immutability": {
"count": 3
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/PlexInfo.tsx": {
"react-hooks/static-components": {
"count": 3
}
},
"src/components/PlexPlayer.tsx": {
"react-hooks/immutability": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/PlexSearchBox.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/SavedViewsProvider.tsx": {
"react-hooks/set-state-in-effect": {
"count": 3
}
},
"src/components/Scheduler.tsx": {
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/components/SetupWizard.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/TagFilterChips.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/WatchPage.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/Welcome.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/WizardProvider.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/comfortPin.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/downloadRow.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/ui/Avatar.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/ui/BackToTop.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/ui/Combobox.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/ui/CommandPalette.tsx": {
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/components/ui/Pager.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/ui/Toaster.tsx": {
"react-hooks/globals": {
"count": 1
},
"react-hooks/set-state-in-effect": {
"count": 1
},
"react-hooks/static-components": {
"count": 1
}
},
"src/components/ui/anchoredPopoverHooks.ts": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/ui/sortableA11y.ts": {
"react-hooks/immutability": {
"count": 1
}
},
"src/components/usePlexPlaylistEdits.ts": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/lib/plexPlayerHooks.subtitle.dom.test.tsx": {
"react-hooks/immutability": {
"count": 1
}
},
"src/lib/plexPlayerHooks.ts": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/lib/useCardPager.ts": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/lib/useFocusKeeper.restore.dom.test.tsx": {
"react-hooks/globals": {
"count": 1
}
}
}
+19
View File
@@ -187,6 +187,25 @@ export default tseslint.config(
},
rules: {
...reactHooks.configs.recommended.rules,
// 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 lands 283 findings, and they were
// triaged rather than bulk-suppressed, because the two answers are different:
//
// react-hooks/refs 217 — OFF, see below.
// the other 66 — real, and suppressed into eslint-suppressions.json as a RATCHET:
// new violations fail the gate, the existing list burns down in E27 S2
// (45 of them are `set-state-in-effect`, which IS that sprint's subject).
//
// `refs` is off because it fires on this app's popover architecture, not on its history, so
// suppressing it would not help the next popover anyone writes. Measured over the 217:
// 79 are `pop.refs.setReference` / `getReferenceProps` — @floating-ui/react's callback-ref
// setters, which are the documented way to use it — and 74 more are passing the popover handle
// down as a prop (`popover={pop}`, `ref={fade.ref}`), which is what `components/ui`'s popover
// engine is FOR. That is 153 of 217 flagged for having the word `refs` in an object that is not
// a React ref. The remaining ~64 are genuine render-phase `someRef.current = x` writes and are
// real; they are E27 S2's work, not this rule's, because the rule cannot tell them apart here.
// Re-evaluate when the rule can: it shipped in 1.0.0 and this looks like over-approximation.
"react-hooks/refs": "off",
"local/jsx-comment-text": "error",
"local/focus-safe-disabled": "error",
"local/literal-a11y-text": "error",
+78 -56
View File
@@ -18,8 +18,8 @@
"hls.js": "^1.6.16",
"i18next": "^23.11.5",
"lucide-react": "^0.408.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react": "19.3.0",
"react-dom": "19.3.0",
"react-i18next": "^14.1.2"
},
"devDependencies": {
@@ -28,12 +28,13 @@
"@tailwindcss/postcss": "^4.3.3",
"@testing-library/react": "^16.3.2",
"@types/node": "^24.13.6",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@vitejs/plugin-react": "^4.3.1",
"babel-plugin-react-compiler": "1.0.0",
"eslint": "9.39.1",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-react-hooks": "7.1.1",
"eslint-plugin-react-refresh": "0.4.24",
"globals": "16.5.0",
"jsdom": "^25.0.1",
@@ -2922,32 +2923,24 @@
"undici-types": "~7.18.0"
}
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
"version": "18.3.31",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz",
"integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==",
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
"integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.2.2"
}
},
"node_modules/@types/react-dom": {
"version": "18.3.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz",
"integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^18.0.0"
"@types/react": "^19.3.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
@@ -3469,6 +3462,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/babel-plugin-react-compiler": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz",
"integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.26.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -4084,16 +4087,23 @@
}
},
"node_modules/eslint-plugin-react-hooks": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz",
"integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==",
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz",
"integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/core": "^7.24.4",
"@babel/parser": "^7.24.4",
"hermes-parser": "^0.25.1",
"zod": "^3.25.0 || ^4.0.0",
"zod-validation-error": "^3.5.0 || ^4.0.0"
},
"engines": {
"node": ">=10"
"node": ">=18"
},
"peerDependencies": {
"eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0"
"eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0"
}
},
"node_modules/eslint-plugin-react-refresh": {
@@ -4586,6 +4596,23 @@
"node": ">= 0.4"
}
},
"node_modules/hermes-estree": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
"integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==",
"dev": true,
"license": "MIT"
},
"node_modules/hermes-parser": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz",
"integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==",
"dev": true,
"license": "MIT",
"dependencies": {
"hermes-estree": "0.25.1"
}
},
"node_modules/hls.js": {
"version": "1.6.16",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz",
@@ -4776,6 +4803,7 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
@@ -5279,18 +5307,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/loupe": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
@@ -5824,28 +5840,24 @@
"license": "MIT"
},
"node_modules/react": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz",
"integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz",
"integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
"scheduler": "^0.28.0"
},
"peerDependencies": {
"react": "^18.3.1"
"react": "^19.3.0"
}
},
"node_modules/react-i18next": {
@@ -6016,13 +6028,10 @@
}
},
"node_modules/scheduler": {
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
}
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
"integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==",
"license": "MIT"
},
"node_modules/semver": {
"version": "6.3.1",
@@ -6836,6 +6845,19 @@
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zod-validation-error": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz",
"integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
}
}
}
+6 -5
View File
@@ -34,8 +34,8 @@
"hls.js": "^1.6.16",
"i18next": "^23.11.5",
"lucide-react": "^0.408.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react": "19.3.0",
"react-dom": "19.3.0",
"react-i18next": "^14.1.2"
},
"devDependencies": {
@@ -44,12 +44,13 @@
"@tailwindcss/postcss": "^4.3.3",
"@testing-library/react": "^16.3.2",
"@types/node": "^24.13.6",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"@vitejs/plugin-react": "^4.3.1",
"babel-plugin-react-compiler": "1.0.0",
"eslint": "9.39.1",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-react-hooks": "7.1.1",
"eslint-plugin-react-refresh": "0.4.24",
"globals": "16.5.0",
"jsdom": "^25.0.1",
+26
View File
@@ -0,0 +1,26 @@
import react from "@vitejs/plugin-react";
import reactCompiler from "babel-plugin-react-compiler";
// ONE React transform, shared by vite.config.ts and vitest.config.ts (E27 S1).
//
// It is a shared factory rather than two `react()` calls because the two configs must not drift:
// if the compiler ran only in the production build, every vitest run would be exercising code the
// user never receives, and a compiler-induced behaviour change would ship green. That is precisely
// the defect `0.93.2` was released to fix — a gate and a build image that had quietly stopped being
// the same thing — so the rule here is that the gate compiles the code the same way the build does.
//
// The React Compiler auto-memoises: it infers what `useCallback`/`useMemo` were being hand-written
// for and applies the equivalent itself. It is enabled repo-wide with no opt-out list because it
// bails out on its own for anything it cannot prove safe (its rules are also linted, via
// eslint-plugin-react-hooks). `target` follows the installed React major and must move with it.
//
// The plugin is IMPORTED rather than named by the usual `"babel-plugin-react-compiler"` string. Two
// reasons, both about the gate: a string is invisible to knip, which then reports the dependency as
// unused, and — worse — a typo in that string is a silent no-op that ships an uncompiled bundle,
// while a bad import is a resolve error on the tsc lane.
export const reactWithCompiler = () =>
react({
babel: {
plugins: [[reactCompiler, { target: "19" }]],
},
});
@@ -12,7 +12,10 @@ type SettingsTab = "sync" | "playback" | "subtitle" | "clock";
// panel owns no state of its own. `now` is the live wall clock (passed in, not a second ticker) used
// only for the clock-date-style preview buttons.
export function PlexPlayerSettings(props: {
menuRef: RefObject<HTMLDivElement>;
// `| null` is not optional under React 19's types: `useRef<T>(null)` is now `RefObject<T | null>`,
// which is the honest shape — the element is null until the div mounts. The old `RefObject<T>` lied
// about that and 18's types let it.
menuRef: RefObject<HTMLDivElement | null>;
settingsTabs: { key: SettingsTab; label: string }[];
activeTab: SettingsTab;
setSettingsTab: (tab: SettingsTab) => void;
@@ -127,6 +127,15 @@ export default function VirtualGrid<T>({
const rows = useMemo(() => chunk(items, colCount), [items, colCount]);
// The React Compiler (E27 S1) SKIPS memoising this whole component, and says so: TanStack
// Virtual's `useVirtualizer()` hands back functions that cannot be memoised without going stale,
// so the compiler bails rather than risk a frozen viewport. That is the correct trade here — this
// component's cost is the row window it mounts, not its render closures — and it is the ONE such
// site in the app, which is why this is a targeted disable rather than the rule being turned off:
// a second incompatible library anywhere else must still stop the gate. The caution in the rule's
// own text applies, though: values taken from this API and handed to a memoised child can go
// stale, so keep the virtualizer's output inside this component.
// eslint-disable-next-line react-hooks/incompatible-library -- see above; @tanstack/react-virtual
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => scrollEl,
+10 -1
View File
@@ -30,5 +30,14 @@
"allowJs": true,
"checkJs": true
},
"include": ["vite.config.ts", "vitest.config.ts", "tailwind.config.js", "postcss.config.js"]
// reactPlugin.ts is the ONE React transform both configs import (E27 S1) — it is build tooling,
// not app source, so it belongs here and not under tsconfig.json's ["src"]. A file imported by a
// listed one but absent from `include` is TS6307, not a silent pass.
"include": [
"vite.config.ts",
"vitest.config.ts",
"reactPlugin.ts",
"tailwind.config.js",
"postcss.config.js"
]
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { reactWithCompiler } from "./reactPlugin";
// During `vite dev` (host with Node), proxy API calls to the backend container.
// Use 127.0.0.1 (not "localhost") so Node doesn't resolve to IPv6 ::1, which the
@@ -20,7 +20,7 @@ const proxy = {
};
export default defineConfig({
plugins: [react()],
plugins: [reactWithCompiler()],
server: { port: 5173, proxy },
build: { outDir: "dist" },
});
+2 -2
View File
@@ -1,5 +1,5 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import { reactWithCompiler } from "./reactPlugin";
// Unit + component/hook tests, deliberately separate from vite.config.ts so the production build
// never has to load vitest. The DEFAULT environment is node (the pure src/lib helpers — fast, no
@@ -9,7 +9,7 @@ import react from "@vitejs/plugin-react";
// free React transform; it is a no-op for the plain .ts node tests (they carry no JSX). jsdom is a
// devDependency referenced only via that docblock env string — knip's vitest plugin still sees it.
export default defineConfig({
plugins: [react()],
plugins: [reactWithCompiler()],
test: {
// .tsx included on purpose: a test file the glob misses does not fail, it is simply never
// collected, and the gate still prints green.