diff --git a/eslint.config.js b/eslint.config.js index 7f2b095e7e..f2a8276779 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -7,6 +7,8 @@ const js = require("@eslint/js"); const importPlugin = require("eslint-plugin-import"); const jsxA11y = require("eslint-plugin-jsx-a11y"); const prettier = require("eslint-plugin-prettier"); +const eslintPluginPrettier = require("eslint-config-prettier"); +const eslintPluginReactHooks = require("eslint-plugin-react-hooks"); module.exports = [ js.configs.recommended, @@ -31,6 +33,8 @@ module.exports = [ import: importPlugin, "jsx-a11y": jsxA11y, prettier, + eslintPluginPrettier, + "react-hooks": eslintPluginReactHooks, }, settings: { react: { @@ -51,6 +55,12 @@ module.exports = [ "react/jsx-no-useless-fragment": "off", "react/require-default-props": "off", "react/jsx-props-no-spreading": "off", + "react/jsx-uses-vars": "error", // Marks JSX variables as used + "react/jsx-uses-react": "error", // Marks React as used in JSX files + + // React Hooks rules + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "warn", // Import rules "import/no-relative-packages": "off", @@ -74,7 +84,8 @@ module.exports = [ "no-console": 1, "no-unused-vars": ["warn", { argsIgnorePattern: "^_", - varsIgnorePattern: "^(React|_)", + varsIgnorePattern: "^(React|_|expect|test|describe|it|beforeEach|afterEach|beforeAll|afterAll|jest|vi|fixture|page)$", + ignoreRestSiblings: true, }], "no-undef": "off", // Disable for config files and test files @@ -110,6 +121,7 @@ module.exports = [ import: importPlugin, "jsx-a11y": jsxA11y, prettier, + "react-hooks": eslintPluginReactHooks, }, settings: { react: { @@ -141,11 +153,16 @@ module.exports = [ extensions: [".js", ".jsx", ".ts", ".tsx"], }], "@typescript-eslint/ban-ts-comment": ["warn"], - "no-unused-vars": "off", + "no-unused-vars": ["warn", { + argsIgnorePattern: "^_", + varsIgnorePattern: "^(React|_|expect|test|describe|it|beforeEach|afterEach|beforeAll|afterAll|jest|vi|fixture|page)$", + ignoreRestSiblings: true, + }], "react/require-default-props": "off", - "@typescript-eslint/no-unused-vars": ["error", { + "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_", - varsIgnorePattern: "^(React|_)", + varsIgnorePattern: "^(React|_|expect|test|describe|it|beforeEach|afterEach|beforeAll|afterAll|jest|vi|fixture|page)$", + ignoreRestSiblings: true, }], "@typescript-eslint/no-empty-function": "off", @@ -160,6 +177,12 @@ module.exports = [ "no-promise-executor-return": "off", "default-param-last": "off", "react/jsx-props-no-spreading": "off", + "react/jsx-uses-vars": "error", // Marks JSX variables as used + "react/jsx-uses-react": "error", // Marks React as used in JSX files + + // React Hooks rules + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "warn", "prettier/prettier": ["error", {}, { usePrettierrc: true, }], diff --git a/package.json b/package.json index 258aaa4a19..1fd9f464ed 100644 --- a/package.json +++ b/package.json @@ -77,12 +77,12 @@ "eslint": "^9", "eslint-config-prettier": "^8.2.0", "eslint-plugin-import": "^2.22.1", - "eslint-plugin-import-helpers": "^1.1.0", + "eslint-plugin-import-helpers": "^2", "eslint-plugin-jest": "^28", "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-prettier": "^5.5.1", "eslint-plugin-react": "^7.37.4", - "eslint-plugin-react-hooks": "^4", + "eslint-plugin-react-hooks": "^5", "eslint-plugin-storybook": "^9.0.13", "gh-pages": "^6.0.0", "globals": "^16.2.0", @@ -93,7 +93,7 @@ "mini-css-extract-plugin": "^2.0.0", "nunjucks": "^3.2.0", "playwright": "1.50.1", - "prettier": "^2.2.1", + "prettier": "^3", "purgecss-webpack-plugin": "^4.0.3", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/packages/app-rfi/src/components/AsuRfi/index.js b/packages/app-rfi/src/components/AsuRfi/index.js index d9a47e769d..54dfde6143 100644 --- a/packages/app-rfi/src/components/AsuRfi/index.js +++ b/packages/app-rfi/src/components/AsuRfi/index.js @@ -63,10 +63,6 @@ const AsuRfi = props => { } }, []); - if (typeof submissionUrl === "undefined") { - return <>; - } - const rfiState = useRfiState(betterPropNames(props)); const noRfiAvailable = `RFI form not displayed. ${programOfInterest} has rfiDisplay set to false or does not exist`; @@ -75,6 +71,11 @@ const AsuRfi = props => { console.log(noRfiAvailable); } }, [rfiState.showForm]); + + if (typeof submissionUrl === "undefined") { + return <>; + } + if (!rfiState.showForm) { return
{noRfiAvailable}
; } diff --git a/packages/app-rfi/src/components/controls/RfiSelect.js b/packages/app-rfi/src/components/controls/RfiSelect.js index 8c87e8b286..6b28fa996f 100644 --- a/packages/app-rfi/src/components/controls/RfiSelect.js +++ b/packages/app-rfi/src/components/controls/RfiSelect.js @@ -1,5 +1,4 @@ // DISABLED@ts-check -/* eslint-disable no-unused-vars */ import { Field, useField, useFormikContext } from "formik"; import PropTypes from "prop-types"; diff --git a/packages/shared/assets/index.js b/packages/shared/assets/index.js index 176567913e..2fbf92956c 100644 --- a/packages/shared/assets/index.js +++ b/packages/shared/assets/index.js @@ -1,17 +1,6 @@ import * as _images from "./img/list"; export { imageName } from "./img/named"; -const _imageArray = Object.values(_images).reduce( - (result, val) => [...result, val.default], - [] -); - -export const imageArray = [ - ..._imageArray, - ..._imageArray, - ..._imageArray, - ..._imageArray, - ..._imageArray, -]; // make it bigger +export const imageArray = Object.values(_images); export const imageAny = () => imageArray[Math.floor(Math.random() * imageArray.length)]; diff --git a/packages/static-site/package.json b/packages/static-site/package.json index 8794bb2285..53ec649634 100644 --- a/packages/static-site/package.json +++ b/packages/static-site/package.json @@ -28,7 +28,7 @@ "@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/parser": "^6.0.0", "@vitejs/plugin-react": "^4.3.1", - "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-hooks": "^5", "eslint-plugin-react-refresh": "^0.4.3", "typescript": "^5.0.2", "vite": "^5.3.5", diff --git a/packages/unity-bootstrap-theme/src/js/calendar.js b/packages/unity-bootstrap-theme/src/js/calendar.js index 27ea34cf9c..d42c03c48c 100644 --- a/packages/unity-bootstrap-theme/src/js/calendar.js +++ b/packages/unity-bootstrap-theme/src/js/calendar.js @@ -90,8 +90,8 @@ function initCalendar() { calendarContainer.innerHTML = `

${months[state.month]} ${ - state.year - }

+ state.year + }
${desktopDaysOfWeek.map(day => `

${day}

`).join("")} @@ -113,8 +113,8 @@ function initCalendar() { : "" }> ${ - date.date - } + date.date + } ` ) .join("")} diff --git a/packages/unity-react-core/src/components/ComponentCarousel/components/ImageGalleryCarousel/ImageGalleryCarousel.jsx b/packages/unity-react-core/src/components/ComponentCarousel/components/ImageGalleryCarousel/ImageGalleryCarousel.jsx index 60997ebd7f..4ec496fca5 100644 --- a/packages/unity-react-core/src/components/ComponentCarousel/components/ImageGalleryCarousel/ImageGalleryCarousel.jsx +++ b/packages/unity-react-core/src/components/ComponentCarousel/components/ImageGalleryCarousel/ImageGalleryCarousel.jsx @@ -8,7 +8,7 @@ * */ import PropTypes from "prop-types"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useState, useCallback } from "react"; import { BaseCarousel } from "../../core/components/BaseCarousel"; import { @@ -92,21 +92,26 @@ const htmlTemplate = ({ id, imageSource, imageAltText }) => ({ * @returns { JSX.Element } */ const CustomNavComponent = ({ instanceName, imageItems, hasContent }) => { - if (!imageItems || imageItems.length === 0) { - return null; - } const ATTR_INDEX = "data-current-index"; - const [title, setTitle] = useState(imageItems[0].title); - - const [content, setContent] = useState(imageItems[0].content); + const [title, setTitle] = useState(imageItems?.[0]?.title || ""); + const [content, setContent] = useState(imageItems?.[0]?.content || ""); - const onItemClick = currentIndex => { - const item = imageItems[currentIndex]; - setTitle(item.title); - setContent(item.content); - }; + const onItemClick = useCallback( + currentIndex => { + const item = imageItems?.[currentIndex]; + if (item) { + setTitle(item.title); + setContent(item.content); + } + }, + [imageItems] + ); useEffect(() => { + if (!imageItems || imageItems.length === 0) { + return; + } + /** @type {HTMLElement} */ const textArea = document.querySelector( `.image-gallery figcaption .uds-caption-text div` @@ -127,21 +132,35 @@ const CustomNavComponent = ({ instanceName, imageItems, hasContent }) => { } const currentSlider = document.querySelector(`#${instanceName}`); + if (!currentSlider) { + return; + } function onDataCurrentIndexChange(mutations) { for (const mutation of mutations) { if (mutation && mutation.attributeName === ATTR_INDEX) { - return onItemClick(+currentSlider.getAttribute(ATTR_INDEX)); + const index = currentSlider.getAttribute(ATTR_INDEX); + if (index !== null) { + onItemClick(+index); + } + return; } } - return null; } const observer = new MutationObserver(onDataCurrentIndexChange); observer.observe(currentSlider, { attributes: true, }); - }, [instanceName]); + + return () => { + observer.disconnect(); + }; + }, [instanceName, imageItems, onItemClick]); + + if (!imageItems || imageItems.length === 0) { + return null; + } const bulletItems = imageItems.map(item => item.imageSource); return ( diff --git a/packages/unity-react-core/src/components/TabbedPanels/TabbedPanels.jsx b/packages/unity-react-core/src/components/TabbedPanels/TabbedPanels.jsx index c644951c5c..abf43081d5 100644 --- a/packages/unity-react-core/src/components/TabbedPanels/TabbedPanels.jsx +++ b/packages/unity-react-core/src/components/TabbedPanels/TabbedPanels.jsx @@ -29,19 +29,22 @@ function useRefs() { const Tab = ({ id, bgColor, selected, children }) => { const { isBootstrap } = useBaseSpecificFramework(); + + if (!(selected || isBootstrap)) { + return null; + } + return ( - (selected || isBootstrap) && ( -
- {children} -
- ) +
+ {children} +
); }; @@ -59,61 +62,58 @@ const TabbedPanels = ({ onTabChange = _ => {}, }) => { const childrenArray = React.Children.toArray(children); - if (childrenArray.length === 0) { - return null; - } + + // Move all hooks before any early returns const isMounted = useRef(false); const [activeTabID, setActiveTabID] = useState( - initialTab && initialTab !== "null" ? initialTab : childrenArray[0].props.id + initialTab && initialTab !== "null" + ? initialTab + : childrenArray[0]?.props?.id || "" ); const headerTabs = useRef(null); const [headerTabItems, setHeaderTabItems] = useRefs(); - - const updateActiveTabID = tab => { - onTabChange(tab); - - headerTabItems.current[tab]?.focus(); - setActiveTabID(tab); - }; - const [scrollLeft, setScrollLeft] = useState(0); const [scrollableWidth, setScrollableWidth] = useState(); - const handleResize = () => { + const handleResize = useCallback(() => { setScrollableWidth( headerTabs.current?.scrollWidth - headerTabs.current?.offsetWidth ); - }; + }, []); - const handleScroll = () => { + const handleScroll = useCallback(() => { setScrollLeft(headerTabs.current?.scrollLeft); - }; + }, []); - const throttleScroll = () => { + const throttleScroll = useCallback(() => { const timeout = 150; // prevent function from being called excessively throttle(handleScroll, timeout); // ensure function executes after scrolling stops debounce(handleScroll, timeout); - }; + }, [handleScroll]); - const throttleResize = () => { + const throttleResize = useCallback(() => { const timeout = 150; // prevent function from being called excessively throttle(handleResize, timeout); // ensure function executes after scrolling stops debounce(handleResize, timeout); - }; + }, [handleResize]); + // Move all useEffect hooks before early return useEffect(() => { - headerTabs.current.addEventListener("scroll", throttleScroll); - handleScroll(); + const currentHeaderTabs = headerTabs.current; + if (currentHeaderTabs) { + currentHeaderTabs.addEventListener("scroll", throttleScroll); + handleScroll(); + } return () => { - if (headerTabs.current) { - headerTabs.current.removeEventListener("scroll", throttleScroll); + if (currentHeaderTabs) { + currentHeaderTabs.removeEventListener("scroll", throttleScroll); } }; - }, [scrollableWidth]); + }, [scrollableWidth, throttleScroll, handleScroll]); useEffect(() => { window.addEventListener("resize", throttleResize); @@ -121,11 +121,13 @@ const TabbedPanels = ({ return () => { window.removeEventListener("resize", throttleResize); }; - }, []); + }, [throttleResize, handleResize]); useEffect(() => { - headerTabItems.current[activeTabID]?.scrollIntoView(); - }, [activeTabID]); + if (headerTabItems.current[activeTabID]) { + headerTabItems.current[activeTabID].scrollIntoView(); + } + }, [activeTabID, headerTabItems]); useEffect(() => { if ( @@ -136,12 +138,24 @@ const TabbedPanels = ({ ) { setActiveTabID(initialTab); } - }, [initialTab]); + }, [initialTab, activeTabID]); useEffect(() => { isMounted.current = true; }, []); + // Early return check after all hooks + if (childrenArray.length === 0) { + return null; + } + + const updateActiveTabID = tab => { + onTabChange(tab); + + headerTabItems.current[tab]?.focus(); + setActiveTabID(tab); + }; + const trackArrowsEvent = { event: "select", action: "click", diff --git a/packages/unity-react-core/src/components/TabbedPanels/components/TabHeader.jsx b/packages/unity-react-core/src/components/TabbedPanels/components/TabHeader.jsx index cececf7772..fd9f9b0306 100644 --- a/packages/unity-react-core/src/components/TabbedPanels/components/TabHeader.jsx +++ b/packages/unity-react-core/src/components/TabbedPanels/components/TabHeader.jsx @@ -31,28 +31,24 @@ const TabHeader = forwardRef(function TabHeader(props, ref) { const inputRef = useRef(null); - useImperativeHandle( - ref, - () => { - return { - focus() { - inputRef.current.focus(); - }, - scrollIntoView() { - const middle = - inputRef.current?.offsetWidth / 2 + inputRef.current.offsetLeft; - const targetMiddle = - inputRef.current?.offsetParent?.scrollLeft + - inputRef.current?.offsetParent?.offsetWidth / 2; + useImperativeHandle(ref, () => { + return { + focus() { + inputRef.current.focus(); + }, + scrollIntoView() { + const middle = + inputRef.current?.offsetWidth / 2 + inputRef.current.offsetLeft; + const targetMiddle = + inputRef.current?.offsetParent?.scrollLeft + + inputRef.current?.offsetParent?.offsetWidth / 2; - inputRef.current?.offsetParent?.scrollBy({ - left: middle - targetMiddle, - }); - }, - }; - }, - [] - ); + inputRef.current?.offsetParent?.scrollBy({ + left: middle - targetMiddle, + }); + }, + }; + }, []); const func = e => { if (e.keyCode === 37) { diff --git a/packages/unity-react-core/src/components/Video/Video.jsx b/packages/unity-react-core/src/components/Video/Video.jsx index 2f2bec8534..4014451b54 100644 --- a/packages/unity-react-core/src/components/Video/Video.jsx +++ b/packages/unity-react-core/src/components/Video/Video.jsx @@ -23,7 +23,7 @@ const defaultGAEvent = { * @returns {JSX.Element} * @ignore */ -const videoTemplate = ({ +const VideoTemplate = ({ url = "", vttUrl, caption, @@ -107,7 +107,7 @@ const Video = props => { } = props; return type === "youtube" ? youtubeTemplate({ url, title, caption, className }) - : videoTemplate({ + : VideoTemplate({ url, vttUrl, title, diff --git a/packages/unity-react-core/tsconfig.json b/packages/unity-react-core/tsconfig.json index 6aef0eb2ab..6aa829e7b2 100644 --- a/packages/unity-react-core/tsconfig.json +++ b/packages/unity-react-core/tsconfig.json @@ -17,6 +17,7 @@ "allowJs": true, "checkJs": true, "allowSyntheticDefaultImports": true, + "skipLibCheck": true, "plugins": [{ "name": "typescript-plugin-css-modules" }], "types": ["@vitest/browser/providers/playwright"], "paths": { diff --git a/yarn.lock b/yarn.lock index 98d244bc23..bab02d52c2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -498,7 +498,7 @@ __metadata: "@typescript-eslint/eslint-plugin": "npm:^6.0.0" "@typescript-eslint/parser": "npm:^6.0.0" "@vitejs/plugin-react": "npm:^4.3.1" - eslint-plugin-react-hooks: "npm:^4.6.0" + eslint-plugin-react-hooks: "npm:^5" eslint-plugin-react-refresh: "npm:^0.4.3" react: "npm:^18.2.0" react-dom: "npm:^18.2.0" @@ -12498,12 +12498,12 @@ __metadata: eslint: "npm:^9" eslint-config-prettier: "npm:^8.2.0" eslint-plugin-import: "npm:^2.22.1" - eslint-plugin-import-helpers: "npm:^1.1.0" + eslint-plugin-import-helpers: "npm:^2" eslint-plugin-jest: "npm:^28" eslint-plugin-jsx-a11y: "npm:^6.10.2" eslint-plugin-prettier: "npm:^5.5.1" eslint-plugin-react: "npm:^7.37.4" - eslint-plugin-react-hooks: "npm:^4" + eslint-plugin-react-hooks: "npm:^5" eslint-plugin-storybook: "npm:^9.0.13" gh-pages: "npm:^6.0.0" globals: "npm:^16.2.0" @@ -12514,7 +12514,7 @@ __metadata: mini-css-extract-plugin: "npm:^2.0.0" nunjucks: "npm:^3.2.0" playwright: "npm:1.50.1" - prettier: "npm:^2.2.1" + prettier: "npm:^3" purgecss-webpack-plugin: "npm:^4.0.3" react: "npm:^18.3.1" react-dom: "npm:^18.3.1" @@ -17081,12 +17081,12 @@ __metadata: languageName: node linkType: hard -"eslint-plugin-import-helpers@npm:^1.1.0": - version: 1.3.1 - resolution: "eslint-plugin-import-helpers@npm:1.3.1" +"eslint-plugin-import-helpers@npm:^2": + version: 2.0.1 + resolution: "eslint-plugin-import-helpers@npm:2.0.1" peerDependencies: - eslint: 5.x - 8.x - checksum: 10c0/35859fae44ca3505d108992a630ecd717458e2fc827f2a59e92554ed2263efb0c3eb65b985c98060b8efd60344d8c6749b68d5ccb3b632a07df2020a753ce94f + eslint: 9.x + checksum: 10c0/0d9709e926a7e1287078aa174b6ee011a3b4f84cca55b14fa1e2b48e70edef409c4c236d0b725a7008f580a20a6475d30475da1dcb2efb1c16a97a01b9ceabc0 languageName: node linkType: hard @@ -17194,12 +17194,12 @@ __metadata: languageName: node linkType: hard -"eslint-plugin-react-hooks@npm:^4, eslint-plugin-react-hooks@npm:^4.6.0": - version: 4.6.2 - resolution: "eslint-plugin-react-hooks@npm:4.6.2" +"eslint-plugin-react-hooks@npm:^5": + version: 5.2.0 + resolution: "eslint-plugin-react-hooks@npm:5.2.0" peerDependencies: - eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 - checksum: 10c0/4844e58c929bc05157fb70ba1e462e34f1f4abcbc8dd5bbe5b04513d33e2699effb8bca668297976ceea8e7ebee4e8fc29b9af9d131bcef52886feaa2308b2cc + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + checksum: 10c0/1c8d50fa5984c6dea32470651807d2922cc3934cf3425e78f84a24c2dfd972e7f019bee84aefb27e0cf2c13fea0ac1d4473267727408feeb1c56333ca1489385 languageName: node linkType: hard @@ -27882,7 +27882,7 @@ __metadata: languageName: node linkType: hard -"prettier@npm:^2.2.1, prettier@npm:^2.7.1, prettier@npm:^2.8.0, prettier@npm:^2.8.1, prettier@npm:^2.8.7": +"prettier@npm:^2.7.1, prettier@npm:^2.8.0, prettier@npm:^2.8.1, prettier@npm:^2.8.7": version: 2.8.8 resolution: "prettier@npm:2.8.8" bin: @@ -27891,6 +27891,15 @@ __metadata: languageName: node linkType: hard +"prettier@npm:^3": + version: 3.6.2 + resolution: "prettier@npm:3.6.2" + bin: + prettier: bin/prettier.cjs + checksum: 10c0/488cb2f2b99ec13da1e50074912870217c11edaddedeadc649b1244c749d15ba94e846423d062e2c4c9ae683e2d65f754de28889ba06e697ac4f988d44f45812 + languageName: node + linkType: hard + "pretty-error@npm:^4.0.0": version: 4.0.0 resolution: "pretty-error@npm:4.0.0"