diff --git a/.eslintrc.js b/.eslintrc.js index 127f563e7..357020498 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -90,7 +90,7 @@ module.exports = { 'react/prop-types': 'off', 'react-hooks/exhaustive-deps': 'error', // Checks effect dependencies 'react-hooks/rules-of-hooks': 'error', // Checks rules of Hooks - 'react-compiler/react-compiler': ['warn', {__unstable_donotuse_reportAllBailouts: true}], // Set to error once existing warnings are fixed + 'react-compiler/react-compiler': ['error', {__unstable_donotuse_reportAllBailouts: true}], 'react/no-unescaped-entities': 'off', 'no-restricted-imports': [ 'error', diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 44f3a78f4..e46a99e75 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -38,7 +38,7 @@ jobs: - run: pnpm install - name: Register Problem Matcher for ESLint that handles -f compact and shows warnings and errors inline on PRs run: echo "::add-matcher::.github/eslint-compact.json" - - run: "pnpm lint -f compact --rule 'no-warning-comments: [off]' --max-warnings 33" + - run: "pnpm lint -f compact --rule 'no-warning-comments: [off]' --max-warnings 0" test: needs: [build] diff --git a/package.config.ts b/package.config.ts index cfc254862..cd657229e 100644 --- a/package.config.ts +++ b/package.config.ts @@ -15,5 +15,23 @@ export default defineConfig({ }, tsconfig: 'tsconfig.dist.json', babel: {reactCompiler: true}, - reactCompilerOptions: {target: '18'}, + reactCompilerOptions: { + target: '18', + logger: { + logEvent(filename, event) { + /* eslint-disable no-console */ + if (event.kind === 'CompileError') { + console.group(`[${filename}] ${event.kind}`) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const {reason, description, severity, loc, suggestions} = event.detail as any + + console.error(`[${severity}] ${reason}`) + console.log(`${filename}:${loc.start?.line}:${loc.start?.column} ${description}`) + console.log(suggestions) + + console.groupEnd() + } + }, + }, + }, }) diff --git a/package.json b/package.json index 6a827f964..ba48b5145 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@sanity/ui", - "version": "2.8.23", + "version": "2.9.0-canary.6", "keywords": [ "sanity", "ui", diff --git a/src/core/components/breadcrumbs/breadcrumbs.tsx b/src/core/components/breadcrumbs/breadcrumbs.tsx index 4a184e444..44cd34f15 100644 --- a/src/core/components/breadcrumbs/breadcrumbs.tsx +++ b/src/core/components/breadcrumbs/breadcrumbs.tsx @@ -1,13 +1,4 @@ -import { - Children, - forwardRef, - Fragment, - isValidElement, - useCallback, - useMemo, - useRef, - useState, -} from 'react' +import {Children, forwardRef, Fragment, isValidElement, useCallback, useRef, useState} from 'react' import {useArrayProp, useClickOutsideEvent} from '../../hooks' import {Box, Popover, Stack, Text} from '../../primitives' import {ExpandButton, Root} from './breadcrumbs.styles' @@ -39,46 +30,43 @@ export const Breadcrumbs = forwardRef(function Breadcrumbs( useClickOutsideEvent(collapse, () => [expandElementRef.current, popoverElementRef.current]) - const rawItems = useMemo(() => Children.toArray(children).filter(isValidElement), [children]) + const rawItems = Children.toArray(children).filter(isValidElement) - const items = useMemo(() => { - const len = rawItems.length + let items = rawItems + const len = rawItems.length - if (maxLength && len > maxLength) { - const beforeLength = Math.ceil(maxLength / 2) - const afterLength = Math.floor(maxLength / 2) + if (maxLength && rawItems.length > maxLength) { + const beforeLength = Math.ceil(maxLength / 2) + const afterLength = Math.floor(maxLength / 2) - return [ - ...rawItems.slice(0, beforeLength - 1), - - {rawItems.slice(beforeLength - 1, len - afterLength)} - - } - key="button" - open={open} - placement="top" - portal - ref={popoverElementRef} - > - - , - ...rawItems.slice(len - afterLength), - ] - } - - return rawItems - }, [collapse, expand, maxLength, open, rawItems, space]) + items = [ + ...rawItems.slice(0, beforeLength - 1), + + {rawItems.slice(beforeLength - 1, len - afterLength)} + + } + key="button" + open={open} + placement="top" + portal + ref={popoverElementRef} + > + + , + ...rawItems.slice(len - afterLength), + ] + } return ( diff --git a/src/core/components/menu/useMenuController.ts b/src/core/components/menu/useMenuController.ts index dd4abbbcb..92d446030 100644 --- a/src/core/components/menu/useMenuController.ts +++ b/src/core/components/menu/useMenuController.ts @@ -36,31 +36,28 @@ export function useMenuController(props: { activeIndexRef.current = nextActiveIndex }, []) - const mount = useCallback( - (element: HTMLElement | null, selected?: boolean): (() => void) => { - if (!element) return () => undefined + const mount = (element: HTMLElement | null, selected?: boolean): (() => void) => { + if (!element) return () => undefined - if (elementsRef.current.indexOf(element) === -1) { - elementsRef.current.push(element) - _sortElements(rootElementRef.current, elementsRef.current) - } + if (elementsRef.current.indexOf(element) === -1) { + elementsRef.current.push(element) + _sortElements(rootElementRef.current, elementsRef.current) + } - if (selected) { - const selectedIndex = elementsRef.current.indexOf(element) + if (selected) { + const selectedIndex = elementsRef.current.indexOf(element) - setActiveIndex(selectedIndex) - } + setActiveIndex(selectedIndex) + } - return () => { - const idx = elementsRef.current.indexOf(element) + return () => { + const idx = elementsRef.current.indexOf(element) - if (idx > -1) { - elementsRef.current.splice(idx, 1) - } + if (idx > -1) { + elementsRef.current.splice(idx, 1) } - }, - [rootElementRef, setActiveIndex], - ) + } + } const handleKeyDown = useCallback( (event: React.KeyboardEvent) => { @@ -170,7 +167,7 @@ export function useMenuController(props: { [setActiveIndex], ) - const handleItemMouseLeave = useCallback(() => { + const handleItemMouseLeave = () => { // Set the active index to -2 to deactivate all menu items // when the user moves the mouse away from the menu item. // We avoid using -1 because it would focus the first menu item, @@ -178,7 +175,7 @@ export function useMenuController(props: { // between two menu items or a menu divider. setActiveIndex(-2) rootElementRef.current?.focus() - }, [rootElementRef, setActiveIndex]) + } // Set focus on the currently active element useEffect(() => { diff --git a/src/core/components/tree/treeItem.tsx b/src/core/components/tree/treeItem.tsx index a80635071..571e58c09 100644 --- a/src/core/components/tree/treeItem.tsx +++ b/src/core/components/tree/treeItem.tsx @@ -1,6 +1,6 @@ import {ToggleArrowRightIcon} from '@sanity/icons' import {ThemeFontWeightKey} from '@sanity/ui/theme' -import {memo, useCallback, useEffect, useId, useMemo, useRef} from 'react' +import {memo, useCallback, useEffect, useId, useMemo, useRef, useState} from 'react' import {styled} from 'styled-components' import {Box, BoxProps, Flex, Text} from '../../primitives' import { @@ -64,18 +64,21 @@ export const TreeItem = memo(function TreeItem( weight, ...restProps } = props - const rootRef = useRef(null) + const [rootElement, setRootElement] = useState(null) const treeitemRef = useRef(null) const tree = useTree() const {path, registerItem, setExpanded, setFocusedElement} = tree const _id = useId() const id = idProp || _id - const itemPath = useMemo(() => path.concat([id || '']), [id, path]) - const itemKey = itemPath.join('/') + const [itemPath, itemKey] = useMemo(() => { + const result = path.concat([id || '']) + + return [result, result.join('/')] + }, [id, path]) const itemState = tree.state[itemKey] - const focused = tree.focusedElement === rootRef.current + const focused = tree.focusedElement === rootElement const expanded = itemState?.expanded === undefined ? expandedProp : itemState?.expanded || false - const tabIndex = tree.focusedElement && tree.focusedElement === rootRef.current ? 0 : -1 + const tabIndex = tree.focusedElement && tree.focusedElement === rootElement ? 0 : -1 const contextValue = useMemo( () => ({...tree, level: tree.level + 1, path: itemPath}), [itemPath, tree], @@ -94,28 +97,28 @@ export const TreeItem = memo(function TreeItem( ) { event.stopPropagation() setExpanded(itemKey, !expanded) - setFocusedElement(rootRef.current) + setFocusedElement(rootElement) } }, - [expanded, itemKey, onClick, setExpanded, setFocusedElement], + [expanded, itemKey, onClick, rootElement, setExpanded, setFocusedElement], ) const handleKeyDown = useCallback( (event: React.KeyboardEvent) => { if (focused && event.key === 'Enter') { - const el = treeitemRef.current || rootRef.current + const el = treeitemRef.current || rootElement el?.click() } }, - [focused], + [focused, rootElement], ) useEffect(() => { - if (!rootRef.current) return + if (!rootElement) return - return registerItem(rootRef.current, itemPath.join('/'), expanded, selected) - }, [expanded, itemPath, registerItem, selected]) + return registerItem(rootElement, itemPath.join('/'), expanded, selected) + }, [expanded, itemPath, registerItem, rootElement, selected]) const content = ( @@ -154,7 +157,7 @@ export const TreeItem = memo(function TreeItem( data-ui="TreeItem" {...restProps} onClick={handleClick} - ref={rootRef} + ref={setRootElement} role="none" > diff --git a/src/core/hooks/useMatchMedia.ts b/src/core/hooks/useMatchMedia.ts index c155698ef..f13926c98 100644 --- a/src/core/hooks/useMatchMedia.ts +++ b/src/core/hooks/useMatchMedia.ts @@ -11,34 +11,24 @@ export function useMatchMedia( mediaQueryString: `(${string})`, getServerSnapshot?: () => boolean, ): boolean { + /** + * `subscribe` and `getSnapshot` are only called on the client and both need access to the same `matchMedia` instance + * we don't want to eagerly instantiate it to ensure it's only created when actually used + */ + const cachedMatchMedia = useMemo( + () => (typeof window === 'undefined' ? null : window.matchMedia(mediaQueryString)), + [mediaQueryString], + ) const {subscribe, getSnapshot} = useMemo(() => { - /** - * `subscribe` and `getSnapshot` are only called on the client and both need access to the same `matchMedia` instance - * we don't want to eagerly instantiate it to ensure it's only created when actually used - */ - let MEDIA_QUERY_CACHE: MediaQueryList | undefined - - const getMatchMedia = (): MediaQueryList => { - if (!MEDIA_QUERY_CACHE) { - // As this function is only called during `subscribe` and `getSnapshot`, we can assume that the - // the `window` global is available and we're in a browser environment - MEDIA_QUERY_CACHE = window.matchMedia(mediaQueryString) - } - - return MEDIA_QUERY_CACHE - } - return { subscribe: (onStoreChange: () => void): (() => void) => { - const matchMedia = getMatchMedia() - - matchMedia.addEventListener('change', onStoreChange) + cachedMatchMedia!.addEventListener('change', onStoreChange) - return () => matchMedia.removeEventListener('change', onStoreChange) + return () => cachedMatchMedia!.removeEventListener('change', onStoreChange) }, - getSnapshot: () => getMatchMedia().matches, + getSnapshot: () => cachedMatchMedia!.matches, } - }, [mediaQueryString]) + }, [cachedMatchMedia]) useDebugValue(mediaQueryString) diff --git a/src/core/primitives/popover/popover.tsx b/src/core/primitives/popover/popover.tsx index 64bbbc65d..4484caaf6 100644 --- a/src/core/primitives/popover/popover.tsx +++ b/src/core/primitives/popover/popover.tsx @@ -1,6 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { - Middleware, RootBoundary, arrow, autoUpdate, @@ -21,8 +20,10 @@ import { useCallback, useEffect, useImperativeHandle, + useLayoutEffect, useMemo, useRef, + useState, } from 'react' import {useArrayProp, useElementSize, useMediaIndex, usePrefersReducedMotion} from '../../hooks' import {origin} from '../../middleware/origin' @@ -126,15 +127,14 @@ export const Popover = memo( __unstable_margins: margins = DEFAULT_POPOVER_MARGINS, animate: _animate = false, arrow: arrowProp = false, - boundaryElement = boundaryElementContext.element, + boundaryElement: _boundaryElement, children: childProp, constrainSize = false, content, disabled, - fallbackPlacements = props.fallbackPlacements ?? - DEFAULT_FALLBACK_PLACEMENTS[props.placement ?? 'bottom'], + fallbackPlacements: _fallbackPlacements, matchReferenceWidth, - floatingBoundary = props.boundaryElement ?? boundaryElementContext.element, + floatingBoundary: _floatingBoundary, // eslint-disable-next-line @typescript-eslint/no-unused-vars onActivate, open, @@ -144,16 +144,23 @@ export const Popover = memo( portal, preventOverflow = true, radius: radiusProp = 3, - referenceBoundary = props.boundaryElement ?? boundaryElementContext.element, + referenceBoundary: _referenceBoundary, referenceElement, scheme, shadow: shadowProp = 3, tone = 'inherit', width: widthProp = 'auto', - zOffset: zOffsetProp = layer.popover.zOffset, + zOffset: _zOffsetProp, updateRef, ...restProps } = props + const boundaryElement = _boundaryElement ?? boundaryElementContext.element + const fallbackPlacements = + _fallbackPlacements ?? DEFAULT_FALLBACK_PLACEMENTS[props.placement ?? 'bottom'] + const floatingBoundary = _floatingBoundary ?? boundaryElement ?? boundaryElementContext.element + const referenceBoundary = + _referenceBoundary ?? boundaryElement ?? boundaryElementContext.element + const zOffsetProp = _zOffsetProp ?? layer.popover.zOffset const prefersReducedMotion = usePrefersReducedMotion() const animate = prefersReducedMotion ? false : _animate const boundarySize = useElementSize(boundaryElement)?.border @@ -163,7 +170,7 @@ export const Popover = memo( const widthArrayProp = useArrayProp(widthProp) const zOffset = useArrayProp(zOffsetProp) const ref = useRef(null) - const arrowRef = useRef(null) + const [arrowElement, setArrowElement] = useState(null) const rootBoundary: RootBoundary = 'viewport' useImperativeHandle( @@ -174,94 +181,70 @@ export const Popover = memo( const mediaIndex = useMediaIndex() const boundaryWidth = constrainSize || preventOverflow ? boundarySize?.width : undefined - // Update width when - // - media index changes - // - `width` property changes - const width = calcCurrentWidth({ - container, - mediaIndex, - width: widthArrayProp, - }) - const widthRef = useRef(width) - - useEffect(() => { - widthRef.current = width - }, [width]) - - // Update max width when - // - boundary width changes - // - `width` property changes - const maxWidth = calcMaxWidth({boundaryWidth, currentWidth: width}) - const maxWidthRef = useRef(maxWidth) - - useEffect(() => { - maxWidthRef.current = maxWidth - }, [maxWidth]) - - // Keep track of reference element width (see `size` middleware below) - const referenceWidthRef = useRef() - // Force apply width & max width to floating element - useEffect(() => { + useLayoutEffect(() => { const floatingElement = ref.current - if (!open || !floatingElement) return + // If constrainSize or matchReferenceWidth is true, then the styles are set by the `size` middleware + if (!open || !floatingElement || constrainSize || matchReferenceWidth) return - const referenceWidth = referenceWidthRef.current + const currentWidth = calcCurrentWidth({ + container, + mediaIndex, + width: widthArrayProp, + }) + const maxWidth = calcMaxWidth({boundaryWidth, currentWidth}) - if (matchReferenceWidth) { - if (referenceWidth !== undefined) { - floatingElement.style.width = `${referenceWidth}px` - } - } else if (width !== undefined) { - floatingElement.style.width = `${width}px` + if (currentWidth !== undefined) { + floatingElement.style.width = `${currentWidth}px` } if (typeof maxWidth === 'number') { floatingElement.style.maxWidth = `${maxWidth}px` } - }, [width, matchReferenceWidth, maxWidth, open]) - - const middleware = useMemo(() => { - const ret: Middleware[] = [] + }, [ + boundaryWidth, + constrainSize, + container, + matchReferenceWidth, + mediaIndex, + open, + widthArrayProp, + ]) - // Flip the floating element when leaving the boundary box - if (constrainSize || preventOverflow) { - ret.push( + const {x, y, middlewareData, placement, refs, strategy, update} = useFloating({ + middleware: [ + // Flip the floating element when leaving the boundary box + (constrainSize || preventOverflow) && flip({ boundary: floatingBoundary || undefined, fallbackPlacements, padding: DEFAULT_POPOVER_PADDING, rootBoundary, }), - ) - } - - // Define distance between reference and floating element - ret.push(offset({mainAxis: DEFAULT_POPOVER_DISTANCE})) - - // Track sizes - if (constrainSize || matchReferenceWidth) { - ret.push( + // Define distance between reference and floating element + offset({mainAxis: DEFAULT_POPOVER_DISTANCE}), + // Track sizes + (constrainSize || matchReferenceWidth) && size({ apply({availableWidth, availableHeight, elements, referenceWidth}) { - // not fresh, so use refs - - referenceWidthRef.current = referenceWidth - - const _currentWidth = widthRef.current - const _maxWidth = maxWidthRef.current + const currentWidth = calcCurrentWidth({ + container, + mediaIndex, + width: widthArrayProp, + }) + const maxWidth = calcMaxWidth({boundaryWidth, currentWidth}) if (matchReferenceWidth) { elements.floating.style.width = `${referenceWidth}px` - } else if (_currentWidth !== undefined) { - elements.floating.style.width = `${_currentWidth}px` + } else if (currentWidth !== undefined) { + elements.floating.style.width = `${currentWidth}px` } if (constrainSize) { elements.floating.style.maxWidth = `${Math.min( availableWidth, - _maxWidth ?? Infinity, + maxWidth ?? Infinity, )}px` elements.floating.style.maxHeight = `${availableHeight}px` @@ -273,59 +256,28 @@ export const Popover = memo( matchReferenceWidth, padding: DEFAULT_POPOVER_PADDING, }), - ) - } - - // Shift the popover so its sits within the boundary element - if (preventOverflow) { - ret.push( + // Shift the popover so its sits within the boundary element + preventOverflow && shift({ boundary: floatingBoundary || undefined, rootBoundary, padding: DEFAULT_POPOVER_PADDING, }), - ) - } - - // Place arrow - if (arrowProp) { - ret.push( + // Place arrow + arrowProp && arrow({ - element: arrowRef, + element: arrowElement, padding: DEFAULT_POPOVER_PADDING, }), - ) - } - - // Determine the origin to scale from. - // Must be placed after `@sanity/ui/size` and `shift` middleware. - if (animate) { - ret.push(origin) - } - - ret.push( + // Determine the origin to scale from. + // Must be placed after `@sanity/ui/size` and `shift` middleware. + animate && origin, hide({ boundary: referenceBoundary || undefined, padding: DEFAULT_POPOVER_PADDING, strategy: 'referenceHidden', }), - ) - - return ret - }, [ - animate, - arrowProp, - constrainSize, - fallbackPlacements, - floatingBoundary, - margins, - matchReferenceWidth, - preventOverflow, - referenceBoundary, - ]) - - const {x, y, middlewareData, placement, refs, strategy, update} = useFloating({ - middleware, + ], placement: placementProp, whileElementsMounted: autoUpdate, }) @@ -338,10 +290,6 @@ export const Popover = memo( const originX = middlewareData['@sanity/ui/origin']?.originX const originY = middlewareData['@sanity/ui/origin']?.originY - const setArrow = useCallback((arrowEl: HTMLDivElement | null) => { - arrowRef.current = arrowEl - }, []) - const setFloating = useCallback( (node: HTMLDivElement | null) => { ref.current = node @@ -350,36 +298,24 @@ export const Popover = memo( [refs], ) + const [childElement, setChildElement] = useState(null) const setReference = useCallback( (node: HTMLElement | null) => { refs.setReference(node) - - const childRef = getElementRef(childProp as any) - - if (typeof childRef === 'function') { - childRef(node) - } else if (childRef) { - childRef.current = node - } + setChildElement(node) }, - [childProp, refs], + [refs], ) + useImperativeHandle(getElementRef(childProp as any), () => childElement, [childElement]) + const child = useMemo(() => { if (!childProp || referenceElement) return null return cloneElement(childProp, {ref: setReference}) }, [childProp, referenceElement, setReference]) - useEffect(() => { - if (updateRef) { - if (typeof updateRef === 'function') { - updateRef(update) - } else if (updateRef) { - updateRef.current = update - } - } - }, [update, updateRef]) + useImperativeHandle(updateRef, () => update, [update]) useEffect(() => { if (child) return @@ -397,7 +333,7 @@ export const Popover = memo( __unstable_margins={margins} animate={animate} arrow={arrowProp} - arrowRef={setArrow} + arrowRef={setArrowElement} arrowX={arrowX} arrowY={arrowY} hidden={referenceHidden} @@ -412,7 +348,6 @@ export const Popover = memo( originY={originY} strategy={strategy} tone={tone} - width={matchReferenceWidth ? referenceWidthRef.current : width} x={x} y={y} > @@ -447,7 +382,8 @@ Popover.displayName = 'Memo(ForwardRef(Popover))' // https://github.com/facebook/react/pull/28348 // // Access the ref using the method that doesn't yield a warning. -function getElementRef(element: React.ReactElement) { +function getElementRef(element?: React.ReactElement) { + if (!element) return null // React <=18 in DEV let getter = Object.getOwnPropertyDescriptor(element.props, 'ref')?.get let mayWarn = getter && 'isReactWarning' in getter && getter.isReactWarning diff --git a/src/core/primitives/popover/popoverCard.tsx b/src/core/primitives/popover/popoverCard.tsx index fb1290028..6a6dcfa2e 100644 --- a/src/core/primitives/popover/popoverCard.tsx +++ b/src/core/primitives/popover/popoverCard.tsx @@ -51,7 +51,6 @@ export const PopoverCard = memo( shadow?: number | number[] strategy: Strategy tone: CardTone - width: number | undefined x: number | null y: number | null } & Omit, 'as' | 'height' | 'width'>, @@ -76,7 +75,6 @@ export const PopoverCard = memo( strategy, style, tone, - width, x: xProp, y: yProp, ...restProps @@ -101,12 +99,11 @@ export const PopoverCard = memo( originY, position: strategy, top: y, - width, zIndex, willChange: animate ? 'transform' : undefined, ...style, }), - [animate, originX, originY, strategy, style, width, x, y, zIndex], + [animate, originX, originY, strategy, style, x, y, zIndex], ) const arrowStyle: CSSProperties = useMemo( diff --git a/src/core/primitives/tooltip/tooltip.tsx b/src/core/primitives/tooltip/tooltip.tsx index 7f3a916f0..1c3b82208 100644 --- a/src/core/primitives/tooltip/tooltip.tsx +++ b/src/core/primitives/tooltip/tooltip.tsx @@ -97,28 +97,31 @@ export const Tooltip = forwardRef(function Tooltip( const { animate: _animate = false, arrow: arrowProp = false, - boundaryElement = boundaryElementContext?.element, + boundaryElement: _boundaryElement, children: childProp, content, disabled, - fallbackPlacements: fallbackPlacementsProp = props.fallbackPlacements ?? - DEFAULT_FALLBACK_PLACEMENTS[props.placement ?? 'bottom'], + fallbackPlacements: _fallbackPlacementsProp, padding = 2, placement: placementProp = 'bottom', portal: portalProp, radius = 2, scheme, shadow = 2, - zOffset = layer.tooltip.zOffset, + zOffset: _zOffset, delay, ...restProps } = props + const boundaryElement = _boundaryElement ?? boundaryElementContext?.element + const fallbackPlacementsProp = + _fallbackPlacementsProp ?? DEFAULT_FALLBACK_PLACEMENTS[props.placement ?? 'bottom'] + const zOffset = _zOffset ?? layer.tooltip.zOffset const prefersReducedMotion = usePrefersReducedMotion() const animate = prefersReducedMotion ? false : _animate const fallbackPlacements = useArrayProp(fallbackPlacementsProp) const ref = useRef(null) const [referenceElement, setReferenceElement] = useState(null) - const arrowRef = useRef(null) + const [arrowElement, setArrowElement] = useState(null) const rootBoundary: RootBoundary = 'viewport' const [tooltipMaxWidth, setTooltipMaxWidth] = useState(0) @@ -155,7 +158,7 @@ export const Tooltip = forwardRef(function Tooltip( // Place arrow if (arrowProp) { - ret.push(arrow({element: arrowRef, padding: DEFAULT_TOOLTIP_PADDING})) + ret.push(arrow({element: arrowElement, padding: DEFAULT_TOOLTIP_PADDING})) } // Determine the origin to scale from. @@ -165,7 +168,7 @@ export const Tooltip = forwardRef(function Tooltip( } return ret - }, [animate, arrowProp, boundaryElement, fallbackPlacements]) + }, [animate, arrowElement, arrowProp, boundaryElement, fallbackPlacements]) const {floatingStyles, placement, middlewareData, refs, update} = useFloating({ middleware, @@ -223,42 +226,42 @@ export const Tooltip = forwardRef(function Tooltip( handleIsOpenChange(false) childProp?.props?.onBlur?.(e) }, - [childProp?.props, handleIsOpenChange], + [childProp, handleIsOpenChange], ) const handleClick = useCallback( (e: MouseEvent) => { handleIsOpenChange(false, true) childProp?.props.onClick?.(e) }, - [childProp?.props, handleIsOpenChange], + [childProp, handleIsOpenChange], ) const handleContextMenu = useCallback( (e: MouseEvent) => { handleIsOpenChange(false, true) childProp?.props.onContextMenu?.(e) }, - [childProp?.props, handleIsOpenChange], + [childProp, handleIsOpenChange], ) const handleFocus = useCallback( (e: FocusEvent) => { handleIsOpenChange(true) childProp?.props?.onFocus?.(e) }, - [childProp?.props, handleIsOpenChange], + [childProp, handleIsOpenChange], ) const handleMouseEnter = useCallback( (e: MouseEvent) => { handleIsOpenChange(true) childProp?.props?.onMouseEnter?.(e) }, - [childProp?.props, handleIsOpenChange], + [childProp, handleIsOpenChange], ) const handleMouseLeave = useCallback( (e: MouseEvent) => { handleIsOpenChange(false) childProp?.props?.onMouseLeave?.(e) }, - [childProp?.props, handleIsOpenChange], + [childProp, handleIsOpenChange], ) // Handle closing the tooltip when the mouse leaves the referenceElement @@ -309,7 +312,7 @@ export const Tooltip = forwardRef(function Tooltip( const setArrow = useCallback( (arrowEl: HTMLDivElement | null) => { - arrowRef.current = arrowEl + setArrowElement(arrowEl) update() }, [update], @@ -324,9 +327,9 @@ export const Tooltip = forwardRef(function Tooltip( ) const childRef = useRef(null) + const [childElement, setChildElement] = useState(null) - // Merge refs so that any ref we are overriding is called as well - useImperativeHandle((childProp as any)?.ref, () => childRef.current) + useImperativeHandle(getElementRef(childProp as any), () => childElement, [childElement]) const child = useMemo(() => { if (!childProp) return null @@ -338,7 +341,7 @@ export const Tooltip = forwardRef(function Tooltip( onMouseLeave: handleMouseLeave, onClick: handleClick, onContextMenu: handleContextMenu, - ref: childRef, + ref: setChildElement, }) }, [ childProp, @@ -462,3 +465,30 @@ function useCloseOnMouseLeave({ return () => window.removeEventListener('mousemove', handleMouseMove) }, [onMouseMove, showTooltip]) } + +// Before React 19 accessing `element.props.ref` will throw a warning and suggest using `element.ref` +// After React 19 accessing `element.ref` does the opposite. +// https://github.com/facebook/react/pull/28348 +// +// Access the ref using the method that doesn't yield a warning. +function getElementRef(element?: React.ReactElement) { + if (!element) return null + // React <=18 in DEV + let getter = Object.getOwnPropertyDescriptor(element.props, 'ref')?.get + let mayWarn = getter && 'isReactWarning' in getter && getter.isReactWarning + + if (mayWarn) { + return (element as any).ref + } + + // React 19 in DEV + getter = Object.getOwnPropertyDescriptor(element, 'ref')?.get + mayWarn = getter && 'isReactWarning' in getter && getter.isReactWarning + + if (mayWarn) { + return element.props.ref + } + + // Not DEV + return element.props.ref || (element as any).ref +} diff --git a/src/core/utils/portal/portalProvider.tsx b/src/core/utils/portal/portalProvider.tsx index 41918f7d2..3da51f198 100644 --- a/src/core/utils/portal/portalProvider.tsx +++ b/src/core/utils/portal/portalProvider.tsx @@ -1,4 +1,4 @@ -import {useMemo, useRef, useSyncExternalStore} from 'react' +import {useMemo, useState, useSyncExternalStore} from 'react' import {PortalContext} from './portalContext' import {PortalContextValue} from './types' @@ -51,13 +51,13 @@ const emptySubscribe = () => () => {} * equality comparison (eg by identity), and only goes one level deep. */ function useUnique(value: ValueType): ValueType { - const valueRef = useRef(value) + const [cachedValue, setCachedValue] = useState(value) - if (!_isEqual(valueRef.current, value)) { - valueRef.current = value + if (!_isEqual(cachedValue, value)) { + setCachedValue(value) } - return valueRef.current + return cachedValue } function _isEqual(objA: Comparable, objB: Comparable): boolean {