diff --git a/.yarn/patches/@radix-ui-react-compose-refs-npm-1.1.2-f0371f8267.patch b/.yarn/patches/@radix-ui-react-compose-refs-npm-1.1.2-f0371f8267.patch new file mode 100644 index 000000000..cd0d1a98e --- /dev/null +++ b/.yarn/patches/@radix-ui-react-compose-refs-npm-1.1.2-f0371f8267.patch @@ -0,0 +1,30 @@ +diff --git a/dist/index.js b/dist/index.js +index 5ba7a95bc7a29605f32aa010a14224ad87bf6589..699fc222db43d83ac2e3218c0aa5c9db46dc697d 100644 +--- a/dist/index.js ++++ b/dist/index.js +@@ -39,7 +39,9 @@ module.exports = __toCommonJS(index_exports); + var React = __toESM(require("react")); + function setRef(ref, value) { + if (typeof ref === "function") { +- return ref(value); ++ if (value !== null) { ++ return ref(value); ++ } + } else if (ref !== null && ref !== void 0) { + ref.current = value; + } +diff --git a/dist/index.mjs b/dist/index.mjs +index 7dd9172a7228d70a25f708d0fd577cfc69ea15a8..d8a2f56ecb5951410e1a5748d9a8db56eb966351 100644 +--- a/dist/index.mjs ++++ b/dist/index.mjs +@@ -2,7 +2,9 @@ + import * as React from "react"; + function setRef(ref, value) { + if (typeof ref === "function") { +- return ref(value); ++ if (value !== null) { ++ return ref(value); ++ } + } else if (ref !== null && ref !== void 0) { + ref.current = value; + } diff --git a/.yarn/patches/@radix-ui-react-radio-group-npm-1.3.8-6cdda6336a.patch b/.yarn/patches/@radix-ui-react-radio-group-npm-1.3.8-6cdda6336a.patch new file mode 100644 index 000000000..0b9f0cb76 --- /dev/null +++ b/.yarn/patches/@radix-ui-react-radio-group-npm-1.3.8-6cdda6336a.patch @@ -0,0 +1,54 @@ +diff --git a/dist/index.js b/dist/index.js +index deae02992529c4daef1710bb7394a94edeea11f5..cb1ae63b7067854f3277ceb9a7cdb66735768e0e 100644 +--- a/dist/index.js ++++ b/dist/index.js +@@ -78,10 +78,10 @@ var Radio = React.forwardRef( + form, + ...radioProps + } = props; +- const [button, setButton] = React.useState(null); +- const composedRefs = (0, import_react_compose_refs.useComposedRefs)(forwardedRef, (node) => setButton(node)); ++ const button = React.useRef(null); ++ const composedRefs = (0, import_react_compose_refs.useComposedRefs)(forwardedRef, button); + const hasConsumerStoppedPropagationRef = React.useRef(false); +- const isFormControl = button ? form || !!button.closest("form") : true; ++ const isFormControl = button.current ? form || !!button.current.closest("form") : true; + return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(RadioProvider, { scope: __scopeRadio, checked, disabled, children: [ + /* @__PURE__ */ (0, import_jsx_runtime.jsx)( + import_react_primitive.Primitive.button, +@@ -107,7 +107,7 @@ var Radio = React.forwardRef( + isFormControl && /* @__PURE__ */ (0, import_jsx_runtime.jsx)( + RadioBubbleInput, + { +- control: button, ++ control: button.current, + bubbles: !hasConsumerStoppedPropagationRef.current, + name, + value, +diff --git a/dist/index.mjs b/dist/index.mjs +index 46bb67f279d7e0a06cb177ad6a8b3de12745d4a5..808c57a8f43de24c89df3cfa3cf6b0f582ddf8e6 100644 +--- a/dist/index.mjs ++++ b/dist/index.mjs +@@ -37,10 +37,10 @@ var Radio = React.forwardRef( + form, + ...radioProps + } = props; +- const [button, setButton] = React.useState(null); +- const composedRefs = useComposedRefs(forwardedRef, (node) => setButton(node)); ++ const button = React.useRef(null); ++ const composedRefs = useComposedRefs(forwardedRef, button); + const hasConsumerStoppedPropagationRef = React.useRef(false); +- const isFormControl = button ? form || !!button.closest("form") : true; ++ const isFormControl = button.current ? form || !!button.current.closest("form") : true; + return /* @__PURE__ */ jsxs(RadioProvider, { scope: __scopeRadio, checked, disabled, children: [ + /* @__PURE__ */ jsx( + Primitive.button, +@@ -66,7 +66,7 @@ var Radio = React.forwardRef( + isFormControl && /* @__PURE__ */ jsx( + RadioBubbleInput, + { +- control: button, ++ control: button.current, + bubbles: !hasConsumerStoppedPropagationRef.current, + name, + value, diff --git a/package.json b/package.json index f16b32830..0c46bacd9 100644 --- a/package.json +++ b/package.json @@ -134,7 +134,7 @@ "@radix-ui/react-dropdown-menu": "2.1.16", "@radix-ui/react-navigation-menu": "1.2.14", "@radix-ui/react-popover": "1.1.15", - "@radix-ui/react-radio-group": "1.3.8", + "@radix-ui/react-radio-group": "patch:@radix-ui/react-radio-group@npm%3A1.3.8#./.yarn/patches/@radix-ui-react-radio-group-npm-1.3.8-6cdda6336a.patch", "@radix-ui/react-slot": "1.2.4", "@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-tooltip": "1.2.8", @@ -144,6 +144,7 @@ "sonner": "2.0.7" }, "resolutions": { + "@radix-ui/react-compose-refs": "patch:@radix-ui/react-compose-refs@npm%3A1.1.2#./.yarn/patches/@radix-ui-react-compose-refs-npm-1.1.2-f0371f8267.patch", "ast-types": "patch:ast-types@npm%3A0.16.1#./.yarn/patches/ast-types-npm-0.16.1-596f974e68.patch", "axe-core": "4.11.1" } diff --git a/package.json.md b/package.json.md index c4026dec4..5410e6ef4 100644 --- a/package.json.md +++ b/package.json.md @@ -2,20 +2,29 @@ ## peerDependencies +- react : Core runtime required by all Echoes React components. +- react-dom : Required for DOM rendering and portals used by some components. +- react-intl : Required for components relying on internationalization. +- react-router-dom : Required by components integrating with routing/navigation context. +- @emotion/react : Required styling runtime for Emotion-based components. +- @emotion/styled : Required for Emotion styled-component APIs used in the library. + ## Dependencies -- @mantine/core : Used as a base some components (Select, MultiSelect), currently in v6 because v7 only supports React 18. We had to create a patch to prevent some TS error with React 18, we will be able to drop it once we upgrade to Mantine v7. -- @mantine/hooks : Used as a base some components (Select, MultiSelect), currently in v6 because v7 only supports React 18 +- @mantine/core : Used as a base for some components (Select, MultiSelect). We maintain a patch to add `withExpandedAttribute` on `Combobox.Target` in Select so the trigger exposes the expected `aria-expanded` state for accessibility and tests. +- @mantine/hooks : Used with `@mantine/core` as a base for some components (Select, MultiSelect). - Sonner : Used as the base component for Toast notifications +- @radix-ui/react-radio-group (patch): Replaces an internal callback-ref + `useState` pattern with `useRef` to avoid ref-driven render loops (notably with React 19) that can cause maximum update depth errors. ## DevDependencies -- @testing-library/react : Used for testing, must match the version of React so we are stuck with v12 until we upgrade to React 18 +- @testing-library/react : Used for component and interaction testing. It should stay aligned with our React major version. -- @emotion/cache : peer dependencies of Mantine v6 -- @emotion/serialize : peer dependencies of Mantine v6 -- @emotion/utils : peer dependencies of Mantine v6 +- @emotion/cache : Used by Emotion/Mantine styling internals. +- @emotion/serialize : Used by Emotion style serialization internals. +- @emotion/utils : Used by Emotion runtime/style utilities. ## Resolutions +- @radix-ui/react-compose-refs (patch): Prevents forwarding `null` to function refs. This avoids repeated ref teardown/setup feedback loops that can retrigger state updates. - ast-types: Transitive dependency of Storybook. It is apparently incompatible with typescript 5.4+, but we've never been impacted (why?). With the bump to storybook 9, it requires a patch not to fail ts-check. See [this issue](https://github.com/benjamn/ast-types/issues/948) diff --git a/src/common/helpers/useForwardedRef.ts b/src/common/helpers/useForwardedRef.ts index a331e8a88..ae463bfc8 100644 --- a/src/common/helpers/useForwardedRef.ts +++ b/src/common/helpers/useForwardedRef.ts @@ -17,7 +17,7 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { type ForwardedRef, useRef, useState } from 'react'; +import { type ForwardedRef, useCallback, useRef, useState } from 'react'; /** * This hook may be used to intercept a forwarded ref, providing a local ref @@ -35,16 +35,29 @@ import { type ForwardedRef, useRef, useState } from 'react'; */ export function useForwardedRefWithState(forwardedRef: ForwardedRef) { const [ref, setRef] = useState(null); + const previousElementRef = useRef(null); - const setForwardedRef = (element: T | null) => { - if (typeof forwardedRef === 'function') { - forwardedRef(element); - } else if (forwardedRef) { - forwardedRef.current = element; - } + const setForwardedRef = useCallback( + (element: T | null) => { + // React 19 can invoke function refs with null during detach/reattach cycles. + // Skip null for function refs to avoid commit-phase state update feedback loops. + if (typeof forwardedRef === 'function') { + if (element !== null) { + forwardedRef(element); + } + } else if (forwardedRef) { + forwardedRef.current = element; + } - setRef(element); - }; + if (element === null || previousElementRef.current === element) { + return; + } + + previousElementRef.current = element; + setRef(element); + }, + [forwardedRef], + ); return [ref, setForwardedRef] as const; } @@ -66,14 +79,22 @@ export function useForwardedRefWithState(forwardedRef: ForwardedRef) { export function useForwardedRef(forwardedRef: ForwardedRef) { const ref = useRef(null); - const setForwardedRef = (element: T | null) => { - if (typeof forwardedRef === 'function') { - forwardedRef(element); - } else if (forwardedRef) { - forwardedRef.current = element; - } - ref.current = element; - }; + const setForwardedRef = useCallback( + (element: T | null) => { + if (typeof forwardedRef === 'function') { + if (element !== null) { + forwardedRef(element); + } + } else if (forwardedRef) { + forwardedRef.current = element; + } + + if (element !== null) { + ref.current = element; + } + }, + [forwardedRef], + ); return [ref, setForwardedRef] as const; } diff --git a/yarn.lock b/yarn.lock index be351a21e..cb0577ad6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3523,6 +3523,19 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-compose-refs@patch:@radix-ui/react-compose-refs@npm%3A1.1.2#./.yarn/patches/@radix-ui-react-compose-refs-npm-1.1.2-f0371f8267.patch::locator=%40sonarsource%2Fechoes-react%40workspace%3A.": + version: 1.1.2 + resolution: "@radix-ui/react-compose-refs@patch:@radix-ui/react-compose-refs@npm%3A1.1.2#./.yarn/patches/@radix-ui-react-compose-refs-npm-1.1.2-f0371f8267.patch::version=1.1.2&hash=dc9d5d&locator=%40sonarsource%2Fechoes-react%40workspace%3A." + peerDependencies: + "@types/react": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + checksum: 10/7bb2f8147fa61a50ab9b692dcecbd265c1e4452f266916378befb1d3d7a7195f8dbd2f0b7fdd48ae664252bfeac83d760f128ca207eff4266dd1313badd2c4b2 + languageName: node + linkType: hard + "@radix-ui/react-context@npm:1.1.2": version: 1.1.2 resolution: "@radix-ui/react-context@npm:1.1.2" @@ -3913,6 +3926,34 @@ __metadata: languageName: node linkType: hard +"@radix-ui/react-radio-group@patch:@radix-ui/react-radio-group@npm%3A1.3.8#./.yarn/patches/@radix-ui-react-radio-group-npm-1.3.8-6cdda6336a.patch::locator=%40sonarsource%2Fechoes-react%40workspace%3A.": + version: 1.3.8 + resolution: "@radix-ui/react-radio-group@patch:@radix-ui/react-radio-group@npm%3A1.3.8#./.yarn/patches/@radix-ui-react-radio-group-npm-1.3.8-6cdda6336a.patch::version=1.3.8&hash=ecd358&locator=%40sonarsource%2Fechoes-react%40workspace%3A." + dependencies: + "@radix-ui/primitive": "npm:1.1.3" + "@radix-ui/react-compose-refs": "npm:1.1.2" + "@radix-ui/react-context": "npm:1.1.2" + "@radix-ui/react-direction": "npm:1.1.1" + "@radix-ui/react-presence": "npm:1.1.5" + "@radix-ui/react-primitive": "npm:2.1.3" + "@radix-ui/react-roving-focus": "npm:1.1.11" + "@radix-ui/react-use-controllable-state": "npm:1.2.2" + "@radix-ui/react-use-previous": "npm:1.1.1" + "@radix-ui/react-use-size": "npm:1.1.1" + peerDependencies: + "@types/react": "*" + "@types/react-dom": "*" + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + "@types/react": + optional: true + "@types/react-dom": + optional: true + checksum: 10/2c3334ffc762ddc423910a56131ebd23574c1eca308b837bcfefea6d766a6cdfc8f4771769a50a2011d44c29156cd1c825d47cc1dacef57f9a811b4b86cd3060 + languageName: node + linkType: hard + "@radix-ui/react-roving-focus@npm:1.1.11": version: 1.1.11 resolution: "@radix-ui/react-roving-focus@npm:1.1.11" @@ -4689,7 +4730,7 @@ __metadata: "@radix-ui/react-dropdown-menu": "npm:2.1.16" "@radix-ui/react-navigation-menu": "npm:1.2.14" "@radix-ui/react-popover": "npm:1.1.15" - "@radix-ui/react-radio-group": "npm:1.3.8" + "@radix-ui/react-radio-group": "patch:@radix-ui/react-radio-group@npm%3A1.3.8#./.yarn/patches/@radix-ui-react-radio-group-npm-1.3.8-6cdda6336a.patch" "@radix-ui/react-slot": "npm:1.2.4" "@radix-ui/react-toggle-group": "npm:1.1.11" "@radix-ui/react-tooltip": "npm:1.2.8"