diff --git a/eslint.config.js b/eslint.config.js index 54614d3f..fc042a48 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -3,7 +3,7 @@ import globals from "globals"; import tseslint from "typescript-eslint"; import eslintConfigPrettier from "eslint-config-prettier"; -// import reactHooks from "eslint-plugin-react-hooks"; +import reactHooks from "eslint-plugin-react-hooks"; import vitest from "@vitest/eslint-plugin"; import { reactRefresh } from "eslint-plugin-react-refresh"; @@ -17,7 +17,7 @@ export default defineConfig( js.configs.recommended, tseslint.configs.strictTypeChecked, tseslint.configs.stylisticTypeChecked, - // reactHooks.configs.flat.recommended, + reactHooks.configs.flat.recommended, reactRefresh.configs.vite(), eslintConfigPrettier, { diff --git a/package-lock.json b/package-lock.json index 5b4ab064..5134b9e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "timezones-ical-library": "^2.2.1" }, "devDependencies": { + "@babel/core": "^7.29.7", "@chakra-ui/cli": "^3.36.0", "@eslint/js": "^10.0.1", "@react-router/dev": "^8.1.0", @@ -47,8 +48,10 @@ "@types/react": "^19.2.17", "@types/react-dom": "19.2.3", "@vitest/eslint-plugin": "^1.6.20", + "babel-plugin-react-compiler": "^1.0.0", "eslint": "^10.6.0", "eslint-config-prettier": "^10.1.8", + "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.3", "globals": "^17.7.0", "jsdom": "^29.1.1", @@ -56,6 +59,7 @@ "typescript": "^6.0.3", "typescript-eslint": "^8.62.1", "vite": "^8.1.0", + "vite-plugin-babel": "^1.7.3", "vitest": "^4.1.9" }, "engines": { @@ -4272,6 +4276,16 @@ "npm": ">=6" } }, + "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": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -4886,6 +4900,26 @@ "eslint": ">=7.0.0" } }, + "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", + "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": ">=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 || ^10.0.0" + } + }, "node_modules/eslint-plugin-react-refresh": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.3.tgz", @@ -5391,6 +5425,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/hoist-non-react-statics": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", @@ -7623,6 +7674,17 @@ } } }, + "node_modules/vite-plugin-babel": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/vite-plugin-babel/-/vite-plugin-babel-1.7.3.tgz", + "integrity": "sha512-PsomVbH74/XyqMzXSwBDqb/1o5sAqE68gPlHLfx4FiMGbvKDhaZVQNv/gj/7oRQ5BvuIlyTKBtNEA9MnirGwRA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@babel/core": "^7.0.0", + "vite": "^2.7.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/vite/node_modules/picomatch": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", @@ -7913,6 +7975,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" + } } } } diff --git a/package.json b/package.json index 900d5c01..607c3794 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "node": "^22.22.0 || >=24" }, "devDependencies": { + "@babel/core": "^7.29.7", "@chakra-ui/cli": "^3.36.0", "@eslint/js": "^10.0.1", "@react-router/dev": "^8.1.0", @@ -59,8 +60,10 @@ "@types/react": "^19.2.17", "@types/react-dom": "19.2.3", "@vitest/eslint-plugin": "^1.6.20", + "babel-plugin-react-compiler": "^1.0.0", "eslint": "^10.6.0", "eslint-config-prettier": "^10.1.8", + "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-refresh": "^0.5.3", "globals": "^17.7.0", "jsdom": "^29.1.1", @@ -68,6 +71,7 @@ "typescript": "^6.0.3", "typescript-eslint": "^8.62.1", "vite": "^8.1.0", + "vite-plugin-babel": "^1.7.3", "vitest": "^4.1.9" } } diff --git a/src/components/ActivityButtons.tsx b/src/components/ActivityButtons.tsx index 248dfc6f..8fbf181e 100644 --- a/src/components/ActivityButtons.tsx +++ b/src/components/ActivityButtons.tsx @@ -15,7 +15,7 @@ import { parseColor, } from "@chakra-ui/react"; import type { ComponentPropsWithRef, SubmitEventHandler } from "react"; -import { useContext, useLayoutEffect, useState } from "react"; +import { useContext, useState } from "react"; import { ColorPickerInput } from "./ui/colorpicker-input"; @@ -74,7 +74,7 @@ function OverrideLocations(props: { secs: Sections }) { setRoom(secs.roomOverride); }; const onConfirm = () => { - secs.roomOverride = room.trim(); + secs.setRoomOverride(room.trim()); setIsOverriding(false); state.updateActivities(); }; @@ -115,6 +115,7 @@ function OverrideLocations(props: { secs: Sections }) { /** Div containing section manual selection interface. */ function ClassManualSections(props: { cls: Class | PEClass }) { + "use no memo"; const { cls } = props; const { state } = useContext(HydrantContext); const genSelected = (cls: Class | PEClass) => @@ -126,26 +127,23 @@ function ClassManualSections(props: { cls: Class | PEClass }) { : LockOption.Auto, ); const [selected, setSelected] = useState(genSelected(cls)); - useLayoutEffect(() => { - setSelected(genSelected(cls)); - }, [cls]); - - const RenderOptions = () => { - const getLabel = (sec: SectionLockOption, humanReadable?: boolean) => { - if (sec === LockOption.Auto) { - return humanReadable ? "Auto (default)" : LockOption.Auto; - } else if (sec === LockOption.None) { - return LockOption.None; - } else if (!humanReadable) { - return sec.rawTime; - } else if (sec instanceof PESection) { - return `${sec.sectionNumber}: ${sec.parsedTime}`; - } else { - return sec.parsedTime; - } - }; - - return ( + + const getLabel = (sec: SectionLockOption, humanReadable?: boolean) => { + if (sec === LockOption.Auto) { + return humanReadable ? "Auto (default)" : LockOption.Auto; + } else if (sec === LockOption.None) { + return LockOption.None; + } else if (!humanReadable) { + return sec.rawTime; + } else if (sec instanceof PESection) { + return `${sec.sectionNumber}: ${sec.parsedTime}`; + } else { + return sec.parsedTime; + } + }; + + return ( + <> {cls.sections.map((secs, sectionIndex) => { const options = [LockOption.Auto, LockOption.None, ...secs.sections]; @@ -192,12 +190,6 @@ function ClassManualSections(props: { cls: Class | PEClass }) { ); })} - ); - }; - - return ( - - ); } @@ -257,8 +249,9 @@ function ActivityColor(props: { activity: Activity; onHide: () => void }) { /** Buttons in class description to add/remove class, and lock sections. */ export function ClassButtons(props: { cls: Class | PEClass }) { + "use no memo"; const { cls } = props; - const { state } = useContext(HydrantContext); + const { state, hydrantState } = useContext(HydrantContext); const [showManual, setShowManual] = useState(false); const [showColors, setShowColors] = useState(false); const isSelected = state.isSelectedActivity(cls); @@ -296,7 +289,9 @@ export function ClassButtons(props: { cls: Class | PEClass }) { )} - {isSelected && showManual && } + {isSelected && showManual && ( + + )} {isSelected && showColors && ( ; + setDays: React.Dispatch>>; +}) => { + const { days, setDays } = props; + + return ( + <> + {WEEKDAY_STRINGS.map((day) => ( + { + setDays({ ...days, [day]: !!e.checked }); + }} + > + {day} + + ))} + + ); +}; + /** Form to add a timeslot to a custom activity. */ function CustomActivityAddTime(props: { activity: CustomActivity }) { const { activity } = props; @@ -332,24 +350,6 @@ function CustomActivityAddTime(props: { activity: CustomActivity }) { } }; - const RenderCheckboxes = () => { - return ( - <> - {WEEKDAY_STRINGS.map((day) => ( - { - setDays({ ...days, [day]: !!e.checked }); - }} - > - {day} - - ))} - - ); - }; - const timesCollection = createListCollection({ items: TIMESLOT_STRINGS, }); @@ -395,7 +395,7 @@ function CustomActivityAddTime(props: { activity: CustomActivity }) { Add time - + {renderTimeDropdown("start")} to {renderTimeDropdown("end")} diff --git a/src/components/ActivityDescription.tsx b/src/components/ActivityDescription.tsx index f67709b1..9aa5ab5a 100644 --- a/src/components/ActivityDescription.tsx +++ b/src/components/ActivityDescription.tsx @@ -304,6 +304,7 @@ function ClassDescription(props: { cls: Class }) { /** Full custom activity description, from title to timeslots. */ function CustomActivityDescription(props: { activity: CustomActivity }) { + "use no memo"; const { activity } = props; const { state } = useContext(HydrantContext); diff --git a/src/components/Banner.tsx b/src/components/Banner.tsx index b96e8002..846b8851 100644 --- a/src/components/Banner.tsx +++ b/src/components/Banner.tsx @@ -47,7 +47,7 @@ export const AnnouncementsBanner = () => { color="whiteAlpha.900" _hover={{ bg: "blackAlpha.300" }} onClick={() => { - state.showBanner = false; + state.setShowBanner(false); }} /> @@ -58,7 +58,7 @@ export const AnnouncementsBanner = () => { color="whiteAlpha.900" _hover={{ bg: "blackAlpha.300" }} onClick={() => { - state.showBanner = false; + state.setShowBanner(false); }} /> diff --git a/src/components/ClassTable.tsx b/src/components/ClassTable.tsx index 32a2df66..cd0a5fae 100644 --- a/src/components/ClassTable.tsx +++ b/src/components/ClassTable.tsx @@ -307,6 +307,7 @@ function ClassFlags(props: { /** Callback for updating the grid filter manually. */ updateFilter: () => void; }) { + "use no memo"; const { setFlagsFilter, updateFilter } = props; const { state } = useContext(HydrantContext); @@ -325,11 +326,11 @@ function ClassFlags(props: { // this callback needs to get called when the set of classes change, because // the filter has to change as well useEffect(() => { - state.fitsScheduleCallback = () => { + state.setFitsScheduleCallback(() => { if (flags.get("fits")) { updateFilter(); } - }; + }); }, [state, flags, updateFilter]); const onChange = (flag: Filter, value: boolean) => { diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 66d2758d..1722882b 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -28,6 +28,7 @@ import hydraAnt from "../assets/hydraAnt.png"; import { SIPBLogo } from "./ButtonsLinks"; export function PreferencesDialog() { + "use no memo"; const { state, hydrantState } = useContext(HydrantContext); const { preferences: originalPreferences } = hydrantState; diff --git a/src/components/PEClassTable.tsx b/src/components/PEClassTable.tsx index c367418f..e4b5eaca 100644 --- a/src/components/PEClassTable.tsx +++ b/src/components/PEClassTable.tsx @@ -236,6 +236,7 @@ function ClassFlags(props: { /** Callback for updating the grid filter manually. */ updateFilter: () => void; }) { + "use no memo"; const { setFlagsFilter, updateFilter } = props; const { state } = useContext(HydrantContext); @@ -254,11 +255,11 @@ function ClassFlags(props: { // this callback needs to get called when the set of classes change, because // the filter has to change as well useEffect(() => { - state.fitsScheduleCallback = () => { + state.setFitsScheduleCallback(() => { if (flags.get("fits")) { updateFilter(); } - }; + }); }, [state, flags, updateFilter]); const onChange = (flag: Filter, value: boolean) => { @@ -452,7 +453,7 @@ export function PEClassTable() { .join(" "), }, ]; - }, [state]); + }, []); const defaultColDef: ColDef = useMemo(() => { return { diff --git a/src/components/ScheduleSwitcher.tsx b/src/components/ScheduleSwitcher.tsx index 1695d126..ff5ae6b9 100644 --- a/src/components/ScheduleSwitcher.tsx +++ b/src/components/ScheduleSwitcher.tsx @@ -13,7 +13,7 @@ import { Clipboard, } from "@chakra-ui/react"; import type { ComponentPropsWithoutRef, ReactNode } from "react"; -import { useContext, useEffect, useState } from "react"; +import { useContext, useState } from "react"; import type { Save } from "../lib/schema"; import { HydrantContext } from "../lib/hydrant"; @@ -248,9 +248,18 @@ export function ScheduleSwitcher() { const [name, setName] = useState(currentName); const defaultScheduleId = state.defaultSchedule; - useEffect(() => { + const [prevSaves, setPrevSaves] = useState(saves); + const [prevSaveId, setPrevSaveId] = useState(saveId); + + if (prevSaves !== saves) { + setPrevSaves(saves); + setName(saves.find((save) => save.id === saveId)?.name ?? ""); + } + + if (prevSaveId !== saveId) { + setPrevSaveId(saveId); setName(saves.find((save) => save.id === saveId)?.name ?? ""); - }, [saves, saveId]); + } const [renderHeading, renderButtons] = (() => { if (isRenaming) { diff --git a/src/emotion/emotion-client.tsx b/src/emotion/emotion-client.tsx index 16e09813..3a77179c 100644 --- a/src/emotion/emotion-client.tsx +++ b/src/emotion/emotion-client.tsx @@ -58,6 +58,7 @@ export function useInjectStyles(cache: EmotionCache) { useSafeLayoutEffect(() => { if (!injectRef.current) return; + // eslint-disable-next-line react-hooks/immutability cache.sheet.container = document.head; const tags = cache.sheet.tags; diff --git a/src/lib/activity.ts b/src/lib/activity.ts index 6702fd77..69f55c24 100644 --- a/src/lib/activity.ts +++ b/src/lib/activity.ts @@ -317,6 +317,10 @@ export class Sections { /** Overridden location for this particular section. */ roomOverride = ""; + setRoomOverride(room: string) { + this.roomOverride = room; + } + constructor( cls: BaseActivity, rawTimes: string[], diff --git a/src/lib/class.ts b/src/lib/class.ts index 7fb8088f..681d21fb 100644 --- a/src/lib/class.ts +++ b/src/lib/class.ts @@ -115,7 +115,11 @@ export const getFlagImg = (flag: keyof Flags): string => { export class ClassSections extends Sections { declare cls: Class; - declare kind: SectionKind; + kind: SectionKind; + + setRoomOverride(room: string) { + this.roomOverride = room; + } constructor( cls: Class, diff --git a/src/lib/hydrant.ts b/src/lib/hydrant.ts index 7c5ca592..78bab860 100644 --- a/src/lib/hydrant.ts +++ b/src/lib/hydrant.ts @@ -70,6 +70,7 @@ export function useHydrant({ globalState }: { globalState: State }): { } }; state.updateState(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [colorMode, state]); return { state, hydrantState }; diff --git a/src/lib/state.ts b/src/lib/state.ts index f9e8e049..121d66a8 100644 --- a/src/lib/state.ts +++ b/src/lib/state.ts @@ -104,6 +104,10 @@ export class State { this.initState(); } + setFitsScheduleCallback(callback: () => void): void { + this.fitsScheduleCallback = callback; + } + /** All activities. */ get selectedActivities(): Activity[] { return [ @@ -411,7 +415,7 @@ export class State { ); } - set showBanner(show: boolean) { + setShowBanner(show: boolean) { this.preferences.showBanner = show; this.preferences.showBannerChanged = new Date().valueOf(); this.updateState(); diff --git a/src/routes/overrides.($prefillId).tsx b/src/routes/overrides.($prefillId).tsx index 9b5e4acc..3b673f61 100644 --- a/src/routes/overrides.($prefillId).tsx +++ b/src/routes/overrides.($prefillId).tsx @@ -268,27 +268,24 @@ export default function App({ loaderData }: Route.ComponentProps) { return uiSchema; }, [data.length, error]); - const getDataFromFile = useCallback( - async (fileName: string) => { - try { - const textToml = await overrides[fileName].data(); - const mod = TOML.parse(textToml); + const getDataFromFile = useCallback(async (fileName: string) => { + try { + const textToml = await overrides[fileName].data(); + const mod = TOML.parse(textToml); - const newData = Object.entries(mod).map(([key, value_1]) => { - const { number: num, ...rest } = value_1 as Record; - return { - number: key, - ...rest, - }; - }); - return newData; - } catch (err) { - console.error("Error loading TOML file:", err); - return []; - } - }, - [overrides], - ); + const newData = Object.entries(mod).map(([key, value_1]) => { + const { number: num, ...rest } = value_1 as Record; + return { + number: key, + ...rest, + }; + }); + return newData; + } catch (err) { + console.error("Error loading TOML file:", err); + return []; + } + }, []); const handleChange = (e: Select.ValueChangeDetails) => { const fileName = e.value[0]; diff --git a/vite.config.ts b/vite.config.ts index 1aaa9681..341022bd 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,11 +1,26 @@ /// -import { reactRouter } from "@react-router/dev/vite"; import { defineConfig } from "vite"; +import babel from "vite-plugin-babel"; +import { reactRouter } from "@react-router/dev/vite"; + +const ReactCompilerConfig = {/* ... */}; // https://vitejs.dev/config/ export default defineConfig({ base: process.env.SUBDIR ?? undefined, - plugins: [reactRouter()], + plugins: [ + reactRouter(), + babel({ + filter: /\.[jt]sx?$/, + babelConfig: { + presets: ["@babel/preset-typescript"], + plugins: [ + ["babel-plugin-react-compiler", ReactCompilerConfig], + ["@babel/plugin-transform-typescript", { allowDeclareFields: true }], + ], + }, + }), + ], test: { globals: true, environment: "jsdom",