diff --git a/.eslintrc.js b/.eslintrc.js index 2b0dd2c186..a017112b4e 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,5 +1,5 @@ module.exports = { - plugins: ["matrix-org"], + plugins: ["matrix-org", "eslint-plugin-react-compiler"], extends: ["plugin:matrix-org/babel", "plugin:matrix-org/react", "plugin:matrix-org/a11y"], parserOptions: { project: ["./tsconfig.json"], @@ -170,6 +170,8 @@ module.exports = { "jsx-a11y/role-supports-aria-props": "off", "matrix-org/require-copyright-header": "error", + + "react-compiler/react-compiler": "error", }, overrides: [ { @@ -262,6 +264,7 @@ module.exports = { // These are fine in tests "no-restricted-globals": "off", + "react-compiler/react-compiler": "off", }, }, { diff --git a/__mocks__/maplibre-gl.js b/__mocks__/maplibre-gl.js index c410e4f24c..b47d4c02f8 100644 --- a/__mocks__/maplibre-gl.js +++ b/__mocks__/maplibre-gl.js @@ -17,6 +17,7 @@ class MockMap extends EventEmitter { setCenter = jest.fn(); setStyle = jest.fn(); fitBounds = jest.fn(); + remove = jest.fn(); } const MockMapInstance = new MockMap(); diff --git a/package.json b/package.json index 5976379ab2..fcb66584ed 100644 --- a/package.json +++ b/package.json @@ -233,6 +233,7 @@ "eslint-plugin-jsx-a11y": "^6.5.1", "eslint-plugin-matrix-org": "^2.0.2", "eslint-plugin-react": "^7.28.0", + "eslint-plugin-react-compiler": "^19.0.0-beta-df7b47d-20241124", "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-unicorn": "^56.0.0", "express": "^4.18.2", diff --git a/src/components/structures/RoomSearchView.tsx b/src/components/structures/RoomSearchView.tsx index 82146bcc5e..9e6263bcfb 100644 --- a/src/components/structures/RoomSearchView.tsx +++ b/src/components/structures/RoomSearchView.tsx @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ -import React, { forwardRef, useCallback, useContext, useEffect, useRef, useState } from "react"; +import React, { forwardRef, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { ISearchResults, IThreadBundledRelationship, @@ -58,7 +58,7 @@ export const RoomSearchView = forwardRef( const [results, setResults] = useState(null); const aborted = useRef(false); // A map from room ID to permalink creator - const permalinkCreators = useRef(new Map()).current; + const permalinkCreators = useMemo(() => new Map(), []); const innerRef = useRef(); useEffect(() => { diff --git a/src/components/views/elements/EffectsOverlay.tsx b/src/components/views/elements/EffectsOverlay.tsx index 3e5a5ead60..ad7d9c825e 100644 --- a/src/components/views/elements/EffectsOverlay.tsx +++ b/src/components/views/elements/EffectsOverlay.tsx @@ -58,11 +58,10 @@ const EffectsOverlay: FunctionComponent = ({ roomWidth }) => { if (canvas) canvas.height = UIStore.instance.windowHeight; UIStore.instance.on(UI_EVENTS.Resize, resize); + const currentEffects = effectsRef.current; // this is not a react node ref, warning can be safely ignored return () => { dis.unregister(dispatcherRef); UIStore.instance.off(UI_EVENTS.Resize, resize); - // eslint-disable-next-line react-hooks/exhaustive-deps - const currentEffects = effectsRef.current; // this is not a react node ref, warning can be safely ignored for (const effect in currentEffects) { const effectModule: ICanvasEffect = currentEffects.get(effect)!; if (effectModule && effectModule.isRunning) { diff --git a/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx index 98597c7360..a28d274646 100644 --- a/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ -import React, { ForwardedRef, forwardRef, MutableRefObject, useRef } from "react"; +import React, { ForwardedRef, forwardRef, MutableRefObject, useMemo } from "react"; import classNames from "classnames"; import EditorStateTransfer from "../../../../utils/EditorStateTransfer"; @@ -44,7 +44,7 @@ export default function EditWysiwygComposer({ className, ...props }: EditWysiwygComposerProps): JSX.Element { - const defaultContextValue = useRef(getDefaultContextValue({ editorStateTransfer })); + const defaultContextValue = useMemo(() => getDefaultContextValue({ editorStateTransfer }), []); const initialContent = useInitialContent(editorStateTransfer); const isReady = !editorStateTransfer || initialContent !== undefined; @@ -55,7 +55,7 @@ export default function EditWysiwygComposer({ } return ( - + getDefaultContextValue({ eventRelation: props.eventRelation }), []); return ( - + } diff --git a/src/components/views/settings/ThemeChoicePanel.tsx b/src/components/views/settings/ThemeChoicePanel.tsx index 83f17a2f7b..3355913abd 100644 --- a/src/components/views/settings/ThemeChoicePanel.tsx +++ b/src/components/views/settings/ThemeChoicePanel.tsx @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -import React, { ChangeEvent, JSX, useCallback, useMemo, useRef, useState } from "react"; +import React, { ChangeEvent, JSX, useCallback, useMemo, useState } from "react"; import { InlineField, ToggleControl, @@ -39,12 +39,12 @@ import { useSettingValue } from "../../../hooks/useSettings"; */ export function ThemeChoicePanel(): JSX.Element { const themeState = useTheme(); - const themeWatcher = useRef(new ThemeWatcher()); + const themeWatcher = useMemo(() => new ThemeWatcher(), []); const customThemeEnabled = useSettingValue("feature_custom_themes"); return ( - {themeWatcher.current.isSystemThemeSupported() && ( + {themeWatcher.isSystemThemeSupported() && ( )} diff --git a/src/components/views/settings/tabs/room/VoipRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/VoipRoomSettingsTab.tsx index 783ea1bce3..14de26629b 100644 --- a/src/components/views/settings/tabs/room/VoipRoomSettingsTab.tsx +++ b/src/components/views/settings/tabs/room/VoipRoomSettingsTab.tsx @@ -8,6 +8,7 @@ Please see LICENSE files in the repository root for full details. import React, { useCallback, useMemo, useState } from "react"; import { JoinRule, EventType, RoomState, Room } from "matrix-js-sdk/src/matrix"; +import { RoomPowerLevelsEventContent } from "matrix-js-sdk/src/types"; import { _t } from "../../../../../languageHandler"; import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch"; @@ -24,48 +25,49 @@ interface ElementCallSwitchProps { const ElementCallSwitch: React.FC = ({ room }) => { const isPublic = useMemo(() => room.getJoinRule() === JoinRule.Public, [room]); - const [content, events, maySend] = useRoomState( + const [content, maySend] = useRoomState( room, useCallback( (state: RoomState) => { - const content = state?.getStateEvents(EventType.RoomPowerLevels, "")?.getContent(); + const content = state + ?.getStateEvents(EventType.RoomPowerLevels, "") + ?.getContent(); return [ content ?? {}, - content?.["events"] ?? {}, state?.maySendStateEvent(EventType.RoomPowerLevels, room.client.getSafeUserId()), - ]; + ] as const; }, [room.client], ), ); const [elementCallEnabled, setElementCallEnabled] = useState(() => { - return events[ElementCall.MEMBER_EVENT_TYPE.name] === 0; + return content.events?.[ElementCall.MEMBER_EVENT_TYPE.name] === 0; }); const onChange = useCallback( (enabled: boolean): void => { setElementCallEnabled(enabled); + // Take a copy to avoid mutating the original + const newContent = { events: {}, ...content }; + if (enabled) { - const userLevel = events[EventType.RoomMessage] ?? content.users_default ?? 0; + const userLevel = newContent.events[EventType.RoomMessage] ?? content.users_default ?? 0; const moderatorLevel = content.kick ?? 50; - events[ElementCall.CALL_EVENT_TYPE.name] = isPublic ? moderatorLevel : userLevel; - events[ElementCall.MEMBER_EVENT_TYPE.name] = userLevel; + newContent.events[ElementCall.CALL_EVENT_TYPE.name] = isPublic ? moderatorLevel : userLevel; + newContent.events[ElementCall.MEMBER_EVENT_TYPE.name] = userLevel; } else { - const adminLevel = events[EventType.RoomPowerLevels] ?? content.state_default ?? 100; + const adminLevel = newContent.events[EventType.RoomPowerLevels] ?? content.state_default ?? 100; - events[ElementCall.CALL_EVENT_TYPE.name] = adminLevel; - events[ElementCall.MEMBER_EVENT_TYPE.name] = adminLevel; + newContent.events[ElementCall.CALL_EVENT_TYPE.name] = adminLevel; + newContent.events[ElementCall.MEMBER_EVENT_TYPE.name] = adminLevel; } - room.client.sendStateEvent(room.roomId, EventType.RoomPowerLevels, { - events: events, - ...content, - }); + room.client.sendStateEvent(room.roomId, EventType.RoomPowerLevels, newContent); }, - [room.client, room.roomId, content, events, isPublic], + [room.client, room.roomId, content, isPublic], ); const brand = SdkConfig.get("element_call").brand ?? DEFAULTS.element_call.brand; diff --git a/src/components/views/spaces/QuickThemeSwitcher.tsx b/src/components/views/spaces/QuickThemeSwitcher.tsx index 195fcb9899..f4c229ae04 100644 --- a/src/components/views/spaces/QuickThemeSwitcher.tsx +++ b/src/components/views/spaces/QuickThemeSwitcher.tsx @@ -27,7 +27,7 @@ type Props = { const MATCH_SYSTEM_THEME_ID = "MATCH_SYSTEM_THEME_ID"; const QuickThemeSwitcher: React.FC = ({ requestClose }) => { - const orderedThemes = useMemo(getOrderedThemes, []); + const orderedThemes = useMemo(() => getOrderedThemes(), []); const themeState = useTheme(); const nonHighContrast = findNonHighContrastTheme(themeState.theme); diff --git a/src/components/views/spaces/threads-activity-centre/useUnreadThreadRooms.ts b/src/components/views/spaces/threads-activity-centre/useUnreadThreadRooms.ts index 110c9d51f8..1ea10bed68 100644 --- a/src/components/views/spaces/threads-activity-centre/useUnreadThreadRooms.ts +++ b/src/components/views/spaces/threads-activity-centre/useUnreadThreadRooms.ts @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { ClientEvent, MatrixClient, MatrixEventEvent, Room } from "matrix-js-sdk/src/matrix"; import { throttle } from "lodash"; @@ -42,14 +42,12 @@ export function useUnreadThreadRooms(forceComputation: boolean): Result { setResult(computeUnreadThreadRooms(mxClient, msc3946ProcessDynamicPredecessor, settingTACOnlyNotifs)); }, [mxClient, msc3946ProcessDynamicPredecessor, settingTACOnlyNotifs]); - // The exhautive deps lint rule can't compute dependencies here since it's not a plain inline func. - // We make this as simple as possible so its only dep is doUpdate itself. - // eslint-disable-next-line react-hooks/exhaustive-deps - const scheduleUpdate = useCallback( - throttle(doUpdate, MIN_UPDATE_INTERVAL_MS, { - leading: false, - trailing: true, - }), + const scheduleUpdate = useMemo( + () => + throttle(doUpdate, MIN_UPDATE_INTERVAL_MS, { + leading: false, + trailing: true, + }), [doUpdate], ); diff --git a/src/contexts/ToastContext.tsx b/src/contexts/ToastContext.tsx index 4ae4875c96..e9bd392a60 100644 --- a/src/contexts/ToastContext.tsx +++ b/src/contexts/ToastContext.tsx @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ -import { ReactNode, createContext, useCallback, useContext, useEffect, useRef, useState } from "react"; +import { ReactNode, createContext, useCallback, useContext, useEffect, useState, useMemo } from "react"; /** * A ToastContext helps components display any kind of toast message and can be provided @@ -33,19 +33,19 @@ export function useToastContext(): ToastRack { * the ToastRack object that should be provided to the context */ export function useActiveToast(): [ReactNode | undefined, ToastRack] { - const toastRack = useRef(new ToastRack()); + const toastRack = useMemo(() => new ToastRack(), []); - const [activeToast, setActiveToast] = useState(toastRack.current.getActiveToast()); + const [activeToast, setActiveToast] = useState(toastRack.getActiveToast()); const updateCallback = useCallback(() => { - setActiveToast(toastRack.current.getActiveToast()); + setActiveToast(toastRack.getActiveToast()); }, [setActiveToast, toastRack]); useEffect(() => { - toastRack.current.setCallback(updateCallback); + toastRack.setCallback(updateCallback); }, [toastRack, updateCallback]); - return [activeToast, toastRack.current]; + return [activeToast, toastRack]; } interface DisplayedToast { diff --git a/src/utils/location/useMap.ts b/src/utils/location/useMap.ts index 308aedc205..300a15a4ec 100644 --- a/src/utils/location/useMap.ts +++ b/src/utils/location/useMap.ts @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ -import { useEffect, useState } from "react"; +import { useEffect, useMemo } from "react"; import type { Map as MapLibreMap } from "maplibre-gl"; import { createMap } from "./map"; @@ -26,29 +26,25 @@ interface UseMapProps { */ export const useMap = ({ interactive, bodyId, onError }: UseMapProps): MapLibreMap | undefined => { const cli = useMatrixClientContext(); - const [map, setMap] = useState(); - useEffect( - () => { - try { - setMap(createMap(cli, !!interactive, bodyId, onError)); - } catch (error) { - console.error("Error encountered in useMap", error); - if (error instanceof Error) { - onError?.(error); - } + const map = useMemo(() => { + try { + return createMap(cli, !!interactive, bodyId, onError); + } catch (error) { + console.error("Error encountered in useMap", error); + if (error instanceof Error) { + onError?.(error); } - return () => { - if (map) { - map.remove(); - setMap(undefined); - } - }; - }, - // map is excluded as a dependency - // eslint-disable-next-line react-hooks/exhaustive-deps - [interactive, bodyId, onError], - ); + } + }, [bodyId, cli, interactive, onError]); + + // cleanup + useEffect(() => { + if (!map) return; + return () => { + map.remove(); + }; + }, [map]); return map; }; diff --git a/yarn.lock b/yarn.lock index aa431c0204..9845293aa7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -56,7 +56,7 @@ resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.26.2.tgz#278b6b13664557de95b8f35b90d96785850bb56e" integrity sha512-Z0WgzSEa+aUcdiJuCIqgujCshpMWgUpgOxXotrYPSA53hA3qopNaqcJpyr0hVb1FeWdnqFA35/fUtXgBK8srQg== -"@babel/core@^7.0.0", "@babel/core@^7.11.6", "@babel/core@^7.12.10", "@babel/core@^7.12.3", "@babel/core@^7.18.5", "@babel/core@^7.21.3", "@babel/core@^7.23.9": +"@babel/core@^7.0.0", "@babel/core@^7.11.6", "@babel/core@^7.12.10", "@babel/core@^7.12.3", "@babel/core@^7.18.5", "@babel/core@^7.21.3", "@babel/core@^7.23.9", "@babel/core@^7.24.4": version "7.26.0" resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.26.0.tgz#d78b6023cc8f3114ccf049eb219613f74a747b40" integrity sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg== @@ -303,6 +303,13 @@ dependencies: "@babel/types" "^7.25.8" +"@babel/parser@^7.24.4": + version "7.26.3" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.26.3.tgz#8c51c5db6ddf08134af1ddbacf16aaab48bac234" + integrity sha512-WJ/CvmY8Mea8iDXo6a7RK2wbmJITT5fN3BEkRuFlxVyNx8jOKIIhmC4fSkTcPcf8JyavbBwIe6OpiCOBXt/IcA== + dependencies: + "@babel/types" "^7.26.3" + "@babel/parser@^7.25.9", "@babel/parser@^7.26.0", "@babel/parser@^7.26.2": version "7.26.2" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.26.2.tgz#fd7b6f487cfea09889557ef5d4eeb9ff9a5abd11" @@ -1150,6 +1157,14 @@ "@babel/helper-string-parser" "^7.25.9" "@babel/helper-validator-identifier" "^7.25.9" +"@babel/types@^7.26.3": + version "7.26.3" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.26.3.tgz#37e79830f04c2b5687acc77db97fbc75fb81f3c0" + integrity sha512-vN5p+1kl59GVKMvTHt55NzzmYVxprfJD+ql7U9NFIfKCBkYE55LYtS+WtPlaYOyzydrKI8Nezd+aZextrd+FMA== + dependencies: + "@babel/helper-string-parser" "^7.25.9" + "@babel/helper-validator-identifier" "^7.25.9" + "@bcoe/v8-coverage@^0.2.3": version "0.2.3" resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" @@ -5628,6 +5643,18 @@ eslint-plugin-matrix-org@^2.0.2: resolved "https://registry.yarnpkg.com/eslint-plugin-matrix-org/-/eslint-plugin-matrix-org-2.0.2.tgz#95b86b0f16704ab19740f7c3c62eae69e20365e6" integrity sha512-cQy5Rjeq6uyu1mLXlPZwEJdyM0NmclrnEz68y792FSuuxzMyJNNYLGDQ5CkYW8H+PrD825HUFZ34pNXnjMOzOw== +eslint-plugin-react-compiler@^19.0.0-beta-df7b47d-20241124: + version "19.0.0-beta-df7b47d-20241124" + resolved "https://registry.yarnpkg.com/eslint-plugin-react-compiler/-/eslint-plugin-react-compiler-19.0.0-beta-df7b47d-20241124.tgz#468751d3a8a6781189405ee56b39b80545306df8" + integrity sha512-82PfnllC8jP/68KdLAbpWuYTcfmtGLzkqy2IW85WopKMTr+4rdQpp+lfliQ/QE79wWrv/dRoADrk3Pdhq25nTw== + dependencies: + "@babel/core" "^7.24.4" + "@babel/parser" "^7.24.4" + "@babel/plugin-transform-private-methods" "^7.25.9" + hermes-parser "^0.25.1" + zod "^3.22.4" + zod-validation-error "^3.0.3" + eslint-plugin-react-hooks@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.0.0.tgz#72e2eefbac4b694f5324154619fee44f5f60f101" @@ -6574,6 +6601,18 @@ he@^1.2.0: resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== +hermes-estree@0.25.1: + version "0.25.1" + resolved "https://registry.yarnpkg.com/hermes-estree/-/hermes-estree-0.25.1.tgz#6aeec17d1983b4eabf69721f3aa3eb705b17f480" + integrity sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw== + +hermes-parser@^0.25.1: + version "0.25.1" + resolved "https://registry.yarnpkg.com/hermes-parser/-/hermes-parser-0.25.1.tgz#5be0e487b2090886c62bd8a11724cd766d5f54d1" + integrity sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA== + dependencies: + hermes-estree "0.25.1" + highlight.js@^11.3.1: version "11.10.0" resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-11.10.0.tgz#6e3600dc4b33d6dc23d5bd94fbf72405f5892b92"