From 6aeeb3778d8c30933c7fd3a5027acd8519a2ae2c Mon Sep 17 00:00:00 2001 From: Cody Olsen Date: Tue, 29 Oct 2024 15:13:49 +0100 Subject: [PATCH] feat: use react compiler --- .eslintrc.js | 2 +- .github/workflows/main.yml | 2 +- .storybook/main.ts | 4 +- package.config.ts | 39 +++- package.json | 2 +- pnpm-lock.yaml | 18 +- .../components/autocomplete/autocomplete.tsx | 73 ++++--- .../components/breadcrumbs/breadcrumbs.tsx | 82 +++---- src/core/components/dialog/dialog.tsx | 6 +- src/core/components/menu/menu.tsx | 26 ++- src/core/components/menu/menuButton.tsx | 50 ++--- src/core/components/menu/menuGroup.tsx | 3 +- src/core/components/menu/menuItem.tsx | 6 +- src/core/components/menu/useMenuController.ts | 39 ++-- src/core/components/tab/tabList.tsx | 14 +- src/core/components/toast/toastProvider.tsx | 11 +- src/core/components/toast/toastState.ts | 6 + src/core/components/tree/treeItem.tsx | 33 +-- src/core/constants.ts | 2 +- src/core/hooks/useArrayProp.ts | 23 +- src/core/hooks/useMatchMedia.ts | 34 ++- src/core/primitives/avatar/avatarStack.tsx | 7 +- src/core/primitives/popover/popover.tsx | 204 ++++++------------ src/core/primitives/popover/popoverCard.tsx | 5 +- src/core/primitives/tooltip/tooltip.tsx | 64 ++++-- src/core/theme/themeProvider.tsx | 10 +- src/core/utils/elementQuery/elementQuery.tsx | 33 ++- src/core/utils/layer/layerProvider.tsx | 2 +- src/core/utils/portal/portalProvider.tsx | 10 +- 29 files changed, 384 insertions(+), 426 deletions(-) create mode 100644 src/core/components/toast/toastState.ts diff --git a/.eslintrc.js b/.eslintrc.js index 491976d9e..ab8d19e0c 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', // Set to error once existing warnings are fixed + 'react-compiler/react-compiler': 'error', 'react/no-unescaped-entities': 'off', 'no-restricted-imports': [ 'error', diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 07b392309..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 12" + - run: "pnpm lint -f compact --rule 'no-warning-comments: [off]' --max-warnings 0" test: needs: [build] diff --git a/.storybook/main.ts b/.storybook/main.ts index 9dba041a7..adf3a670b 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -25,7 +25,9 @@ const config: StorybookConfig = { return mergeConfig(config, { plugins: [ viteReact({ - babel: {plugins: [['babel-plugin-react-compiler', {target: '18'}]]}, + babel: { + plugins: [['babel-plugin-react-compiler', {target: '18'}]], + }, }), tsconfigPaths(), ], diff --git a/package.config.ts b/package.config.ts index cfc254862..dbb8801e8 100644 --- a/package.config.ts +++ b/package.config.ts @@ -1,19 +1,40 @@ -import {defineConfig} from '@sanity/pkg-utils' +import { defineConfig } from "@sanity/pkg-utils"; export default defineConfig({ extract: { rules: { - 'ae-internal-missing-underscore': 'off', - 'ae-incompatible-release-tags': 'warn', - 'ae-missing-release-tag': 'warn', + "ae-internal-missing-underscore": "off", + "ae-incompatible-release-tags": "warn", + "ae-missing-release-tag": "warn", }, }, legacyExports: true, strictOptions: { // disable warning when not using browserslist in package.json - noImplicitBrowsersList: 'off', + noImplicitBrowsersList: "off", }, - tsconfig: 'tsconfig.dist.json', - babel: {reactCompiler: true}, - reactCompilerOptions: {target: '18'}, -}) + tsconfig: "tsconfig.dist.json", + babel: { reactCompiler: true }, + 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 155be141a..6da2066d7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@sanity/ui", - "version": "2.8.17", + "version": "2.9.0-canary.6", "keywords": [ "sanity", "ui", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fedfd1c0a..8a0bbda95 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,7 +68,7 @@ importers: version: 5.0.0(semantic-release@24.0.0(typescript@5.6.3)) '@sanity/ui-workshop': specifier: ^2.0.16 - version: 2.0.16(@sanity/icons@3.4.0(react@18.3.1))(@sanity/ui@2.8.17(react-dom@18.3.1(react@18.3.1))(react-is@18.3.1)(react@18.3.1)(styled-components@6.1.13(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@types/node@20.12.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.1.13(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(terser@5.30.3) + version: 2.0.16(@sanity/icons@3.4.0(react@18.3.1))(@sanity/ui@2.9.0-canary.6(react-dom@18.3.1(react@18.3.1))(react-is@18.3.1)(react@18.3.1)(styled-components@6.1.13(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@types/node@20.12.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.1.13(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(terser@5.30.3) '@storybook/addon-a11y': specifier: ^8.4.0 version: 8.4.0(storybook@8.4.0(prettier@3.3.3)) @@ -1964,13 +1964,13 @@ packages: react-dom: ^18 styled-components: ^5.2 || ^6 - '@sanity/ui@2.8.17': - resolution: {integrity: sha512-lckBR2qEeRMI4nIMtLmc1/4898gJ8e3bYRGl+/JRpqg4cgCOYM8eM+o8Mz14po+XQ/n5Jac0wLlOsHOhTxiMTw==} + '@sanity/ui@2.9.0-canary.6': + resolution: {integrity: sha512-Cwp2/99hyUmG/1TyRf6hUMC2Cf5U24oKYR1AKWPPhd0AaWz2YDmD03BAUCfk8SDbWREt8uKkqfwjRNd8AHQitw==} engines: {node: '>=14.0.0'} peerDependencies: - react: ^18 || >=19.0.0-0 - react-dom: ^18 || >=19.0.0-0 - react-is: ^18 || >=19.0.0-0 + react: ^18 + react-dom: ^18 + react-is: ^18 styled-components: ^5.2 || ^6 '@sec-ant/readable-stream@0.4.1': @@ -9483,10 +9483,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@sanity/ui-workshop@2.0.16(@sanity/icons@3.4.0(react@18.3.1))(@sanity/ui@2.8.17(react-dom@18.3.1(react@18.3.1))(react-is@18.3.1)(react@18.3.1)(styled-components@6.1.13(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@types/node@20.12.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.1.13(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(terser@5.30.3)': + '@sanity/ui-workshop@2.0.16(@sanity/icons@3.4.0(react@18.3.1))(@sanity/ui@2.9.0-canary.6(react-dom@18.3.1(react@18.3.1))(react-is@18.3.1)(react@18.3.1)(styled-components@6.1.13(react-dom@18.3.1(react@18.3.1))(react@18.3.1)))(@types/node@20.12.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.1.13(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(terser@5.30.3)': dependencies: '@sanity/icons': 3.4.0(react@18.3.1) - '@sanity/ui': 2.8.17(react-dom@18.3.1(react@18.3.1))(react-is@18.3.1)(react@18.3.1)(styled-components@6.1.13(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) + '@sanity/ui': 2.9.0-canary.6(react-dom@18.3.1(react@18.3.1))(react-is@18.3.1)(react@18.3.1)(styled-components@6.1.13(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) '@vitejs/plugin-react': 4.3.3(vite@5.4.10(@types/node@20.12.7)(terser@5.30.3)) axe-core: 4.10.2 cac: 6.7.14 @@ -9517,7 +9517,7 @@ snapshots: - supports-color - terser - '@sanity/ui@2.8.17(react-dom@18.3.1(react@18.3.1))(react-is@18.3.1)(react@18.3.1)(styled-components@6.1.13(react-dom@18.3.1(react@18.3.1))(react@18.3.1))': + '@sanity/ui@2.9.0-canary.6(react-dom@18.3.1(react@18.3.1))(react-is@18.3.1)(react@18.3.1)(styled-components@6.1.13(react-dom@18.3.1(react@18.3.1))(react@18.3.1))': dependencies: '@floating-ui/react-dom': 2.1.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@sanity/color': 3.0.6 diff --git a/src/core/components/autocomplete/autocomplete.tsx b/src/core/components/autocomplete/autocomplete.tsx index 9677cc5d9..94b121d1b 100644 --- a/src/core/components/autocomplete/autocomplete.tsx +++ b/src/core/components/autocomplete/autocomplete.tsx @@ -17,6 +17,7 @@ import { useMemo, useReducer, useRef, + useState, } from 'react' import {EMPTY_ARRAY, EMPTY_RECORD} from '../../constants' import {_hasFocus, _raf, focusFirstDescendant} from '../../helpers' @@ -184,21 +185,22 @@ const InnerAutocomplete = forwardRef(function InnerAutocomplete< typeof filterOptionProp === 'function' ? filterOptionProp : DEFAULT_FILTER_OPTION // Element refs - const rootElementRef = useRef(null) - const resultsPopoverElementRef = useRef(null) - const inputElementRef = useRef(null) + const [rootElement, setRootElement] = useState(null) + const [resultsPopoverElement, setResultsPopoverElement] = useState(null) + const [inputElement, setInputElement] = useState(null) const listBoxElementRef = useRef(null) // Value refs const listFocusedRef = useRef(false) const valueRef = useRef(value) const valuePropRef = useRef(valueProp) - const popoverMouseWithinRef = useRef(false) + const [popoverMouseWithin, setPopoverMouseWithin] = useState(false) // Forward ref to parent useImperativeHandle( forwardedRef, - () => inputElementRef.current, + () => inputElement, + [inputElement], ) const listBoxId = `${id}-listbox` @@ -222,13 +224,13 @@ const InnerAutocomplete = forwardRef(function InnerAutocomplete< // NOTE: This is a workaround for a bug that may happen in Chrome (clicking the scrollbar // closes the results in certain situations): // - Do not handle blur if the mouse is within the popover - if (popoverMouseWithinRef.current) { + if (popoverMouseWithin) { return } const elements: HTMLElement[] = (relatedElements || []).concat( - rootElementRef.current ? [rootElementRef.current] : [], - resultsPopoverElementRef.current ? [resultsPopoverElementRef.current] : [], + rootElement ? [rootElement] : [], + resultsPopoverElement ? [resultsPopoverElement] : [], ) let focusInside = false @@ -244,13 +246,20 @@ const InnerAutocomplete = forwardRef(function InnerAutocomplete< if (focusInside === false) { dispatch({type: 'root/blur'}) - popoverMouseWithinRef.current = false + setPopoverMouseWithin(false) if (onQueryChange) onQueryChange(null) if (onBlur) onBlur(event) } }, 0) }, - [onBlur, onQueryChange, relatedElements], + [ + onBlur, + onQueryChange, + popoverMouseWithin, + relatedElements, + resultsPopoverElement, + rootElement, + ], ) const handleRootFocus = useCallback((event: FocusEvent) => { @@ -269,7 +278,7 @@ const InnerAutocomplete = forwardRef(function InnerAutocomplete< (v: string) => { dispatch({type: 'value/change', value: v}) - popoverMouseWithinRef.current = false + setPopoverMouseWithin(false) if (onSelect) onSelect(v) @@ -278,9 +287,9 @@ const InnerAutocomplete = forwardRef(function InnerAutocomplete< if (onChange) onChange(v) if (onQueryChange) onQueryChange(null) - inputElementRef.current?.focus() + inputElement?.focus() }, - [onChange, onSelect, onQueryChange], + [onSelect, onChange, onQueryChange, inputElement], ) const handleRootKeyDown = useCallback( @@ -324,9 +333,9 @@ const InnerAutocomplete = forwardRef(function InnerAutocomplete< if (event.key === 'Escape') { dispatch({type: 'root/escape'}) - popoverMouseWithinRef.current = false + setPopoverMouseWithin(false) if (onQueryChange) onQueryChange(null) - inputElementRef.current?.focus() + inputElement?.focus() return } @@ -338,12 +347,12 @@ const InnerAutocomplete = forwardRef(function InnerAutocomplete< (listEl === target || listEl?.contains(target)) && !AUTOCOMPLETE_LISTBOX_IGNORE_KEYS.includes(event.key) ) { - inputElementRef.current?.focus() + inputElement?.focus() return } }, - [activeValue, filteredOptions, filteredOptionsLen, onQueryChange], + [activeValue, filteredOptions, filteredOptionsLen, inputElement, onQueryChange], ) const handleInputChange = useCallback( @@ -377,11 +386,11 @@ const InnerAutocomplete = forwardRef(function InnerAutocomplete< ) const handlePopoverMouseEnter = useCallback(() => { - popoverMouseWithinRef.current = true + setPopoverMouseWithin(true) }, []) const handlePopoverMouseLeave = useCallback(() => { - popoverMouseWithinRef.current = false + setPopoverMouseWithin(false) }, []) const handleClearButtonClick = useCallback(() => { @@ -389,8 +398,8 @@ const InnerAutocomplete = forwardRef(function InnerAutocomplete< valueRef.current = '' if (onChange) onChange('') if (onQueryChange) onQueryChange(null) - inputElementRef.current?.focus() - }, [onChange, onQueryChange]) + inputElement?.focus() + }, [inputElement, onChange, onQueryChange]) const handleClearButtonFocus = useCallback(() => { dispatch({type: 'input/focus'}) @@ -482,9 +491,9 @@ const InnerAutocomplete = forwardRef(function InnerAutocomplete< if (openButtonProps.onClick) openButtonProps.onClick(event) - _raf(() => inputElementRef.current?.focus()) + _raf(() => inputElement?.focus()) }, - [openButtonProps, dispatchOpen], + [dispatchOpen, openButtonProps, inputElement], ) const openButtonNode = useMemo( @@ -554,7 +563,7 @@ const InnerAutocomplete = forwardRef(function InnerAutocomplete< prefix={prefix} radius={radius} readOnly={readOnly} - ref={inputElementRef} + ref={setInputElement} role="combobox" spellCheck={false} suffix={suffix || openButtonNode} @@ -566,10 +575,10 @@ const InnerAutocomplete = forwardRef(function InnerAutocomplete< (event: KeyboardEvent) => { // If the focus is currently in the list, move focus to the input element if (event.key === 'Tab') { - if (listFocused) inputElementRef.current?.focus() + if (listFocused) inputElement?.focus() } }, - [listFocused], + [inputElement, listFocused], ) const content = useMemo(() => { @@ -635,11 +644,11 @@ const InnerAutocomplete = forwardRef(function InnerAutocomplete< { content, hidden: !expanded, - inputElement: inputElementRef.current, + inputElement, onMouseEnter: handlePopoverMouseEnter, onMouseLeave: handlePopoverMouseLeave, }, - resultsPopoverElementRef, + {current: resultsPopoverElement}, ) } @@ -661,8 +670,8 @@ const InnerAutocomplete = forwardRef(function InnerAutocomplete< placement={AUTOCOMPLETE_POPOVER_PLACEMENT} portal radius={radius} - ref={resultsPopoverElementRef} - referenceElement={inputElementRef.current} + ref={setResultsPopoverElement} + referenceElement={inputElement} {...popover} /> ) @@ -672,9 +681,11 @@ const InnerAutocomplete = forwardRef(function InnerAutocomplete< filteredOptionsLen, handlePopoverMouseEnter, handlePopoverMouseLeave, + inputElement, popover, radius, renderPopover, + resultsPopoverElement, ]) return ( @@ -683,7 +694,7 @@ const InnerAutocomplete = forwardRef(function InnerAutocomplete< onBlur={handleRootBlur} onFocus={handleRootFocus} onKeyDown={handleRootKeyDown} - ref={rootElementRef} + ref={setRootElement} > {input} {results} 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/dialog/dialog.tsx b/src/core/components/dialog/dialog.tsx index 159b3d3be..83bd88d2d 100644 --- a/src/core/components/dialog/dialog.tsx +++ b/src/core/components/dialog/dialog.tsx @@ -305,13 +305,15 @@ export const Dialog = forwardRef(function Dialog( onFocus, padding: paddingProp = 3, portal: portalProp, - position: positionProp = dialog.position || 'fixed', + position: _positionProp, scheme, width: widthProp = 0, - zOffset: zOffsetProp = dialog.zOffset || layer.dialog.zOffset, + zOffset: _zOffsetProp, animate: _animate = false, ...restProps } = props + const positionProp = _positionProp ?? (dialog.position || 'fixed') + const zOffsetProp = _zOffsetProp ?? (dialog.zOffset || layer.dialog.zOffset) const prefersReducedMotion = usePrefersReducedMotion() const animate = prefersReducedMotion ? false : _animate const portal = usePortal() diff --git a/src/core/components/menu/menu.tsx b/src/core/components/menu/menu.tsx index f13dbc79e..512b4d6c5 100644 --- a/src/core/components/menu/menu.tsx +++ b/src/core/components/menu/menu.tsx @@ -14,19 +14,21 @@ export interface MenuProps extends ResponsivePaddingProps { /** * @deprecated Use `shouldFocus="first"` instead. */ - focusFirst?: boolean + 'focusFirst'?: boolean /** * @deprecated Use `shouldFocus="last"` instead. */ - focusLast?: boolean - onClickOutside?: (event: MouseEvent) => void - onEscape?: () => void - onItemClick?: () => void - onItemSelect?: (index: number) => void - originElement?: HTMLElement | null - registerElement?: (el: HTMLElement) => () => void - shouldFocus?: 'first' | 'last' | null - space?: number | number[] + 'focusLast'?: boolean + 'onClickOutside'?: (event: MouseEvent) => void + 'onEscape'?: () => void + 'onItemClick'?: () => void + 'onItemSelect'?: (index: number) => void + 'originElement'?: HTMLElement | null + 'registerElement'?: (el: HTMLElement) => () => void + 'shouldFocus'?: 'first' | 'last' | null + 'space'?: number | number[] + 'aria-labelledby'?: string + 'onBlurCapture'?: (event: FocusEvent) => void } const Root = styled(Box)` @@ -57,10 +59,12 @@ export const Menu = forwardRef(function Menu( originElement, padding = 1, registerElement, - shouldFocus = (props.focusFirst && 'first') || (props.focusLast && 'last') || null, + shouldFocus: _shouldFocus, space = 1, ...restProps } = props + const shouldFocus = + _shouldFocus ?? ((props.focusFirst && 'first') || (props.focusLast && 'last') || null) const ref = useRef(null) diff --git a/src/core/components/menu/menuButton.tsx b/src/core/components/menu/menuButton.tsx index 1779d5f9f..00bb74fc8 100644 --- a/src/core/components/menu/menuButton.tsx +++ b/src/core/components/menu/menuButton.tsx @@ -169,7 +169,7 @@ export const MenuButton = forwardRef(function MenuButton( }, [buttonElement, disableRestoreFocusOnClose]) const handleBlur = useCallback( - (event: React.FocusEvent) => { + (event: FocusEvent) => { const target = event.relatedTarget if (!(target instanceof Node)) { @@ -199,32 +199,19 @@ export const MenuButton = forwardRef(function MenuButton( return () => setChildMenuElements((els) => els.filter((_el) => _el !== el)) }, []) - const menuProps: MenuProps = useMemo( - () => ({ - 'aria-labelledby': id, - 'onBlurCapture': handleBlur, - 'onClickOutside': handleMenuClickOutside, - 'onEscape': handleMenuEscape, - 'onItemClick': handleItemClick, - 'originElement': buttonElement, - registerElement, - shouldFocus, - }), - [ - buttonElement, - handleMenuClickOutside, - handleMenuEscape, - handleItemClick, - id, - handleBlur, - registerElement, - shouldFocus, - ], - ) + const menuProps: MenuProps = { + 'aria-labelledby': id, + 'onBlurCapture': handleBlur, + 'onClickOutside': handleMenuClickOutside, + 'onEscape': handleMenuEscape, + 'onItemClick': handleItemClick, + 'originElement': buttonElement, + registerElement, + shouldFocus, + } const menu = menuProp && cloneElement(menuProp, menuProps) - const ref = useRef(null) const button = useMemo( () => buttonProp && @@ -236,7 +223,7 @@ export const MenuButton = forwardRef(function MenuButton( 'onMouseDown': handleMouseDown, 'aria-haspopup': true, 'aria-expanded': open, - 'ref': ref, + 'ref': setButtonElement, 'selected': buttonProp.props.selected ?? open, }), [buttonProp, handleButtonClick, handleButtonKeyDown, handleMouseDown, id, open], @@ -245,19 +232,10 @@ export const MenuButton = forwardRef(function MenuButton( // Forward button ref to parent useImperativeHandle( forwardedRef, - () => ref.current, + () => buttonElement, + [buttonElement], ) - // If there's a button then we need to set the reference element to the cloned button ref - // and if button changes we make sure to update or remove the reference element. - useEffect(() => { - if (!button) return undefined - - setButtonElement(ref.current) - - return () => setButtonElement(null) - }, [button]) - const popoverProps: MenuButtonProps['popover'] = useMemo( () => ({ boundaryElement: deprecated_boundaryElement, diff --git a/src/core/components/menu/menuGroup.tsx b/src/core/components/menu/menuGroup.tsx index ed76aeb81..99abd4258 100644 --- a/src/core/components/menu/menuGroup.tsx +++ b/src/core/components/menu/menuGroup.tsx @@ -53,9 +53,10 @@ export function MenuGroup( onClickOutside, onEscape, onItemClick, - onItemMouseEnter = menu.onMouseEnter, + onItemMouseEnter: _onItemMouseEnter, registerElement, } = menu + const onItemMouseEnter = _onItemMouseEnter ?? menu.onMouseEnter const [rootElement, setRootElement] = useState(null) const [open, setOpen] = useState(false) const [shouldFocus, setShouldFocus] = useState<'first' | 'last' | null>(null) diff --git a/src/core/components/menu/menuItem.tsx b/src/core/components/menu/menuItem.tsx index accf543f1..d1abcf69e 100644 --- a/src/core/components/menu/menuItem.tsx +++ b/src/core/components/menu/menuItem.tsx @@ -72,9 +72,11 @@ export const MenuItem = forwardRef(function MenuItem( activeElement, mount, onItemClick, - onItemMouseEnter = menu.onMouseEnter, - onItemMouseLeave = menu.onMouseLeave, + onItemMouseEnter: _onItemMouseEnter, + onItemMouseLeave: _onItemMouseLeave, } = menu + const onItemMouseEnter = _onItemMouseEnter ?? menu.onMouseEnter + const onItemMouseLeave = _onItemMouseLeave ?? menu.onMouseLeave const [rootElement, setRootElement] = useState(null) const active = Boolean(activeElement) && activeElement === rootElement const ref = useRef(null) 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/tab/tabList.tsx b/src/core/components/tab/tabList.tsx index 4b7f676bd..146d32d19 100644 --- a/src/core/components/tab/tabList.tsx +++ b/src/core/components/tab/tabList.tsx @@ -1,4 +1,4 @@ -import {cloneElement, forwardRef, useCallback, useMemo, useState} from 'react' +import {cloneElement, forwardRef, useCallback, useState, Children, isValidElement} from 'react' import {styled} from 'styled-components' import {Inline, InlineProps} from '../../primitives' @@ -9,10 +9,6 @@ export interface TabListProps extends Omit { children: Array } -function _isReactElement(node: unknown): node is React.ReactElement { - return Boolean(node) -} - //Limits the width of tabs in tablist const CustomInline = styled(Inline)` & > div { @@ -33,22 +29,18 @@ export const TabList = forwardRef(function TabList( const {children: childrenProp, ...restProps} = props const [focusedIndex, setFocusedIndex] = useState(-1) - const children = useMemo(() => childrenProp.filter(_isReactElement), [childrenProp]) + const children: React.ReactElement[] = Children.toArray(childrenProp).filter(isValidElement) const tabs = children.map((child, childIndex) => cloneElement(child, { focused: focusedIndex === childIndex, key: childIndex, - onFocus: () => handleTabFocus(childIndex), + onFocus: () => setFocusedIndex(childIndex), }), ) const numTabs = tabs.length - const handleTabFocus = useCallback((tabIdx: number) => { - setFocusedIndex(tabIdx) - }, []) - const handleKeyDown = useCallback( (event: React.KeyboardEvent) => { if (event.key === 'ArrowLeft') { diff --git a/src/core/components/toast/toastProvider.tsx b/src/core/components/toast/toastProvider.tsx index e6ee27d35..67e054728 100644 --- a/src/core/components/toast/toastProvider.tsx +++ b/src/core/components/toast/toastProvider.tsx @@ -8,6 +8,7 @@ import {Box} from '../../primitives' import {Layer} from '../../utils' import {Toast} from './toast' import {ToastContext} from './toastContext' +import {generateToastId} from './toastState' import {ToastContextValue, ToastParams} from './types' type ToastState = { @@ -45,8 +46,6 @@ const ToastContainer = styled.div` width: 100%; ` -let toastId = 0 - /** * @public */ @@ -60,20 +59,20 @@ export function ToastProvider(props: ToastProviderProps): React.ReactElement { () => ({ initial: { opacity: 0, - [POPOVER_MOTION_CONTENT_OPACITY_PROPERTY as string]: 0, + [POPOVER_MOTION_CONTENT_OPACITY_PROPERTY]: 0, y: 32, scale: 0.25, willChange: 'transform', }, animate: { opacity: [0, 1, 1], - [POPOVER_MOTION_CONTENT_OPACITY_PROPERTY as string]: [0, 0, 1], + [POPOVER_MOTION_CONTENT_OPACITY_PROPERTY]: [0, 0, 1], y: 0, scale: 1, }, exit: { opacity: [1, 1, 0], - [POPOVER_MOTION_CONTENT_OPACITY_PROPERTY as string]: [1, 0, 0], + [POPOVER_MOTION_CONTENT_OPACITY_PROPERTY]: [1, 0, 0], scale: 0.5, transition: prefersReducedMotion ? {duration: 0} : {duration: 0.2}, }, @@ -86,7 +85,7 @@ export function ToastProvider(props: ToastProviderProps): React.ReactElement { // Wrap setState in startTransition to allow React to give input state updates higher priority const setState: typeof _setState = (state) => startTransition(() => _setState(state)) - const id = params.id || String(toastId++) + const id = params.id || generateToastId() const duration = params.duration || 5000 const dismiss = () => { diff --git a/src/core/components/toast/toastState.ts b/src/core/components/toast/toastState.ts new file mode 100644 index 000000000..97c7d9dc3 --- /dev/null +++ b/src/core/components/toast/toastState.ts @@ -0,0 +1,6 @@ +let toastId = 0 + +/** @internal */ +export function generateToastId(): string { + return String(toastId++) +} 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/constants.ts b/src/core/constants.ts index 86c479530..446a178de 100644 --- a/src/core/constants.ts +++ b/src/core/constants.ts @@ -13,7 +13,7 @@ export const EMPTY_RECORD: Record = {} /** * @internal */ -export const POPOVER_MOTION_CONTENT_OPACITY_PROPERTY = '--motion-content-opacity' +export const POPOVER_MOTION_CONTENT_OPACITY_PROPERTY = '--motion-content-opacity' as string /** * Shared `framer-motion` variants used by `Popover` and `Tooltip` components. diff --git a/src/core/hooks/useArrayProp.ts b/src/core/hooks/useArrayProp.ts index f5d4371ae..fe6855d1e 100644 --- a/src/core/hooks/useArrayProp.ts +++ b/src/core/hooks/useArrayProp.ts @@ -1,4 +1,4 @@ -import {useMemo} from 'react' +import {useState} from 'react' import {_getArrayProp} from '../styles' /** @beta */ @@ -12,14 +12,19 @@ export function useArrayProp( val: T | T[] | undefined, defaultVal?: T[], ): T[] { - // JSON.stringify is fast, but it's not faster than useMemo's referencial equality check - const __perf_hash__ = useMemo(() => JSON.stringify(val ?? defaultVal), [defaultVal, val]) + const [[cachedVal, cachedHash], setCache] = useState<[T[], string]>(() => [ + _getArrayProp(val, defaultVal), + JSON.stringify(val ?? defaultVal), + ]) - return useMemo( - () => _getArrayProp(val, defaultVal), + const hash = JSON.stringify(val ?? defaultVal) - // Improve performance: Keep object identify for a given hash of the value - // eslint-disable-next-line react-hooks/exhaustive-deps - [__perf_hash__], - ) + if (hash !== cachedHash) { + // If the cached hash has changed, update the cache right away. + // Calling setState during render is fine in this case, and preferred over a useEffect loop + // https://19.react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes + setCache([_getArrayProp(val, defaultVal), hash]) + } + + return cachedVal } 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/avatar/avatarStack.tsx b/src/core/primitives/avatar/avatarStack.tsx index a0db0cc60..dada7effa 100644 --- a/src/core/primitives/avatar/avatarStack.tsx +++ b/src/core/primitives/avatar/avatarStack.tsx @@ -1,5 +1,5 @@ import {getTheme_v2} from '@sanity/ui/theme' -import {Children, cloneElement, forwardRef, isValidElement, useMemo} from 'react' +import {Children, cloneElement, forwardRef, isValidElement} from 'react' import {styled, css} from 'styled-components' import {EMPTY_RECORD} from '../../constants' import {useArrayProp} from '../../hooks' @@ -65,10 +65,7 @@ export const AvatarStack = forwardRef(function AvatarStack( size: sizeProp = 1, ...restProps } = props - const children = useMemo( - () => Children.toArray(childrenProp).filter(isValidElement), - [childrenProp], - ) + const children: React.ReactElement[] = Children.toArray(childrenProp).filter(isValidElement) const maxLength = Math.max(maxLengthProp, 0) const size = useArrayProp(sizeProp) 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/theme/themeProvider.tsx b/src/core/theme/themeProvider.tsx index 3864b1adc..ccda05dac 100644 --- a/src/core/theme/themeProvider.tsx +++ b/src/core/theme/themeProvider.tsx @@ -25,12 +25,10 @@ export interface ThemeProviderProps { */ export function ThemeProvider(props: ThemeProviderProps): React.ReactElement { const parentTheme = useContext(ThemeContext) - const { - children, - scheme = parentTheme?.scheme || 'light', - theme: rootTheme = parentTheme?.theme || null, - tone = parentTheme?.tone || 'default', - } = props + const {children} = props + const scheme = props.scheme ?? (parentTheme?.scheme || 'light') + const rootTheme = props.theme ?? (parentTheme?.theme || null) + const tone = props.tone ?? (parentTheme?.tone || 'default') const themeContext: ThemeContextValue | null = useMemo(() => { if (!rootTheme) return null diff --git a/src/core/utils/elementQuery/elementQuery.tsx b/src/core/utils/elementQuery/elementQuery.tsx index 3c555efb3..934e04272 100644 --- a/src/core/utils/elementQuery/elementQuery.tsx +++ b/src/core/utils/elementQuery/elementQuery.tsx @@ -1,4 +1,4 @@ -import {forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState} from 'react' +import {forwardRef, useImperativeHandle, useMemo, useState} from 'react' import {useElementSize} from '../../hooks' import {useTheme_v2} from '../../theme' import {findMaxBreakpoints, findMinBreakpoints} from './helpers' @@ -21,31 +21,30 @@ export const ElementQuery = forwardRef(function ElementQuery( forwardedRef: React.ForwardedRef, ) { const theme = useTheme_v2() - const {children, media = theme.media, ...restProps} = props + const {children, media: _media, ...restProps} = props + const media = _media ?? theme.media - const ref = useRef(null) const [element, setElement] = useState(null) const elementSize = useElementSize(element) const width = useMemo(() => elementSize?.border.width ?? window.innerWidth, [elementSize]) - const max = useMemo(() => findMaxBreakpoints(media, width), [media, width]) - const min = useMemo(() => findMinBreakpoints(media, width), [media, width]) + const max = useMemo(() => { + const eq = findMaxBreakpoints(media, width) - useImperativeHandle(forwardedRef, () => ref.current) + return eq.length ? eq.join(' ') : undefined + }, [media, width]) + const min = useMemo(() => { + const eq = findMinBreakpoints(media, width) - const setRef = useCallback((el: HTMLDivElement | null) => { - ref.current = el - setElement(el) - }, []) + return eq.length ? eq.join(' ') : undefined + }, [media, width]) + + useImperativeHandle(forwardedRef, () => element, [ + element, + ]) return ( -
+
{children}
) diff --git a/src/core/utils/layer/layerProvider.tsx b/src/core/utils/layer/layerProvider.tsx index faa7caa14..8795ce4a9 100644 --- a/src/core/utils/layer/layerProvider.tsx +++ b/src/core/utils/layer/layerProvider.tsx @@ -85,7 +85,7 @@ export function LayerProvider(props: LayerProviderProps): React.ReactElement { parentDispose?.() } }, - [parentRegisterChild], + [parentRegisterChild, setSize, setChildLayers], ) // Register this layer on mount 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 {