diff --git a/change/@fluentui-react-utilities-66a3d122-bdde-45be-a7b3-d6ae2b28a1d6.json b/change/@fluentui-react-utilities-66a3d122-bdde-45be-a7b3-d6ae2b28a1d6.json new file mode 100644 index 0000000000000..613156a3a20b8 --- /dev/null +++ b/change/@fluentui-react-utilities-66a3d122-bdde-45be-a7b3-d6ae2b28a1d6.json @@ -0,0 +1,7 @@ +{ + "type": "minor", + "comment": "feat: add custom RefAttributes interface which is used within ForwardRefComponent to mitigate breaking changes shipped as patch release in @types/react@18.2.61", + "packageName": "@fluentui/react-utilities", + "email": "martinhochel@microsoft.com", + "dependentChangeType": "patch" +} diff --git a/packages/eslint-plugin/src/configs/react.js b/packages/eslint-plugin/src/configs/react.js index ad66e8190d60b..2d58c4295cb6c 100644 --- a/packages/eslint-plugin/src/configs/react.js +++ b/packages/eslint-plugin/src/configs/react.js @@ -23,6 +23,8 @@ module.exports = { 'jsdoc/check-tag-names': [ 'error', { + // Allow TSDoc tags + definedTags: ['remarks'], jsxTags: true, }, ], diff --git a/packages/react-components/react-utilities/etc/react-utilities.api.md b/packages/react-components/react-utilities/etc/react-utilities.api.md index 70e8390f3ef24..70291d8f45118 100644 --- a/packages/react-components/react-utilities/etc/react-utilities.api.md +++ b/packages/react-components/react-utilities/etc/react-utilities.api.md @@ -63,7 +63,7 @@ export type FluentTriggerComponent = { }; // @public -export type ForwardRefComponent = React_2.ForwardRefExoticComponent>>; +export type ForwardRefComponent = React_2.ForwardRefExoticComponent>>; // @public export function getEventClientCoords(event: TouchOrMouseEvent): { @@ -190,6 +190,12 @@ export interface PriorityQueue { // @public (undocumented) export type ReactTouchOrMouseEvent = React_2.MouseEvent | React_2.TouchEvent; +// @public +export interface RefAttributes extends React_2.Attributes { + // (undocumented) + ref?: React_2.Ref | undefined; +} + // @public export type RefObjectFunction = React_2.RefObject & ((value: T | null) => void); diff --git a/packages/react-components/react-utilities/src/compose/index.ts b/packages/react-components/react-utilities/src/compose/index.ts index 9dd0db0086066..756a54c8a9cf0 100644 --- a/packages/react-components/react-utilities/src/compose/index.ts +++ b/packages/react-components/react-utilities/src/compose/index.ts @@ -8,6 +8,7 @@ export type { EventHandler, ExtractSlotProps, ForwardRefComponent, + RefAttributes, InferredElementRefType, IsSingleton, PropsWithoutChildren, diff --git a/packages/react-components/react-utilities/src/compose/types.test.ts b/packages/react-components/react-utilities/src/compose/types.test.ts index b0cd6e7eaf590..7207d5478c735 100644 --- a/packages/react-components/react-utilities/src/compose/types.test.ts +++ b/packages/react-components/react-utilities/src/compose/types.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/naming-convention */ import * as React from 'react'; import type * as Types from './types'; @@ -54,6 +55,47 @@ describe(`types`, () => { componentEl = { greeting: 123, who: false }; expect(componentEl).toBeDefined(); + + // + // v9 ForwardRefComponent + RefAttributes with React 18.2.61 types issue + // + + // @types/react@18.2.61 introduced a change in the `RefAttributes` type to include `LegacyRef` + interface RefAttributesAfterTypesReact18_2_61 extends React.Attributes { + ref?: React.LegacyRef; + } + + // previous change affects forwardRef api + function forwardRefAfterTypesReact18_2_61( + render: React.ForwardRefRenderFunction, + ): React.ForwardRefExoticComponent & RefAttributesAfterTypesReact18_2_61> { + return null as unknown as React.ForwardRefExoticComponent< + React.PropsWithoutRef

& RefAttributesAfterTypesReact18_2_61 + >; + } + + type ButtonProps = Types.ComponentProps<{ + root: NonNullable>; + + icon?: Types.Slot<'span'>; + }>; + const Button = (_props => { + return null; + }) as Types.ForwardRefComponent; + + const Example = forwardRefAfterTypesReact18_2_61((props: { hello?: string }, _ref) => { + const wrong = React.createElement( + Button, + // @ts-expect-error - Type 'LegacyRef | undefined' is not assignable to type 'Ref | undefined'. -> Type 'string' is not assignable to type 'Ref | undefined'. + { + ...(props as RefAttributesAfterTypesReact18_2_61), + }, + ); + const correct = React.createElement(Button, { ...(props as Types.RefAttributes) }); + + return React.createElement(React.Fragment, null, wrong, correct); + }); + console.log(Example); }); }); }); diff --git a/packages/react-components/react-utilities/src/compose/types.ts b/packages/react-components/react-utilities/src/compose/types.ts index 277b29455a4f9..c4194d28ad34a 100644 --- a/packages/react-components/react-utilities/src/compose/types.ts +++ b/packages/react-components/react-utilities/src/compose/types.ts @@ -1,6 +1,6 @@ import * as React from 'react'; import { SLOT_CLASS_NAME_PROP_SYMBOL, SLOT_ELEMENT_TYPE_SYMBOL, SLOT_RENDER_FUNCTION_SYMBOL } from './constants'; -import { DistributiveOmit, ReplaceNullWithUndefined } from '../utils/types'; +import type { DistributiveOmit, ReplaceNullWithUndefined } from '../utils/types'; export type SlotRenderFunction = ( Component: React.ElementType, @@ -216,9 +216,15 @@ export type InferredElementRefType = ObscureEventName extends keyof Props /** * Return type for `React.forwardRef`, including inference of the proper typing for the ref. + * + * @remarks + * {@link React.RefAttributes} is {@link https://github.com/DefinitelyTyped/DefinitelyTyped/discussions/69756 | leaking string references} into `forwardRef` components + * after introducing {@link https://github.com/DefinitelyTyped/DefinitelyTyped/pull/68720 | RefAttributes Type Extension}, which shipped in `@types/react@18.2.61` + * - `forwardRef` component do not support string refs. + * - uses custom `RefAttributes` which is compatible with all React versions enforcing no `string` allowance. */ export type ForwardRefComponent = React.ForwardRefExoticComponent< - Props & React.RefAttributes> + Props & RefAttributes> >; // A definition like this would also work, but typescript is more likely to unnecessarily expand // the props type with this version (and it's likely much more expensive to evaluate) @@ -289,3 +295,24 @@ export type EventHandler> = ( ev: React.SyntheticEvent | Event, data: TData, ) => void; + +/** + * This type should be used in place of `React.RefAttributes` in all components that specify `ref` prop. + * + * If user is using React 18 types `>=18.2.61`, they will run into type issues of incompatible refs, using this type mitigates this issues across react type versions. + * + * @remarks + * + * React 18 types introduced Type Expansion Change to the `RefAttributes` interface as patch release. + * These changes were released in `@types/react@18.2.61` (replacing ref with `LegacyRef`, which leaks `string` into the union type, causing breaking changes between v8/v9 libraries): + * - {@link https://github.com/DefinitelyTyped/DefinitelyTyped/pull/68720 | PR } + * - {@link https://app.unpkg.com/@types/react@18.2.61/files/index.d.ts | shipped definitions } + * + * + * In React 19 types this was "reverted" back to the original `Ref` type. + * In order to maintain compatibility with React 17,18,19, we are forced to use our own version of `RefAttributes`. + * + */ +export interface RefAttributes extends React.Attributes { + ref?: React.Ref | undefined; +} diff --git a/packages/react-components/react-utilities/src/index.ts b/packages/react-components/react-utilities/src/index.ts index 76b147ac229fb..b0909d2611103 100644 --- a/packages/react-components/react-utilities/src/index.ts +++ b/packages/react-components/react-utilities/src/index.ts @@ -20,6 +20,7 @@ export type { ComponentProps, ComponentState, ForwardRefComponent, + RefAttributes, // eslint-disable-next-line @typescript-eslint/no-deprecated ResolveShorthandFunction, // eslint-disable-next-line @typescript-eslint/no-deprecated